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.

  • nameprocedural-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

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.

~/.claude/skills/procedural-generation/SKILL.md

--- name: procedural-generation description: Builds seeded, reproducible procedural content such as noise terrain and biomes, BSP and cellular-automata dungeons, wave function collapse… license: MIT ---

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(), static UnityEngine.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_distribution is 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

ContentTechniqueNotes
Terrain heightmaps, islandsGradient/simplex noise with fBm, domain warping, falloff masksRedistribute heights with a curve; erosion pass for realism
BiomesTwo low-frequency noise fields (temperature, moisture) mapped through a lookup tableOr Voronoi regions for hard borders
Room-and-corridor dungeonsBSP partition + rooms + corridors, or random room placement + MST + extra loopsAdd 10-15% extra edges so maps are not pure trees
CavesCellular automata (45% fill, 4-5 smoothing steps), keep largest regionConnect 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 varietyPrefab chunk stitching (authored rooms on a generated graph)Most shipped roguelikes use this
Missions, lock-and-key progressionGraph grammar generated first, then mapped to spaceGuarantees solvability by construction
Plants, rivers, roadsL-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 small n; 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# Dictionary after 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 noise 1 - 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 FastNoiseLite resource, 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

RuleCheck
All walkable space reachableFlood fill from start; one region (or remove the rest)
Level completableA* from start to exit, respecting locks, jumps, and one-way drops
Keys before locksTopological order on the progression graph
FairnessNo enemies within N tiles of spawn, minimum resources per room
VarietyOpen-area ratio, room count, path length within designer bands
PerformanceEntity 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

  1. Seed sweep (500+ seeds) passes all validation rules, or failures are understood and fixed.
  2. 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).
  3. Same seed generates identical output twice in one process and across process restarts.
  4. 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 207 lines · 8.1 KB
#!/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.

esc