Appearance
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
- 01 Foundations --
TileTypeenum, hex math,hexChunkCells,HEX_DIRECTIONS - 02 The WFC Solver --
solve,CompatibilityFn - 03 Zone Pass --
ZonePassResultwith the solved zone grid
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 5road_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 vertexThe socket for each edge is derived from its two corner vertices:
| Corner pair | Socket type |
|---|---|
| road + road | road |
| ground + ground | ground |
| road + ground (or ground + road) | transition |
Socket matching rules
The WFC solver matches sockets between adjacent tiles. The rules are strict:
| Socket A | Socket B | Compatible? |
|---|---|---|
road | road | Yes |
ground | ground | Yes |
transition | transition | Yes |
road | ground | No |
road | transition | No |
ground | transition | No |
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 socketRotations of road_edge
Rotating road_edge shifts which 3 corners are road. There are 6 rotations, each producing a different socket pattern:
| Rotation | Road corners | Road edges | Ground edges | Transition edges |
|---|---|---|---|---|
| 0 | 0, 1, 2 | edges 0-1, 1-2 | edges 3-4, 4-5 | edges 2-3, 5-0 |
| 1 | 1, 2, 3 | edges 1-2, 2-3 | edges 4-5, 5-0 | edges 3-4, 0-1 |
| 2 | 2, 3, 4 | edges 2-3, 3-4 | edges 5-0, 0-1 | edges 4-5, 1-2 |
| 3 | 3, 4, 5 | edges 3-4, 4-5 | edges 0-1, 1-2 | edges 5-0, 2-3 |
| 4 | 4, 5, 0 | edges 4-5, 5-0 | edges 1-2, 2-3 | edges 0-1, 3-4 |
| 5 | 5, 0, 1 | edges 5-0, 0-1 | edges 2-3, 3-4 | edges 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:
- They guarantee the road connects through the chunk as intended by the zone pass
- 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.