technique

Layered generation

Run the pipeline coarse to fine, biome per chunk then tiles per chunk then a regime per chunk over time, with seeds handed down so any chunk can be rebuilt alone and the seams do not show.

Before this

This page assumes you are comfortable with:

Why you need this

Each technique in this cluster solves one job. A real generator chains them: something coarse decides where the forest is, something fine places the forest tiles, something else changes the forest while you play, and a last step writes it all out. Layered generation is the wiring between those stages, and the wiring is where most bugs live: seams between chunks, maps that change when you touch an unrelated knob, and worlds that cannot be regenerated piece by piece for streaming.

The idea

Work coarse to fine, and give every piece of work its own seed derived from one master seed.

Stage 1, layout. Divide the world into a chunk grid, for example 8×88 \times 8 chunks of 16×1616 \times 16 tiles each, a 128x128 tile map. Assign each chunk a biome. Two ways: evaluate a noise field at the chunk center and threshold it (the biome is a function of position, so chunks are independent), or walk a Markov chain across the chunk grid row by row, where the transition matrix AA says how likely forest is to follow grassland (Aij=P(next=j∣current=i)A_{ij} = P(\text{next} = j \mid \text{current} = i)). The noise approach gives blobs; the chain gives a controllable sequence and fits a side-scroller's column order.

Stage 2, fill. Fill each chunk with tiles using Wave Function Collapse (or cellular automata for cave biomes), restricted to that biome's tile subset from the catalog. Before solving a chunk, pre-fix its border cells to the tiles of any neighbor that has already been filled. Those cells start with a single-tile domain, and WFC's propagation carries the neighbor's constraints one or two tiles inward, so the join is legal on both sides.

Stage 3, regime. Give each chunk a regime state (calm, contested, depleted, flooded) that advances every game tick by a regime chain. The regime does not change the base tiles; it selects a decor layer, a spawn table, a palette. It has its own seeded stream so that it evolves the same way for the same seed regardless of what the fill did.

Stage 4, export. Write tile index arrays per layer plus a manifest, the format described on Tilemaps and autotiling.

Seed handling. One integer master seed mm. Everything else is a hash of the master seed and the coordinates of the work:

  • chunk seed =hash(m,x,y)= \mathrm{hash}(m, x, y) for the fill of chunk (x,y)(x, y)
  • regime seed =hash(m,"regime",x,y)= \mathrm{hash}(m, \texttt{"regime"}, x, y) for that chunk's regime stream
  • layout seed =hash(m,"layout")= \mathrm{hash}(m, \texttt{"layout"}) for the biome pass

A hash here is a deterministic scramble of its inputs into a 32-bit integer; a small one is in the code below. Two consequences fall out. First, any chunk can be regenerated alone: chunk (5,2)(5, 2)'s seed depends only on mm, 5, and 2, not on how many chunks were generated before it. Second, changing the layout knobs does not change the regime timeline: the regime stream is seeded from its own hash and never reads the layout's generator, so retuning the biome matrix leaves the calm-to-contested schedule exactly where it was.

Worked example

Master seed m=42m = 42, a 3×23 \times 2 chunk grid, chunks of 4×44 \times 4 tiles, two biomes (grass, forest).

Layout by chain. Start at grass. Transition matrix with rows and columns in the order (grass, forest):

A=(0.60.40.30.7)A = \begin{pmatrix} 0.6 & 0.4 \\ 0.3 & 0.7 \end{pmatrix}

Walk the chunks in reading order with the layout stream seeded by hash(42,"layout")\mathrm{hash}(42, \texttt{"layout"}). Draws and results:

Chunk Previous uu Row of AA Biome
(0, 0) start grass
(1, 0) grass 0.71 (0.6, 0.4) forest
(2, 0) forest 0.25 (0.3, 0.7) grass
(0, 1) grass 0.12 (0.6, 0.4) grass
(1, 1) grass 0.88 (0.6, 0.4) forest
(2, 1) forest 0.44 (0.3, 0.7) forest

The layout: grass, forest, grass over grass, forest, forest.

Fill with pre-fixed borders. Fill in the same reading order. Chunk (0, 0) has no filled neighbors, so its 16 cells all start with the full grass subset. Chunk (1, 0) has a filled neighbor on its left, so its 4 left-border cells are set to whatever chunk (0, 0) placed in its right column; because (1, 0) is forest, the catalog must contain a transition tile (grass-to-forest edge) that is legal on both sides, and the fill's domain includes it. Chunk (0, 1) has a filled neighbor above, so its top row is fixed. Chunk (1, 1) has both a left and a top neighbor, so 7 of its 16 cells are fixed before WFC runs, and the remaining 9 are solved under those constraints with the seed hash(42,1,1)\mathrm{hash}(42, 1, 1).

Chunk Fixed cells before fill From
(0, 0) 0
(1, 0) 4 left
(2, 0) 4 left
(0, 1) 4 top
(1, 1) 7 left and top (the corner is shared)
(2, 1) 7 left and top

Regime. Each chunk starts in calm and advances per tick with a regime matrix from the parameter file, using the stream seeded by hash(42,"regime",x,y)\mathrm{hash}(42, \texttt{"regime"}, x, y). Chunk (1, 1) at tick 0 is calm, draws 0.93 against row calm =(0.9,0.1)= (0.9, 0.1) and moves to contested at tick 1, and so on. Change the biome matrix to make forest rarer, regenerate, and chunk (1, 1) still turns contested at tick 1.

Seams

A seam is a visible line where two chunks meet. Three ways to avoid one:

Approach How Symptom it avoids
Pre-fix borders Copy the neighbor's border tiles into the new chunk's domain before solving A straight line of illegal pairs (grass hard against water) along every chunk edge
Overlap and blend Generate each chunk one tile larger on every side, then for height-style layers average the overlapping values, and for tile layers let the later chunk own the shared strip A one-tile step in elevation or a repeated tile row at the boundary
Transition chunks When two neighboring chunks have different biomes, insert a chunk (or a border strip) whose tile subset is the transition set: forest edge, beach, cliff foot A biome that changes on a ruler-straight line with no shore or treeline

Pre-fixing is the default for tile fills. Overlap and blend is for continuous layers such as height. Transition chunks are a layout decision and can combine with either.

Determinism gotchas

  • Order of random draws. A seeded generator gives the same sequence, but only if you consume it in the same order. Filling chunks in parallel and sharing one generator between threads produces a different map every run. Give every unit of work its own generator from its own hash, and never share.
  • Floating point across machines. Two machines can disagree in the last bit of a floating-point multiply, and a hash that mixes floats will then disagree entirely. Hash integers only: chunk coordinates, tick numbers, string bytes. Interpolate in floating point only after the seeds are fixed.
  • Version the parameter file. If you change the meaning of a knob, or fix a bug in the fill order, every old seed now produces a different map, and any saved game that stored only its seed is broken. Put a version field in the parameter file, keep the old code path for old versions, and store the version alongside the seed in saves.
  • Iteration order over sets. A tile set built in a different order is a different set to WFC's weighted draw. Sort tile lists by index before use.

Streaming

Because every chunk's seed is a function of (m,x,y)(m, x, y), you can generate chunks around the player on demand: when the player comes within one chunk of an unfilled chunk, fill it, pre-fixing from whichever neighbors exist. A chunk that scrolls off screen can be thrown away and regenerated identically later. The only state you must keep is anything the player changed, stored as a diff on top of the generated chunk. Regime streams advance by tick count, so a chunk that comes back after 300 ticks fast-forwards its chain 300 steps from its own seed.

The parameter file

Everything above except the tiles themselves fits in a small JSON file. This one describes the worked example:

{
  "version": 3,
  "masterSeed": 42,
  "chunkSize": 16,
  "chunkGrid": [8, 8],
  "biomes": ["grass", "forest"],
  "biomeChain": [[0.6, 0.4], [0.3, 0.7]],
  "biomeTiles": {
    "grass": [0, 1, 2, 3, 12, 13],
    "forest": [4, 5, 6, 7, 12, 13]
  },
  "wfc": { "chunkRetries": 8, "learnFrom": "samples/topdown-grass-forest.json" },
  "regimes": ["calm", "contested", "depleted"],
  "regimeChain": [[0.9, 0.1, 0.0], [0.2, 0.7, 0.1], [0.3, 0.0, 0.7]],
  "layers": ["ground", "decor", "collision"]
}

Tiles 12 and 13 are the transition tiles shared by both biomes, which is what makes the pre-fixed border solvable. The hash that turns this into per-chunk seeds:

function hash32(...parts) {                 // integers and short strings only
  let h = 0x811c9dc5 | 0;
  for (const p of parts) {
    for (const ch of String(p)) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619); }
    h ^= 0xff; h = Math.imul(h, 16777619);  // separator so (1, 23) differs from (12, 3)
  }
  return h >>> 0;
}
const chunkSeed  = (m, x, y) => hash32(m, x, y);
const regimeSeed = (m, x, y) => hash32(m, "regime", x, y);

Export

One array of tile indices per layer per chunk (or per whole map, stitched), each of length chunk width times chunk height, row-major with yy increasing downward, plus a manifest:

{
  "version": 3,
  "masterSeed": 42,
  "tileset": "topdown-grass-forest",
  "chunkSize": 16,
  "chunks": [
    { "x": 0, "y": 0, "biome": "grass", "regime": "calm", "layers": { "ground": "c0_0_ground.bin", "decor": "c0_0_decor.bin" } }
  ]
}

The game reads the manifest, loads the arrays, and draws them through the tileset exactly as Tilemaps and autotiling describes. Because the manifest carries the seed and version, a client that has the generator can skip the arrays entirely and rebuild them.

In a map generator

This page is the map generator's spine. Catalog feeds the biome tile subsets; layout, fill, regime, and export are stages 1 to 4 above. Every other page in the cluster plugs into one of those slots: one master seed, one parameter file, three generators behind one interface.

Common mistakes

  • One generator for everything. The map looks fine until you add a chunk retry or reorder a loop, and then every seed changes. Symptom: saved seeds stop reproducing after an unrelated code change. One hash-derived generator per unit of work.
  • Filling chunks without fixing borders. Every chunk edge shows a straight line of mismatched tiles. Pre-fix, overlap, or transition.
  • Transition tiles missing from a biome's subset. WFC in a forest chunk cannot place anything legal next to the fixed grass border and contradicts on every retry. Symptom: a chunk that never fills. Put the shared edge tiles in both subsets.
  • Regime seeded from the fill's generator. Retuning the tile weights changes when regions become contested. Symptom: a balance change to the art also changes the difficulty curve. Separate streams.
  • No version field. A parameter change silently breaks every saved seed. Version the file and the save.
  • Streaming without storing player diffs. A chunk regenerates perfectly, and the wall the player broke is back. Store changes as a diff keyed by chunk.

Cost

Let the map be Cx×CyC_x \times C_y chunks of w×hw \times h tiles each, with KK tiles per biome subset, nn noise octaves, and TT game ticks. Layout is O(CxCy⋅n)O(C_x C_y \cdot n) for noise or O(CxCy)O(C_x C_y) for a chain walk. Fill is O(CxCy⋅wh⋅K⋅propagation)O(C_x C_y \cdot w h \cdot K \cdot \text{propagation}) from the WFC page, and it dominates everything else by orders of magnitude. Regime is O(CxCy⋅T)O(C_x C_y \cdot T) across the whole game, trivial per tick. Export is O(CxCy⋅wh)O(C_x C_y \cdot w h) per layer. Memory is one integer per tile per layer plus the domains of whichever chunk is being filled right now, so streaming keeps it at O(whK)O(w h K) for the active chunk plus the loaded tile arrays. It starts to hurt when fill retries pile up: a biome with a tight rulebook that contradicts on half its chunks doubles the dominant term, and the fix is on the WFC page, not here.

Going further

  • Hierarchical seeding in large open-world generators, where region seeds hash down to chunk seeds hash down to object seeds.
  • Poisson disk sampling for placing objects (trees, rocks, spawn points) inside a filled chunk without clumps.
  • Content-addressed chunk caches, so a chunk is stored once per (version, seed, x, y).
  • Dirty-region regeneration: re-running only the chunks whose inputs changed when a knob moves.

Back to Dynamic map generation