Skip to content

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

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 zone

The 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 TypeRoad PathEntry-Exit Edges (rotation 0)
road_straightThrough opposite edgesNW edge to SE edge
road_leftTurns left (counterclockwise)NW edge to W edge
road_rightTurns right (clockwise)NW edge to SW edge
openNo roadNone

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  -> E

Each 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 states

Each 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):

  1. Decode both states to get their zone type and rotation
  2. Determine which edges of A have road (based on zone type + rotation)
  3. Check whether edge d of A carries road
  4. Check whether the opposite edge of B carries road
  5. 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 rotation

Road 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:

  1. Start from all road zones in row 0
  2. Flood fill through adjacent road zones (following matching road edges)
  3. 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 .0

The exact output depends on the seed, but you should see:

  1. A mix of S (straight), L (left turn), R (right turn), and . (open) cells
  2. Road zones forming a visually traceable path from the top row to the bottom row
  3. Road zones connected to each other (no isolated road chunks)
  4. 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.

Next: 04 - Tile Pass: Hex-Resolution Tiles