Procedural generation
Generate levels, terrain, and loot from compact rules and a seed. The throughline of good procgen is determinism: a single seed reproduces the same world, so bugs are repeatable and players can share seeds. This skill owns the core algorithms — noise, seeded RNG, dungeon layout, weighted tables; genres like roguelike and survival-crafting consume it.
When to use
- Use to generate maps, dungeons, terrain heightmaps, item drops, or any content
- Use when results must be reproducible from a seed (debugging, daily
- Use to pick weighted random outcomes (loot rarity, spawn tables).
you do not want to author by hand.
challenges, shareable worlds).
**When *not* to use:** for the engine's tile API to *paint* the result, use godot-tilemap or unity-tilemap-2d. For routing AI through the generated map, use game-ai. For carefully hand-paced levels, use level-design — procgen and authored design are complementary, not interchangeable.
Core workflow
- Own your randomness. Create one seeded RNG instance and pass it
- Pick the technique for the content. Continuous terrain/heightmaps → noise.
- Generate into a plain data grid/array first, decoupled from rendering.
- Validate before shipping the result to the player. Is every room
- Tune with the seed fixed so each parameter change is visible in isolation,
everywhere. Never call the global/static random in generation code — it makes results irreproducible and order-dependent.
Discrete rooms/corridors → space partitioning or agent-based carving. Outcomes with rarities → weighted tables.
Generation fills int[][] or a dict; a separate pass draws it.
reachable? Is the spawn safe? Is there a path to the exit? Reject or repair layouts that fail; do not hand the player a broken map.
then sweep seeds to check the distribution, not just one lucky map.
Patterns
1. Seeded, deterministic RNG (the foundation)
import random
rng = random.Random(seed) # a dedicated instance — NOT the global random.*
room_count = rng.randint(5, 12) # same seed -> same sequence, every run
# RIGHT: thread `rng` through every function that makes a choice.
# WRONG: calling random.randint(...) (global state) — order-dependent, unseedable.
Engine equivalents: Godot var rng = RandomNumberGenerator.new(); rng.seed = s; Unity var rng = new System.Random(seed) (or UnityEngine.Random.InitState). Store the seed in the save file so a world can be regenerated.
2. Fractal (fBm) noise for heightmaps
# Sum several octaves: each higher octave has higher frequency, lower amplitude.
def fbm(noise, x, y, octaves=5, lacunarity=2.0, gain=0.5):
total, amp, freq, norm = 0.0, 1.0, 1.0, 0.0
for _ in range(octaves):
total += amp * noise(x * freq, y * freq) # noise() returns ~0..1
norm += amp # track total amplitude
amp *= gain # each octave contributes less
freq *= lacunarity # ...at a higher frequency
return total / norm # normalize back into 0..1
# Redistribute to carve flat valleys / sharpen peaks: higher exp -> more lowland.
elevation = pow(fbm(noise, nx, ny), 2.2)
Use a real noise library (FastNoiseLite, opensimplex, Unity.Mathematics.noise, or Mathf.PerlinNoise) — do not implement gradient noise yourself. Seed elevation and moisture with different seeds so a biome lookup over both fields isn't perfectly correlated. Full biome lookup and island shaping are in references/noise.md.
3. Weighted loot table (rarity-correct selection)
# Roll proportional to weight: common drops far more often than legendary.
def weighted_pick(rng, table): # table: list of (item, weight)
total = sum(w for _, w in table)
roll = rng.uniform(0, total) # a point on the cumulative line
upto = 0.0
for item, w in table:
upto += w
if roll < upto: # first bucket the roll falls into
return item
return table[-1][0] # float-safety fallback
loot = weighted_pick(rng, [("common", 70), ("rare", 25), ("legendary", 5)])
Weights need not sum to 100 — they are relative. To prevent bad streaks, use a "pity"/bag system (see references/dungeon-generation.md notes on distributions).
4. Rooms-and-corridors dungeon (sketch)
# 1. Place non-overlapping rooms; 2. connect them; 3. carve into the grid.
rooms = []
for _ in range(attempts):
r = Rect(rng.randint(1, W-w-1), rng.randint(1, H-h-1), w, h)
if not any(r.intersects(o.expand(1)) for o in rooms): # keep a 1-tile gap
rooms.append(r)
for a, b in zip(rooms, rooms[1:]): # connect each room to the next
carve_l_corridor(grid, a.center, b.center, rng) # horizontal then vertical
The complete generator (BSP partitioning, L-corridors, reachability check, and random-walk caves) is in references/dungeon-generation.md.
Pitfalls
- Using the global RNG inside generation makes worlds unreproducible and
- Correlated noise fields: sampling elevation and moisture from the *same*
- Octave artifacts: adding octaves without renormalizing pushes values out of
- No connectivity check: rooms or caves can end up isolated. Flood-fill from
- Unbounded placement loops: "keep trying until N rooms fit" can spin forever
- Seeding once globally, then relying on frame timing: any non-deterministic
breaks the moment call order changes. Always pass a seeded instance.
seed/offset produces biomes that line up in bands. Offset or reseed each field.
0..1; divide by the summed amplitude (and beware library output ranges — some return -1..1, some 0..1).
the spawn and discard/reconnect unreachable regions before play.
on a small grid. Cap attempts and accept fewer rooms.
input (time, physics, hash randomization) leaking into generation destroys reproducibility.
References
references/noise.md— octaves/lacunarity/gain, redistribution, islandreferences/dungeon-generation.md— BSP, rooms+corridors, random-walk caves,
shaping, two-axis biome lookup, blue-noise object scatter.
cellular-automata smoothing, connectivity validation, distribution/pity tables.
Related skills
godot-tilemap,unity-tilemap-2d— paint the generated grid into the engine.game-ai— pathfinding over the generated graph.level-design— pacing and hand-authored structure that procgen complements.roguelike,survival-crafting— genres that compose this skill.