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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- procedural-generation Installs to ~/.agents/skills/procedural-generation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install procedural-generation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project procedural-generation Installs to .agents/skills/procedural-generation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/procedural-generation.zipunzip -o procedural-generation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) procedural-generation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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.Randomstatic state,randi()without a dedicatedRandomNumberGenerator). 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/powin 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
| Content | Default | Alternatives |
|---|---|---|
| Rooms joined by corridors | BSP split + rooms + corridors | Random room placement with rejection, then MST plus a few extra edges for loops |
| Organic caves | Cellular automata (45% fill, 4-5 smoothing steps), keep largest region | Drunkard’s walk; noise threshold |
| Hand-authored feel from small pieces | Prefab rooms stitched on a graph (Spelunky-style path first) | Wave function collapse for tile adjacency |
| Terrain heightmaps | Simplex / OpenSimplex2 noise with fBm | Domain warping for ridges and erosion-like shapes; hydraulic erosion pass offline |
| Biomes | Two noise fields (temperature, moisture) into a lookup table | Voronoi regions for hard borders |
| Object scatter (trees, rocks) | Poisson disk sampling (Bridson) | Jittered grid (cheaper, more regular) |
| Mission / lock-and-key structure | Graph 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.004means 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.yaml
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.py
#!/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.