Give every cell on a grid the same tiny rule, run it a few times, and caves appear out of static. That’s cellular automata cave generation, one of the oldest tricks in procedural generation. The same idea behind Conway’s Game of Life carves the caverns in Brogue and moves every falling pixel in Noita.
Our Voronoi tutorial promised this one, so here it is. We’ll build it up in four labs: the Game of Life, a cave generator using the classic 4-5 rule, a cleanup pass that makes those caves actually playable, and a Perlin-seeded version that gives them real geography. Then we’ll finish in a falling-sand sandbox. By the end, you’ll be able to generate a connected cave level from a single seed, in JavaScript, C# or C++.
What Is a Cellular Automaton?
Picture a stadium crowd doing the wave. Nobody conducts it. Each person watches the people right next to them and stands up when they do. A cellular automaton works the same way: no cell knows the big picture, yet a big picture emerges.
Formally, a cellular automaton (CA) has five ingredients:
- Grid: a 2D array of cells, like a tilemap.
- States: what each cell can be: alive or dead, rock or floor, sand, water or empty.
- Neighborhood: which nearby cells a cell looks at. The Moore neighborhood is the 8 cells around it (including diagonals). The von Neumann neighborhood is just the 4 cells above, below, left and right. (The formal definitions include the center cell; in code we count only the neighbors.)
- Rule: how a cell’s next state depends on its current state and its neighbors.
- Generations: every cell updates at the same time, then we repeat.
You’ve seen CA-like behavior in the wild:
- Wildfires: fire models often spread burning from cell to cell across a landscape grid.
- Traffic jams: the Nagel–Schreckenberg model (1992) simulates highway traffic as a one-dimensional cellular automaton.
- Seashells: the pigment pattern on the Conus textile shell famously resembles Stephen Wolfram’s “Rule 30” automaton.
Why Use Cellular Automata in Games?
- Caves and organic levels: smoothed noise looks carved by water, not placed by a designer.
- Lakes, groves and terrain features: the same smoothing turns scattered trees into forests and puddles into lakes.
- Spreading systems: fire, gas, poison, corruption, creep. Anything that moves tile to tile is a CA.
- Falling-sand physics: every pixel becomes a particle with simple rules, and sand piles, water flows and wood burns.
- Living maps: run a few generations between visits and the world grows, heals or decays.
- Cheap and parallel: each cell only reads its neighbors, which makes CAs cache-friendly and a natural fit for GPU shaders.
Step-by-Step Lab #1: Conway’s Game of Life
Before caves, the classic. John Conway devised the Game of Life in 1970, and Martin Gardner introduced it to the world in his “Mathematical Games” column in the October 1970 issue of Scientific American. Its whole rulebook fits on a sticky note, written B3/S23:
- Born (B3): a dead cell with exactly 3 live neighbors comes alive.
- Survives (S23): a live cell with 2 or 3 live neighbors stays alive.
- Otherwise: it dies of loneliness (fewer than 2) or overcrowding (more than 3).
Step 1: Count the neighbors
Every CA in this tutorial starts with the same helper. We store the grid as a flat array of 0s and 1s, so cell (x, y) lives at index y * width + x. Because cells are 0 or 1, adding up the neighbors is the same as counting the live ones. The edgeValue parameter decides what an off-map cell counts as. That one detail will matter a lot for caves.
Commented Code
/*
Step 1: Count the neighbors
---------------------------
Every cellular automaton boils down to one question per cell:
"How many of my neighbors are alive (or solid)?"
We store the grid as a flat Uint8Array (1 = alive/wall, 0 = dead/floor),
so cell (x, y) lives at index y * width + x.
*/
function countNeighbors(grid, width, height, x, y, edgeValue) {
let count = 0;
// Visit the 3x3 block around (x, y): the 8-cell "Moore neighborhood".
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue; // Skip the cell itself.
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
// Off the map: pretend the missing cell has edgeValue.
// Life uses 0 (empty space); caves use 1 (solid rock) so they never leak off the edge.
count += edgeValue;
} else {
count += grid[ny * width + nx];
}
}
}
return count;
}
/*
Explanation:
- Because cells are 0 or 1, adding them up IS the neighbor count. No if-statements needed.
- edgeValue lets the same function serve both Game of Life and cave generation.
Next step: use that count to decide what every cell becomes next generation.
*/
Uncommented Code
function countNeighbors(grid, width, height, x, y, edgeValue) {
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
count += edgeValue;
} else {
count += grid[ny * width + nx];
}
}
}
return count;
}
Commented Code
// Step 1: Count the neighbors
// ---------------------------
// Every cellular automaton asks one question per cell: "How many of my neighbors are alive (or solid)?"
// The grid is a flat byte[] (1 = alive/wall, 0 = dead/floor); cell (x, y) lives at index y * width + x.
// All methods in this tutorial live inside: public static class CaveGen { ... }
public static int CountNeighbors(byte[] grid, int width, int height, int x, int y, int edgeValue)
{
int count = 0;
// Visit the 3x3 block around (x, y): the 8-cell "Moore neighborhood".
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue; // Skip the cell itself.
int nx = x + dx;
int ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height)
{
// Off the map: Life passes 0 (empty space), caves pass 1 (solid rock).
count += edgeValue;
}
else
{
count += grid[ny * width + nx]; // Cells are 0 or 1, so adding them counts them.
}
}
}
return count;
}
// Next step: use that count to decide what every cell becomes next generation.
Uncommented Code
public static int CountNeighbors(byte[] grid, int width, int height, int x, int y, int edgeValue)
{
int count = 0;
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
int nx = x + dx;
int ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height)
{
count += edgeValue;
}
else
{
count += grid[ny * width + nx];
}
}
}
return count;
}
Commented Code
// Step 1: Count the neighbors
// ---------------------------
// Every cellular automaton asks one question per cell: "How many of my neighbors are alive (or solid)?"
// The grid is a flat std::vector<uint8_t> (1 = alive/wall, 0 = dead/floor); (x, y) lives at y * width + x.
#include <cstdint>
#include <vector>
using Grid = std::vector<uint8_t>;
int countNeighbors(const Grid& grid, int width, int height, int x, int y, int edgeValue) {
int count = 0;
// Visit the 3x3 block around (x, y): the 8-cell "Moore neighborhood".
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue; // Skip the cell itself.
int nx = x + dx;
int ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
count += edgeValue; // Off the map: Life passes 0, caves pass 1 (solid rock).
} else {
count += grid[ny * width + nx]; // Cells are 0 or 1, so adding them counts them.
}
}
}
return count;
}
// Next step: use that count to decide what every cell becomes next generation.
Uncommented Code
#include <cstdint>
#include <vector>
using Grid = std::vector<uint8_t>;
int countNeighbors(const Grid& grid, int width, int height, int x, int y, int edgeValue) {
int count = 0;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx;
int ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
count += edgeValue;
} else {
count += grid[ny * width + nx];
}
}
}
return count;
}
// Step 1: Count the neighbors
// The grid is a flat array of 0s and 1s; cell (x, y) lives at index y * width + x.
// edgeValue = what an off-map cell counts as (0 for Life, 1 for caves).
FUNCTION countNeighbors(grid, width, height, x, y, edgeValue)
DECLARE count = 0
FOR dy FROM -1 TO 1 DO
FOR dx FROM -1 TO 1 DO
IF dx == 0 AND dy == 0 THEN
CONTINUE // Skip the cell itself
END IF
DECLARE nx = x + dx
DECLARE ny = y + dy
IF nx < 0 OR ny < 0 OR nx >= width OR ny >= height THEN
count = count + edgeValue // Off the map
ELSE
count = count + grid[ny * width + nx]
END IF
END FOR
END FOR
RETURN count // 0..8
END FUNCTION
Step 2: Apply the rules with a second buffer
Here’s the bug everyone writes once: updating the grid in place. Cells later in the loop would see a half-updated world, and patterns smear in one direction. The fix is a double buffer: read from grid, write into a fresh next, then swap.
Commented Code
/*
Step 2: Apply the rules with a second buffer
--------------------------------------------
Conway's rule is "B3/S23":
- Born: a dead cell with exactly 3 live neighbors comes alive.
- Survives: a live cell with 2 or 3 live neighbors stays alive.
- Everything else dies (or stays dead).
*/
function lifeStep(grid, width, height) {
// Write results into a NEW grid. If we updated `grid` in place, cells later in the
// loop would see a half-updated world and the pattern would smear in one direction.
const next = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const n = countNeighbors(grid, width, height, x, y, 0); // Off-map counts as dead.
if (grid[i] === 1) {
next[i] = n === 2 || n === 3 ? 1 : 0; // Survival: 2 or 3 neighbors.
} else {
next[i] = n === 3 ? 1 : 0; // Birth: exactly 3 neighbors.
}
}
}
return next; // The caller swaps: grid = lifeStep(grid, width, height);
}
/*
Explanation:
- One call = one generation. Call it in a loop (or once per animation frame) to watch patterns evolve.
- The double buffer (grid -> next) is the single most important detail in any CA.
Next step: swap the Life rules for cave rules and grow a dungeon.
*/
Uncommented Code
function lifeStep(grid, width, height) {
const next = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const n = countNeighbors(grid, width, height, x, y, 0);
if (grid[i] === 1) {
next[i] = n === 2 || n === 3 ? 1 : 0;
} else {
next[i] = n === 3 ? 1 : 0;
}
}
}
return next;
}
Commented Code
// Step 2: Apply the rules with a second buffer
// --------------------------------------------
// Conway's "B3/S23": a dead cell with exactly 3 live neighbors is born;
// a live cell with 2 or 3 live neighbors survives; everything else dies.
public static byte[] LifeStep(byte[] grid, int width, int height)
{
// Write into a NEW array. Updating `grid` in place would let later cells
// see a half-updated world, and patterns would smear in one direction.
var next = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
int n = CountNeighbors(grid, width, height, x, y, 0); // Off-map counts as dead.
if (grid[i] == 1)
{
next[i] = (byte)(n == 2 || n == 3 ? 1 : 0); // Survival.
}
else
{
next[i] = (byte)(n == 3 ? 1 : 0); // Birth.
}
}
}
return next; // Caller swaps: grid = CaveGen.LifeStep(grid, width, height);
}
// One call = one generation. The double buffer is the most important detail in any CA.
Uncommented Code
public static byte[] LifeStep(byte[] grid, int width, int height)
{
var next = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
int n = CountNeighbors(grid, width, height, x, y, 0);
if (grid[i] == 1)
{
next[i] = (byte)(n == 2 || n == 3 ? 1 : 0);
}
else
{
next[i] = (byte)(n == 3 ? 1 : 0);
}
}
}
return next;
}
Commented Code
// Step 2: Apply the rules with a second buffer
// --------------------------------------------
// Conway's "B3/S23": a dead cell with exactly 3 live neighbors is born;
// a live cell with 2 or 3 live neighbors survives; everything else dies.
Grid lifeStep(const Grid& grid, int width, int height) {
// Write into a NEW grid. Updating in place would let later cells see a
// half-updated world, and patterns would smear in one direction.
Grid next(width * height, 0);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
int n = countNeighbors(grid, width, height, x, y, 0); // Off-map counts as dead.
if (grid[i] == 1) {
next[i] = (n == 2 || n == 3) ? 1 : 0; // Survival.
} else {
next[i] = (n == 3) ? 1 : 0; // Birth.
}
}
}
return next; // Caller swaps: grid = lifeStep(grid, width, height);
}
// One call = one generation. The double buffer is the most important detail in any CA.
Uncommented Code
Grid lifeStep(const Grid& grid, int width, int height) {
Grid next(width * height, 0);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
int n = countNeighbors(grid, width, height, x, y, 0);
if (grid[i] == 1) {
next[i] = (n == 2 || n == 3) ? 1 : 0;
} else {
next[i] = (n == 3) ? 1 : 0;
}
}
}
return next;
}
// Step 2: Apply the rules with a second buffer (Conway's B3/S23)
FUNCTION lifeStep(grid, width, height)
DECLARE next = NEW ARRAY OF width * height ZEROS // Never update the grid in place
FOR y FROM 0 TO height - 1 DO
FOR x FROM 0 TO width - 1 DO
DECLARE i = y * width + x
DECLARE n = countNeighbors(grid, width, height, x, y, 0)
IF grid[i] == 1 THEN
next[i] = 1 IF n == 2 OR n == 3 ELSE 0 // Survival
ELSE
next[i] = 1 IF n == 3 ELSE 0 // Birth
END IF
END FOR
END FOR
RETURN next
END FUNCTION
// Game loop: grid = lifeStep(grid, width, height) once per tick
Lab: Game of Life
👉 Try this: press Play on Bill Gosper’s glider gun, the first known pattern that grows forever. It fires a new glider every 30 generations. Then Clear and draw a straight line of 10 cells. Notice how the gliders smash into the edges: off-map cells count as dead here, so the edges act like a wall of nothing.
Step-by-Step Lab #2: Cave Generation with Cellular Automata
Swap “alive and dead” for “rock and floor,” change the rule, and the Game of Life becomes a dungeon generator. The recipe is two steps: fill the map with random rock, then smooth it a few times.
Step 1: Fill the map with seeded noise
Every cell gets a weighted coin flip. We reuse the SeededRandom class from our Perlin noise tutorial, so the same seed always produces the same cave. That makes levels shareable and bugs reproducible. We also force a solid border so the cave can never leak off the map.
Commented Code
/*
Step 1: Fill the map with seeded noise
--------------------------------------
Caves start as static: every cell gets a coin flip, weighted by fillChance.
We use the same SeededRandom class from our Perlin noise tutorial, so the same seed
always produces the same cave (great for sharing levels and debugging).
*/
class SeededRandom {
constructor(seed) {
// Park-Miller "minimal standard" generator: keep the seed in 1..2147483646.
this.seed = seed % 2147483647;
if (this.seed <= 0) this.seed += 2147483646;
}
next() {
// 16807 is a primitive root of the prime 2147483647, so the sequence has a full cycle.
return this.seed = this.seed * 16807 % 2147483647;
}
nextFloat() {
// Map the integer to a float in [0, 1).
return (this.next() - 1) / 2147483646;
}
}
const WALL = 1; // Solid rock.
const FLOOR = 0; // Open space the player can walk on.
function randomFill(width, height, fillChance, seed) {
const rng = new SeededRandom(seed);
const grid = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Force a solid border so the cave is always sealed.
const isBorder = x === 0 || y === 0 || x === width - 1 || y === height - 1;
// Short-circuit: border cells don't consume a random number. Every port does the same,
// which keeps the JavaScript, C# and C++ versions in lockstep for a given seed.
grid[y * width + x] = isBorder || rng.nextFloat() < fillChance ? WALL : FLOOR;
}
}
return grid;
}
/*
Explanation:
- fillChance around 0.45 (45% rock) is the classic starting point.
- Lower values give big open caverns; higher values give tight, twisty tunnels.
Next step: smooth the static into cave shapes.
*/
Uncommented Code
class SeededRandom {
constructor(seed) {
this.seed = seed % 2147483647;
if (this.seed <= 0) this.seed += 2147483646;
}
next() {
return this.seed = this.seed * 16807 % 2147483647;
}
nextFloat() {
return (this.next() - 1) / 2147483646;
}
}
const WALL = 1;
const FLOOR = 0;
function randomFill(width, height, fillChance, seed) {
const rng = new SeededRandom(seed);
const grid = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const isBorder = x === 0 || y === 0 || x === width - 1 || y === height - 1;
grid[y * width + x] = isBorder || rng.nextFloat() < fillChance ? WALL : FLOOR;
}
}
return grid;
}
Commented Code
// Step 1: Fill the map with seeded noise
// --------------------------------------
// Caves start as static: each cell gets a weighted coin flip. SeededRandom is the
// Park-Miller generator from our Perlin noise tutorial, so the same seed gives the same cave.
// SeededRandom is its own class (outside CaveGen).
public class SeededRandom
{
private long seed; // long, because seed * 16807 overflows a 32-bit int.
public SeededRandom(long seed)
{
// Keep the seed in 1..2147483646.
this.seed = seed % 2147483647;
if (this.seed <= 0) this.seed += 2147483646;
}
public long Next()
{
return seed = seed * 16807 % 2147483647;
}
public double NextFloat()
{
return (Next() - 1) / 2147483646.0; // A double in [0, 1).
}
}
public const byte Wall = 1; // Solid rock.
public const byte Floor = 0; // Walkable space.
public static byte[] RandomFill(int width, int height, double fillChance, long seed)
{
var rng = new SeededRandom(seed);
var grid = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
// A solid border keeps the cave sealed.
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
// || short-circuits, so border cells don't consume a random number.
// That keeps this port in lockstep with the JavaScript and C++ versions.
grid[y * width + x] = isBorder || rng.NextFloat() < fillChance ? Wall : Floor;
}
}
return grid;
}
// fillChance ~0.45 is the classic start: lower = open caverns, higher = tight tunnels.
Uncommented Code
public class SeededRandom
{
private long seed;
public SeededRandom(long seed)
{
this.seed = seed % 2147483647;
if (this.seed <= 0) this.seed += 2147483646;
}
public long Next()
{
return seed = seed * 16807 % 2147483647;
}
public double NextFloat()
{
return (Next() - 1) / 2147483646.0;
}
}
public const byte Wall = 1;
public const byte Floor = 0;
public static byte[] RandomFill(int width, int height, double fillChance, long seed)
{
var rng = new SeededRandom(seed);
var grid = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
grid[y * width + x] = isBorder || rng.NextFloat() < fillChance ? Wall : Floor;
}
}
return grid;
}
Commented Code
// Step 1: Fill the map with seeded noise
// --------------------------------------
// Caves start as static: each cell gets a weighted coin flip. SeededRandom is the
// Park-Miller generator from our Perlin noise tutorial, so the same seed gives the same cave.
class SeededRandom {
public:
explicit SeededRandom(int64_t seed) {
// Keep the seed in 1..2147483646.
this->seed = seed % 2147483647;
if (this->seed <= 0) this->seed += 2147483646;
}
int64_t next() {
return seed = seed * 16807 % 2147483647; // 64-bit math: the product overflows 32 bits.
}
double nextFloat() {
return (next() - 1) / 2147483646.0; // A double in [0, 1).
}
private:
int64_t seed;
};
constexpr uint8_t WALL = 1; // Solid rock.
constexpr uint8_t FLOOR = 0; // Walkable space.
Grid randomFill(int width, int height, double fillChance, int64_t seed) {
SeededRandom rng(seed);
Grid grid(width * height, FLOOR);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// A solid border keeps the cave sealed.
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
// || short-circuits, so border cells don't consume a random number, which keeps
// this port in lockstep with the JavaScript and C# versions for any given seed.
grid[y * width + x] = (isBorder || rng.nextFloat() < fillChance) ? WALL : FLOOR;
}
}
return grid;
}
// fillChance ~0.45 is the classic start: lower = open caverns, higher = tight tunnels.
Uncommented Code
class SeededRandom {
public:
explicit SeededRandom(int64_t seed) {
this->seed = seed % 2147483647;
if (this->seed <= 0) this->seed += 2147483646;
}
int64_t next() {
return seed = seed * 16807 % 2147483647;
}
double nextFloat() {
return (next() - 1) / 2147483646.0;
}
private:
int64_t seed;
};
constexpr uint8_t WALL = 1;
constexpr uint8_t FLOOR = 0;
Grid randomFill(int width, int height, double fillChance, int64_t seed) {
SeededRandom rng(seed);
Grid grid(width * height, FLOOR);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
grid[y * width + x] = (isBorder || rng.nextFloat() < fillChance) ? WALL : FLOOR;
}
}
return grid;
}
// Step 1: Fill the map with seeded noise
// SeededRandom is the Park-Miller generator from the Perlin noise tutorial:
// next() -> seed = seed * 16807 MOD 2147483647
// nextFloat() -> (next() - 1) / 2147483646, a number in [0, 1)
DECLARE WALL = 1
DECLARE FLOOR = 0
FUNCTION randomFill(width, height, fillChance, seed)
DECLARE rng = NEW SeededRandom(seed)
DECLARE grid = NEW ARRAY OF width * height
FOR y FROM 0 TO height - 1 DO
FOR x FROM 0 TO width - 1 DO
DECLARE isBorder = (x == 0 OR y == 0 OR x == width - 1 OR y == height - 1)
IF isBorder THEN
grid[y * width + x] = WALL // Seal the edges (no random number used)
ELSE IF rng.nextFloat() < fillChance THEN
grid[y * width + x] = WALL
ELSE
grid[y * width + x] = FLOOR
END IF
END FOR
END FOR
RETURN grid
END FUNCTION
Step 2: Smooth it with the 4-5 rule
The classic cave rule was popularized by Jim Babcock’s article on RogueBasin. Count the walls among a cell’s 8 neighbors:
- A wall stays a wall if at least 4 of its neighbors are walls.
- A floor becomes a wall if at least 5 of its neighbors are walls.
Lonely rock crumbles, crowded floor fills in, and the static clumps into caverns. In CA notation that’s B5678/S45678, with walls as the “live” cells. Notice that we pass WALL as the edgeValue this time: treating the void beyond the map as solid rock pulls stone toward the edges and seals the cave.
Commented Code
/*
Step 2: Smooth it with the 4-5 rule
-----------------------------------
The classic cave rule, counting walls in the 8 surrounding cells:
- A WALL stays a wall if at least 4 neighbors are walls (survivalLimit).
- A FLOOR becomes a wall if at least 5 neighbors are walls (birthLimit).
Lonely rock crumbles, crowded floor fills in, and noise clumps into caves.
*/
function smoothStep(grid, width, height, birthLimit, survivalLimit) {
const next = new Uint8Array(width * height); // Double buffer, same as Life.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
// Off-map counts as WALL, which pulls rock toward the edges and seals the cave.
const walls = countNeighbors(grid, width, height, x, y, WALL);
if (grid[i] === WALL) {
next[i] = walls >= survivalLimit ? WALL : FLOOR;
} else {
next[i] = walls >= birthLimit ? WALL : FLOOR;
}
}
}
return next;
}
function generateCave(width, height, seed, fillChance = 0.45, iterations = 5, birthLimit = 5, survivalLimit = 4) {
let grid = randomFill(width, height, fillChance, seed);
// Each pass smooths a little more. 4-6 passes is usually plenty;
// after that the shape barely changes.
for (let i = 0; i < iterations; i++) {
grid = smoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
/*
Explanation:
- In CA notation this is B5678/S45678 with walls as the "live" cells.
- Try birthLimit 6 for wider caverns, or survivalLimit 3 for chunkier rock.
Next step: the result looks like a cave, but it isn't playable yet. Some pockets are sealed off.
*/
Uncommented Code
function smoothStep(grid, width, height, birthLimit, survivalLimit) {
const next = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const walls = countNeighbors(grid, width, height, x, y, WALL);
if (grid[i] === WALL) {
next[i] = walls >= survivalLimit ? WALL : FLOOR;
} else {
next[i] = walls >= birthLimit ? WALL : FLOOR;
}
}
}
return next;
}
function generateCave(width, height, seed, fillChance = 0.45, iterations = 5, birthLimit = 5, survivalLimit = 4) {
let grid = randomFill(width, height, fillChance, seed);
for (let i = 0; i < iterations; i++) {
grid = smoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
Commented Code
// Step 2: Smooth it with the 4-5 rule
// -----------------------------------
// Counting walls among the 8 neighbors:
// - a WALL stays a wall with at least 4 wall neighbors (survivalLimit)
// - a FLOOR becomes a wall with at least 5 wall neighbors (birthLimit)
public static byte[] SmoothStep(byte[] grid, int width, int height, int birthLimit, int survivalLimit)
{
var next = new byte[width * height]; // Double buffer, same as Life.
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
// Off-map counts as Wall, which pulls rock toward the edges.
int walls = CountNeighbors(grid, width, height, x, y, Wall);
if (grid[i] == Wall)
{
next[i] = walls >= survivalLimit ? Wall : Floor;
}
else
{
next[i] = walls >= birthLimit ? Wall : Floor;
}
}
}
return next;
}
public static byte[] GenerateCave(int width, int height, long seed, double fillChance = 0.45,
int iterations = 5, int birthLimit = 5, int survivalLimit = 4)
{
var grid = RandomFill(width, height, fillChance, seed);
// 4-6 passes is usually enough; after that the shape barely changes.
for (int i = 0; i < iterations; i++)
{
grid = SmoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
// In CA notation this is B5678/S45678 with walls as the "live" cells.
// Usage: byte[] cave = CaveGen.GenerateCave(80, 50, 123456);
Uncommented Code
public static byte[] SmoothStep(byte[] grid, int width, int height, int birthLimit, int survivalLimit)
{
var next = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
int walls = CountNeighbors(grid, width, height, x, y, Wall);
if (grid[i] == Wall)
{
next[i] = walls >= survivalLimit ? Wall : Floor;
}
else
{
next[i] = walls >= birthLimit ? Wall : Floor;
}
}
}
return next;
}
public static byte[] GenerateCave(int width, int height, long seed, double fillChance = 0.45,
int iterations = 5, int birthLimit = 5, int survivalLimit = 4)
{
var grid = RandomFill(width, height, fillChance, seed);
for (int i = 0; i < iterations; i++)
{
grid = SmoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
Commented Code
// Step 2: Smooth it with the 4-5 rule
// -----------------------------------
// Counting walls among the 8 neighbors:
// - a WALL stays a wall with at least 4 wall neighbors (survivalLimit)
// - a FLOOR becomes a wall with at least 5 wall neighbors (birthLimit)
Grid smoothStep(const Grid& grid, int width, int height, int birthLimit, int survivalLimit) {
Grid next(width * height, FLOOR); // Double buffer, same as Life.
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
// Off-map counts as WALL, which pulls rock toward the edges.
int walls = countNeighbors(grid, width, height, x, y, WALL);
if (grid[i] == WALL) {
next[i] = walls >= survivalLimit ? WALL : FLOOR;
} else {
next[i] = walls >= birthLimit ? WALL : FLOOR;
}
}
}
return next;
}
Grid generateCave(int width, int height, int64_t seed, double fillChance = 0.45,
int iterations = 5, int birthLimit = 5, int survivalLimit = 4) {
Grid grid = randomFill(width, height, fillChance, seed);
// 4-6 passes is usually enough; after that the shape barely changes.
for (int i = 0; i < iterations; i++) {
grid = smoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
// In CA notation this is B5678/S45678 with walls as the "live" cells.
// Usage: Grid cave = generateCave(80, 50, 123456);
Uncommented Code
Grid smoothStep(const Grid& grid, int width, int height, int birthLimit, int survivalLimit) {
Grid next(width * height, FLOOR);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
int walls = countNeighbors(grid, width, height, x, y, WALL);
if (grid[i] == WALL) {
next[i] = walls >= survivalLimit ? WALL : FLOOR;
} else {
next[i] = walls >= birthLimit ? WALL : FLOOR;
}
}
}
return next;
}
Grid generateCave(int width, int height, int64_t seed, double fillChance = 0.45,
int iterations = 5, int birthLimit = 5, int survivalLimit = 4) {
Grid grid = randomFill(width, height, fillChance, seed);
for (int i = 0; i < iterations; i++) {
grid = smoothStep(grid, width, height, birthLimit, survivalLimit);
}
return grid;
}
// Step 2: Smooth it with the 4-5 rule
// A wall stays a wall with >= 4 wall neighbors; a floor becomes a wall with >= 5.
FUNCTION smoothStep(grid, width, height, birthLimit, survivalLimit)
DECLARE next = NEW ARRAY OF width * height
FOR y FROM 0 TO height - 1 DO
FOR x FROM 0 TO width - 1 DO
DECLARE i = y * width + x
DECLARE walls = countNeighbors(grid, width, height, x, y, WALL) // Off-map = rock
IF grid[i] == WALL THEN
next[i] = WALL IF walls >= survivalLimit ELSE FLOOR
ELSE
next[i] = WALL IF walls >= birthLimit ELSE FLOOR
END IF
END FOR
END FOR
RETURN next
END FUNCTION
FUNCTION generateCave(width, height, seed, fillChance = 0.45, iterations = 5, birthLimit = 5, survivalLimit = 4)
DECLARE grid = randomFill(width, height, fillChance, seed)
FOR i FROM 1 TO iterations DO
grid = smoothStep(grid, width, height, birthLimit, survivalLimit)
END FOR
RETURN grid
END FUNCTION
Lab: Cave Generator
👉 Try this: drag Passes from 0 to 5 and watch the noise organize itself. Most of the change happens in the first two passes. Then push Birth to 6 for huge open caverns, or drop Survive to 3 for chunky rock. Same seed, completely different level.
Key Parameters
- Fill: the starting share of rock. Around 45% is the classic sweet spot. Lower gives open caverns, and higher gives tight, twisty tunnels. Around 50% the floor starts breaking into separate pockets, which is exactly what Lab #3 fixes.
- Passes: how many smoothing generations to run. Four to six is plenty. After that the shape barely changes.
- Birth limit: how many wall neighbors turn a floor into rock. Higher means more open space.
- Survival limit: how many wall neighbors a rock cell needs to stay rock. Lower means thicker walls.
- Edge value: what off-map cells count as. Use 1 (rock) to seal caves, or 0 if your map wraps or continues into another chunk.
Step-by-Step Lab #3: Make It Playable: Regions & Tunnels
Look closely at any cave from Lab #2 and you’ll find pockets sealed off from everything else. A player who spawns in one is stuck forever. Fixing that is the step most CA tutorials skip, and it’s the one players notice.
Step 1: Flood fill to find every region
A region is a group of floor cells you can walk between. We find them all with a breadth-first flood fill, using the 4-way von Neumann neighborhood. Diagonal gaps don’t count as connected, because most characters can’t squeeze between two corners.
Commented Code
/*
Step 1: Flood fill to find every region
---------------------------------------
A "region" is a group of same-type cells you can walk between using only
up/down/left/right moves. We find them all with a breadth-first flood fill.
*/
function findRegions(grid, width, height, tileType) {
const visited = new Uint8Array(width * height);
const regions = [];
for (let start = 0; start < grid.length; start++) {
// Skip cells we've already claimed, or cells of the wrong type.
if (visited[start] || grid[start] !== tileType) continue;
// The region array doubles as the BFS queue: `head` walks forward while we push new cells.
const region = [start];
visited[start] = 1;
for (let head = 0; head < region.length; head++) {
const cell = region[head];
const x = cell % width;
const y = (cell - x) / width;
// 4-way neighbors (the von Neumann neighborhood). Diagonal squeezes don't count as
// connected, because most characters can't slip between two corners. -1 = off the map.
const neighbors = [
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
];
for (const n of neighbors) {
if (n >= 0 && !visited[n] && grid[n] === tileType) {
visited[n] = 1;
region.push(n);
}
}
}
regions.push(region); // Each region is a list of cell indices.
}
return regions;
}
/*
Explanation:
- findRegions(grid, w, h, FLOOR) returns every separate cave.
- findRegions(grid, w, h, WALL) returns every rock island and pillar.
- Runs in O(cells): each cell is visited exactly once.
Next step: decide which regions to keep.
*/
Uncommented Code
function findRegions(grid, width, height, tileType) {
const visited = new Uint8Array(width * height);
const regions = [];
for (let start = 0; start < grid.length; start++) {
if (visited[start] || grid[start] !== tileType) continue;
const region = [start];
visited[start] = 1;
for (let head = 0; head < region.length; head++) {
const cell = region[head];
const x = cell % width;
const y = (cell - x) / width;
const neighbors = [
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
];
for (const n of neighbors) {
if (n >= 0 && !visited[n] && grid[n] === tileType) {
visited[n] = 1;
region.push(n);
}
}
}
regions.push(region);
}
return regions;
}
Commented Code
// Step 1: Flood fill to find every region
// ---------------------------------------
// A region is a group of same-type cells connected by up/down/left/right moves.
// Breadth-first flood fill from every unvisited cell finds them all in O(cells).
public static List<List<int>> FindRegions(byte[] grid, int width, int height, byte tileType)
{
var visited = new bool[width * height];
var regions = new List<List<int>>();
for (int start = 0; start < grid.Length; start++)
{
if (visited[start] || grid[start] != tileType) continue;
// The region list doubles as the BFS queue: `head` walks forward while we append.
var region = new List<int> { start };
visited[start] = true;
for (int head = 0; head < region.Count; head++)
{
int cell = region[head];
int x = cell % width;
int y = cell / width;
// 4-way (von Neumann) neighbors; -1 means "off the map".
// Diagonals don't connect: most characters can't squeeze between two corners.
int[] neighbors =
{
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
};
foreach (int n in neighbors)
{
if (n >= 0 && !visited[n] && grid[n] == tileType)
{
visited[n] = true;
region.Add(n);
}
}
}
regions.Add(region); // A region = a list of cell indices.
}
return regions;
}
// FindRegions(grid, w, h, Floor) = every separate cave; (..., Wall) = every rock island.
Uncommented Code
public static List<List<int>> FindRegions(byte[] grid, int width, int height, byte tileType)
{
var visited = new bool[width * height];
var regions = new List<List<int>>();
for (int start = 0; start < grid.Length; start++)
{
if (visited[start] || grid[start] != tileType) continue;
var region = new List<int> { start };
visited[start] = true;
for (int head = 0; head < region.Count; head++)
{
int cell = region[head];
int x = cell % width;
int y = cell / width;
int[] neighbors =
{
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
};
foreach (int n in neighbors)
{
if (n >= 0 && !visited[n] && grid[n] == tileType)
{
visited[n] = true;
region.Add(n);
}
}
}
regions.Add(region);
}
return regions;
}
Commented Code
// Step 1: Flood fill to find every region
// ---------------------------------------
// A region is a group of same-type cells connected by up/down/left/right moves.
// Breadth-first flood fill from every unvisited cell finds them all in O(cells).
using Region = std::vector<int>; // A region = a list of cell indices.
std::vector<Region> findRegions(const Grid& grid, int width, int height, uint8_t tileType) {
std::vector<bool> visited(grid.size(), false);
std::vector<Region> regions;
for (int start = 0; start < (int)grid.size(); start++) {
if (visited[start] || grid[start] != tileType) continue;
// The region vector doubles as the BFS queue: `head` walks forward while we push_back.
Region region{start};
visited[start] = true;
for (size_t head = 0; head < region.size(); head++) {
int cell = region[head];
int x = cell % width;
int y = cell / width;
// 4-way (von Neumann) neighbors; -1 means "off the map".
// Diagonals don't connect: most characters can't squeeze between two corners.
int neighbors[4] = {
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
};
for (int n : neighbors) {
if (n >= 0 && !visited[n] && grid[n] == tileType) {
visited[n] = true;
region.push_back(n);
}
}
}
regions.push_back(region);
}
return regions;
}
// findRegions(grid, w, h, FLOOR) = every separate cave; (..., WALL) = every rock island.
Uncommented Code
using Region = std::vector<int>;
std::vector<Region> findRegions(const Grid& grid, int width, int height, uint8_t tileType) {
std::vector<bool> visited(grid.size(), false);
std::vector<Region> regions;
for (int start = 0; start < (int)grid.size(); start++) {
if (visited[start] || grid[start] != tileType) continue;
Region region{start};
visited[start] = true;
for (size_t head = 0; head < region.size(); head++) {
int cell = region[head];
int x = cell % width;
int y = cell / width;
int neighbors[4] = {
x > 0 ? cell - 1 : -1,
x < width - 1 ? cell + 1 : -1,
y > 0 ? cell - width : -1,
y < height - 1 ? cell + width : -1,
};
for (int n : neighbors) {
if (n >= 0 && !visited[n] && grid[n] == tileType) {
visited[n] = true;
region.push_back(n);
}
}
}
regions.push_back(region);
}
return regions;
}
// Step 1: Flood fill to find every region (4-way connectivity)
FUNCTION findRegions(grid, width, height, tileType)
DECLARE visited = NEW ARRAY OF width * height FALSES
DECLARE regions = EMPTY LIST
FOR start FROM 0 TO width * height - 1 DO
IF visited[start] OR grid[start] != tileType THEN
CONTINUE
END IF
DECLARE region = LIST CONTAINING start // Doubles as the BFS queue
visited[start] = TRUE
DECLARE head = 0
WHILE head < LENGTH(region) DO
DECLARE cell = region[head]
DECLARE x = cell MOD width
DECLARE y = FLOOR(cell / width)
FOR EACH n IN [left, right, up, down neighbors of (x, y) that are on the map] DO
IF NOT visited[n] AND grid[n] == tileType THEN
visited[n] = TRUE
APPEND n TO region
END IF
END FOR
head = head + 1
END WHILE
APPEND region TO regions
END FOR
RETURN regions // A list of lists of cell indices
END FUNCTION
Step 2: Remove pockets and pillars
Tiny rock islands read as noise, and tiny caves are unreachable closets. One pass dissolves rock islands below a minimum size. A second pass fills in caves below a minimum size and returns the survivors. From here you have two options. Option A keeps only the biggest cave and fills in the rest. It’s simple and guaranteed connected, but it throws away good space.
Commented Code
/*
Step 2: Remove pockets and pillars
----------------------------------
Tiny rock islands look like noise, and tiny caves are unreachable closets.
Clean both up, then return the caves that survive.
*/
function removeSmallRegions(grid, width, height, minWallSize, minRoomSize) {
// 1) Dissolve rock islands smaller than minWallSize into floor.
for (const region of findRegions(grid, width, height, WALL)) {
if (region.length < minWallSize) {
for (const cell of region) grid[cell] = FLOOR;
}
}
// 2) Fill in caves smaller than minRoomSize, and keep the rest as "rooms".
const rooms = [];
for (const region of findRegions(grid, width, height, FLOOR)) {
if (region.length < minRoomSize) {
for (const cell of region) grid[cell] = WALL;
} else {
rooms.push(region);
}
}
return rooms;
}
// Option A: the fast and ruthless approach. Keep only the biggest cave, fill in everything else.
function keepLargestRoom(grid, rooms) {
if (rooms.length === 0) return rooms;
let largest = rooms[0];
for (const room of rooms) {
if (room.length > largest.length) largest = room;
}
for (const room of rooms) {
if (room === largest) continue;
for (const cell of room) grid[cell] = WALL;
}
return [largest];
}
/*
Explanation:
- removeSmallRegions edits the grid in place AND tells you which caves are left.
- keepLargestRoom guarantees connectivity, but it throws away good space. Brogue keeps only
its largest blob, and regenerates when that blob comes out too small.
Next step: Option B, which keeps every cave and digs tunnels to join them.
*/
Uncommented Code
function removeSmallRegions(grid, width, height, minWallSize, minRoomSize) {
for (const region of findRegions(grid, width, height, WALL)) {
if (region.length < minWallSize) {
for (const cell of region) grid[cell] = FLOOR;
}
}
const rooms = [];
for (const region of findRegions(grid, width, height, FLOOR)) {
if (region.length < minRoomSize) {
for (const cell of region) grid[cell] = WALL;
} else {
rooms.push(region);
}
}
return rooms;
}
function keepLargestRoom(grid, rooms) {
if (rooms.length === 0) return rooms;
let largest = rooms[0];
for (const room of rooms) {
if (room.length > largest.length) largest = room;
}
for (const room of rooms) {
if (room === largest) continue;
for (const cell of room) grid[cell] = WALL;
}
return [largest];
}
Commented Code
// Step 2: Remove pockets and pillars
// ----------------------------------
// Tiny rock islands read as noise; tiny caves are unreachable closets. Clean up both.
public static List<List<int>> RemoveSmallRegions(byte[] grid, int width, int height, int minWallSize, int minRoomSize)
{
// 1) Dissolve small rock islands into floor.
foreach (var region in FindRegions(grid, width, height, Wall))
{
if (region.Count < minWallSize)
{
foreach (int cell in region) grid[cell] = Floor;
}
}
// 2) Fill small caves with rock; keep the rest as rooms.
var rooms = new List<List<int>>();
foreach (var region in FindRegions(grid, width, height, Floor))
{
if (region.Count < minRoomSize)
{
foreach (int cell in region) grid[cell] = Wall;
}
else
{
rooms.Add(region);
}
}
return rooms;
}
// Option A: keep only the biggest cave (simple, guaranteed connected, but wasteful).
public static List<List<int>> KeepLargestRoom(byte[] grid, List<List<int>> rooms)
{
if (rooms.Count == 0) return rooms;
var largest = rooms[0];
foreach (var room in rooms)
{
if (room.Count > largest.Count) largest = room; // Strict >: ties keep the first one found.
}
foreach (var room in rooms)
{
if (room == largest) continue;
foreach (int cell in room) grid[cell] = Wall;
}
return new List<List<int>> { largest };
}
// Next: Option B keeps every cave and digs tunnels between them.
Uncommented Code
public static List<List<int>> RemoveSmallRegions(byte[] grid, int width, int height, int minWallSize, int minRoomSize)
{
foreach (var region in FindRegions(grid, width, height, Wall))
{
if (region.Count < minWallSize)
{
foreach (int cell in region) grid[cell] = Floor;
}
}
var rooms = new List<List<int>>();
foreach (var region in FindRegions(grid, width, height, Floor))
{
if (region.Count < minRoomSize)
{
foreach (int cell in region) grid[cell] = Wall;
}
else
{
rooms.Add(region);
}
}
return rooms;
}
public static List<List<int>> KeepLargestRoom(byte[] grid, List<List<int>> rooms)
{
if (rooms.Count == 0) return rooms;
var largest = rooms[0];
foreach (var room in rooms)
{
if (room.Count > largest.Count) largest = room;
}
foreach (var room in rooms)
{
if (room == largest) continue;
foreach (int cell in room) grid[cell] = Wall;
}
return new List<List<int>> { largest };
}
Commented Code
// Step 2: Remove pockets and pillars
// ----------------------------------
// Tiny rock islands read as noise; tiny caves are unreachable closets. Clean up both.
std::vector<Region> removeSmallRegions(Grid& grid, int width, int height, int minWallSize, int minRoomSize) {
// 1) Dissolve small rock islands into floor.
for (const Region& region : findRegions(grid, width, height, WALL)) {
if ((int)region.size() < minWallSize) {
for (int cell : region) grid[cell] = FLOOR;
}
}
// 2) Fill small caves with rock; keep the rest as rooms.
std::vector<Region> rooms;
for (const Region& region : findRegions(grid, width, height, FLOOR)) {
if ((int)region.size() < minRoomSize) {
for (int cell : region) grid[cell] = WALL;
} else {
rooms.push_back(region);
}
}
return rooms;
}
// Option A: keep only the biggest cave (simple, guaranteed connected, but wasteful).
std::vector<Region> keepLargestRoom(Grid& grid, const std::vector<Region>& rooms) {
if (rooms.empty()) return rooms;
size_t largest = 0;
for (size_t r = 0; r < rooms.size(); r++) {
if (rooms[r].size() > rooms[largest].size()) largest = r; // Strict >: ties keep the first.
}
for (size_t r = 0; r < rooms.size(); r++) {
if (r == largest) continue;
for (int cell : rooms[r]) grid[cell] = WALL;
}
return {rooms[largest]};
}
// Next: Option B keeps every cave and digs tunnels between them.
Uncommented Code
std::vector<Region> removeSmallRegions(Grid& grid, int width, int height, int minWallSize, int minRoomSize) {
for (const Region& region : findRegions(grid, width, height, WALL)) {
if ((int)region.size() < minWallSize) {
for (int cell : region) grid[cell] = FLOOR;
}
}
std::vector<Region> rooms;
for (const Region& region : findRegions(grid, width, height, FLOOR)) {
if ((int)region.size() < minRoomSize) {
for (int cell : region) grid[cell] = WALL;
} else {
rooms.push_back(region);
}
}
return rooms;
}
std::vector<Region> keepLargestRoom(Grid& grid, const std::vector<Region>& rooms) {
if (rooms.empty()) return rooms;
size_t largest = 0;
for (size_t r = 0; r < rooms.size(); r++) {
if (rooms[r].size() > rooms[largest].size()) largest = r;
}
for (size_t r = 0; r < rooms.size(); r++) {
if (r == largest) continue;
for (int cell : rooms[r]) grid[cell] = WALL;
}
return {rooms[largest]};
}
// Step 2: Remove pockets and pillars
FUNCTION removeSmallRegions(grid, width, height, minWallSize, minRoomSize)
FOR EACH region IN findRegions(grid, width, height, WALL) DO
IF LENGTH(region) < minWallSize THEN
SET every cell in region TO FLOOR // Dissolve tiny rock islands
END IF
END FOR
DECLARE rooms = EMPTY LIST
FOR EACH region IN findRegions(grid, width, height, FLOOR) DO
IF LENGTH(region) < minRoomSize THEN
SET every cell in region TO WALL // Fill unreachable closets
ELSE
APPEND region TO rooms
END IF
END FOR
RETURN rooms
END FUNCTION
// Option A: keep only the biggest cave
FUNCTION keepLargestRoom(grid, rooms)
IF rooms IS EMPTY THEN RETURN rooms
DECLARE largest = the room with the most cells (first one wins ties)
FOR EACH room IN rooms DO
IF room != largest THEN
SET every cell in room TO WALL
END IF
END FOR
RETURN [largest]
END FUNCTION
Step 3: Carve tunnels between caves
Option B keeps every cave and connects them. Start from the largest cave, find the closest unconnected cave, dig a tunnel to it, and repeat until everything is joined. That’s Prim’s minimum spanning tree in disguise. Measuring only between edge cells (floor cells touching rock) keeps the search fast, and Bresenham’s line algorithm draws each tunnel.
Commented Code
/*
Step 3: Carve tunnels between caves
-----------------------------------
Option B: keep every cave and connect them. Start from the biggest cave, then repeatedly
find the closest unconnected cave and dig a tunnel to it, like growing a tree of caves.
*/
// An edge cell is a floor cell touching rock. Tunnels only need to start and end on edges,
// which shrinks the search from thousands of cells to a few hundred.
// (Safe to index cell +/- 1 and +/- width: the border is always rock, so floor never touches it.)
function isRoomEdge(grid, width, cell) {
return grid[cell - 1] === WALL || grid[cell + 1] === WALL ||
grid[cell - width] === WALL || grid[cell + width] === WALL;
}
// Squared distance is enough for comparisons, and it skips a slow square root.
function distanceSquared(a, b, width) {
const dx = (a % width) - (b % width);
const dy = Math.floor(a / width) - Math.floor(b / width);
return dx * dx + dy * dy;
}
// Stamp a round brush of floor, never touching the outer border.
function carveCircle(grid, width, height, cx, cy, radius) {
for (let y = cy - radius; y <= cy + radius; y++) {
for (let x = cx - radius; x <= cx + radius; x++) {
const inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius) {
grid[y * width + x] = FLOOR;
}
}
}
}
// Walk a straight line from cell `from` to cell `to` (Bresenham's line algorithm),
// stamping the brush at every step. On a diagonal step we also stamp after the x move,
// so the tunnel stays 4-connected: walkable without squeezing between two corners.
function carveTunnel(grid, width, height, from, to, radius) {
let x = from % width;
let y = Math.floor(from / width);
const tx = to % width;
const ty = Math.floor(to / width);
const dx = Math.abs(tx - x);
const dy = -Math.abs(ty - y);
const sx = x < tx ? 1 : -1;
const sy = y < ty ? 1 : -1;
let err = dx + dy;
while (true) {
carveCircle(grid, width, height, x, y, radius);
if (x === tx && y === ty) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; carveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
function connectRooms(grid, width, height, rooms, radius = 1) {
if (rooms.length < 2) return 0;
const edges = rooms.map(room => room.filter(cell => isRoomEdge(grid, width, cell)));
// Biggest cave first: it becomes the "mainland" every other cave joins.
// (JavaScript's sort is stable, so equal sizes keep their order. That matters for determinism.)
const order = rooms.map((room, i) => i).sort((a, b) => rooms[b].length - rooms[a].length);
const connected = [order[0]];
const pending = order.slice(1);
let tunnels = 0;
while (pending.length > 0) {
// Find the closest pair of edge cells between ANY pending cave and ANY connected cave.
let best = null;
for (let p = 0; p < pending.length; p++) {
for (const a of edges[pending[p]]) {
for (const c of connected) {
for (const b of edges[c]) {
const d = distanceSquared(a, b, width);
if (best === null || d < best.d) best = { d, a, b, p };
}
}
}
}
// Dig it, then move that cave into the connected set.
carveTunnel(grid, width, height, best.a, best.b, radius);
connected.push(pending[best.p]);
pending.splice(best.p, 1);
tunnels++;
}
return tunnels;
}
/*
Explanation:
- Always connecting to the NEAREST cave keeps tunnels short and natural-looking.
- This is Prim's minimum spanning tree in disguise: every cave ends up reachable.
- radius 1 gives 3-wide tunnels; radius 0 gives 1-wide corridors.
Next step: give the starting noise some structure with Perlin noise.
*/
Uncommented Code
function isRoomEdge(grid, width, cell) {
return grid[cell - 1] === WALL || grid[cell + 1] === WALL ||
grid[cell - width] === WALL || grid[cell + width] === WALL;
}
function distanceSquared(a, b, width) {
const dx = (a % width) - (b % width);
const dy = Math.floor(a / width) - Math.floor(b / width);
return dx * dx + dy * dy;
}
function carveCircle(grid, width, height, cx, cy, radius) {
for (let y = cy - radius; y <= cy + radius; y++) {
for (let x = cx - radius; x <= cx + radius; x++) {
const inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius) {
grid[y * width + x] = FLOOR;
}
}
}
}
function carveTunnel(grid, width, height, from, to, radius) {
let x = from % width;
let y = Math.floor(from / width);
const tx = to % width;
const ty = Math.floor(to / width);
const dx = Math.abs(tx - x);
const dy = -Math.abs(ty - y);
const sx = x < tx ? 1 : -1;
const sy = y < ty ? 1 : -1;
let err = dx + dy;
while (true) {
carveCircle(grid, width, height, x, y, radius);
if (x === tx && y === ty) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; carveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
function connectRooms(grid, width, height, rooms, radius = 1) {
if (rooms.length < 2) return 0;
const edges = rooms.map(room => room.filter(cell => isRoomEdge(grid, width, cell)));
const order = rooms.map((room, i) => i).sort((a, b) => rooms[b].length - rooms[a].length);
const connected = [order[0]];
const pending = order.slice(1);
let tunnels = 0;
while (pending.length > 0) {
let best = null;
for (let p = 0; p < pending.length; p++) {
for (const a of edges[pending[p]]) {
for (const c of connected) {
for (const b of edges[c]) {
const d = distanceSquared(a, b, width);
if (best === null || d < best.d) best = { d, a, b, p };
}
}
}
}
carveTunnel(grid, width, height, best.a, best.b, radius);
connected.push(pending[best.p]);
pending.splice(best.p, 1);
tunnels++;
}
return tunnels;
}
Commented Code
// Step 3: Carve tunnels between caves
// -----------------------------------
// Start from the biggest cave, then repeatedly dig to the nearest unconnected cave
// (Prim's minimum spanning tree in disguise) until every cave is reachable.
// A floor cell touching rock. The border is always rock, so these indices never go out of range.
static bool IsRoomEdge(byte[] grid, int width, int cell)
{
return grid[cell - 1] == Wall || grid[cell + 1] == Wall ||
grid[cell - width] == Wall || grid[cell + width] == Wall;
}
// Squared distance: fine for comparing, and no square root needed.
static int DistanceSquared(int a, int b, int width)
{
int dx = (a % width) - (b % width);
int dy = (a / width) - (b / width);
return dx * dx + dy * dy;
}
// Stamp a round brush of floor, never touching the outer border.
static void CarveCircle(byte[] grid, int width, int height, int cx, int cy, int radius)
{
for (int y = cy - radius; y <= cy + radius; y++)
{
for (int x = cx - radius; x <= cx + radius; x++)
{
bool inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius)
{
grid[y * width + x] = Floor;
}
}
}
}
// Bresenham's line from `from` to `to`, stamping the brush at every step.
// Diagonal steps also stamp after the x move, keeping the tunnel 4-connected.
static void CarveTunnel(byte[] grid, int width, int height, int from, int to, int radius)
{
int x = from % width;
int y = from / width;
int tx = to % width;
int ty = to / width;
int dx = Math.Abs(tx - x);
int dy = -Math.Abs(ty - y);
int sx = x < tx ? 1 : -1;
int sy = y < ty ? 1 : -1;
int err = dx + dy;
while (true)
{
CarveCircle(grid, width, height, x, y, radius);
if (x == tx && y == ty) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; CarveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
public static int ConnectRooms(byte[] grid, int width, int height, List<List<int>> rooms, int radius = 1)
{
if (rooms.Count < 2) return 0;
// Only edge cells can be tunnel endpoints, which shrinks the search dramatically.
var edges = rooms.Select(room => room.Where(cell => IsRoomEdge(grid, width, cell)).ToList()).ToList();
// Biggest first. OrderByDescending is a STABLE sort (List.Sort is not), which keeps
// results identical to the JavaScript and C++ versions when two caves are the same size.
var order = Enumerable.Range(0, rooms.Count).OrderByDescending(i => rooms[i].Count).ToList();
var connected = new List<int> { order[0] };
var pending = order.Skip(1).ToList();
int tunnels = 0;
while (pending.Count > 0)
{
// Closest pair of edge cells between any pending cave and any connected cave.
int bestD = -1, bestA = 0, bestB = 0, bestP = 0;
for (int p = 0; p < pending.Count; p++)
{
foreach (int a in edges[pending[p]])
{
foreach (int c in connected)
{
foreach (int b in edges[c])
{
int d = DistanceSquared(a, b, width);
if (bestD < 0 || d < bestD) { bestD = d; bestA = a; bestB = b; bestP = p; }
}
}
}
}
CarveTunnel(grid, width, height, bestA, bestB, radius);
connected.Add(pending[bestP]);
pending.RemoveAt(bestP);
tunnels++;
}
return tunnels;
}
// radius 1 = 3-wide tunnels; radius 0 = 1-wide corridors.
Uncommented Code
static bool IsRoomEdge(byte[] grid, int width, int cell)
{
return grid[cell - 1] == Wall || grid[cell + 1] == Wall ||
grid[cell - width] == Wall || grid[cell + width] == Wall;
}
static int DistanceSquared(int a, int b, int width)
{
int dx = (a % width) - (b % width);
int dy = (a / width) - (b / width);
return dx * dx + dy * dy;
}
static void CarveCircle(byte[] grid, int width, int height, int cx, int cy, int radius)
{
for (int y = cy - radius; y <= cy + radius; y++)
{
for (int x = cx - radius; x <= cx + radius; x++)
{
bool inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius)
{
grid[y * width + x] = Floor;
}
}
}
}
static void CarveTunnel(byte[] grid, int width, int height, int from, int to, int radius)
{
int x = from % width;
int y = from / width;
int tx = to % width;
int ty = to / width;
int dx = Math.Abs(tx - x);
int dy = -Math.Abs(ty - y);
int sx = x < tx ? 1 : -1;
int sy = y < ty ? 1 : -1;
int err = dx + dy;
while (true)
{
CarveCircle(grid, width, height, x, y, radius);
if (x == tx && y == ty) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; CarveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
public static int ConnectRooms(byte[] grid, int width, int height, List<List<int>> rooms, int radius = 1)
{
if (rooms.Count < 2) return 0;
var edges = rooms.Select(room => room.Where(cell => IsRoomEdge(grid, width, cell)).ToList()).ToList();
var order = Enumerable.Range(0, rooms.Count).OrderByDescending(i => rooms[i].Count).ToList();
var connected = new List<int> { order[0] };
var pending = order.Skip(1).ToList();
int tunnels = 0;
while (pending.Count > 0)
{
int bestD = -1, bestA = 0, bestB = 0, bestP = 0;
for (int p = 0; p < pending.Count; p++)
{
foreach (int a in edges[pending[p]])
{
foreach (int c in connected)
{
foreach (int b in edges[c])
{
int d = DistanceSquared(a, b, width);
if (bestD < 0 || d < bestD) { bestD = d; bestA = a; bestB = b; bestP = p; }
}
}
}
}
CarveTunnel(grid, width, height, bestA, bestB, radius);
connected.Add(pending[bestP]);
pending.RemoveAt(bestP);
tunnels++;
}
return tunnels;
}
Commented Code
// Step 3: Carve tunnels between caves
// -----------------------------------
// Start from the biggest cave, then repeatedly dig to the nearest unconnected cave
// (Prim's minimum spanning tree in disguise) until every cave is reachable.
#include <algorithm>
#include <cstdlib>
#include <numeric>
// A floor cell touching rock. The border is always rock, so these indices stay in range.
bool isRoomEdge(const Grid& grid, int width, int cell) {
return grid[cell - 1] == WALL || grid[cell + 1] == WALL ||
grid[cell - width] == WALL || grid[cell + width] == WALL;
}
// Squared distance: fine for comparing, and no square root needed.
int distanceSquared(int a, int b, int width) {
int dx = (a % width) - (b % width);
int dy = (a / width) - (b / width);
return dx * dx + dy * dy;
}
// Stamp a round brush of floor, never touching the outer border.
void carveCircle(Grid& grid, int width, int height, int cx, int cy, int radius) {
for (int y = cy - radius; y <= cy + radius; y++) {
for (int x = cx - radius; x <= cx + radius; x++) {
bool inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius) {
grid[y * width + x] = FLOOR;
}
}
}
}
// Bresenham's line from `from` to `to`, stamping the brush at every step.
// Diagonal steps also stamp after the x move, keeping the tunnel 4-connected.
void carveTunnel(Grid& grid, int width, int height, int from, int to, int radius) {
int x = from % width;
int y = from / width;
int tx = to % width;
int ty = to / width;
int dx = std::abs(tx - x);
int dy = -std::abs(ty - y);
int sx = x < tx ? 1 : -1;
int sy = y < ty ? 1 : -1;
int err = dx + dy;
while (true) {
carveCircle(grid, width, height, x, y, radius);
if (x == tx && y == ty) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; carveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
int connectRooms(Grid& grid, int width, int height, const std::vector<Region>& rooms, int radius = 1) {
if (rooms.size() < 2) return 0;
// Only edge cells can be tunnel endpoints, which shrinks the search dramatically.
std::vector<Region> edges;
for (const Region& room : rooms) {
Region edge;
for (int cell : room) {
if (isRoomEdge(grid, width, cell)) edge.push_back(cell);
}
edges.push_back(edge);
}
// Biggest first. stable_sort (not sort) keeps equal-sized caves in order, so results
// match the JavaScript and C# versions exactly.
std::vector<int> order(rooms.size());
std::iota(order.begin(), order.end(), 0);
std::stable_sort(order.begin(), order.end(),
[&](int a, int b) { return rooms[a].size() > rooms[b].size(); });
std::vector<int> connected{order[0]};
std::vector<int> pending(order.begin() + 1, order.end());
int tunnels = 0;
while (!pending.empty()) {
// Closest pair of edge cells between any pending cave and any connected cave.
int bestD = -1, bestA = 0, bestB = 0, bestP = 0;
for (int p = 0; p < (int)pending.size(); p++) {
for (int a : edges[pending[p]]) {
for (int c : connected) {
for (int b : edges[c]) {
int d = distanceSquared(a, b, width);
if (bestD < 0 || d < bestD) { bestD = d; bestA = a; bestB = b; bestP = p; }
}
}
}
}
carveTunnel(grid, width, height, bestA, bestB, radius);
connected.push_back(pending[bestP]);
pending.erase(pending.begin() + bestP);
tunnels++;
}
return tunnels;
}
// radius 1 = 3-wide tunnels; radius 0 = 1-wide corridors.
Uncommented Code
#include <algorithm>
#include <cstdlib>
#include <numeric>
bool isRoomEdge(const Grid& grid, int width, int cell) {
return grid[cell - 1] == WALL || grid[cell + 1] == WALL ||
grid[cell - width] == WALL || grid[cell + width] == WALL;
}
int distanceSquared(int a, int b, int width) {
int dx = (a % width) - (b % width);
int dy = (a / width) - (b / width);
return dx * dx + dy * dy;
}
void carveCircle(Grid& grid, int width, int height, int cx, int cy, int radius) {
for (int y = cy - radius; y <= cy + radius; y++) {
for (int x = cx - radius; x <= cx + radius; x++) {
bool inside = x > 0 && y > 0 && x < width - 1 && y < height - 1;
if (inside && (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius) {
grid[y * width + x] = FLOOR;
}
}
}
}
void carveTunnel(Grid& grid, int width, int height, int from, int to, int radius) {
int x = from % width;
int y = from / width;
int tx = to % width;
int ty = to / width;
int dx = std::abs(tx - x);
int dy = -std::abs(ty - y);
int sx = x < tx ? 1 : -1;
int sy = y < ty ? 1 : -1;
int err = dx + dy;
while (true) {
carveCircle(grid, width, height, x, y, radius);
if (x == tx && y == ty) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; carveCircle(grid, width, height, x, y, radius); }
if (e2 <= dx) { err += dx; y += sy; }
}
}
int connectRooms(Grid& grid, int width, int height, const std::vector<Region>& rooms, int radius = 1) {
if (rooms.size() < 2) return 0;
std::vector<Region> edges;
for (const Region& room : rooms) {
Region edge;
for (int cell : room) {
if (isRoomEdge(grid, width, cell)) edge.push_back(cell);
}
edges.push_back(edge);
}
std::vector<int> order(rooms.size());
std::iota(order.begin(), order.end(), 0);
std::stable_sort(order.begin(), order.end(),
[&](int a, int b) { return rooms[a].size() > rooms[b].size(); });
std::vector<int> connected{order[0]};
std::vector<int> pending(order.begin() + 1, order.end());
int tunnels = 0;
while (!pending.empty()) {
int bestD = -1, bestA = 0, bestB = 0, bestP = 0;
for (int p = 0; p < (int)pending.size(); p++) {
for (int a : edges[pending[p]]) {
for (int c : connected) {
for (int b : edges[c]) {
int d = distanceSquared(a, b, width);
if (bestD < 0 || d < bestD) { bestD = d; bestA = a; bestB = b; bestP = p; }
}
}
}
}
carveTunnel(grid, width, height, bestA, bestB, radius);
connected.push_back(pending[bestP]);
pending.erase(pending.begin() + bestP);
tunnels++;
}
return tunnels;
}
// Step 3: Carve tunnels between caves (Option B)
FUNCTION connectRooms(grid, width, height, rooms, radius = 1)
IF LENGTH(rooms) < 2 THEN RETURN 0
// Edge cells = floor cells with rock directly left, right, above, or below
DECLARE edges = FOR EACH room: the cells of room that touch a WALL
DECLARE order = room indices sorted by size, biggest first (stable sort)
DECLARE connected = [order[0]] // The "mainland"
DECLARE pending = the rest of order
DECLARE tunnels = 0
WHILE pending IS NOT EMPTY DO
// Find the closest pair of edge cells between any pending cave and any connected cave
DECLARE best = NONE
FOR p FROM 0 TO LENGTH(pending) - 1 DO
FOR EACH a IN edges[pending[p]] DO
FOR EACH c IN connected DO
FOR EACH b IN edges[c] DO
DECLARE d = (ax - bx)^2 + (ay - by)^2
IF best IS NONE OR d < best.d THEN
best = { d, a, b, p }
END IF
END FOR
END FOR
END FOR
END FOR
carveTunnel(grid, best.a, best.b, radius) // Bresenham line, stamping a circle of FLOOR
MOVE pending[best.p] INTO connected
tunnels = tunnels + 1
END WHILE
RETURN tunnels
END FUNCTION
FUNCTION carveTunnel(grid, from, to, radius)
FOR EACH (x, y) ON THE LINE from -> to (Bresenham, one axis at a time so it stays 4-connected) DO
SET every cell within radius of (x, y), except the outer border, TO FLOOR
END FOR
END FUNCTION
Lab: Regions & Tunnels
👉 Try this: in Show Regions, every color is a separate cave a player could never leave. Switch to Keep Largest and watch most of the map get filled in. Now try Connect All: the coral cells are new tunnels. Set Tunnel radius to 0 for tight 1-tile corridors.
Step-by-Step Lab #4: Perlin-Seeded Caves
White noise has no large-scale structure, so caves come out evenly spread, like holes in Swiss cheese. Blend in smooth Perlin noise and the cave gets geography: dense rock in some regions and open caverns in others. Keep a little white noise in the mix, because that’s what gives the walls their rough texture.
Commented Code
/*
Seed the fill with Perlin noise
-------------------------------
White noise has no large-scale structure, so caves come out evenly spread.
Blend in smooth noise and the cave gets "geography": dense rock in some areas,
open caverns in others.
noise01(x, y) is any 2D noise that returns 0..1. Using the Perlin class from our
Perlin noise tutorial (whose noise() returns roughly -1..1):
const perlin = new Perlin(seed);
const noise01 = (x, y) => (perlin.noise(x, y, 0) + 1) / 2;
*/
function noiseFill(width, height, fillChance, seed, noise01, scale = 0.08, noiseWeight = 0.6) {
const rng = new SeededRandom(seed);
const grid = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const isBorder = x === 0 || y === 0 || x === width - 1 || y === height - 1;
if (isBorder) {
grid[i] = WALL;
continue;
}
// scale = how zoomed-in the noise is (smaller = bigger blobs).
// noiseWeight = 0 is pure white noise; 1 is pure Perlin.
const value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.nextFloat();
grid[i] = value < fillChance ? WALL : FLOOR;
}
}
return grid;
}
/*
Explanation:
- Swap randomFill for noiseFill, then run the same smoothStep passes as before.
- Keep a little white noise (noiseWeight below 1). It's what gives the walls their rough texture.
*/
Uncommented Code
function noiseFill(width, height, fillChance, seed, noise01, scale = 0.08, noiseWeight = 0.6) {
const rng = new SeededRandom(seed);
const grid = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const isBorder = x === 0 || y === 0 || x === width - 1 || y === height - 1;
if (isBorder) {
grid[i] = WALL;
continue;
}
const value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.nextFloat();
grid[i] = value < fillChance ? WALL : FLOOR;
}
}
return grid;
}
Commented Code
// Seed the fill with Perlin noise
// -------------------------------
// Blend smooth noise into the random fill so the cave gets large-scale structure.
// noise01 is any 2D noise returning 0..1. In Unity, Mathf.PerlinNoise(x, y) already does:
// byte[] grid = CaveGen.NoiseFill(80, 50, 0.45, seed, (x, y) => Mathf.PerlinNoise((float)x, (float)y));
public static byte[] NoiseFill(int width, int height, double fillChance, long seed,
Func<double, double, double> noise01, double scale = 0.08, double noiseWeight = 0.6)
{
var rng = new SeededRandom(seed);
var grid = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
if (isBorder)
{
grid[i] = Wall;
continue;
}
// scale: smaller = bigger blobs. noiseWeight: 0 = pure white noise, 1 = pure Perlin.
double value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.NextFloat();
grid[i] = value < fillChance ? Wall : Floor;
}
}
return grid;
}
// Then smooth exactly as before: grid = CaveGen.SmoothStep(grid, w, h, 5, 4) a few times.
Uncommented Code
public static byte[] NoiseFill(int width, int height, double fillChance, long seed,
Func<double, double, double> noise01, double scale = 0.08, double noiseWeight = 0.6)
{
var rng = new SeededRandom(seed);
var grid = new byte[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int i = y * width + x;
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
if (isBorder)
{
grid[i] = Wall;
continue;
}
double value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.NextFloat();
grid[i] = value < fillChance ? Wall : Floor;
}
}
return grid;
}
Commented Code
// Seed the fill with Perlin noise
// -------------------------------
// Blend smooth noise into the random fill so the cave gets large-scale structure.
// noise01 is any 2D noise that returns 0..1, for example the Perlin class from our Perlin noise tutorial.
#include <functional>
Grid noiseFill(int width, int height, double fillChance, int64_t seed,
const std::function<double(double, double)>& noise01,
double scale = 0.08, double noiseWeight = 0.6) {
SeededRandom rng(seed);
Grid grid(width * height, FLOOR);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
if (isBorder) {
grid[i] = WALL;
continue;
}
// scale: smaller = bigger blobs. noiseWeight: 0 = pure white noise, 1 = pure Perlin.
double value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.nextFloat();
grid[i] = value < fillChance ? WALL : FLOOR;
}
}
return grid;
}
// Then smooth exactly as before: grid = smoothStep(grid, w, h, 5, 4) a few times.
Uncommented Code
#include <functional>
Grid noiseFill(int width, int height, double fillChance, int64_t seed,
const std::function<double(double, double)>& noise01,
double scale = 0.08, double noiseWeight = 0.6) {
SeededRandom rng(seed);
Grid grid(width * height, FLOOR);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * width + x;
bool isBorder = x == 0 || y == 0 || x == width - 1 || y == height - 1;
if (isBorder) {
grid[i] = WALL;
continue;
}
double value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.nextFloat();
grid[i] = value < fillChance ? WALL : FLOOR;
}
}
return grid;
}
// Seed the fill with Perlin noise
// noise01(x, y) = any smooth 2D noise in 0..1 (e.g., the Perlin class from our tutorial)
FUNCTION noiseFill(width, height, fillChance, seed, noise01, scale = 0.08, noiseWeight = 0.6)
DECLARE rng = NEW SeededRandom(seed)
DECLARE grid = NEW ARRAY OF width * height
FOR y FROM 0 TO height - 1 DO
FOR x FROM 0 TO width - 1 DO
IF (x, y) IS ON THE BORDER THEN
grid[y * width + x] = WALL
CONTINUE
END IF
// Blend smooth noise with a little white noise for rough walls
DECLARE value = noiseWeight * noise01(x * scale, y * scale) + (1 - noiseWeight) * rng.nextFloat()
grid[y * width + x] = WALL IF value < fillChance ELSE FLOOR
END FOR
END FOR
RETURN grid
END FUNCTION
// Then run smoothStep a few times, exactly as before
Lab: White Noise vs Perlin Noise
randomFillnoiseFill👉 Try this: tick Raw fill to see both inputs before smoothing. The Perlin-seeded one already has “continents.” Then slide Noise weight to 0% (pure white noise) and back up to 100% (pure Perlin, which gets very blobby).
Making It Interactive: A Falling-Sand Sandbox
Same grid, same “look at your neighbors” thinking, but now cells hold materials that move. Sand falls and piles. Water falls and spreads. Fire burns wood and turns water into steam. There’s one important difference from our cave CA: falling sand updates in place, scanning from the bottom row up and alternating left-to-right with right-to-left every frame. Particles move rather than appear and disappear, so a per-cell “moved this frame” flag stops a grain from falling twice in one frame.
Lab: Falling Sand
Commented Code
/*
The Sand Rule (and why falling sand updates in place)
-----------------------------------------------------
Each cell holds a material ID. Sand tries to fall straight down; if that's blocked,
it slides diagonally. That one rule is enough to make convincing piles.
*/
const EMPTY = 0;
const SAND = 1;
const WATER = 2;
const STONE = 3;
const WOOD = 4;
const FIRE = 5;
const STEAM = 6;
// Heavier materials sink through lighter ones: sand through water, water through steam.
function canDisplace(mover, target) {
if (target === EMPTY) return true;
if (mover === SAND && target === WATER) return true;
if (mover === WATER && target === STEAM) return true;
return false;
}
// Swap two cells (and their lifetimes), and flag the destination as already moved this frame.
function swapCells(world, a, b) {
const t = world.cells[a];
world.cells[a] = world.cells[b];
world.cells[b] = t;
const l = world.life[a];
world.life[a] = world.life[b];
world.life[b] = l;
world.moved[b] = 1;
}
function updateSand(world, x, y, dir) {
const { width, height, cells } = world;
if (y === height - 1) return; // Resting on the floor of the world.
const i = y * width + x;
const below = i + width;
if (canDisplace(SAND, cells[below])) {
swapCells(world, i, below); // 1) Fall straight down.
return;
}
// 2) Blocked? Slide diagonally. `dir` is random each time, so piles don't lean one way.
const first = x + dir;
const second = x - dir;
if (first >= 0 && first < width && canDisplace(SAND, cells[below + dir])) {
swapCells(world, i, below + dir);
} else if (second >= 0 && second < width && canDisplace(SAND, cells[below - dir])) {
swapCells(world, i, below - dir);
}
}
function stepWorld(world, frame, rand) {
const { width, height, cells, moved } = world;
moved.fill(0);
// Alternate the horizontal scan direction every frame to cancel left/right bias.
const dir = frame % 2 === 0 ? 1 : -1;
// Bottom row first: a grain that falls into an already-processed row won't move twice.
// (Unlike the cave CA, this updates IN PLACE. Particles move rather than appear and disappear.)
for (let y = height - 1; y >= 0; y--) {
for (let k = 0; k < width; k++) {
const x = dir === 1 ? k : width - 1 - k;
const i = y * width + x;
if (moved[i]) continue;
const c = cells[i];
if (c === SAND) updateSand(world, x, y, rand() < 0.5 ? 1 : -1);
else if (c === WATER) updateWater(world, x, y, rand() < 0.5 ? 1 : -1);
else if (c === FIRE) updateFire(world, x, y, rand);
}
}
// Steam rises, so it gets its own top-down pass.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (cells[i] === STEAM && !moved[i]) updateSteam(world, x, y, rand);
}
}
}
/*
Explanation:
- updateWater is updateSand plus "flow sideways if you can't fall".
- updateFire burns neighboring wood, turns water into steam, and burns out after a lifetime.
- Noita scales this same idea to an entire world, updated in chunks.
*/
Uncommented Code
const EMPTY = 0;
const SAND = 1;
const WATER = 2;
const STONE = 3;
const WOOD = 4;
const FIRE = 5;
const STEAM = 6;
function canDisplace(mover, target) {
if (target === EMPTY) return true;
if (mover === SAND && target === WATER) return true;
if (mover === WATER && target === STEAM) return true;
return false;
}
function swapCells(world, a, b) {
const t = world.cells[a];
world.cells[a] = world.cells[b];
world.cells[b] = t;
const l = world.life[a];
world.life[a] = world.life[b];
world.life[b] = l;
world.moved[b] = 1;
}
function updateSand(world, x, y, dir) {
const { width, height, cells } = world;
if (y === height - 1) return;
const i = y * width + x;
const below = i + width;
if (canDisplace(SAND, cells[below])) {
swapCells(world, i, below);
return;
}
const first = x + dir;
const second = x - dir;
if (first >= 0 && first < width && canDisplace(SAND, cells[below + dir])) {
swapCells(world, i, below + dir);
} else if (second >= 0 && second < width && canDisplace(SAND, cells[below - dir])) {
swapCells(world, i, below - dir);
}
}
function stepWorld(world, frame, rand) {
const { width, height, cells, moved } = world;
moved.fill(0);
const dir = frame % 2 === 0 ? 1 : -1;
for (let y = height - 1; y >= 0; y--) {
for (let k = 0; k < width; k++) {
const x = dir === 1 ? k : width - 1 - k;
const i = y * width + x;
if (moved[i]) continue;
const c = cells[i];
if (c === SAND) updateSand(world, x, y, rand() < 0.5 ? 1 : -1);
else if (c === WATER) updateWater(world, x, y, rand() < 0.5 ? 1 : -1);
else if (c === FIRE) updateFire(world, x, y, rand);
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (cells[i] === STEAM && !moved[i]) updateSteam(world, x, y, rand);
}
}
}
// The sand rule: fall down, else slide diagonally
// Falling sand updates IN PLACE, bottom row first, with a "moved" flag per cell
FUNCTION updateSand(world, x, y, dir)
IF y IS THE BOTTOM ROW THEN RETURN
DECLARE below = cell at (x, y + 1)
IF below IS EMPTY OR WATER THEN
SWAP (x, y) WITH below // Fall
ELSE IF cell at (x + dir, y + 1) IS EMPTY OR WATER THEN
SWAP (x, y) WITH (x + dir, y + 1) // Slide one way...
ELSE IF cell at (x - dir, y + 1) IS EMPTY OR WATER THEN
SWAP (x, y) WITH (x - dir, y + 1) // ...or the other
END IF
END FUNCTION
FUNCTION stepWorld(world, frame)
CLEAR all moved flags
DECLARE scanDirection = LEFT-TO-RIGHT IF frame IS EVEN ELSE RIGHT-TO-LEFT
FOR y FROM height - 1 DOWNTO 0 DO // Bottom up
FOR EACH x IN scanDirection DO
IF cell (x, y) WAS NOT MOVED THIS FRAME THEN
RUN the update rule for its material (sand, water, fire...)
END IF
END FOR
END FOR
RUN steam (it rises) in a separate top-down pass
END FUNCTION
Common Pitfalls
- Updating in place (for Life and caves): always read from one grid and write to another. In-place updates make patterns drift in the scan direction.
- Leaky edges: if off-map cells count as floor, caves bleed into the border. Count them as rock, and force a solid border.
- Expecting more passes to fix things: the 4-5 rule settles fast. On a 96×60 map, the first pass changes about 2,000 cells, the fifth fewer than 100, and the tenth about 15. If you don’t like the caves, change the fill or the limits, not the pass count.
- Disconnected caves: smoothing alone guarantees nothing. Always flood fill, then either keep the largest region or tunnel everything together.
- Diagonal leaks: if your flood fill uses 4 neighbors but your character moves in 8 directions (or the reverse), “connected” stops meaning “reachable.” Match the two.
- Slow big maps: use flat typed arrays, only update chunks that changed (Noita splits its world into 64×64 chunks, each with a “dirty rectangle”), or move the CA into a GPU shader that ping-pongs between two textures.
Real Games That Use Cellular Automata
- Brogue: carves its caves and lakes as CA “blobs.” According to the Brogue CE source, it seeds 55% of the cells, runs 5 rounds (B678/S45678 for caves, B5678/S45678 for lakes), then keeps only the largest connected blob and regenerates if it comes out too small. One twist: in Brogue the “live” cells are the open blob, not the walls.
- Noita: simulates every pixel falling-sand style. In a 2019 interview with 80.lv, the developers described updating the world from the bottom up, splitting it into 64×64 chunks with dirty rectangles, and multithreading those chunks in a checkerboard pattern. Petri Purho’s GDC 2019 talk, “Exploring the Tech and Design of ‘Noita'”, covers how they scaled it to a whole world. We take both games apart side by side in How Brogue and Noita Use Cellular Automata.
- Dwarf Fortress: Tarn Adams has described the game’s fluid simulation as a specialized cellular automaton.
- Sandboxels: R74n’s falling-sand toy began in the browser in 2021 and launched on Steam in May 2025.
Not everything that looks like a CA cave is one: Minecraft’s cheese, spaghetti and noodle caves come from 3D noise, not cellular automata. CA is one tool in the toolbox, and it combines beautifully with the others.
Extensions and Variations
- Multi-state automata: grass → tree → fire → ash → grass gives you a forest that burns and regrows on its own.
- Different rules per biome: use the regions from our Voronoi tutorial to run tight cave rules in “mines” and loose ones in “caverns.”
- The 5×5 variant: RogueBasin’s extended rule also counts walls within two cells, which drops pillars into big empty rooms so they feel less barren.
- CA inside dungeons: run a couple of smoothing passes on a room-and-corridor dungeon to roughen its walls.
- 3D caves: the same rule works in 3D with 26 neighbors for voxel worlds.
- Autotiling: turn the raw 0/1 grid into good-looking tiles with marching squares (coming later in this series).
Final Thoughts
Cellular automata are a lesson in emergence: no cell knows it’s part of a cave, yet caves appear. The core is tiny (count neighbors, apply a rule, swap buffers), and the craft is everything around it: seeding, cleanup, connectivity and parameters. That’s what turns a pretty pattern into a level someone can actually play.
Next steps: this is part of our Procedural World series. If you haven’t yet, start with Perlin noise and Voronoi diagrams. Up next, we combine noise and Voronoi into full biome maps.
What should the next lab be? Tell us in the comments.
Further Learning
- Cellular Automata Method for Generating Random Cave-Like Levels (RogueBasin, original article by Jim Babcock)
- The Cellular Automaton Method for Cave Generation (Jeremy Kun)
- Game of Life coding challenge (The Coding Train)
- Brogue CE source code (see
Grid.cfor the blob generator)


This Post Has 0 Comments