Cellular Automata Maps
About this tutorial
This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!
If you enjoy this and would like me to keep writing, please consider supporting my Patreon.
Sometimes, you need a break from rectangular rooms. You might want a nice, organic looking cavern; a winding forest trail, or a spooky quarry. One Knight in the Dungeon uses cellular automata for this purpose, inspired by this excellent article. This chapter will help you create natural looking maps.
Scaffolding
Once again, we're going to take a bunch of code from the previous tutorial and re-use it for the new generator. Create a new file, map_builders/cellular_automata.rs
and place the following in it:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, Rect, apply_room_to_map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER}; use rltk::RandomNumberGenerator; use specs::prelude::*; const MIN_ROOM_SIZE : i32 = 8; pub struct CellularAutomataBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map> } impl MapBuilder for CellularAutomataBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { //self.build(); - we should write this } fn spawn_entities(&mut self, ecs : &mut World) { // We need to rewrite this, too. } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl CellularAutomataBuilder { pub fn new(new_depth : i32) -> CellularAutomataBuilder { CellularAutomataBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), } } } }
Once again, we'll make the name random_builder
a lie and only return the one we're working on:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 3); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(CellularAutomataBuilder::new(new_depth)) } }
Putting together the basic map
The first step is to make the map completely chaotic, with about 55% of tiles being solid. You can tweak that number for different effects, but I quite like the result. Here's the build
function:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // First we completely randomize the map, setting 55% of it to be floor. for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let roll = rng.roll_dice(1, 100); let idx = self.map.xy_idx(x, y); if roll > 55 { self.map.tiles[idx] = TileType::Floor } else { self.map.tiles[idx] = TileType::Wall } } } self.take_snapshot(); } }
This makes a mess of an unusable level. Walls and floors everywhere with no rhyme or reason to them - and utterly unplayable. That's ok, because cellular automata are designed to make a level out of noise. It works by iterating each cell, counting the number of neighbors, and turning walls into floors or walls based on density. Here's a working builder:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // First we completely randomize the map, setting 55% of it to be floor. for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let roll = rng.roll_dice(1, 100); let idx = self.map.xy_idx(x, y); if roll > 55 { self.map.tiles[idx] = TileType::Floor } else { self.map.tiles[idx] = TileType::Wall } } } self.take_snapshot(); // Now we iteratively apply cellular automata rules for _i in 0..15 { let mut newtiles = self.map.tiles.clone(); for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let idx = self.map.xy_idx(x, y); let mut neighbors = 0; if self.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - self.map.width as usize] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + self.map.width as usize] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if neighbors > 4 || neighbors == 0 { newtiles[idx] = TileType::Wall; } else { newtiles[idx] = TileType::Floor; } } } self.map.tiles = newtiles.clone(); self.take_snapshot(); } } }
This is actually very simple:
- We randomize the map, as above.
- We count from 0 to 9, for 10 iterations of the algorithm.
- For each iteration:
- We take a copy of the map tiles, placing it into
newtiles
. We do this so we aren't writing to the tiles we are counting, which gives a very odd map. - We iterate every cell on the map and count the number of tiles neighboring the tile that are walls.
- If there are more than 4, or zero, neighboring walls - then the tile (in
newtiles
) becomes a wall. Otherwise, it becomes a floor. - We copy the
newtiles
back into themap
. - We take a snapshot.
- We take a copy of the map tiles, placing it into
This is a very simple algorithm - but produces quite beautiful results. Here it is in action:
.
Picking a starting point
Picking a starting point for the player is a little more difficult than it has been in previous chapters. We don't have a list of rooms to query! Instead, we'll start in the middle and move left until we hit some open space. The code for this is quite simple:
#![allow(unused)] fn main() { // Find a starting point; start at the middle and walk left until we find an open tile self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); } }
Placing an exit - and culling unreachable areas
We want the exit to be quite a way away from the player. We also don't want to keep areas that the player absolutely can't reach. Fortunately, the process to find an exit and the process to find orphans are quite similar. We can use a Dijkstra Map. If you haven't already read it, I recommend reading The Incredible Power of Dijkstra Maps. Fortunately, RLTK implements a very fast version of Dijkstra for you, so you won't have to fight with the algorithm. Here's the code:
#![allow(unused)] fn main() { // Find all tiles we can reach from the starting point let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(self.map.width, self.map.height, &map_starts , &self.map, 200.0); let mut exit_tile = (0, 0.0f32); for (i, tile) in self.map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; // We can't get to this tile - so we'll make it a wall if distance_to_start == std::f32::MAX { *tile = TileType::Wall; } else { // If it is further away than our current exit candidate, move the exit if distance_to_start > exit_tile.1 { exit_tile.0 = i; exit_tile.1 = distance_to_start; } } } } self.take_snapshot(); self.map.tiles[exit_tile.0] = TileType::DownStairs; self.take_snapshot(); }
This is a dense piece of code that does a lot, lets walk through it:
- We create a vector called
map_starts
and give it a single value: the tile index on which the player starts. Dijkstra maps can have multiple starting points (distance 0), so this has to be a vector even though there is only one choice. - We ask RLTK to make a Dijkstra Map for us. It has dimensions that match the main map, uses the starts, has read access to the map itself, and we'll stop counting at 200 steps (a safety feature in case of runaways!)
- We set an
exit_tile
tuple
to0
and0.0
. The first zero is the tile index of the exit, the second zero is the distance to the exit. - We iterate the map tiles, using Rust's awesome enumerate feature. By adding
.enumerate()
to the end of a range iteration, it adds the cell index as the first parameter in a tuple. We then destructure to obtain both the tile and the index. - If the tile is a floor,
- We obtain the distance to the starting point from the Dijkstra map.
- If the distance is the maximum value for an
f32
(a marker the Dijkstra map uses for "unreachable"), then it doesn't need to be a floor at all - nobody can get there. So we turn it into a wall. - If the distance is greater than the distance in our
exit_tile
tuple, we store both the new distance and the new tile index. - Once we've visited every tile, we take a snapshot to show the removed area.
- We set the tile at the
exit_tile
(most distant reachable tile) to be a downward staircase.
If you cargo run
, you actually have quite a playable map now! There's just one problem: there are no other entities on the map.
Populating our cave: freeing the spawn system from rooms.
If we were feeling lazy, we could simply iterate the map - find open spaces and have a random chance to spawn something. But that's not really very much fun. It makes more sense for monsters to be grouped together, with some "dead spaces" so you can catch your breath (and regain some health).
As a first step, we're going to revisit how we spawn entities. Right now, pretty much everything that isn't the player arrives into the world via the spawner.rs
-provided spawn_room
function. It has served us well up to now, but we want to be a bit more flexible; we might want to spawn in corridors, we might want to spawn in semi-open areas that don't fit a rectangle, and so on. Also, a look over spawn_room
shows that it does several things in one function - which isn't the best design. A final objective is to keep the spawn_room
interface available - so we can still use it, but to also offer more detailed options.
The first thing we'll do is separate out the actual spawning:
#![allow(unused)] fn main() { /// Spawns a named entity (name in tuple.1) at the location in (tuple.0) fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { let x = (*spawn.0 % MAPWIDTH) as i32; let y = (*spawn.0 / MAPWIDTH) as i32; match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), "Bear Trap" => bear_trap(ecs, x, y), _ => {} } } }
Now we can replace the last for
loop in spawn_room
with the following:
#![allow(unused)] fn main() { // Actually spawn the monsters for spawn in spawn_points.iter() { spawn_entity(ecs, &spawn); } }
Now, we'll replace spawn_room
with a simplified version that calls our theoretical function:
#![allow(unused)] fn main() { pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) { let mut possible_targets : Vec<usize> = Vec::new(); { // Borrow scope - to keep access to the map separated let map = ecs.fetch::<Map>(); for y in room.y1 + 1 .. room.y2 { for x in room.x1 + 1 .. room.x2 { let idx = map.xy_idx(x, y); if map.tiles[idx] == TileType::Floor { possible_targets.push(idx); } } } } spawn_region(ecs, &possible_targets, map_depth); } }
This function maintains the same interface/signature as the previous call - so our old code will still work. Instead of actually spawning anything, it builds a vector of all of the tiles in the room (checking that they are floors - something we didn't do before; monsters in walls is no longer possible!). It then calls a new function, spawn_region
that accepts a similar signature - but wants a list of available tiles into which it can spawn things. Here's the new function:
#![allow(unused)] fn main() { pub fn spawn_region(ecs: &mut World, area : &[usize], map_depth: i32) { let spawn_table = room_table(map_depth); let mut spawn_points : HashMap<usize, String> = HashMap::new(); let mut areas : Vec<usize> = Vec::from(area); // Scope to keep the borrow checker happy { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3); if num_spawns == 0 { return; } for _i in 0 .. num_spawns { let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize }; let map_idx = areas[array_index]; spawn_points.insert(map_idx, spawn_table.roll(&mut rng)); areas.remove(array_index); } } // Actually spawn the monsters for spawn in spawn_points.iter() { spawn_entity(ecs, &spawn); } } }
This is similar to the previous spawning code, but not quite the same (although the results are basically the same!). We'll go through it, just to be sure we understand what we're doing:
- We obtain a spawn table for the current map depth.
- We setup a
HashMap
calledspawn_points
, listing pairs of data (map index and name tag) for everything we've decided to spawn. - We create a new
Vector
of areas, copied from the passed in slice. (A slice is a "view" of an array or vector). We're making a new one so we aren't modifying the parent area list. The caller might want to use that data for something else and it's good to avoid changing people's data without asking. Changing data without warning is called a "side effect" and it's good to avoid them in general (unless you actually want them). - We make a new scope, because Rust doesn't like us using the ECS to obtain the random number generator, and then using it later to spawn entities. The scope makes Rust "forget" our first borrow as soon as it ends.
- We obtain a random number generator from the ECS.
- We calculate the number of entities to spawn. This is the same random function as we used before, but we've added an
i32::min
call: we want the smaller of EITHER the number of available tiles, OR the random calculation. This way, we'll never try to spawn more entities than we have room for. - If the number to spawn is zero, we bail out of the function (nothing to do, here!).
- Repeating for zero to the number of spawns (minus 1 - we're not using an inclusive range):
- We pick an
array_index
from areas. If there is only one entry, we use it. Otherwise, we roll a dice (from 1 to the number of entries, subtract one because the array is zero-based). - The
map_idx
(location in the map tiles array) is the value located at thearray_index
index of the array. So we obtain that. - We insert a spawn into the
spawn_points
map, listing both the index and a random roll on the spawn table. - We remove the entry we just used from
areas
- that way, we can't accidentally pick it again. Note that we're not checking to see if the array is empty: in step 6 above, we guaranteed that we won't spawn more entities than we have room for, so (at least in theory) that particular bug can't happen!
- We pick an
The best way to test this is to uncomment out the random_builder
code (and comment the CellularAutomataBuilder
entry) and give it a go. It should play just like before. Once you've tested it, go back to always spawning the map type we're working on.
Grouped placement in our map - Enter the Voronoi!
Voronoi Diagrams are a wonderfully useful piece of math. Given a group of points, it builds a diagram of regions surrounding each point (which could be random, or might mean something; that's the beauty of math, it's up to you!) - with no empty space. We'd like to do something similar for our maps: subdivide the map into random regions and spawn inside those regions. Fortunately, RLTK provides a type of noise to help with that: cellular noise.
First of all, what is noise. "Noise" in this case doesn't refer to the loud heavy metal you accidentally pipe out of your patio speakers at 2am while wondering what a stereo receiver you found in your new house does (true story...); it refers to random data - like the noise on old analog TVs if you didn't tune to a station (ok, I'm showing my age there). Like most things random, there's lots of ways to make it not-really-random and group it into useful patterns. A noise library provides lots of types of noise. Perlin/Simplex noise makes really good approximations of landscapes. White noise looks like someone randomly threw paint at a piece of paper. Cellular Noise randomly places points on a grid and then plots Voronoi diagrams around them. We're interested in the latter.
This is a somewhat complicated way to do things, so we'll take it a step at a time. Lets start by adding a structure to store generated areas into our CellularAutomataBuilder
structure:
#![allow(unused)] fn main() { pub struct CellularAutomataBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>> } }
In new
, we also have to initialize it:
#![allow(unused)] fn main() { impl CellularAutomataBuilder { pub fn new(new_depth : i32) -> CellularAutomataBuilder { CellularAutomataBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new() } } ... }
The idea here is that we have a HashMap
(dictionary in other languages) keyed on the ID number of an area. The area consists of a vector
of tile ID numbers. Ideally, we'd generate 20-30 distinct areas all with spaces to spawn entities into.
Here's the next section of the build
code:
#![allow(unused)] fn main() { // Now we build a noise map for use in spawning entities later let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64); noise.set_noise_type(rltk::NoiseType::Cellular); noise.set_frequency(0.08); noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan); for y in 1 .. self.map.height-1 { for x in 1 .. self.map.width-1 { let idx = self.map.xy_idx(x, y); if self.map.tiles[idx] == TileType::Floor { let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0; let cell_value = cell_value_f as i32; if self.noise_areas.contains_key(&cell_value) { self.noise_areas.get_mut(&cell_value).unwrap().push(idx); } else { self.noise_areas.insert(cell_value, vec![idx]); } } } } }
Since this is quite complicated, lets walk through it:
- We create a new
FastNoise
object, from RLTK's port of Auburns' excellentFastNoise
library. - We specify that we want Cellular noise. That's the same as Voronoi noise in this case.
- We specify a frequency of
0.08
. This number was found by playing with different values! - We specify the
Manhattan
distance function. There are three to choose from, and they give differing shapes. Manhattan tends to favor elongated shapes, which I like for this purpose. Try all three and see what you like. - We iterate the whole map:
- We get the
idx
of the tile, in the map'stiles
vectors. - We check to make sure it's a floor - and skip if it isn't.
- We query
FastNoise
for a noise value for the coordinates (converting them tof32
floating point numbers, because the library likes floats). We multiply by10240.0
because the default is very small numbers - and this brings it up into a reasonable range. - We convert the result to an integer.
- If the
noise_areas
map contains the area number we just generated, we add the tile index to the vector. - If the
noise_areas
map DOENS'T contain the area number we just generated, we make a new vector of tile indices with the map index number in it.
- We get the
This generates between 20 and 30 areas quite consistently, and they only contain valid floor tiles. So the last remaining job is to actually spawn some entities. We update our spawn_entities
function:
#![allow(unused)] fn main() { fn spawn_entities(&mut self, ecs : &mut World) { for area in self.noise_areas.iter() { spawner::spawn_region(ecs, area.1, self.depth); } } }
This is quite simple: it iterates through each area and calls the new spawn_region
with the vector of available map tiles for that region.
The game is now quite playable on these new maps:
.
Restoring randomness
Once again, we should restore randomness to our map building. In map_builders/mod.rs
:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 4); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
Wrap-Up
We've made a pretty nice map generator and fixed our dependency upon rooms. Cellular Automata are a really flexible algorithm and can be used for all kinds of organic looking maps. With a bit of tweaking to the rules, you can make a really large variety of maps.
The source code for this chapter may be found here
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Copyright (C) 2019, Herbert Wolverson.