Appearance
Zone Pass: Coarse Layout
The zone pass is where level structure emerges. Before worrying about individual hex cells, heights, or textures, the pipeline needs to decide the big picture: where does the road go, and which chunks are open ground? The zone pass answers that question by running the Wave Function Collapse (WFC) solver over a small grid where each cell represents an entire chunk.
Goal
Produce a coarse zone grid -- one ZoneType per chunk in a rectangular strip -- with a guaranteed connected road running from the first row to the last row. This grid feeds the tile pass (which fills each chunk with hex-resolution tiles) and the noise pass (which selects height parameters per zone type).
Prerequisites
- 01 Foundations --
ZoneTypeenum,Rng,positionalSeed - 02 The WFC Solver --
solve,CompatibilityFn
Key Concepts
The zone grid
A level is a strip of hex chunks: narrow and long. A typical configuration is 5 chunks wide and 12 chunks tall. The zone grid is a rectangular array at that scale -- 60 cells total, each representing one chunk.
Zone grid (5 wide x 12 tall):
col: 0 1 2 3 4
row 0: [ ] [ ] [ R ] [ ] [ ] <- start (row 0)
row 1: [ ] [ ] [ R ] [ ] [ ]
row 2: [ ] [ R ] [ R ] [ ] [ ]
row 3: [ ] [ R ] [ ] [ ] [ ]
row 4: [ ] [ R ] [ R ] [ ] [ ]
row 5: [ ] [ ] [ R ] [ ] [ ]
...
row 11:[ ] [ ] [ R ] [ ] [ ] <- end (last row)
R = road zone (straight, left, or right)
= open zoneThe road does not have to be a straight line -- it can zigzag left and right across the strip. But it must form a connected path from at least one cell in row 0 to at least one cell in the last row.
Zone types with rotations
Each zone type describes how a road passes through a chunk. On a flat-top hex, each chunk has 6 edges. The road enters on one edge and exits on another:
| Zone Type | Road Path | Entry-Exit Edges (rotation 0) |
|---|---|---|
road_straight | Through opposite edges | NW edge to SE edge |
road_left | Turns left (counterclockwise) | NW edge to W edge |
road_right | Turns right (clockwise) | NW edge to SW edge |
open | No road | None |
Rotation 0 for road zones runs the road along the NW-to-SE axis -- the primary axis running "down" the strip. Each 60-degree rotation shifts the entry and exit edges:
road_straight rotations (entry -> exit):
rot 0: NW -> SE (down the strip)
rot 1: NE -> SW
rot 2: E -> W
rot 3: SE -> NW (up the strip -- mirror of rot 0)
rot 4: SW -> NE
rot 5: W -> EEach road zone type has 6 rotations. Open zones are rotationally symmetric -- only 1 state. Total zone states:
road_straight: 6 rotations = 6 states
road_left: 6 rotations = 6 states
road_right: 6 rotations = 6 states
open: 1 rotation = 1 state
--------
19 statesEach state is encoded as a string: "road_straight:0", "road_left:3", "open:0", and so on. The solver treats these as opaque strings -- the compatibility function knows how to decode them.
Edge matching
The compatibility function enforces one rule: road edges must match across chunk boundaries. If chunk A has road on its SE edge, the chunk to its SE must have road on its NW edge.
To check compatibility between two zone states A and B in direction d (from A toward B):
- Decode both states to get their zone type and rotation
- Determine which edges of A have road (based on zone type + rotation)
- Check whether edge
dof A carries road - Check whether the opposite edge of B carries road
- They are compatible if both edges agree: both have road, or neither has road
Example: A = road_straight:0 (NW->SE), direction = SE (dir 5)
A has road on edge 5 (SE) ✓
B must have road on edge 2 (NW) -- the opposite of SE
B = road_straight:0 (NW->SE): edge 2 (NW) has road ✓ -> COMPATIBLE
B = open:0: edge 2 (NW) no road ✗ -> INCOMPATIBLE
B = road_left:2: check edge 2... -> depends on rotationRoad connectivity validation
WFC guarantees local constraint satisfaction -- every pair of adjacent chunks has matching edges. But local correctness does not guarantee global connectivity. Consider this scenario:
[open] [road loop] [open]
[open] [road loop] [open]
[open] [open] [open]The road zones form a closed loop in the upper portion of the grid. Every adjacent pair matches perfectly, but there is no path from row 0 to the last row.
To catch this, the zone pass runs a Breadth-First Search (BFS) flood fill after WFC solves the zone grid:
- Start from all road zones in row 0
- Flood fill through adjacent road zones (following matching road edges)
- Check whether any road zone in the last row is reached
If the flood fill fails, the entire zone grid is discarded and the solver retries with a new sub-seed. Up to 20 attempts before returning null.
For narrow strips (5 wide), most WFC solutions will produce a through-path naturally -- the strip is too narrow for isolated loops. The check is cheap insurance, not a frequent failure handler.
Implementation Steps
Step 1: Define the zone state encoding
File: src/wfc/zone-pass.ts
Start by defining how zone states are encoded and decoded, and which edges each state has road on:
typescript
import { ZoneType } from "./types";
/** Encoded zone state: "type:rotation" */
export type ZoneState = string;
/** Decode a zone state string into its type and rotation. */
export function decodeZoneState(state: ZoneState): { type: ZoneType; rotation: number } {
const [type, rot] = state.split(":");
return { type: type as ZoneType, rotation: parseInt(rot, 10) };
}
/** Encode a zone type and rotation into a state string. */
export function encodeZoneState(type: ZoneType, rotation: number): ZoneState {
return `${type}:${rotation}`;
}Step 2: Define road edge lookup
File: src/wfc/zone-pass.ts (continue)
For each zone type, define which edges carry road at rotation 0. Then rotate by adding the rotation value modulo 6.
typescript
/**
* Road edge definitions at rotation 0.
* Edge indices: 0=E, 1=NE, 2=NW, 3=W, 4=SW, 5=SE
*
* road_straight:0 runs NW (edge 2) to SE (edge 5)
* road_left:0 runs NW (edge 2) to W (edge 3) -- turns counterclockwise
* road_right:0 runs NW (edge 2) to SW (edge 4) -- turns clockwise
*/
const BASE_ROAD_EDGES: Record<string, number[]> = {
[ZoneType.RoadStraight]: [2, 5], // NW and SE (opposite edges)
[ZoneType.RoadLeft]: [2, 3], // NW and W
[ZoneType.RoadRight]: [2, 4], // NW and SW
[ZoneType.Open]: [], // no road edges
};
/**
* Get the set of road-carrying edges for a zone state.
* Applies rotation by shifting edge indices.
*/
export function getRoadEdges(state: ZoneState): Set<number> {
const { type, rotation } = decodeZoneState(state);
const base = BASE_ROAD_EDGES[type] ?? [];
return new Set(base.map((e) => (e + rotation) % 6));
}Rotation simply shifts the edge indices. At rotation 0, road_straight has road on edges 2 (NW) and 5 (SE). At rotation 1, those shift to edges 3 (W) and 0 (E). This works because the 6 edges are numbered sequentially around the hex.
Step 3: Build the compatibility function
File: src/wfc/zone-pass.ts (continue)
typescript
import type { CompatibilityFn } from "./solver";
import { oppositeDir } from "../utils/hex-math";
/**
* Zone compatibility: two zone states can be adjacent in direction `dir`
* if and only if their road presence on the shared edge agrees.
*
* - A has road on edge `dir`, B must have road on edge `oppositeDir(dir)`
* - A has no road on edge `dir`, B must have no road on edge `oppositeDir(dir)`
*/
export const zoneCompatibility: CompatibilityFn = (stateA, stateB, dir) => {
const roadsA = getRoadEdges(stateA);
const roadsB = getRoadEdges(stateB);
const aHasRoad = roadsA.has(dir);
const bHasRoad = roadsB.has(oppositeDir(dir));
return aHasRoad === bHasRoad;
};This is the entire constraint set for the zone pass. Road edges must align, non-road edges must align. The solver handles the rest.
Step 4: Build the state list
File: src/wfc/zone-pass.ts (continue)
typescript
/** Generate all 19 zone states: 6 rotations each for road types, 1 for open. */
export function buildZoneStates(): ZoneState[] {
const states: ZoneState[] = [];
const roadTypes = [ZoneType.RoadStraight, ZoneType.RoadLeft, ZoneType.RoadRight];
for (const type of roadTypes) {
for (let rot = 0; rot < 6; rot++) {
states.push(encodeZoneState(type, rot));
}
}
states.push(encodeZoneState(ZoneType.Open, 0));
return states;
}Step 5: Implement the zone pass with connectivity check
File: src/wfc/zone-pass.ts (continue)
typescript
import { createRng, positionalSeed } from "./rng";
import { solveHexGrid } from "./solver";
import type { WfcGrid, WfcCell } from "./types";
export interface ZonePassResult {
grid: WfcGrid;
/** Lookup: "col,row" -> ZoneState */
lookup: Map<string, ZoneState>;
}
/**
* Run the zone pass: WFC over a chunk-scale grid, validated for road connectivity.
*
* Retries up to maxAttempts times with different sub-seeds on connectivity failure.
*/
export function runZonePass(
width: number,
height: number,
masterSeed: number,
maxAttempts: number = 20
): ZonePassResult | null {
const states = buildZoneStates();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const subSeed = positionalSeed(masterSeed, attempt, 0x20_4E);
const rng = createRng(subSeed);
const result = solveHexGrid(width, height, states, zoneCompatibility, rng, {
maxBacktracks: 1000,
});
if (!result) continue; // Solver failed, try next seed
// Build lookup map
const lookup = new Map<string, ZoneState>();
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
lookup.set(`${c},${r}`, result.cells[r * width + c]);
}
}
// Connectivity check: BFS from row 0 road zones to last row
if (isRoadConnected(lookup, width, height)) {
const grid: WfcGrid = {
width,
height,
cells: result.cells.map((state) => ({
candidates: [state],
collapsed: state,
})),
};
return { grid, lookup };
}
// Connectivity failed, retry
}
return null; // All attempts exhausted
}Note 0x20_4E as the position salt for zone pass sub-seeds. The exact value does not matter -- it just needs to be a distinct constant that separates zone pass entropy from tile pass entropy, avoiding accidental correlation.
Step 6: Implement the BFS connectivity check
File: src/wfc/zone-pass.ts (continue)
typescript
/**
* Check that road zones form a connected path from row 0 to the last row.
* Uses BFS flood fill through adjacent road zones.
*/
function isRoadConnected(
lookup: Map<string, ZoneState>,
width: number,
height: number
): boolean {
// Find all road zones in row 0
const visited = new Set<string>();
const queue: string[] = [];
for (let c = 0; c < width; c++) {
const key = `${c},0`;
const state = lookup.get(key);
if (state && decodeZoneState(state).type !== ZoneType.Open) {
queue.push(key);
visited.add(key);
}
}
// BFS through adjacent road zones
while (queue.length > 0) {
const current = queue.shift()!;
const [colStr, rowStr] = current.split(",");
const col = parseInt(colStr, 10);
const row = parseInt(rowStr, 10);
// Check hex neighbors (depends on even/odd column for offset coords)
const neighborCoords = getChunkNeighbors(col, row, width, height);
for (const { col: nc, row: nr, dir } of neighborCoords) {
const nKey = `${nc},${nr}`;
if (visited.has(nKey)) continue;
const currentState = lookup.get(current)!;
const neighborState = lookup.get(nKey);
if (!neighborState) continue;
// Only flood through connected road edges
const currentRoads = getRoadEdges(currentState);
const neighborRoads = getRoadEdges(neighborState);
if (currentRoads.has(dir) && neighborRoads.has(oppositeDir(dir))) {
visited.add(nKey);
queue.push(nKey);
}
}
}
// Check if any road zone in the last row was reached
for (let c = 0; c < width; c++) {
const key = `${c},${height - 1}`;
if (visited.has(key)) return true;
}
return false;
}The BFS only traverses road edges that match -- it follows the actual road connectivity, not just adjacency. A road zone in the last row is reachable only if there is a continuous chain of matching road edges from row 0.
Step 7: Add a helper for chunk neighbor coordinates
File: src/wfc/zone-pass.ts (continue)
typescript
/**
* Get valid neighbors of chunk (col, row) in the zone grid.
* Returns column, row, and the direction from current to neighbor.
*/
function getChunkNeighbors(
col: number,
row: number,
width: number,
height: number
): Array<{ col: number; row: number; dir: number }> {
const isEven = col % 2 === 0;
const offsets = isEven
? [
{ dc: 1, dr: 0, dir: 0 },
{ dc: 1, dr: -1, dir: 1 },
{ dc: 0, dr: -1, dir: 2 },
{ dc: -1, dr: 0, dir: 3 },
{ dc: -1, dr: 1, dir: 4 },
{ dc: 0, dr: 1, dir: 5 },
]
: [
{ dc: 1, dr: 0, dir: 0 },
{ dc: 0, dr: -1, dir: 1 },
{ dc: -1, dr: -1, dir: 2 },
{ dc: -1, dr: 0, dir: 3 },
{ dc: 0, dr: 1, dir: 4 },
{ dc: 1, dr: 1, dir: 5 },
];
const result: Array<{ col: number; row: number; dir: number }> = [];
for (const { dc, dr, dir } of offsets) {
const nc = col + dc;
const nr = row + dr;
if (nc >= 0 && nc < width && nr >= 0 && nr < height) {
result.push({ col: nc, row: nr, dir });
}
}
return result;
}The even/odd column offset is the same pattern used by the solver's hex grid wrapper. Offset coordinates are the natural fit for a rectangular arrangement of hex chunks.
Visual Checkpoint
Generate a zone grid and render it as ASCII. Road zones show their direction, open zones show a dot.
typescript
import { runZonePass, decodeZoneState } from "./wfc/zone-pass";
import { ZoneType } from "./wfc/types";
const result = runZonePass(5, 12, 42);
if (!result) {
console.log("Zone pass failed after all attempts");
} else {
const symbols: Record<string, string> = {
[ZoneType.RoadStraight]: "S",
[ZoneType.RoadLeft]: "L",
[ZoneType.RoadRight]: "R",
[ZoneType.Open]: ".",
};
console.log("Zone grid (5x12):");
console.log(" 0 1 2 3 4");
for (let row = 0; row < 12; row++) {
const cells: string[] = [];
for (let col = 0; col < 5; col++) {
const state = result.lookup.get(`${col},${row}`)!;
const { type, rotation } = decodeZoneState(state);
const sym = symbols[type] ?? "?";
cells.push(`${sym}${rotation}`);
}
const indent = row % 2 === 1 ? " " : "";
console.log(`${indent} ${cells.join(" ")}`);
}
}Expected output looks something like:
Zone grid (5x12):
0 1 2 3 4
.0 .0 S0 .0 .0
.0 .0 S0 .0 .0
.0 .0 L2 .0 .0
.0 S3 .0 .0 .0
.0 S0 .0 .0 .0
.0 R1 .0 .0 .0
.0 .0 S0 .0 .0
.0 .0 S0 .0 .0
.0 .0 S0 .0 .0
.0 .0 S0 .0 .0
.0 .0 R4 .0 .0
.0 .0 S0 .0 .0The exact output depends on the seed, but you should see:
- A mix of
S(straight),L(left turn),R(right turn), and.(open) cells - Road zones forming a visually traceable path from the top row to the bottom row
- Road zones connected to each other (no isolated road chunks)
- Open zones filling the remaining space
If the grid is all dots or has disconnected road islands, the connectivity check or compatibility function has a bug.
What's Next
The zone grid tells us what kind of content each chunk holds, but not the internal cell-by-cell layout. The tile pass takes each chunk, seeds its border cells based on the zone type, and runs a per-chunk WFC solve to fill every hex cell with a specific tile identity -- road, edge, or ground. That is where the coarse layout becomes a detailed hex grid.