Procedural Generation skill for Claude Code
Seeded worlds that replay exactly and always work. Claude builds generators that take an explicit seed, derive independent sub-seeds per purpose and chunk, and validate every output for connectivity and solvability before use. It picks the right technique for the content, handles WFC contradictions, keeps chunk generation off the main thread, and locks behavior down with seed sweeps and golden-hash tests.
- name
procedural-generation - size195 lines · ≈2,450 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds seeded, reproducible procedural content such as noise terrain and biomes, BSP and cellular-automata dungeons, wave function collapse layouts, Poisson disk placement, and chunked infinite worlds, with validation passes for connectivity and solvability and golden-hash regression tests. Use when generating levels, maps, terrain, loot, or worlds from a seed, when the same seed gives different results across runs or platforms, generated levels are unplayable or boring, WFC hits contradictions, or chunk generation causes hitches. Try asking
- Generate roguelike dungeons from a seed and guarantee every level is completable
- The same world seed gives different terrain on Android and PC, find out why
- Our WFC town generator fails with contradictions half the time, fix it
Use it when
- Generating levels, terrain, loot or worlds from a seed
- The same seed produces different results across runs or platforms
- Generated levels are disconnected, unwinnable or repetitive
- Chunk generation causes frame hitches
Skip it when
- Pathfinding and AI behavior inside generated levels — use game-ai-pathfinding
- Procedural patterns computed per pixel on the GPU — use shader-programming
How to check the result
A sweep of 500+ seeds passes connectivity and solvability checks, golden-hash tests pass on every target platform, and generation stays inside its time budget.
The common mistake it prevents
Drawing all randomness from one shared RNG stream, so adding a single feature silently reshuffles every existing world and breaks saved seeds.
Done well means: the same seed produces the same content on every supported platform and build of the same generator version, every generated output passes automated validation before a player sees it, sub-systems draw from independent seeded streams, and designers can tune parameters and replay any seed from a log line.
Ground rules
- Every generator takes an explicit seed and owns its RNG instance. Never use global
random state (
Math.random(),random.random(), staticUnityEngine.Random) shared with gameplay; one extra call elsewhere reshuffles the whole world. - Use a specified PRNG algorithm (PCG32, xoshiro128**, SplitMix64) implemented in the
project when cross-platform or cross-language reproducibility matters. Standard library
distributions can differ by platform or version (C++
std::uniform_int_distributionis implementation-defined across standard libraries). - Derive sub-seeds by hashing, not by sequence:
seed_for(world_seed, "loot", chunk_x, chunk_y). Adding a new feature then cannot shift the output of existing ones. - Version the generator. Save files store
(seed, generator_version); old saves keep loading with the old version or are migrated deliberately. - Generate, then validate, then (if invalid) retry with a derived seed a bounded number of times and log it. Never ship content that only “usually” works.
Workflow
- [ ] 1. Write the requirements as checkable rules (connected, start-to-exit path, N rooms, difficulty band)
- [ ] 2. Pick the technique (table below); prototype at small size with ASCII or debug images
- [ ] 3. Wire seeds: world seed -> hashed sub-seeds per purpose and per chunk
- [ ] 4. Add validation; sweep 500-1000 seeds and inspect the failures
- [ ] 5. Expose tuning parameters in data; show the seed in debug UI and logs
- [ ] 6. Add golden-hash tests for a few seeds; measure generation time on target hardware
Choosing a technique
| Content | Technique | Notes |
|---|---|---|
| Terrain heightmaps, islands | Gradient/simplex noise with fBm, domain warping, falloff masks | Redistribute heights with a curve; erosion pass for realism |
| Biomes | Two low-frequency noise fields (temperature, moisture) mapped through a lookup table | Or Voronoi regions for hard borders |
| Room-and-corridor dungeons | BSP partition + rooms + corridors, or random room placement + MST + extra loops | Add 10-15% extra edges so maps are not pure trees |
| Caves | Cellular automata (45% fill, 4-5 smoothing steps), keep largest region | Connect or remove isolated pockets |
| Tile-coherent layouts (towns, interiors, 2D levels) | Wave function collapse (simple tiled or overlapping model) | Needs good tile sets and contradiction handling |
| Object placement (trees, rocks, enemies) | Poisson disk sampling (Bridson) | Even spacing without grid look |
| Handcrafted feel with variety | Prefab chunk stitching (authored rooms on a generated graph) | Most shipped roguelikes use this |
| Missions, lock-and-key progression | Graph grammar generated first, then mapped to space | Guarantees solvability by construction |
| Plants, rivers, roads | L-systems, agent walkers, A* over cost fields |
Seeds and RNG
// SplitMix32-style hash for deriving sub-seeds; same output in every language using 32-bit math
function hash32(...parts: (number | string)[]): number {
let h = 0x9e3779b9;
for (const p of parts) {
const s = String(p);
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 0x85ebca6b);
h ^= h >>> 13;
}
h = Math.imul(h ^ 0x7f, 0xc2b2ae35); // separator between parts
}
h ^= h >>> 16;
return h >>> 0;
}
// mulberry32: small, fast, specified PRNG
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const lootRng = mulberry32(hash32(worldSeed, 'loot', chunkX, chunkY));
- Integer ranges:
Math.floor(rng() * n)is fine for smalln; for 32-bit-wide ranges use rejection sampling to avoid modulo bias. - Shuffles: Fisher-Yates with the seeded RNG; never
array.sort(() => rng() - 0.5). - Iteration order must be stable: hash maps and sets in many languages (Python string sets
with hash randomization, C#
Dictionaryafter removals, Go maps) do not guarantee order. Sort keys before iterating in generation code. - Floats: the same float ops can differ across compilers, CPUs, and GPU vs CPU. For cross-platform reproducibility keep generation decisions on integers or quantize noise before thresholding.
Noise terrain recipe
- Base: fBm of simplex noise, 4-6 octaves, lacunarity about 2.0, gain about 0.5, base frequency so that features are 100-500 tiles wide.
- Shape: multiply by a radial or edge falloff for islands;
pow(h, 1.5-3)flattens valleys and sharpens peaks; ridged noise1 - abs(n)for mountain ranges. - Domain warp (offset the sample position by another noise) removes the “obviously noise” look.
- Use a maintained library (FastNoiseLite exists for C#, C++, JS, GDScript via Godot’s
FastNoiseLiteresource, Rust, and more) rather than writing simplex noise. - Normalize deliberately: fBm output range depends on octave count and gain; measure min and max over a sample before mapping to biomes.
Wave function collapse
- Start with the simple tiled model and explicit adjacency rules; the overlapping model learns rules from a sample image but is slower and harder to control.
- Choose the lowest-entropy cell (weighted), collapse with the seeded RNG, propagate constraints with a queue.
- Contradictions: restart with a derived seed (simple, works for small maps), or backtrack a limited depth. Log restart counts; frequent contradictions mean the tile set lacks transitions.
- Pre-constrain cells (borders, entrances, required rooms) before running; combine WFC with a higher-level graph so global structure (start to exit) is guaranteed.
Chunked and infinite worlds
- Each chunk’s content must be a pure function of
(world_seed, chunk_coords, generator_version). Do not read neighboring chunks’ generated state, or results depend on load order. - Features crossing chunk borders (trees, rivers, structures): decide them on a coarser grid seeded by region coordinates, or generate with a border margin and clip.
- Generate off the main thread; hand finished data back to the main thread for instantiation, spread across frames (time-slice to about 1-2 ms per frame).
- Cache by chunk key; persist player modifications as diffs over regenerated content.
Validation passes
| Rule | Check |
|---|---|
| All walkable space reachable | Flood fill from start; one region (or remove the rest) |
| Level completable | A* from start to exit, respecting locks, jumps, and one-way drops |
| Keys before locks | Topological order on the progression graph |
| Fairness | No enemies within N tiles of spawn, minimum resources per room |
| Variety | Open-area ratio, room count, path length within designer bands |
| Performance | Entity counts and generation time within budget |
Reject and retry with hash32(seed, attempt); cap attempts (e.g. 20) and fall back to a
known-good handcrafted level with an error log.
Reproducibility and validation script
scripts/dungeon_gen.py is a reference BSP and cellular-automata generator with built-in
validation and hashing. Use it to prototype parameters, to demonstrate a seed sweep, or as
a model for the checks to port into the game:
python3 scripts/dungeon_gen.py --algo bsp --seed 42 --width 60 --height 30
python3 scripts/dungeon_gen.py --algo caves --seed 7 --width 80 --height 40 --quiet
python3 scripts/dungeon_gen.py --algo caves --sweep 500 # report failing seeds
python3 scripts/dungeon_gen.py --algo bsp --seed 42 --expect-hash 4f765c9414f640a2
It prints the map, room or region count, a 16-hex-digit hash, and exits 1 when a map is
disconnected, outside the open-area bounds, nondeterministic within one process, or does
not match --expect-hash.
Golden-hash tests
GOLDEN = {("bsp", 42): "4f765c9414f640a2", ("caves", 7): "..."}
def test_generator_is_stable():
for (algo, seed), expected in GOLDEN.items():
assert map_hash(generate(algo, 60, 30, seed)) == expected
A failing golden test means the generator output changed: either a bug or an intentional
change that requires bumping generator_version and updating the hashes in the same
commit.
Gotchas
- Seeding from time without logging the seed makes every bug report unreproducible. Log the seed at generation and show it in the pause/debug menu.
- Adding one RNG call early in a shared stream changes everything after it; per-purpose streams prevent this.
- Purely random placement looks clumpy; use Poisson disk or jittered grids.
- Generators that are technically valid can still be boring; measure variety metrics across a seed sweep and look at a contact sheet of 20-50 outputs.
- Very large generation on load causes hitches and long start times; precompute what is static, stream the rest.
Verification
- Seed sweep (500+ seeds) passes all validation rules, or failures are understood and fixed.
- Golden-hash tests pass on every target platform in CI (run at least one non-x86 or non-desktop target if the game ships there).
- Same seed generates identical output twice in one process and across process restarts.
- Generation time measured on the slowest target stays within its budget; chunk generation does not exceed the per-frame slice.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/dungeon_gen.py
#!/usr/bin/env python3
"""Seeded dungeon/cave generator and reproducibility checker (stdlib only).
Generates a tile map with BSP rooms+corridors or cellular-automata caves, validates it
(connectivity, open-area ratio, room count), prints it as ASCII, and prints a hash of
the map so the same seed can be compared across machines, languages, or commits.
python3 dungeon_gen.py --algo bsp --seed 42 --width 60 --height 30
python3 dungeon_gen.py --algo caves --seed 7 --width 80 --height 40 --quiet
python3 dungeon_gen.py --algo bsp --seed 42 --expect-hash 3f2a... # regression check
python3 dungeon_gen.py --algo bsp --sweep 500 # validate 500 seeds
Exit status: 0 = valid (and hash matched if given), 1 = validation or hash failure,
2 = bad arguments.
"""
from __future__ import annotations
import argparse
import hashlib
import random
import sys
from collections import deque
WALL, FLOOR = "#", "."
def new_grid(w: int, h: int) -> list[list[str]]:
return [[WALL] * w for _ in range(h)]
# ---------- BSP rooms and corridors ----------
def bsp(w: int, h: int, rng: random.Random, min_leaf: int = 8) -> tuple[list[list[str]], int]:
grid = new_grid(w, h)
leaves = [(1, 1, w - 2, h - 2)] # x, y, width, height (inside the outer wall)
changed = True
while changed:
changed = False
nxt = []
for (x, y, lw, lh) in leaves:
can_h = lh >= 2 * min_leaf
can_v = lw >= 2 * min_leaf
if not (can_h or can_v) or (lw < 3 * min_leaf and lh < 3 * min_leaf and rng.random() < 0.25):
nxt.append((x, y, lw, lh))
continue
# split across the longer side to avoid long thin leaves
horizontal = can_h and (not can_v or lh > lw or (lh == lw and rng.random() < 0.5))
if horizontal:
cut = rng.randint(min_leaf, lh - min_leaf)
nxt += [(x, y, lw, cut), (x, y + cut, lw, lh - cut)]
else:
cut = rng.randint(min_leaf, lw - min_leaf)
nxt += [(x, y, cut, lh), (x + cut, y, lw - cut, lh)]
changed = True
leaves = nxt
centers = []
for (x, y, lw, lh) in leaves: # one room per leaf, with a 1-tile margin
rw = rng.randint(max(3, lw // 2), lw - 2)
rh = rng.randint(max(3, lh // 2), lh - 2)
rx = rng.randint(x + 1, x + lw - rw - 1)
ry = rng.randint(y + 1, y + lh - rh - 1)
for yy in range(ry, ry + rh):
for xx in range(rx, rx + rw):
grid[yy][xx] = FLOOR
centers.append((rx + rw // 2, ry + rh // 2))
# connect rooms in leaf order (siblings are adjacent), L-shaped corridors
for (ax, ay), (bx, by) in zip(centers, centers[1:]):
if rng.random() < 0.5:
carve_h(grid, ax, bx, ay); carve_v(grid, ay, by, bx)
else:
carve_v(grid, ay, by, ax); carve_h(grid, ax, bx, by)
return grid, len(centers)
def carve_h(g, x1, x2, y):
for x in range(min(x1, x2), max(x1, x2) + 1):
g[y][x] = FLOOR
def carve_v(g, y1, y2, x):
for y in range(min(y1, y2), max(y1, y2) + 1):
g[y][x] = FLOOR
# ---------- cellular automata caves ----------
def caves(w: int, h: int, rng: random.Random, fill: float = 0.45, steps: int = 5) -> tuple[list[list[str]], int]:
g = [[WALL if (x in (0, w - 1) or y in (0, h - 1) or rng.random() < fill) else FLOOR
for x in range(w)] for y in range(h)]
for _ in range(steps):
n = new_grid(w, h)
for y in range(1, h - 1):
for x in range(1, w - 1):
walls = sum(g[y + dy][x + dx] == WALL for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dx or dy)
n[y][x] = WALL if walls >= 5 else FLOOR if walls <= 3 else g[y][x]
g = n
# keep only the largest connected region so every floor tile is reachable
regions = flood_regions(g)
if regions:
keep = max(regions, key=len)
keep_set = set(keep)
for y in range(h):
for x in range(w):
if g[y][x] == FLOOR and (x, y) not in keep_set:
g[y][x] = WALL
return g, len(regions)
# ---------- validation ----------
def flood_regions(g) -> list[list[tuple[int, int]]]:
h, w = len(g), len(g[0])
seen = [[False] * w for _ in range(h)]
regions = []
for y in range(h):
for x in range(w):
if g[y][x] != FLOOR or seen[y][x]:
continue
region, q = [], deque([(x, y)])
seen[y][x] = True
while q:
cx, cy = q.popleft()
region.append((cx, cy))
for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and g[ny][nx] == FLOOR:
seen[ny][nx] = True
q.append((nx, ny))
regions.append(region)
return regions
def map_hash(g) -> str:
return hashlib.sha256("\n".join("".join(r) for r in g).encode()).hexdigest()[:16]
def generate(algo, w, h, seed):
rng = random.Random(seed) # one RNG instance, owned by the generator, never global random
return bsp(w, h, rng) if algo == "bsp" else caves(w, h, rng)
def validate(algo, g, count, min_rooms, min_open, max_open) -> list[str]:
problems = []
total = (len(g) - 2) * (len(g[0]) - 2)
open_tiles = sum(r.count(FLOOR) for r in g)
ratio = open_tiles / total
regions = flood_regions(g)
if len(regions) != 1:
problems.append(f"{len(regions)} disconnected floor regions (expected 1)")
if not (min_open <= ratio <= max_open):
problems.append(f"open ratio {ratio:.2f} outside [{min_open}, {max_open}]")
if algo == "bsp" and count < min_rooms:
problems.append(f"{count} rooms < minimum {min_rooms}")
return problems
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--algo", choices=["bsp", "caves"], default="bsp")
p.add_argument("--seed", type=int, default=1)
p.add_argument("--width", type=int, default=60)
p.add_argument("--height", type=int, default=30)
p.add_argument("--min-rooms", type=int, default=4)
p.add_argument("--min-open", type=float, default=0.15)
p.add_argument("--max-open", type=float, default=0.70)
p.add_argument("--expect-hash", help="fail unless the map hash equals this value")
p.add_argument("--sweep", type=int, help="validate seeds 0..N-1 and report failures")
p.add_argument("--quiet", action="store_true", help="do not print the map")
a = p.parse_args()
if a.width < 20 or a.height < 16:
print("error: width >= 20 and height >= 16 required", file=sys.stderr)
return 2
if a.sweep:
bad = []
for s in range(a.sweep):
g, n = generate(a.algo, a.width, a.height, s)
probs = validate(a.algo, g, n, a.min_rooms, a.min_open, a.max_open)
if probs:
bad.append((s, probs))
print(f"{a.algo}: {a.sweep - len(bad)}/{a.sweep} seeds valid")
for s, probs in bad[:10]:
print(f" seed {s}: {'; '.join(probs)}")
return 1 if bad else 0
g, n = generate(a.algo, a.width, a.height, a.seed)
g2, _ = generate(a.algo, a.width, a.height, a.seed)
h1, h2 = map_hash(g), map_hash(g2)
if not a.quiet:
print("\n".join("".join(r) for r in g))
label = "rooms" if a.algo == "bsp" else "raw regions"
print(f"algo={a.algo} seed={a.seed} size={a.width}x{a.height} {label}={n} hash={h1}")
status = 0
if h1 != h2:
print("FAIL: same seed produced different maps in one process (hidden nondeterminism)")
status = 1
for prob in validate(a.algo, g, n, a.min_rooms, a.min_open, a.max_open):
print(f"FAIL: {prob}")
status = 1
if a.expect_hash and a.expect_hash != h1:
print(f"FAIL: hash {h1} != expected {a.expect_hash} (generator output changed for this seed)")
status = 1
if status == 0:
print("OK: connected, within open-area bounds, reproducible")
return status
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.