Constraint tiling
The Townscaper/Bad North PCG family: the dual-grid fix, Wave Function Collapse from an example, variant buckets.
There are two families of procedural generation, and they solve different problems. Reach for the right one:
Point-scatter (pcg, scatter) | Constraint tile-assembly (wfc, gridmap, mesh) | |
|---|---|---|
| Question | ”Put N things over a surface" | "Assemble handmade modules so neighbours fit” |
| Output | Instances (trees, rocks, debris) on terrain | A coherent built structure (towns, dungeons, terrain tiles) |
| Driver | Sample → filter → place | Neighbour constraints decide each cell’s module + rotation |
| Examples | Foliage, set dressing, crowds | Townscaper, Bad North, dungeon layouts |
This file is the buildable playbook for the second family — the technique from Oscar Stålberg’s “How One Guy FIXED Procedural Generation”. The first family lives in level-design.md/environment-art.md.
Every recipe below was driven against a live editor. Set up a GridMap first (node add --type GridMap --parent . --name Grid); paint with item ids, then assign a real MeshLibrary (gridmap meshlibrary_from_scene → node set mesh_library) once the logic works.
1. The dual-grid fix — type corners, not cells#
The naive approach types whole cells (land or water), then each cell needs a mesh based on its 4 neighbours — 2⁸ = 256 cases, and shared corners between cells fight over whether they’re convex or concave.
The fix: type the grid’s corners instead. Each cell reads its own 4 corners (2⁴ = 16 configs), which collapse under rotation to just 6 distinct tiles. Modeling corners inside a tile kills the convex/concave conflict for free.
Get the canonical table — author one mesh per tile in its steps=0 orientation:
godot-mcp wfc case_table --states 2# → 6 tiles: empty, outer_corner, edge, diagonal, inner_corner, full# + a mapping from every 4-corner config to {tile, rotation steps}Build a dual-grid island: paint a per-corner type field (1 = land, 0 = water), then solve. Corners are addressed in corner space; a main cell at (cx,cz) owns corners (cx,cz),(cx+1,cz),(cx+1,cz+1),(cx,cz+1).
# water everywhere, then a land blob in the middlegodot-mcp wfc set-corner --node-path Grid --from "0,0" --to "8,8" --state 0godot-mcp wfc set-corner --node-path Grid --from "2,2" --to "6,6" --state 1
# map each canonical tile -> a MeshLibrary item id (-1 = place nothing), then solvegodot-mcp wfc solve-dual --node-path Grid \ --rules '{"0":-1,"1":11,"2":12,"3":13,"4":14,"5":15}'# → sets each cell to the right tile + GridMap orientation (rotation handled for you)Notes that save debugging:
- GridMap orientations are the 24 proper rotations — no reflections — which is why dual-grid canonicalization is rotation-only (and why binary gives exactly 6 tiles). Author meshes to the documented winding (SW,SE,NE,NW, CCW from above); if a tile faces the wrong way, add
--cwto flip the rotation direction rather than re-modeling. set_cornerstores the field as GridMap metadata, so you can paint incrementally (the click-to-toggle workflow) and re-solve. Or pass a whole field inline withsolve_dual --corners '{"x,z":state,…}'.
2. Kill repetition with variant buckets#
Author several mesh variants for a tile, hand them all to the solver, and identical-looking neighbours stop forming. The pick is a deterministic (cell, seed) hash, so re-runs converge.
# a tile id in the rules can be an ARRAY of variants:godot-mcp wfc solve-dual --node-path Grid --seed 7 \ --rules '{"0":-1,"1":11,"2":[20,21,22],"4":40,"5":[30,31]}'
# or paint a single cell from a candidate set directly:godot-mcp gridmap set-cell-variant --node-path Grid --cell "Vector3i(4,0,4)" \ --variants '[20,21,22]' --seed 73. Full-auto layouts — Wave Function Collapse#
When you don’t want to paint corners by hand, learn the rules from a small authored example and let WFC fill a region. This is the “simple tiled model”: author a few cells showing what may touch what, then collapse.
Build: author an example (here a water│sand│grass gradient where water never touches grass), learn its adjacency, collapse a fresh region.
# author the example on a second GridMapgodot-mcp gridmap fill --node-path Example --from "0,0,0" --to "1,0,5" --item 1 # watergodot-mcp gridmap fill --node-path Example --from "2,0,0" --to "3,0,5" --item 2 # sandgodot-mcp gridmap fill --node-path Example --from "4,0,0" --to "5,0,5" --item 3 # grass
# learn adjacency + weights → a rules filegodot-mcp wfc rules-from-example --node-path Example --output-path res://rules.json
# collapse an 8×8 region (single layer); seed makes it reproduciblegodot-mcp wfc collapse --node-path Grid --from "Vector3i(0,0,0)" --to "Vector3i(7,0,7)" \ --rules-path res://rules.json --seed 5 --respect-existing=falseThe payoff is mixed constraints: pre-paint anchor cells and collapse around them — the solver bridges them legally.
godot-mcp gridmap fill --node-path Grid --from "0,0,0" --to "0,0,5" --item 1 # water wallgodot-mcp gridmap fill --node-path Grid --from "6,0,0" --to "6,0,5" --item 3 # grass wallgodot-mcp wfc collapse --node-path Grid --from "Vector3i(0,0,0)" --to "Vector3i(6,0,5)" \ --rules-path res://rules.json --seed 5# → existing cells are pinned; the solver fills the gap with sand: 1 2 2 2 2 2 3collapseis single-layer (from.y == to.y) and retries on contradiction (--max_retries, default 12). A persistent contradiction means the example can’t tile that region — loosen it or add tiles.--respect-existing(default true) and--fixed '{"x,z":item}'are how player/agent edits constrain the solve. Pass--respect-existing=false(andgridmap clearfirst) for a fresh unconstrained fill.
4. Special pieces — the Townscaper moment#
A “special piece” is a multi-cell pattern that, when it appears, gets swapped for something bespoke (4 grass cells touching → a fountain). The match is rotation-aware, and matched cells are consumed so pieces never overlap.
# replace every 2×2 of item 5 with a special piece (10) at the origin, clearing the restgodot-mcp wfc match-pattern --node-path Grid \ --pattern '{"match":{"0,0":5,"1,0":5,"0,1":5,"1,1":5},"replace":{"0,0":10,"1,0":-1,"0,1":-1,"1,1":-1}}'In match, item -1 means “require empty”; in replace, -1 clears the cell. --rotate (default on) tries all 4 orientations and orients the placed piece to match; --limit caps placements.
5. Organic, non-grid layouts#
A square grid reads as a grid. For the Townscaper “irregular but tileable” look, generate an all-quad mesh from a jittered triangular lattice (random triangle-pair merge → subdivide-to-quads → relax).
godot-mcp wfc stalberg-grid --width 8 --depth 8 --seed 1 \ --relax-iterations 12 --emit markers --name Town# → quad corner lists (and Marker3D nodes at each quad centre to see the layout)Tune with --jitter (off-grid push), --relax-iterations (smoothing; boundary stays fixed), --spacing. The relaxation primitive is also exposed standalone — pcg relax --points … --edges … — for de-clumping any point set.
Conform a square module to an irregular cell. The quads above aren’t square, so warp each module to fit: store every vertex as a % of the module’s AABB, rebuild from the cell’s 8 displaced corners.
# handles = 8 AABB corners by bit (x=i&1, y=(i>>1)&1, z=(i>>2)&1);# bottom face = 0,1,4,5 top = 2,3,6,7. Move corners to the target cell's corners.godot-mcp mesh deform-lattice --node-path Module \ --handles '["Vector3(-1,-0.5,-1)","Vector3(1,-0.5,-1)","Vector3(-0.5,0.5,-0.5)","Vector3(0.5,0.5,-0.5)","Vector3(-1,-0.5,1)","Vector3(1,-0.5,1)","Vector3(-0.5,0.5,0.5)","Vector3(0.5,0.5,0.5)"]'# normals are recomputed from the deformed faces. --mesh-path saves a reusable .mesh.The shape of a build#
Constraint tile-assembly composes, like the rest of the toolset — your command sequence is the generator:
- Kit — author modules in the editor (or
csgblockouts →csg bake),gridmap meshlibrary_from_scene→ assign withnode set mesh_library. - Logic — paint item ids and run
wfc solve_dual/collapseuntil the topology is right (read it back withgridmap get_used_cells, not a screenshot). - Variety — variant buckets +
match_patternfor special pieces. - Organic —
stalberg_gridfor the layout,mesh deform_latticeto fit modules to irregular cells.
Wrap risky runs in authoring checkpoint --action capture so a bad seed is one restore away.