Procedural Generation skill for Codex

Seeded worlds that are always playable. Codex writes the playability constraints before the generator, derives an independent RNG stream per stage and per chunk from the world seed, and picks BSP, cellular automata, noise, WFC or Poisson sampling per content layer. Every output is validated for connectivity and counts, failures are repaired instead of rerolled forever, and a bundled Python script sweeps hundreds of seeds to prove it.

  • Game Development
  • $procedural-generation
  • 165 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- procedural-generation

Installs to ~/.agents/skills/procedural-generation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $procedural-generation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds seeded procedural content for games - deterministic PRNG streams, value/Perlin/simplex noise with fBm and domain warping for terrain, BSP and room-and-corridor dungeons, cellular automata caves, wave function collapse, Poisson disk scatter, chunked infinite worlds, and validation that every seed is playable. Use when generating levels, dungeons, terrain, biomes, loot or object placement from a seed, when the same seed gives different worlds, when generated levels are sometimes unwinnable or disconnected, or when infinite terrain shows seams between chunks.

Use it when

  • Generating levels, caves, terrain or biomes from a seed
  • Generated levels are sometimes disconnected or unwinnable
  • A seed stops reproducing the same world after unrelated changes
  • Building chunked infinite worlds with noise

Use something else when

  • You need pathfinding through the generated level: use game-ai
  • You are writing a terrain shader rather than generating data: use glsl-shaders

How to check the result

The same seed produces an identical content hash, golden hashes hold for fixed seeds, and a sweep of 500+ seeds reports zero validation failures with distributions inside design ranges.

The mistake it prevents

Drawing every stage from one shared or global RNG, so adding a single extra random call in decoration reshuffles the layout of every saved seed.

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

--- name: procedural-generation description: Builds seeded procedural content for games - deterministic PRNG streams, value/Perlin/simplex noise with fBm and domain warping for terrain, BSP and… license: MIT ---

Done well means: the same seed and version produce the same content on every platform, every generated level satisfies explicit playability constraints (checked by code, across hundreds of seeds), the generator is split into stages that can be inspected, and designers control the output through a few meaningful parameters rather than magic numbers.

Workflow

- [ ] 1. Write the constraints first: connectivity, start-to-exit path, min/max room count,
         resource counts, difficulty curve
- [ ] 2. Pick the algorithm per layer (tables below)
- [ ] 3. Derive one RNG stream per stage from the world seed
- [ ] 4. Generate in stages: layout -> carve -> decorate -> populate
- [ ] 5. Validate every output; repair or reject-and-retry with a derived seed
- [ ] 6. Sweep 500+ seeds headless and log failures and stats
- [ ] 7. Add a debug view per stage (ASCII, image dump, gizmos)

Read AGENTS.md and find whether the engine has a built-in noise (Godot FastNoiseLite, Unity Mathf.PerlinNoise / Unity.Mathematics.noise) or a library already in use.

Seeds and RNG

  • Never use the global RNG (Math.random, random.random, UnityEngine.Random static state, randi() without a dedicated RandomNumberGenerator). Other systems consume it and change your output.
  • One generator per stage, derived from the world seed and a stage name, so changing how many numbers the decoration stage draws does not reshuffle the layout:
// splitmix64-style hash -> 32-bit seed per stage, then a small fast PRNG
function hash32(seed: number, label: string): number {
  let h = seed ^ 0x9e3779b9;
  for (let i = 0; i < label.length; i++) h = Math.imul(h ^ label.charCodeAt(i), 0x85ebca6b);
  h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
  return h >>> 0;
}
function mulberry32(a: number) {
  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 layoutRng = mulberry32(hash32(worldSeed, "layout"));
const lootRng = mulberry32(hash32(worldSeed, "loot"));
  • For chunked worlds, seed by coordinates: hash32(worldSeed, "chunk:" + cx + "," + cy). Any chunk can be generated alone, in any order, on any thread.
  • Cross-platform determinism: avoid platform Math.sin/pow in decisions that must match across machines (results can differ in the last bits); integer hashing and integer grids are safe. Version your generator (GEN_VERSION = 3) and store it with saves, since any change alters every seed.
  • Shareable seeds: hash a string (“SUNNYCAVE”) to a number; show players the string.

Algorithm choice

ContentDefaultAlternatives
Rooms joined by corridorsBSP split + rooms + corridorsRandom room placement with rejection, then MST plus a few extra edges for loops
Organic cavesCellular automata (45% fill, 4-5 smoothing steps), keep largest regionDrunkard’s walk; noise threshold
Hand-authored feel from small piecesPrefab rooms stitched on a graph (Spelunky-style path first)Wave function collapse for tile adjacency
Terrain heightmapsSimplex / OpenSimplex2 noise with fBmDomain warping for ridges and erosion-like shapes; hydraulic erosion pass offline
BiomesTwo noise fields (temperature, moisture) into a lookup tableVoronoi regions for hard borders
Object scatter (trees, rocks)Poisson disk sampling (Bridson)Jittered grid (cheaper, more regular)
Mission / lock-and-key structureGraph grammar or cyclic generation, then embed into space-

WFC produces local consistency, not global structure: it will not guarantee a path from start to exit. Constrain it (pre-place the path tiles) or validate and retry.

Noise in practice

// fBm over any 2D noise(x, y) in [-1, 1]
function fbm(x: number, y: number, octaves = 5, lacunarity = 2, gain = 0.5): number {
  let sum = 0, amp = 1, freq = 1, norm = 0;
  for (let i = 0; i < octaves; i++) {
    sum += amp * noise(x * freq, y * freq);
    norm += amp;
    amp *= gain;
    freq *= lacunarity;
  }
  return sum / norm;                                  // back to roughly [-1, 1]
}
const height = fbm(wx * 0.004, wy * 0.004);           // scale sets feature size in world units
  • Feature size is controlled by frequency: 0.004 means features roughly 250 units across.
  • Sample noise in world coordinates, never chunk-local coordinates, or chunk edges will not match.
  • Offset each independent field (noise(x + 1000, y) or a separately seeded noise instance) so temperature and moisture are not the same pattern.
  • Perlin noise is zero at integer lattice points; never sample at integer-only coordinates.
  • Redistribution shapes the result: Math.pow(h01, 1.8) flattens lowlands and sharpens peaks.

Validation and repair

Every generator needs a validator that runs in tests and in development builds:

  • Flood fill from the start: all required rooms, exits and keys reachable.
  • Path length start to exit within a range (not trivially short).
  • Counts within bounds: enemies, loot value, room count.
  • No spawn inside walls; no item on top of another.

On failure, prefer a targeted repair (carve a corridor between disconnected regions, delete the smaller region) over infinite rerolling. If you reroll, derive the next seed deterministically (hash32(seed, "retry1")) and cap attempts, then fall back to a known-good handcrafted layout.

Prototype quickly with the bundled script

scripts/dungeon_gen.py generates BSP dungeons or cellular-automata caves from a seed, prints them as ASCII and checks reproducibility, connectivity and floor ratio. Use it to tune parameters or demonstrate the validation loop before porting to the engine:

python3 scripts/dungeon_gen.py --algo bsp --seed 42 --width 64 --height 36
python3 scripts/dungeon_gen.py --algo caves --seed 1 --sweep 500     # exit 1 lists failing seeds

Performance

  • Generate on a worker thread or across frames (time-sliced) for anything over a few ms; never block the main thread during play.
  • Chunked worlds: generate the ring around the player, cache by chunk coordinate, unload far chunks, and regenerate deterministically instead of saving untouched chunks.
  • Save only player modifications as a diff on top of the seed.

Gotchas

  • Iterating a hash set or dictionary while placing content makes output depend on hash order; sort keys first.
  • Floating-point accumulation (x += step) across thousands of iterations drifts between platforms; compute from integer indices (x = i * step).
  • Tuning by eye on one seed. Always look at 20 seeds side by side before judging a parameter.
  • Generator changes silently invalidate saved worlds; bump the generator version and migrate or keep the old generator for old saves.

Verification

  • Same seed twice produces an identical content hash; a test asserts a golden hash per generator version for 3 fixed seeds.
  • A seed sweep (500+) reports 0 validation failures, plus distributions (room count, path length) inside design ranges.
  • Adjacent chunks share identical border samples.
  • Generation time per level or chunk measured against the budget (e.g. under 4 ms per chunk on a worker).

Hand-off summary

**Generator v3:** BSP layout (min leaf 8) -> corridors -> decorate (Poisson props) -> populate
**Seeds:** world seed + per-stage streams (layout, decor, loot); chunk seed from coordinates
**Validation:** connectivity, start->exit 40-120 tiles, 6-14 rooms; repair joins orphan regions
**Sweep:** 1000 seeds, 0 failures; median 9 rooms; 2.1 ms per level
**Golden hashes:** seeds 1, 42, 9001 locked in tests/generator.test.ts

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Procedural Generation"
  short_description: "Seeded worlds that are always playable"
  default_prompt: "$procedural-generation Generate roguelike dungeons from a seed and guarantee every room is reachable"
scripts/dungeon_gen.py182 lines, 6.6 KB
#!/usr/bin/env python3
"""Seeded dungeon and cave generator for prototyping level rules.

Generates a map with BSP rooms or cellular-automata caves, then checks the
properties a generator must guarantee: reproducibility for the same seed,
full connectivity of floor tiles, and a minimum floor ratio.

Usage:
  python3 dungeon_gen.py --algo bsp --seed 42 --width 64 --height 36
  python3 dungeon_gen.py --algo caves --seed 7 --sweep 200   # test 200 seeds, print failures

Exit code 0 when every generated map passes, 1 otherwise. Standard library only.
"""
from __future__ import annotations

import argparse
import hashlib
import random
import sys
from collections import deque

WALL, FLOOR = "#", "."


def bsp_dungeon(w: int, h: int, rng: random.Random, min_leaf: int = 8) -> list[list[str]]:
    grid = [[WALL] * w for _ in range(h)]
    leaves = [(1, 1, w - 2, h - 2)]
    changed = True
    while changed:
        changed = False
        nxt = []
        for x, y, lw, lh in leaves:
            if lw > 2 * min_leaf or lh > 2 * min_leaf:
                horizontal = lh > lw if abs(lw - lh) > 4 else rng.random() < 0.5
                if horizontal and lh > 2 * min_leaf:
                    cut = rng.randint(min_leaf, lh - min_leaf)
                    nxt += [(x, y, lw, cut), (x, y + cut, lw, lh - cut)]
                    changed = True
                    continue
                if not horizontal and lw > 2 * min_leaf:
                    cut = rng.randint(min_leaf, lw - min_leaf)
                    nxt += [(x, y, cut, lh), (x + cut, y, lw - cut, lh)]
                    changed = True
                    continue
            nxt.append((x, y, lw, lh))
        leaves = nxt

    centers = []
    for x, y, lw, lh in leaves:
        rw = rng.randint(max(3, lw // 2), max(3, lw - 2))
        rh = rng.randint(max(3, lh // 2), max(3, lh - 2))
        rx = x + rng.randint(0, max(0, lw - rw - 1))
        ry = y + rng.randint(0, max(0, lh - rh - 1))
        for yy in range(ry, min(ry + rh, h - 1)):
            for xx in range(rx, min(rx + rw, w - 1)):
                grid[yy][xx] = FLOOR
        centers.append((rx + rw // 2, ry + rh // 2))

    # Connect rooms in leaf order with L-shaped corridors (leaf order follows the split tree).
    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


def carve_h(grid, x1, x2, y):
    for x in range(min(x1, x2), max(x1, x2) + 1):
        grid[y][x] = FLOOR


def carve_v(grid, y1, y2, x):
    for y in range(min(y1, y2), max(y1, y2) + 1):
        grid[y][x] = FLOOR


def cave(w: int, h: int, rng: random.Random, fill: float = 0.45, steps: int = 5) -> list[list[str]]:
    grid = [[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):
        new = [row[:] for row in grid]
        for y in range(1, h - 1):
            for x in range(1, w - 1):
                walls = sum(grid[y + dy][x + dx] == WALL
                            for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dx or dy)
                new[y][x] = WALL if walls >= 5 else FLOOR if walls <= 3 else grid[y][x]
        grid = new
    keep_largest_region(grid)
    return grid


def regions(grid) -> list[set[tuple[int, int]]]:
    h, w = len(grid), len(grid[0])
    seen: set[tuple[int, int]] = set()
    out = []
    for y in range(h):
        for x in range(w):
            if grid[y][x] != FLOOR or (x, y) in seen:
                continue
            region = {(x, y)}
            seen.add((x, y))
            q = deque([(x, y)])
            while q:
                cx, cy = q.popleft()
                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 grid[ny][nx] == FLOOR and (nx, ny) not in seen:
                        seen.add((nx, ny))
                        region.add((nx, ny))
                        q.append((nx, ny))
            out.append(region)
    return out


def keep_largest_region(grid) -> None:
    regs = regions(grid)
    if not regs:
        return
    largest = max(regs, key=len)
    for reg in regs:
        if reg is not largest:
            for x, y in reg:
                grid[y][x] = WALL


def generate(algo: str, seed: int, w: int, h: int) -> list[list[str]]:
    rng = random.Random(seed)          # one generator per map, never the global random
    return bsp_dungeon(w, h, rng) if algo == "bsp" else cave(w, h, rng)


def digest(grid) -> str:
    return hashlib.sha256("\n".join("".join(r) for r in grid).encode()).hexdigest()[:16]


def check(algo: str, seed: int, w: int, h: int, min_floor: float) -> tuple[list[str], list[list[str]]]:
    problems = []
    a, b = generate(algo, seed, w, h), generate(algo, seed, w, h)
    if digest(a) != digest(b):
        problems.append("not reproducible for the same seed")
    regs = regions(a)
    if len(regs) != 1:
        problems.append(f"{len(regs)} disconnected floor regions")
    floor = sum(row.count(FLOOR) for row in a) / (w * h)
    if floor < min_floor:
        problems.append(f"floor ratio {floor:.2f} below {min_floor:.2f}")
    return problems, a


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--algo", choices=["bsp", "caves"], default="bsp")
    p.add_argument("--seed", type=int, default=1)
    p.add_argument("--width", type=int, default=64)
    p.add_argument("--height", type=int, default=36)
    p.add_argument("--min-floor", type=float, default=0.2)
    p.add_argument("--sweep", type=int, default=0, help="check this many seeds starting at --seed")
    args = p.parse_args()

    if args.sweep:
        failures = 0
        for s in range(args.seed, args.seed + args.sweep):
            problems, _ = check(args.algo, s, args.width, args.height, args.min_floor)
            if problems:
                failures += 1
                print(f"seed {s}: " + "; ".join(problems))
        print(f"{args.algo}: {args.sweep - failures}/{args.sweep} seeds passed")
        return 1 if failures else 0

    problems, grid = check(args.algo, args.seed, args.width, args.height, args.min_floor)
    print("\n".join("".join(r) for r in grid))
    print(f"\nalgo={args.algo} seed={args.seed} size={args.width}x{args.height} hash={digest(grid)}")
    if problems:
        print("FAIL: " + "; ".join(problems))
        return 1
    print("OK: reproducible, connected, floor ratio within bounds")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc