Grow Terrain from Local Height Rules
Cellular Automata From First Principles 15: Grow Terrain from Local Height Rules
Binary caves ask:
wall or floor?
Terrain needs richer state.
Let each cell store a height:
0.0 = low
1.0 = high
Now local rules can smooth, raise, erode and classify terrain.
The challenge is not creating a pretty array.
It is creating a process whose output we can explain and control.
Start with a height field
import numpy as np
def random_heightmap(
rows=120,
cols=160,
seed=42,
):
rng = np.random.default_rng(seed)
return rng.random(
(rows, cols)
)
Raw independent noise contains variation but little large-scale geography.
Add a local mean
def local_mean(grid):
total = np.zeros_like(grid)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
total += np.roll(
np.roll(
grid,
dy,
axis=0,
),
dx,
axis=1,
)
return total / 9.0
Blend toward the neighborhood:
def smooth_step(
height,
strength=0.35,
):
mean = local_mean(height)
return (
(1 - strength) * height
+ strength * mean
)
Repeated smoothing creates broad spatial regions.
But smoothing alone is not a terrain generator.
If we continue forever, it removes differences.
Add a competing process
Introduce persistent uplift:
uplift = np.zeros_like(height)
uplift[
30:92,
55:108,
] = 0.003
Then:
def terrain_step(
height,
uplift,
):
height = smooth_step(
height,
strength=0.35,
)
height = height + uplift
return np.clip(
height,
0.0,
1.0,
)
Now two local/global influences compete:
smoothing
-> reduces sharp local differences
uplift
-> continually creates elevation
The renderer also applies a gentle radial edge falloff so the demonstration develops an island-like boundary.

Inspect the final height field directly

This is important: the primary generated object is the height field.
Water, plains, hills and mountains are interpretations derived from it.
Derive semantic terrain classes
WATER = 0
PLAINS = 1
HILLS = 2
MOUNTAINS = 3
def classify_height(height):
terrain = np.zeros_like(
height,
dtype=np.uint8,
)
terrain[
(height >= 0.35)
& (height < 0.55)
] = PLAINS
terrain[
(height >= 0.55)
& (height < 0.75)
] = HILLS
terrain[
height >= 0.75
] = MOUNTAINS
return terrain
This separates:
simulation / generation state:
continuous height
game-facing interpretation:
terrain class
That separation keeps the underlying process reusable.
Add a second continuous field
Height alone does not determine every world property.
Add moisture:
moisture = np.zeros_like(height)
moisture[:, :20] = 1.0
Diffuse it inland:
for _ in range(100):
moisture = (
moisture
+ 0.1 * laplacian(moisture)
)
moisture[:, :20] = 1.0
Now each cell can be thought of as:
[height, moisture]
and biome classification can depend on both.
The grid is becoming a layered local state machine.
Global design goals should remain explicit
A game may require:
30-45% water
one large connected continent
flat spawn region
mountains away from spawn
river reaches ocean
Do not force a cellular smoothing rule to guarantee all of those.
Use:
local rules
-> organic structure
measurement
-> evaluate candidate
graph/search constraints
-> guarantee global requirements
This is the same lesson we learned from cave generation.
Measure the world
def terrain_stats(
height,
water_level=0.35,
):
return {
"mean_height": float(
height.mean()
),
"height_std": float(
height.std()
),
"water_fraction": float(
np.mean(
height < water_level
)
),
"mountain_fraction": float(
np.mean(
height > 0.75
)
),
}
Now seeds and parameter sets can be searched instead of judged only by screenshots.
One idea to keep
Terrain generation becomes easier to reason about when we separate three layers:
continuous generated fields
↓
derived semantic classes
↓
global design constraints
Local CA-style rules are excellent at producing spatial texture.
They do not need to carry every high-level requirement themselves.
In the next chapter we will use the same local machinery without pretending to simulate a world at all: we will deliberately treat cellular rules as visual texture generators.