Skip to content

The WFC Terrain Pipeline

Every level in Endless Idle is procedurally generated from a seed. The terrain pipeline takes that seed and produces a complete hex-grid level: zone layout, tile identities, vertex heights, texture blending weights — everything the renderer needs to put chunks on screen.

A level is a straight strip of hex chunks — narrow (e.g. 5 chunks wide) and long (10+ chunks). A main road runs down the center from the starting chunk at row 0 to the boss chunk at the far end. The remaining chunks are open ground. On player death, the seed randomizes and a new level generates — so the pipeline needs to be fast, deterministic, and produce structurally valid output every time.

The output is a GeneratedLevel containing per-chunk vertex arrays ready for the vertexRegistrybuildDirtyChunks → GPU rendering path.

seed + config

  Zone Pass (WFC)      →  coarse grid: one ZoneType per chunk

  Tile Pass (WFC)      →  fine grid: one tile variant per hex cell

  Noise Pass (Perlin)  →  height + texture weights per vertex

  GeneratedLevel       →  per-chunk vertex data → vertexRegistry → GPU

The rest of this page breaks down each component, the decisions behind them, and how they connect.

Why Wave Function Collapse

The core question was: how do we generate levels with a guaranteed walkable road from start to finish, from a random seed?

Pure noise (Perlin, Simplex) is great for organic terrain variation but terrible at structure. It can't guarantee a continuous road.

Handcrafted tile maps would give perfect control but kill replayability. The game needs every death to produce a fresh level, so players never memorize layouts.

Wave Function Collapse sits in the sweet spot. It's a constraint solver that generates structured output from declarative rules. We define what zones can neighbor each other and what tiles exist within each zone, and WFC figures out a valid arrangement. The constraints encode structural intent (road chunks must connect end-to-end) while the randomness ensures variety.

The pipeline runs WFC twice at different scales — once for coarse zone layout, once for fine tile identity — then layers Perlin noise on top for organic height and texture variation. Structure from WFC, naturalism from noise.

The Two-Pass Architecture

A single WFC pass at full tile resolution across the entire level would be expensive and fragile. Each hex chunk contains 3R² + 3R + 1 cells for a given chunk radius R. The exact radius is tunable — at R=4 that's 61 cells, at R=16 it's 817. Either way, a 5×12 strip is 60 chunks, so a global solve across the full level would be thousands of cells. Backtracking at that scale gets slow, and contradictions become more likely with complex constraint sets.

Instead, the pipeline splits the problem:

Pass 1 (Zone Pass) operates on a coarse grid where each cell is one chunk. It assigns zone types — road variants (road_straight, road_left, road_right) and open — with edge-matching adjacency rules. This is a small grid (5×12 or similar) that solves fast.

Pass 2 (Tile Pass) operates per-chunk at tile resolution. Each chunk gets its own WFC solve over its hex-shaped cell grid (3R² + 3R + 1 cells), constrained to only use tile variants that belong to its assigned zone. Chunks solve independently in row-major order, with border cells pre-constrained to match already-solved neighbors.

This decomposition keeps each individual solve tractable and lets the two passes have completely different constraint sets. Zone-level rules encode structure ("road chunks must connect edge-to-edge"). Tile-level rules encode visual coherence ("road tiles connect to other road tiles on shared edges").

The Solver

The WFC solver is generic — it doesn't know about zones or tiles or hexes. It takes a grid of cells in superposition, a compatibility function, and an RNG, and collapses everything.

The core loop:

  1. Find lowest entropy — the uncollapsed cell with the fewest remaining valid states. Ties broken randomly. This heuristic minimizes contradiction risk by resolving the most constrained cells first.

  2. Collapse — pick one of the cell's remaining states at random. This is the irreversible decision that drives generation forward.

  3. Propagate — flood outward from the collapsed cell, filtering each neighbor's candidate states against the compatibility function. If a neighbor loses candidates, it gets pushed onto the propagation stack too. This is where constraints cascade through the grid.

  4. Backtrack on contradiction — if any cell reaches zero valid states, the last collapse was wrong. Restore from a snapshot, exclude the failed state, and try again. The solver keeps a stack of snapshots so it can backtrack multiple levels deep.

The solver accepts a CompatibilityFn(stateA, stateB, direction) → boolean — so the same algorithm serves both passes. The zone pass plugs in zone adjacency rules; the tile pass plugs in tile edge compatibility.

typescript
type CompatibilityFn = (stateA: string, stateB: string, dir: number) => boolean

function solve(
  width: number,
  height: number,
  states: string[],
  isCompatible: CompatibilityFn,
  rng: Rng,
  options?: { maxBacktracks?: number }
): WfcGrid | null

Returning null on failure is intentional. The zone pass wraps the solver in a retry loop — if the first attempt doesn't produce a connected road, it tries again with a different sub-seed. Up to 20 attempts before giving up.

Determinism and the Seeded Random Number Generator

Every call to Math.random() in the pipeline would break determinism. The same seed must produce the exact same level, always — this matters for save/load (we store the seed, not the generated level) and for debugging.

The pipeline uses a seeded pseudorandom number generator based on the mulberry32 algorithm: 32-bit state, fast, statistically uniform. A single createRng(seed) call produces an Rng object with helpers for floats, integers, array picks, and shuffles.

typescript
interface Rng {
  (): number                              // float in [0, 1)
  int(max: number): number                // integer in [0, max)
  pick<T>(arr: readonly T[]): T           // random element
  shuffle<T>(arr: T[]): T[]               // Fisher-Yates in-place
}

Each sub-system that needs randomness (the zone pass, each chunk's tile pass) derives its own sub-seed positionally — from the master seed combined with the chunk's coordinates, not from sequential calls to the parent RNG. For example, a chunk at (q, r) might derive its sub-seed as hash(masterSeed, q, r). This ensures that adding or removing chunks from the grid doesn't change the generation of any other chunk. If sub-seeds were derived sequentially (one RNG call per chunk in iteration order), inserting a new row would shift every subsequent chunk's seed.

Zone Types and Adjacency Rules

The zone pass decides what kind of content occupies each chunk. Road zones come in three shapes that describe the road's path through the chunk. Each shape can be rotated to fit different orientations on the hex grid.

ZoneShapeSeed tiles
road_straightRoad enters one edge, exits the opposite edgeRoad tiles pre-placed on two opposing edges (e.g. NW and SE at rotation 0)
road_leftRoad enters one edge, exits the next edge counter-clockwiseRoad tiles pre-placed on the entry and exit edges
road_rightRoad enters one edge, exits the next edge clockwiseRoad tiles pre-placed on the entry and exit edges
openNo road, filler terrainNo seed tiles

Flat-top hexes have 6 edges: E, NE, NW, W, SW, SE. There is no "north" or "south" edge. A road_straight at rotation 0 runs from the NW edge to the SE edge — this is the axis that runs "down the strip" when the level extends along the r-axis in axial coordinates. The seed tiles are pre-placed road_fill tiles on the border cells of those two edges before the tile solver runs. The solver fills in the interior, guaranteeing a continuous road connection between the two seeded edges. The road turn variants work the same way with different edge pairs.

Rotations multiply the effective variant count. Each 60° rotation shifts which edge pair carries the road. A road_straight rotated once runs NE-to-SW instead of NW-to-SE. A road_left can be rotated to turn in any direction. The solver treats each rotation as a distinct zone state with its own edge compatibility.

Adjacency and connectivity

Zone adjacency ensures that road edges match across chunk boundaries. If chunk A has road tiles on its SE edge, the chunk in the SE direction must have a zone variant with road tiles on its NW edge. open zones have no road on any edge, so they can only border road zones on non-road edges.

After WFC solves the zone grid, a road connectivity check validates that road zones form a connected path from row 0 to the last row via BFS flood fill. This check is necessary — not just a safety net. WFC enforces local pairwise constraints only. Every road zone will have matching road edges with its immediate neighbors, but local correctness doesn't guarantee global connectivity. The solver could produce a road loop in the middle of the strip that satisfies all socket constraints but never reaches row 0 or the last row.

If the connectivity check fails, the attempt is discarded and the solver retries with a new sub-seed, up to 20 attempts. For a narrow strip (5 wide), most zone configurations will produce a through-path naturally — the failure rate should be low — but the check is load-bearing, not optional.

In a typical 5×12 strip, this produces a road spine running roughly down the center with turns weaving left and right. The remaining chunks fill with open ground.

Tile Types, Sockets, and Edge Constraints

WFC needs more than "road" and "ground" to produce clean results. The solver works by matching sockets — the identity of each edge of a tile. Two tiles can sit next to each other only if their shared edge sockets match. With just road and ground as tile types, there's no way to transition between them — road edges can't neighbor ground edges, so the solver either fills an entire region with road or with ground.

The solution is a transition tile: road_edge.

Tile definitions

Each hex tile has 6 vertices (one per corner) plus a center vertex. The tile's identity is determined by which vertices carry road versus ground tint and texture:

TileVertex layoutEdge sockets
road_fillAll 7 vertices are road (road tint, road texture)All 6 edges are road
road_edge3 adjacent corners + center are road, the other 3 corners are ground2 edges road, 2 edges ground, 2 edges transition
groundAll 7 vertices are ground (ground tint, ground texture)All 6 edges are ground

The socket for each edge is derived from the vertex identities at that edge's two corners:

  • Both corners road → road socket
  • Both corners ground → ground socket
  • One road corner, one ground corner → transition socket

Corners are numbered 0–5 counterclockwise starting from East (0° = E, 60° = NE, 120° = NW, 180° = W, 240° = SW, 300° = SE), matching the convention in hex-math.ts. With corners 0, 1, 2 as road and 3, 4, 5 as ground, the edges break down as:

EdgeCornersSocket
0→1road + roadroad
1→2road + roadroad
2→3road + groundtransition
3→4ground + groundground
4→5ground + groundground
5→0ground + roadtransition

So road_edge has 2 road edges, 2 ground edges, and 2 transition edges at the boundary where the road/ground split crosses the hex.

Three socket types

The WFC solver matches sockets between adjacent tiles:

  • roadroad — valid (road_fill next to road_fill, or road_fill next to the road side of a road_edge)
  • groundground — valid (ground next to ground, or ground next to the ground side of a road_edge)
  • transitiontransition — valid (two road_edge tiles meeting at their split edges)
  • roadgroundinvalid
  • roadtransitioninvalid
  • groundtransitioninvalid

The transition socket only matches itself. This means two road_edge tiles can sit next to each other at their split edges, which is how the road curves — a sequence of road_edge tiles with their transition edges touching creates a smooth turn from road to ground across multiple hexes.

A straight road through a chunk looks like: ground → road_edge → road_fill → road_fill → road_edge → ground. The transition sockets face each other where neighboring road_edge tiles meet along the road boundary.

Rotations

road_edge can be rotated in 60° increments to place the road/ground split on different sides of the hex. Each rotation shifts which corners are road and which are ground, producing a different socket configuration. The solver treats each rotation as a distinct tile variant. That gives 6 rotations of road_edge, plus road_fill and ground (which are rotationally symmetric), for a total of 8 tile states.

Tile Pass and Seed Tiles

The tile pass runs WFC at hex-cell resolution within each chunk. Each chunk starts with pre-collapsed seed tiles dictated by its zone type.

For a road_straight chunk (NW-to-SE at rotation 0), the tile pass begins with road_fill tiles locked onto the NW and SE border cells. The solver fills in the remaining cells, using the socket constraints to produce a continuous band of road between the two seeded edges with road_edge transitions to ground on either side.

For open chunks, there are no seed tiles — the solver starts from a full superposition. Since there are no road sockets to satisfy, it fills everything with ground.

Border propagation

Before solving a chunk, the tile pass pre-constrains border cells that face already-solved neighbors. Processing chunks row by row down the strip (increasing r as the outer loop, increasing q within each row) means three neighbors are always resolved first: W (q-1, r) from the same row, NW (q, r-1) and NE (q+1, r-1) from the previous row. The remaining three directions (E, SW, SE) point to chunks not yet solved — those boundaries get constrained later when their chunks are processed.

If a pre-solved neighbor has a road socket on its shared edge, the current chunk's border cell must have a tile with a road socket on that edge — either road_fill or the appropriate rotation of road_edge.

This is how the seed tiles cascade across chunk boundaries — the zone pass guarantees matching road edges between chunks, and the tile pass enforces it at cell resolution via socket matching.

If WFC fails for a particular chunk (rare, but possible with tight constraints), the system falls back to filling the interior with ground tiles while preserving the seed tiles. The road connection is maintained even in the fallback case.

The Noise Pass

WFC produces structured layouts, but the terrain is flat and uniform within each tile type. The noise pass adds organic variation: height displacement and texture weight modulation.

This module is self-contained with its own Perlin noise implementation rather than importing from the biome system. The biome noise module (biomes/noise.ts) is coupled to BiomeDefinition and the activeBiome singleton — runtime state that doesn't exist during offline generation. Keeping the noise pass independent means the WFC pipeline can run without any ECS or global state.

The noise is standard 2D Perlin with fractional Brownian motion (fBm). The orchestrator passes each chunk's zone type (looked up from the zone grid by chunk coordinate) to the noise pass, which selects a config per zone:

ZoneFrequencyOctavesHeight ScaleExponentDomain Warp
road_*0.08210.7none
open0.06341.01.0

Road zones are deliberately flat — low height scale, fewer octaves, and a sub-1.0 exponent that compresses peaks. The player auto-walks the road, so it needs to read as a path. Open zones get more variation: higher octave count and domain warping for less uniform terrain.

For texture blending, the noise pass computes 4 splat channel weights per vertex by sampling fBm at 4 different spatial offsets. The weights are normalized to sum to 1.0. This gives each chunk's terrain smooth, noise-driven texture variation rather than uniform tiling.

Until biome integration is in place, the texture indices in the output are placeholders. The weights are still computed — they define the shape of the blending — but won't render meaningfully until the biome layer assigns real texture IDs to each splat channel. Biome integration will either reuse the computed weights with its own texture mapping, or recompute them using biome-specific noise configs.

The Orchestrator

The top-level generateLevel function ties the full pipeline together. It accepts a LevelConfig (seed, grid dimensions, biome ID) and returns a GeneratedLevel — or null if generation fails.

typescript
interface GeneratedLevel {
  zoneGrid: WfcGrid
  chunks: Map<string, TileVertexData[]>
}

function generateLevel(config: LevelConfig): GeneratedLevel | null

The sequence:

  1. Create a seeded RNG from config.seed
  2. Run the zone pass to get the coarse layout (zone type per chunk)
  3. Run the tile pass per chunk — solve each chunk's hex cell grid using the socket constraints, with seed tiles pre-placed from the zone variant
  4. Apply the noise pass per chunk — compute height displacement and splat weights using the zone-specific noise config
  5. Return both the zone grid and the per-chunk vertex data

The zone grid is exposed because downstream systems may need it (e.g. enemy spawning reading zone types for placement decisions).

The tile pass feeds into the noise pass: tile identity determines the base texture indices per vertex, and the noise pass modulates the splat weights on top. Until biome integration defines the mapping from tile identity to texture IDs, the pipeline uses placeholder texture indices — the structural output (zone layout, road connectivity, tile placement) is correct, but the visual surface will be refined when biome blending is in place.

What the Pipeline Produces

The output is a Map<string, TileVertexData[]> — per-chunk arrays of vertex data keyed by "col,row". Each vertex contains:

typescript
interface TileVertexData {
  x: number                                    // world X position
  z: number                                    // world Z position
  height: number                               // Y displacement from noise
  weights: [number, number, number, number]    // splat channel weights, sum to 1.0
  textureIndices: [number, number, number, number]  // texture IDs per channel
  tint: [number, number, number]               // RGB multiplier, default [1,1,1]
}

This shape mirrors the VertexData type consumed by the chunk rendering system. The commit step (Chunk Integration) writes these into the vertexRegistry, spawns chunk entities tagged IsDirty, and the chunk builder picks them up — constructing BufferGeometry, uploading to the GPU, rendering via WebGPU. The WFC pipeline's only job is to produce the data. Everything downstream is a rendering concern.

Module Dependencies

The pipeline is structured as a linear dependency chain: types → seeded random → constraints → solver → zone pass → tile pass, with the noise pass and orchestrator branching off at the end. Each module depends only on modules earlier in the chain, which means they can be built, tested, and understood incrementally. The solver has no knowledge of zones or tiles. The zone pass has no knowledge of noise. This separation keeps each piece focused and independently testable.

Boundaries

The WFC pipeline is deliberately scoped to data generation only. It has no knowledge of ECS, Three.js, or the rendering stack. Several concerns are intentionally left to other systems:

Biome-driven textures. Zone type doesn't select textures. The pipeline outputs placeholder texture indices. Biome Blending will wire biome definitions to actual texture channels.

Tile identity → visual mapping. The tile pass resolves identities (road, ground) but they don't influence vertex data yet. This bridge gets built when the biome layer defines what each tile type maps to visually.

Committing to the ECS world. The pipeline produces data structures but doesn't spawn entities or write to the vertex registry. Chunk Integration handles the bridge — translating GeneratedLevel into Koota entities with IsDirty tags that the chunk builder picks up.

Ribbon projection. The pipeline produces flat hex positions. Projecting those onto the 3D helical ribbon surface happens during chunk building, not during generation. This keeps the generation pipeline simple and the projection math centralized in one place.