Skip to content

Tile Pass: Hex-Resolution Tiles

The zone pass decided the big picture -- which chunks carry road and which are open ground. Now the tile pass zooms in. Each chunk gets its own Wave Function Collapse (WFC) solve across all its hex cells, placing specific tile identities that determine what each cell looks like: solid road, road-to-ground transition, or open ground.

Goal

Fill every hex cell in every chunk with a tile identity (road_fill, road_edge, or ground) using per-chunk WFC solves. The output is a complete cell-resolution map of the entire level, with clean road-to-ground transitions enforced by socket constraints and cross-chunk continuity enforced by border propagation.

Prerequisites

Key Concepts

Per-chunk WFC

Instead of running one massive WFC solve over the entire level, the tile pass solves each chunk independently. A chunk of radius R has 3R^2 + 3R + 1 cells. At radius 4 that is 61 cells with 8 possible tile states each -- a small, fast solve.

Chunks are processed in row-major order: all chunks in row 0 (left to right), then row 1, and so on. This ordering matters because border cells of the current chunk can be pre-constrained based on already-solved neighbors.

Processing order for a 5x12 grid:

  Row 0:  (0,0) → (1,0) → (2,0) → (3,0) → (4,0)
  Row 1:  (0,1) → (1,1) → (2,1) → (3,1) → (4,1)
  ...
  Row 11: (0,11)→ (1,11)→ (2,11)→ (3,11)→ (4,11)

The three tile types

Every hex cell has 7 vertices: 6 corners (numbered 0-5 counterclockwise from East) plus 1 center vertex. A tile's identity is defined by which of those vertices carry "road" versus "ground" characteristics:

         2 ___ 1
          /     \
         /       \
     3  |    C    |  0    Corners 0-5, counterclockwise from East
         \       /        C = center vertex
          \_____/
         4       5

road_fill -- All 7 vertices are road. This is the interior of a road.

road_fill:

  All corners = road        Edge sockets:
  Center      = road        0-1: road + road = [road]
                             1-2: road + road = [road]
  Every edge is a           2-3: road + road = [road]
  road socket.              3-4: road + road = [road]
                             4-5: road + road = [road]
                             5-0: road + road = [road]

ground -- All 7 vertices are ground. This is open terrain.

ground:

  All corners = ground      Edge sockets:
  Center      = ground      All 6 edges: ground + ground = [ground]

road_edge -- 3 adjacent corners plus center are road, the other 3 corners are ground. This is the transition between road and ground.

road_edge (rotation 0, corners 0,1,2 are road):

          1(R)                Edge sockets:
         / \                  0→1: road  + road   = [road]
        /   \                 1→2: road  + road   = [road]
   (R)2/     \0(R)            2→3: road  + ground = [transition]
       | C(R)|                3→4: ground+ ground = [ground]
   (G)3\     /5(G)            4→5: ground+ ground = [ground]
        \   \                 5→0: ground+ road   = [transition]
         \ /
          4(G)

R = road vertex, G = ground vertex

The socket for each edge is derived from its two corner vertices:

Corner pairSocket type
road + roadroad
ground + groundground
road + ground (or ground + road)transition

Socket matching rules

The WFC solver matches sockets between adjacent tiles. The rules are strict:

Socket ASocket BCompatible?
roadroadYes
groundgroundYes
transitiontransitionYes
roadgroundNo
roadtransitionNo
groundtransitionNo

Each socket type only matches itself. The transition socket is the key insight -- without it, there is no valid way to place a road tile next to a ground tile. The transition socket creates a legal boundary where two road_edge tiles can meet at their split edges.

A cross-section through a road looks like this:

                       road direction
                      ──────────────>

  ground | road_edge | road_fill | road_fill | road_edge | ground
         ↑           ↑                       ↑           ↑
     transition   road                    road      transition
      socket     socket                  socket      socket

Rotations of road_edge

Rotating road_edge shifts which 3 corners are road. There are 6 rotations, each producing a different socket pattern:

RotationRoad cornersRoad edgesGround edgesTransition edges
00, 1, 2edges 0-1, 1-2edges 3-4, 4-5edges 2-3, 5-0
11, 2, 3edges 1-2, 2-3edges 4-5, 5-0edges 3-4, 0-1
22, 3, 4edges 2-3, 3-4edges 5-0, 0-1edges 4-5, 1-2
33, 4, 5edges 3-4, 4-5edges 0-1, 1-2edges 5-0, 2-3
44, 5, 0edges 4-5, 5-0edges 1-2, 2-3edges 0-1, 3-4
55, 0, 1edges 5-0, 0-1edges 2-3, 3-4edges 1-2, 4-5

Combined with road_fill (1 state) and ground (1 state), the total tile state count is 8.

Seed tiles

Before the solver runs on a chunk, certain border cells are pre-collapsed based on the chunk's zone type. For a road_straight:0 chunk (road on NW and SE edges), all hex cells along the NW and SE borders are locked to road_fill. The solver then fills the interior, naturally producing road_edge transitions to ground on either side of the road.

Seed tiles serve two purposes:

  1. They guarantee the road connects through the chunk as intended by the zone pass
  2. They constrain the solver's search space, making solves faster and more predictable

Border propagation between chunks

When solving chunk (col, row), three neighbors have already been solved (due to row-major order):

Already solved when processing (col, row):

       (col, row-1)     (col+1, row-1)
  NW neighbor            NE neighbor
            \            /
             \          /
  (col-1, row) ---- (col, row)
  W neighbor          current chunk

Not yet solved:
  E (col+1, row), SW (col, row+1), SE (col+1, row+1)

For each already-solved neighbor, the tile pass reads the socket values of the neighbor's border cells that face the current chunk. Those sockets become pre-constraints: the current chunk's corresponding border cells can only hold tile states whose matching edge has a compatible socket.

For example, if the W neighbor's eastern border cell has a road socket on its E edge, then the current chunk's western border cell must have a tile with a road socket on its W edge -- either road_fill or the appropriate road_edge rotation.

Implementation Steps

Step 1: Define tile states and socket computation

File: src/wfc/tiles.ts

typescript
import { TileType } from "./types";

/** Socket types for hex tile edges. */
export enum Socket {
  Road = "road",
  Ground = "ground",
  Transition = "transition",
}

/** A tile state: type + rotation, with precomputed edge sockets. */
export interface TileState {
  /** Encoded state string: "type:rotation" */
  id: string;
  /** The base tile type */
  type: TileType;
  /** Rotation (0-5 for road_edge, 0 for road_fill and ground) */
  rotation: number;
  /** Socket for each of the 6 edges, indexed 0-5 (E, NE, NW, W, SW, SE) */
  sockets: [Socket, Socket, Socket, Socket, Socket, Socket];
}

/**
 * Compute the 6 edge sockets for a road_edge tile at the given rotation.
 * Road corners are: rotation, (rotation+1)%6, (rotation+2)%6
 */
function roadEdgeSockets(rotation: number): [Socket, Socket, Socket, Socket, Socket, Socket] {
  const isRoadCorner = new Array(6).fill(false);
  isRoadCorner[rotation % 6] = true;
  isRoadCorner[(rotation + 1) % 6] = true;
  isRoadCorner[(rotation + 2) % 6] = true;

  const sockets: Socket[] = [];
  for (let edge = 0; edge < 6; edge++) {
    const cornerA = edge;
    const cornerB = (edge + 1) % 6;
    const aRoad = isRoadCorner[cornerA];
    const bRoad = isRoadCorner[cornerB];

    if (aRoad && bRoad) sockets.push(Socket.Road);
    else if (!aRoad && !bRoad) sockets.push(Socket.Ground);
    else sockets.push(Socket.Transition);
  }

  return sockets as [Socket, Socket, Socket, Socket, Socket, Socket];
}

/** Build all 8 tile states. */
export function buildTileStates(): TileState[] {
  const states: TileState[] = [];

  // road_fill: all road sockets, 1 rotation
  states.push({
    id: `${TileType.RoadFill}:0`,
    type: TileType.RoadFill,
    rotation: 0,
    sockets: [Socket.Road, Socket.Road, Socket.Road, Socket.Road, Socket.Road, Socket.Road],
  });

  // road_edge: 6 rotations
  for (let rot = 0; rot < 6; rot++) {
    states.push({
      id: `${TileType.RoadEdge}:${rot}`,
      type: TileType.RoadEdge,
      rotation: rot,
      sockets: roadEdgeSockets(rot),
    });
  }

  // ground: all ground sockets, 1 rotation
  states.push({
    id: `${TileType.Ground}:0`,
    type: TileType.Ground,
    rotation: 0,
    sockets: [Socket.Ground, Socket.Ground, Socket.Ground, Socket.Ground, Socket.Ground, Socket.Ground],
  });

  return states;
}

Step 2: Build the tile compatibility function

File: src/wfc/tiles.ts (continue)

typescript
import type { CompatibilityFn } from "./solver";
import { oppositeDir } from "../utils/hex-math";

/**
 * Build a tile compatibility function from the precomputed tile states.
 * Two tiles are compatible in direction `dir` if the socket of A's edge `dir`
 * matches the socket of B's opposite edge.
 */
export function buildTileCompatibility(states: TileState[]): CompatibilityFn {
  // Index states by id for fast lookup
  const stateMap = new Map<string, TileState>();
  for (const s of states) stateMap.set(s.id, s);

  return (idA: string, idB: string, dir: number): boolean => {
    const a = stateMap.get(idA);
    const b = stateMap.get(idB);
    if (!a || !b) return false;

    const socketA = a.sockets[dir];
    const socketB = b.sockets[oppositeDir(dir)];

    return socketA === socketB;
  };
}

The compatibility function is a simple lookup: does the socket on A's edge facing B match the socket on B's edge facing A? Same socket type = compatible.

Step 3: Determine seed tiles from zone state

File: src/wfc/tile-pass.ts

For each chunk, the zone state determines which border cells get pre-collapsed to road_fill. The zone's road edges tell us which sides of the chunk carry road -- the hex cells along those border edges are seeded.

typescript
import { hexChunkCells, hexNeighbor } from "../utils/hex-math";
import { decodeZoneState, getRoadEdges } from "./zone-pass";
import { TileType } from "./types";
import type { ZoneState } from "./zone-pass";

/**
 * Identify which hex cells in a chunk of the given radius sit on the
 * border facing a specific edge direction.
 *
 * For a hex chunk, border cells for direction `dir` are the cells whose
 * neighbor in direction `dir` falls outside the chunk radius.
 */
export function borderCellsForEdge(
  radius: number,
  dir: number
): Array<{ q: number; r: number }> {
  const allCells = hexChunkCells(radius);
  const cellSet = new Set(allCells.map(({ q, r }) => `${q},${r}`));

  return allCells.filter(({ q, r }) => {
    const neighbor = hexNeighbor(q, r, dir);
    return !cellSet.has(`${neighbor.q},${neighbor.r}`);
  });
}

/**
 * Compute seed tiles for a chunk based on its zone state.
 * Returns a map of cell key -> tile state id to pre-collapse.
 */
export function computeSeedTiles(
  zoneState: ZoneState,
  radius: number
): Map<string, string> {
  const roadEdges = getRoadEdges(zoneState);
  const seeds = new Map<string, string>();

  for (const edgeDir of roadEdges) {
    const borderCells = borderCellsForEdge(radius, edgeDir);
    for (const { q, r } of borderCells) {
      seeds.set(`${q},${r}`, `${TileType.RoadFill}:0`);
    }
  }

  return seeds;
}

Border cells for a given direction are cells that have no neighbor in that direction within the chunk. These form the outer ring of the hex chunk on that side.

Step 4: Implement border propagation from solved neighbors

File: src/wfc/tile-pass.ts (continue)

typescript
import type { TileState } from "./tiles";

/**
 * Pre-constrain border cells of the current chunk based on already-solved neighbors.
 *
 * For each solved neighbor, look at its border cells facing the current chunk.
 * Read their sockets on the shared edge. The current chunk's corresponding
 * border cells must have a compatible socket on their facing edge.
 *
 * Returns additional pre-collapse constraints (cell key -> set of valid state ids).
 */
export function borderConstraints(
  chunkCol: number,
  chunkRow: number,
  radius: number,
  solvedChunks: Map<string, Map<string, string>>,  // "col,row" -> (cellKey -> tileStateId)
  tileStates: TileState[],
  gridWidth: number,
  gridHeight: number
): Map<string, Set<string>> {
  const constraints = new Map<string, Set<string>>();

  // Directions to check: W(3), NW(2), NE(1) -- already solved in row-major order
  const dirsToCheck = [3, 2, 1];

  for (const dir of dirsToCheck) {
    // Find the neighbor chunk in this direction
    const neighborCoord = getNeighborChunkCoord(chunkCol, chunkRow, dir);
    if (!neighborCoord) continue;

    const { col: nc, row: nr } = neighborCoord;
    if (nc < 0 || nc >= gridWidth || nr < 0 || nr >= gridHeight) continue;

    const neighborSolved = solvedChunks.get(`${nc},${nr}`);
    if (!neighborSolved) continue;

    const oppositeDirection = (dir + 3) % 6;

    // Get border cells of the NEIGHBOR facing the current chunk
    const neighborBorder = borderCellsForEdge(radius, oppositeDirection);

    // Get border cells of the CURRENT chunk facing the neighbor
    const currentBorder = borderCellsForEdge(radius, dir);

    // Match cells pairwise by their position along the shared edge
    // (Both arrays are ordered consistently by the borderCellsForEdge function)
    const pairs = Math.min(neighborBorder.length, currentBorder.length);

    for (let i = 0; i < pairs; i++) {
      const nCell = neighborBorder[i];
      const nKey = `${nCell.q},${nCell.r}`;
      const nStateId = neighborSolved.get(nKey);
      if (!nStateId) continue;

      const nState = tileStates.find((s) => s.id === nStateId);
      if (!nState) continue;

      // The neighbor's socket on the edge facing us
      const requiredSocket = nState.sockets[oppositeDirection];

      // Filter current chunk's valid states for this border cell
      const cCell = currentBorder[i];
      const cKey = `${cCell.q},${cCell.r}`;

      const validStates = new Set<string>();
      for (const ts of tileStates) {
        if (ts.sockets[dir] === requiredSocket) {
          validStates.add(ts.id);
        }
      }

      constraints.set(cKey, validStates);
    }
  }

  return constraints;
}

Step 5: Implement the per-chunk solve

File: src/wfc/tile-pass.ts (continue)

typescript
import { createRng, positionalSeed } from "./rng";
import { solve } from "./solver";
import { buildTileStates, buildTileCompatibility } from "./tiles";
import type { ZonePassResult } from "./zone-pass";
import { TileType } from "./types";

export interface TilePassResult {
  /** Per-chunk solved tiles: "col,row" -> Map<cellKey, tileStateId> */
  chunks: Map<string, Map<string, string>>;
}

/**
 * Run the tile pass over all chunks in the zone grid.
 * Processes chunks in row-major order for border propagation.
 */
export function runTilePass(
  zoneResult: ZonePassResult,
  chunkRadius: number,
  masterSeed: number
): TilePassResult {
  const tileStates = buildTileStates();
  const stateIds = tileStates.map((s) => s.id);
  const compatibility = buildTileCompatibility(tileStates);
  const allCells = hexChunkCells(chunkRadius);

  // Build a cell index lookup: "q,r" -> index
  const cellIndex = new Map<string, number>();
  allCells.forEach(({ q, r }, i) => cellIndex.set(`${q},${r}`, i));

  const solvedChunks = new Map<string, Map<string, string>>();

  const { width, height } = zoneResult.grid;

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const chunkKey = `${col},${row}`;
      const zoneState = zoneResult.lookup.get(chunkKey);
      if (!zoneState) continue;

      // 1. Compute seed tiles from zone state
      const seeds = computeSeedTiles(zoneState, chunkRadius);

      // 2. Compute border constraints from solved neighbors
      const borders = borderConstraints(
        col, row, chunkRadius, solvedChunks, tileStates, width, height
      );

      // 3. Build pre-collapsed map (cell index -> state id)
      const preCollapsed = new Map<number, string>();
      for (const [key, stateId] of seeds) {
        const idx = cellIndex.get(key);
        if (idx !== undefined) preCollapsed.set(idx, stateId);
      }

      // 4. Create chunk-specific RNG
      const subSeed = positionalSeed(masterSeed, col, row);
      const rng = createRng(subSeed);

      // 5. Build hex neighbor function for this chunk
      function neighbors(index: number): Array<{ index: number; dir: number }> {
        const cell = allCells[index];
        const result: Array<{ index: number; dir: number }> = [];
        for (let dir = 0; dir < 6; dir++) {
          const n = hexNeighbor(cell.q, cell.r, dir);
          const ni = cellIndex.get(`${n.q},${n.r}`);
          if (ni !== undefined) {
            result.push({ index: ni, dir });
          }
        }
        return result;
      }

      // 6. Solve
      const result = solve(allCells.length, stateIds, compatibility, rng, {
        neighbors,
        preCollapsed,
        maxBacktracks: 500,
      });

      // 7. Record results (or fallback)
      const chunkTiles = new Map<string, string>();
      if (result) {
        allCells.forEach(({ q, r }, i) => {
          chunkTiles.set(`${q},${r}`, result.cells[i]);
        });
      } else {
        // Fallback: keep seed tiles, fill rest with ground
        for (const { q, r } of allCells) {
          const key = `${q},${r}`;
          chunkTiles.set(key, seeds.get(key) ?? `${TileType.Ground}:0`);
        }
      }

      solvedChunks.set(chunkKey, chunkTiles);
    }
  }

  return { chunks: solvedChunks };
}

Step 6: Add the neighbor chunk coordinate helper

File: src/wfc/tile-pass.ts (continue)

typescript
/**
 * Get the chunk coordinate of the neighbor in the given direction.
 * Uses offset coordinates (same as zone grid).
 */
function getNeighborChunkCoord(
  col: number,
  row: number,
  dir: number
): { col: number; row: number } | null {
  const isEven = col % 2 === 0;
  const offsets = isEven
    ? [
        { dc: 1, dr: 0 },   // 0: E
        { dc: 1, dr: -1 },  // 1: NE
        { dc: 0, dr: -1 },  // 2: NW
        { dc: -1, dr: 0 },  // 3: W
        { dc: -1, dr: 1 },  // 4: SW
        { dc: 0, dr: 1 },   // 5: SE
      ]
    : [
        { dc: 1, dr: 0 },
        { dc: 0, dr: -1 },
        { dc: -1, dr: -1 },
        { dc: -1, dr: 0 },
        { dc: 0, dr: 1 },
        { dc: 1, dr: 1 },
      ];

  const offset = offsets[dir];
  if (!offset) return null;
  return { col: col + offset.dc, row: row + offset.dr };
}

Visual Checkpoint

Generate tiles for a single road_straight:0 chunk and visualize the hex grid. This is the simplest case -- a straight road running NW to SE through the chunk.

typescript
import { computeSeedTiles } from "./wfc/tile-pass";
import { buildTileStates, buildTileCompatibility } from "./wfc/tiles";
import { hexChunkCells } from "./utils/hex-math";
import { createRng } from "./wfc/rng";
import { solve } from "./wfc/solver";
import { TileType } from "./wfc/types";

const radius = 4;
const allCells = hexChunkCells(radius);
const tileStates = buildTileStates();
const stateIds = tileStates.map((s) => s.id);
const compatibility = buildTileCompatibility(tileStates);

// road_straight:0 means road on NW (edge 2) and SE (edge 5)
const seeds = computeSeedTiles("road_straight:0", radius);

// Build pre-collapsed map
const cellIndex = new Map<string, number>();
allCells.forEach(({ q, r }, i) => cellIndex.set(`${q},${r}`, i));

const preCollapsed = new Map<number, string>();
for (const [key, stateId] of seeds) {
  const idx = cellIndex.get(key);
  if (idx !== undefined) preCollapsed.set(idx, stateId);
}

// Neighbor function
function neighbors(index: number) {
  const cell = allCells[index];
  const result: Array<{ index: number; dir: number }> = [];
  for (let dir = 0; dir < 6; dir++) {
    const nq = cell.q + [1,1,0,-1,-1,0][dir];
    const nr = cell.r + [0,-1,-1,0,1,1][dir];
    const ni = cellIndex.get(`${nq},${nr}`);
    if (ni !== undefined) result.push({ index: ni, dir });
  }
  return result;
}

const rng = createRng(42);
const result = solve(allCells.length, stateIds, compatibility, rng, {
  neighbors, preCollapsed, maxBacktracks: 500
});

if (result) {
  // Print hex grid layer by layer
  const symbols: Record<string, string> = {
    [TileType.RoadFill]: "#",
    [TileType.RoadEdge]: "~",
    [TileType.Ground]: ".",
  };

  for (let r = -radius; r <= radius; r++) {
    const indent = " ".repeat(Math.abs(r));
    const row: string[] = [];
    for (let q = -radius; q <= radius; q++) {
      const key = `${q},${r}`;
      const idx = cellIndex.get(key);
      if (idx === undefined) continue;
      const [type] = result.cells[idx].split(":");
      row.push(symbols[type] ?? "?");
    }
    console.log(indent + row.join(" "));
  }
}

Expected output shows a band of road through the chunk:

         . . . . .
        . . . ~ # .
       . . . ~ # # .
      . . . ~ # # ~ .
     . . . ~ # # ~ . .
      . . ~ # # ~ . .
       . ~ # # ~ . .
        ~ # # ~ . .
         # # ~ . .

You should see:

  • # (road_fill) forming a diagonal band through the center
  • ~ (road_edge) on both sides of the road, creating a clean transition
  • . (ground) filling the remaining space
  • No abrupt # next to . -- every road-to-ground boundary goes through ~

If the road band is missing or the transitions are jagged, check the seed tile placement and the socket matching logic.

What's Next

The tile pass produces a structural map: every cell has an identity, but every cell is flat and visually uniform. The noise pass adds height variation and texture blending weights, turning the flat structural grid into terrain that looks organic. Road zones stay mostly flat while open zones get rolling hills.

Next: 05 - Noise Pass: Height & Texture Weights