Article
Game Development · ⏱ ~15 min read · Last updated: 3 July 2026

Wave Function Collapse for Procedural Generation

Wave Function Collapse (WFC) turns a small hand-authored sample — a handful of tiles or a single bitmap — into an unbounded stream of new layouts that obey the same local rules as the sample, without ever repeating it verbatim. This is a builder's walkthrough: how to extract adjacency rules automatically, how to pick which cell to collapse next, how constraint propagation ripples outward through the grid, what to do when the algorithm paints itself into a corner, and how the "overlapping model" adapts the same machinery to raw pixel textures instead of discrete tile sets.

1. The Core Idea — Quantum Metaphor, Classical Algorithm

WFC borrows its name from quantum mechanics but the algorithm itself is pure classical constraint satisfaction. Every cell of an output grid starts in a superposition of all tile types that could possibly go there. The algorithm repeatedly picks the cell with the least uncertainty, "collapses" it to a single concrete tile, and then propagates that decision to its neighbours, eliminating any tile that is no longer compatible. Repeat until every cell holds exactly one tile — the wave has fully collapsed into a classical, coherent output.

The technique was introduced by Maxim Gumin in 2016 as an open-source project and has since become a staple of procedural level generation in shipped games (Bad North, Caves of Qud, Townscaper) because it produces locally coherent structure — walls line up, roads connect, rivers flow — while still generating a different layout every run.

2. Cells, Superposition and Domains

Formally, the output is a grid of cells C₁ … Cₙ. Each cell has a domain D(Cᵢ) — the set of tile labels still allowed at that position. Before any collapse, every domain equals the full tile alphabet T:

D(Cᵢ) = T = {t₁, t₂, …, t_k} for all i (initial superposition) A cell is "collapsed" once |D(Cᵢ)| = 1. The grid is fully solved once |D(Cᵢ)| = 1 for every i. A contradiction occurs if |D(Cᵢ)| = 0 for any i.

This is exactly the structure of a constraint satisfaction problem (CSP): variables are cells, values are tile labels, and constraints are pairwise adjacency compatibility between neighbouring cells. WFC is, under the hood, a CSP solver with a specific variable-ordering heuristic (minimum remaining values, weighted by frequency) and a specific propagation algorithm (arc-consistency), wrapped in domain-specific vocabulary.

3. Extracting Adjacency Rules from a Sample

Rather than hand-writing "grass can sit next to sand" for every tile pair, WFC's "simple tiled model" scans an example map (or a hand-drawn tileset with edge sockets) and records which tile actually touched which, in which direction, during that sample. The output is a compatibility relation:

Allow(tᵢ, tⱼ, d) = true if tile tᵢ was observed directly adjacent to tile tⱼ in direction d (d ∈ {N, E, S, W}) at least once in the input sample. Frequency weight: w(t) = count(t) / total_tiles — used later to bias entropy and random collapse choice toward tiles that appeared more often in the sample.

A common shortcut for hand-authored tilesets is edge sockets: label each of the four tile edges with a symbol (e.g. "grass", "grass-flip", "path"), and two tiles are compatible across an edge only if the socket symbols match (accounting for horizontal-flip symmetry). This removes the need for a sample image entirely — the tileset author declares adjacency implicitly through edge labels.

4. Choosing the Next Cell — Shannon Entropy

At each step WFC must decide which uncollapsed cell to resolve next. Picking the cell with the lowest Shannon entropy — the least remaining uncertainty — tends to produce fewer contradictions than picking randomly, because it commits to the most constrained parts of the grid first, the same intuition behind minimum-remaining-values heuristics in Sudoku solvers.

H(Cᵢ) = − Σ p(t) · log₂ p(t) for t in D(Cᵢ) where p(t) = w(t) / Σ_{t' ∈ D(Cᵢ)} w(t') (normalised frequency weight) Next cell = argmin over uncollapsed Cᵢ of H(Cᵢ) + tiny random noise (noise breaks ties so equal-entropy cells don't always resolve in the same scan order)

Once the target cell is chosen, WFC collapses it by sampling one tile from D(Cᵢ) with probability proportional to w(t) — so tiles that were common in the source sample remain common in the generated output, not just structurally valid.

5. Constraint Propagation (AC-3 Style)

Collapsing one cell to a single tile invalidates some possibilities in its neighbours, which in turn invalidates possibilities in their neighbours, and so on. WFC propagates these eliminations with a worklist algorithm nearly identical to the classic AC-3 arc-consistency algorithm from CSP theory:

queue ← [collapsed cell] while queue not empty: cell ← queue.pop() for each neighbour n of cell in direction d: removed ← false for tile t in D(n): if no tile t' in D(cell) satisfies Allow(t', t, d): D(n).remove(t) removed ← true if D(n) became empty: → CONTRADICTION if removed: queue.push(n)

Because a change to one cell can cascade arbitrarily far — a collapse in a corner can eventually prune tiles on the opposite side of a large level — propagation must run to a fixed point before the algorithm selects the next cell to collapse. This is the step that gives WFC its signature look: locally-consistent global structure that emerges from purely local rules.

6. Contradictions and Backtracking

Greedy minimum-entropy collapse does not guarantee a solution exists — a domain can shrink to empty (a contradiction) even with a perfectly valid rule set, especially on small grids or with sparse adjacency data. Production implementations handle this in one of three ways:

Why restart wins in practice: because WFC grids for game levels are usually tens to low-hundreds of cells per side, and propagation is fast, "generate, and reroll on failure" typically resolves in a handful of attempts — far simpler to implement correctly than full backtracking search.

7. The Overlapping Model — Texture Synthesis

The "simple tiled model" above needs a hand-built tileset. The overlapping model instead learns directly from a single bitmap by sliding an N×N window (typically N = 2 or 3) across the source image and treating every distinct window pattern as a "tile". Two patterns are adjacency-compatible in a given direction if their overlapping region agrees pixel-for-pixel:

Patterns = { all NxN windows of the source image, with rotations/reflections optionally added } Allow(pᵢ, pⱼ, offset) = true if the (N−|offset.x|) × (N−|offset.y|) overlapping region of pᵢ and pⱼ matches exactly, for offset ∈ {(1,0),(0,1),...} Output pixel(x,y) = the shared pixel value agreed upon by every collapsed pattern whose window covers (x,y)

Because patterns overlap by N−1 pixels with their neighbours, the arc-consistency machinery from Section 5 applies completely unchanged — only the definition of "tile" and "adjacency" moved from a curated rule table to pixel-window comparison. This is why the same WFC core can generate both crisp tile-based dungeon layouts and organic, hand-drawn-looking textures from a 20×20 pixel example with no code changes beyond the input parser.

8. Minimal WFC Implementation

// Simple tiled-model WFC — grid of cells, each holding a Set of allowed tile ids
class WFC {
  constructor(width, height, tiles, adjacency, weights) {
    this.w = width;
    this.h = height;
    this.tiles = tiles;              // array of tile ids
    this.adjacency = adjacency;      // adjacency(a, b, dir) -> bool
    this.weights = weights;          // tile id -> frequency weight
    this.grid = new Array(width * height)
      .fill(null)
      .map(() => new Set(tiles));
    this.dirs = [[0,-1],[1,0],[0,1],[-1,0]]; // N,E,S,W
  }

  idx(x, y) { return y * this.w + x; }

  entropy(domain) {
    const total = [...domain].reduce((s, t) => s + this.weights[t], 0);
    let h = 0;
    for (const t of domain) {
      const p = this.weights[t] / total;
      h -= p * Math.log2(p);
    }
    return h + Math.random() * 1e-6; // tie-break noise
  }

  lowestEntropyCell() {
    let best = -1, bestH = Infinity;
    for (let i = 0; i < this.grid.length; i++) {
      const d = this.grid[i];
      if (d.size > 1) {
        const h = this.entropy(d);
        if (h < bestH) { bestH = h; best = i; }
      }
    }
    return best;
  }

  collapse(i) {
    const domain = [...this.grid[i]];
    const total = domain.reduce((s, t) => s + this.weights[t], 0);
    let r = Math.random() * total;
    let chosen = domain[0];
    for (const t of domain) {
      r -= this.weights[t];
      if (r <= 0) { chosen = t; break; }
    }
    this.grid[i] = new Set([chosen]);
  }

  propagate(start) {
    const queue = [start];
    while (queue.length) {
      const i = queue.pop();
      const x = i % this.w, y = Math.floor(i / this.w);
      for (let d = 0; d < 4; d++) {
        const nx = x + this.dirs[d][0], ny = y + this.dirs[d][1];
        if (nx < 0 || ny < 0 || nx >= this.w || ny >= this.h) continue;
        const ni = this.idx(nx, ny);
        let changed = false;
        for (const t of [...this.grid[ni]]) {
          const ok = [...this.grid[i]].some(t2 => this.adjacency(t2, t, d));
          if (!ok) { this.grid[ni].delete(t); changed = true; }
        }
        if (this.grid[ni].size === 0) throw new Error('contradiction');
        if (changed) queue.push(ni);
      }
    }
  }

  run() {
    while (true) {
      const i = this.lowestEntropyCell();
      if (i === -1) return this.grid.map(d => [...d][0]); // fully collapsed
      this.collapse(i);
      this.propagate(i);
    }
  }
}

9. Practical Tips for Game Levels

Seed a few cells first

Pre-collapse edge cells to "wall" tiles or a spawn room to "floor" before running the solver — this both speeds up convergence and guarantees playable structure at fixed points on the map.

Keep tilesets small

8–30 tiles with clear socket symmetry is easier to author, debug and reason about than hundreds of near-duplicate tiles; add variety with post-process decoration instead.

3D extends naturally

Add a sixth adjacency direction (up/down) and 3D voxel WFC works identically — used for multi-storey dungeons and Townscaper-style block cities.

Cap propagation cost

Worst-case propagation is O(cells × tiles²) per run; for large open-world chunks, generate in bounded tiles and stitch borders using pre-collapsed boundary cells as shared constraints.

WFC is not a replacement for hand-designed levels — it is best used where local coherence plus global variety matters more than hand-placed narrative beats: background cityscapes, dungeon filler floors, terrain textures, or as a fast first draft that a designer then hand-touches. Combined with a simple post-pass (removing unreachable rooms, ensuring a path exists between spawn and goal), it produces levels that feel authored while costing almost nothing to generate.