Generate Caves from Noise
Cellular Automata From First Principles 14: Generate Caves from Noise
A cave generator can be built from a mechanism we already understand:
random initial cells
↓
count nearby walls
↓
apply local smoothing rule
↓
repeat a few times
↓
stop and use the result
Unlike a forest-fire simulation, we are not trying to model an indefinitely evolving world.
Here the cellular automaton is a construction process.
Represent wall and floor
import numpy as np
FLOOR = 0
WALL = 1
Create a random map:
def random_cave(
rows=90,
cols=140,
wall_probability=0.45,
seed=42,
):
rng = np.random.default_rng(seed)
grid = (
rng.random((rows, cols))
< wall_probability
).astype(np.uint8)
return grid
At generation zero the image is only binary noise.
The structure comes from repeated local filtering.
Count nearby walls with fixed boundaries
For game maps, wrapping the left edge onto the right edge is usually undesirable.
Use fixed boundaries rather than np.roll wraparound.
def shift_fixed(
a,
dy,
dx,
):
out = np.zeros_like(a)
# Copy the overlapping region only.
...
return out
Then count the eight-cell Moore neighborhood:
def wall_count(grid):
count = np.zeros_like(
grid,
dtype=np.uint8,
)
walls = grid == WALL
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
count += shift_fixed(
walls,
dy,
dx,
)
return count
Apply a majority-like smoothing rule
def cave_step(
grid,
threshold=5,
):
nearby = wall_count(grid)
next_grid = (
nearby >= threshold
).astype(np.uint8)
return solid_border(next_grid)
Run several generations.

The transformation is easy to understand:
high-frequency isolated detail
↓
local majority-like smoothing
↓
larger contiguous wall/floor regions
The parameter set defines a generator family
The main controls are:
initial wall probability
neighbor threshold
number of smoothing steps
seed
That means there is no single “cave generator.”
There is a parameterized family of generators.
A good workflow is:
generate
measure
reject or retain
rather than manually editing bad outputs.
Pretty does not mean playable
A cave can look organic and still fail every practical requirement.
Typical failures:
most floor disconnected
spawn isolated
exit unreachable
tiny inaccessible pockets
too little floor
too much open space
So generation needs validation.
Find connected floor regions
Use flood fill or breadth-first search over floor cells.
from collections import deque
def reachable_floor(
grid,
start,
):
rows, cols = grid.shape
seen = np.zeros_like(
grid,
dtype=bool,
)
queue = deque([start])
seen[start] = True
while queue:
y, x = queue.popleft()
for dy, dx in [
(-1, 0),
(1, 0),
(0, -1),
(0, 1),
]:
ny = y + dy
nx = x + dx
if not (
0 <= ny < rows
and 0 <= nx < cols
):
continue
if (
seen[ny, nx]
or grid[ny, nx] == WALL
):
continue
seen[ny, nx] = True
queue.append((ny, nx))
return seen
Now connectivity becomes measurable.
Combine local emergence with global constraints
One common cleanup strategy is to keep only the largest connected floor component.

This illustrates an important procedural-generation principle:
cellular automaton
-> organic local geometry
graph algorithm
-> explicit global guarantee
The CA does not need to solve every design constraint.
Use each algorithm where it is strongest.
Build a cave score
Useful measurements include:
floor fraction
largest connected floor fraction
number of floor components
boundary length
shortest path between endpoints
minimum local width
Then search seeds:
for seed in range(10_000):
cave = build_cave(seed)
score = evaluate_cave(cave)
if score >= threshold:
keep(cave)
Now procedural generation becomes:
generator
+
evaluator
+
search
That pattern will return repeatedly throughout the book.
One idea to keep
The CA gives us local texture and organic geometry.
Global graph analysis gives us usability constraints.
Combining them is more powerful than asking one mechanism to do everything.
In the next chapter we will move from binary wall/floor cells to continuous height fields and build terrain from local smoothing, persistent uplift and layered state.