commit 9ea773b1f20332f6913365c151f29e2e4c56eaf7
parent 0088c092a8534fe30ee0d630f84ccba4b648fe42
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sun, 26 Apr 2026 14:38:23 -0700
5.5 update
Diffstat:
1 file changed, 905 insertions(+), 133 deletions(-)
diff --git a/see-screenshot-of-current-sorted-flute.md b/see-screenshot-of-current-sorted-flute.md
@@ -2,178 +2,951 @@
## Context
-The current fire-truck map (see `Screenshot 2026-04-26 at 8.47.40 AM.png`) has two visible defects:
-
-1. **Phantom alleys inside building blocks.** Buildings are drawn at `-0.38 * cell` offset with size `0.76 * cell` (`game.js:183-189`), leaving a ~24% gap between adjacent buildings that reads as a road grid inside every block.
-2. **Malformed intersections.** `buildCity` (`lib.js:140-211`) emits horizontal/vertical line indices and connects every selected line **across the entire bounded rectangle**. So every chosen y crosses every chosen x, every road cell self-stubs to its own four edges, and intersections at boundaries collapse oddly.
-
-Additionally the world is a fixed-bounded rectangle around an initial `buildRoute(64)` route; the route-extension hook (`game.js:431-443`) extends the truck's route but **not** the rendered city, so the truck can leave the map.
+The current fire-truck map (see `Screenshot 2026-04-26 at 8.47.40 AM.png`) has three visible problems.
-The user wants:
-- A finite, **pre-generated 50×50 island** surrounded by water with a 1-cell beach border (no more "infinite" extension).
-- An **outer ring road** around the buildings.
-- A **procedurally generated inner road network** that reads like the reference sketch (`untitled.png`) — a mix of through-streets, T-intersections, 4-ways, and 90° rounded corners, not a uniform grid.
-- Clean rendering with no phantom alleys and no double intersections.
+1. Buildings are drawn at `-0.38 * cell` with size `0.76 * cell` in `games/fire-truck/game.js`, which leaves a gap between adjacent buildings. The gap reads as phantom alleys inside every block.
+2. `buildCity()` in `games/fire-truck/lib.js` creates whole horizontal and vertical road lines across the bounded rectangle. Every selected `x` crosses every selected `y`, so the result is closer to a uniform grid than a city sketch with varied T-intersections, 4-ways, and corners.
+3. `extendRouteIfNeeded()` extends the route but not the rendered city. The truck can eventually drive outside the rendered map.
-## Critique of Original Approach
+The user wants a finite pre-generated `50 x 50` island with water, a beach border, an outer ring road, a varied inner road network, no phantom alleys, and no double/collapsed intersections.
-The original plan proposed "probabilistic T-cuts" (stopping cuts 1 cell short) during recursive subdivision. **This is structurally flawed.**
-1. Stopping a cut short in a recursive partition removes the boundary that child partitions rely on, causing their internal roads to dead-end into buildings.
-2. The assumption that "pure subdivision produces almost-all 4-ways" is mathematically false. Pure binary subdivision (KD-tree) produces **0% 4-ways** (every cut T's into its parent boundary).
+## Target Result
-**The Fix (Mixed Quad/Binary Subdivision):**
-Instead of hacky truncations, we use a mixed subdivision algorithm. At each recursive step, we randomly choose between a **Binary Split** (one line, creates T-intersections at borders) and a **Quad Split** (a cross, creates a guaranteed 4-way in the center). This natively produces a flawless, dead-end-free graph with a perfect mix of T's and 4-ways, zero orphan-rescue passes required.
+Replace the route-first city generator with one deterministic island generator.
-## Approach (high level)
+`buildIsland(opts, rng)` must return the whole playable map and the route that drives on it:
-Replace `buildRoute` + `buildCity` with a single deterministic generator `buildIsland(opts, rng)` that:
+```javascript
+{
+ width: 50,
+ height: 50,
+ grid, // 2D array, grid[y][x]
+ cells, // flat array of all 2500 cells
+ roads, // flat array of road cells
+ buildings, // flat array of building cells
+ beach, // flat array of beach cells
+ water, // flat array of water cells
+ route, // route steps for the truck
+ routeStart, // first truck position and heading
+ bounds // cell-coordinate bounds for camera/rendering
+}
+```
+
+Each cell must use this exact shape:
+
+```javascript
+{
+ x,
+ y,
+ type, // 'water' | 'beach' | 'road' | 'building'
+ exits, // { N: false, E: false, S: false, W: false }
+ meta // classifyIntersection(exits) for roads, null otherwise
+}
+```
+
+Each route step must keep the existing fields that `game.js` already uses and add one explicit approach cell for test/screenshot helpers:
+
+```javascript
+{
+ index,
+ x,
+ y, // decision cell, where the prompt applies
+ approachX,
+ approachY, // road cell immediately before the decision cell
+ headingIn,
+ headingOut,
+ move, // getRelativeMove(headingIn, headingOut)
+ exitX,
+ exitY // road cell immediately after the decision cell
+}
+```
+
+`routeStart` must be the road cell where the truck starts before driving toward `route[0]`:
+
+```javascript
+{ x: 3, y: 2, heading: 'east' }
+```
+
+`bounds` must describe the full island in cell coordinates. Because each cell is centered on integer coordinates, the island spans from `-0.5` to `49.5` on both axes:
+
+```javascript
+{ minX: -0.5, minY: -0.5, maxX: 49.5, maxY: 49.5, width: 50, height: 50 }
+```
+
+## Files To Change
+
+| Path | Required change |
+|---|---|
+| `games/fire-truck/lib.js` | Add `CELL_TYPES`, seeded RNG helpers, `buildIsland`, `buildRouteOnGraph`, and `extendRouteOnGraph`. Keep `HEADINGS`, `turnHeading`, `getRelativeMove`, `stepPosition`, `classifyIntersection`, `getLegalMoves`, `pickPromptMove`, and `summarizeKinds` exported. Remove `buildRoute` and `buildCity` unless temporary local compatibility is needed during the edit. |
+| `games/fire-truck/game.js` | Replace `buildRoute(64)` plus `buildCity(route)` with one `buildIsland(...)` call. Rename `renderCity()` to `renderIsland()`. Render full cell footprints by type. Add camera bounds. Change speed multiplier reads from `window.GAME_SPEED_MULTIPLIER || 1.0` to `window.GAME_SPEED_MULTIPLIER ?? 1.0` so tests can freeze movement with `0`. Add `cellAt(x, y)` and `snapTruckToRouteIndex(index)` test hooks. |
+| `tests/unit/route.test.js` | Keep helper tests for heading/move functions. Replace `buildRoute()` coverage with `buildIsland()` and `extendRouteOnGraph()` route continuity tests. |
+| `tests/unit/projection.test.js` | Replace `buildCity()` coverage with island-grid tests: rings are correct, roads are connected, no road dead-ends, intersection mix exists, and route steps stay on road exits. |
+| `tests/browser/runner.js` | Add fire-truck tests for island debug hooks, fixed cell types at the border, route bounds, and existing movement/failure behavior. |
+| `tests/browser/screenshot.js` | New Playwright script that saves overview, gameplay, intersection, and portal screenshots. |
+| `package.json` | Add `"screenshot": "node tests/browser/screenshot.js"`. |
+
+## Constants
+
+Use these defaults unless the implementation needs a small visual adjustment.
+
+```javascript
+const ISLAND_WIDTH = 50;
+const ISLAND_HEIGHT = 50;
+const MIN_BLOCK = 4;
+const ROUTE_COUNT = 200;
+const ROUTE_EXTENSION_COUNT = 36;
+const CELL_TYPES = {
+ WATER: 'water',
+ BEACH: 'beach',
+ ROAD: 'road',
+ BUILDING: 'building',
+};
+
+const WATER_COLOR = 0x6bc4e8;
+const BEACH_COLOR = 0xf2e2b6;
+const ASPHALT_COLOR = 0x2a2d34;
+const STRIPE_COLOR = 0xffd23f;
+const BUILDING_PALETTE = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb];
+```
+
+## RNG Contract
+
+The current code passes RNG objects with `next()` and `pick(arr)`. Do not require `rng.frac()` or `rng.integerInRange()`.
+
+Add these helpers in `lib.js`:
+
+```javascript
+function createSeededRng(seed) {
+ let state = (seed == null ? 0x12345678 : seed) >>> 0;
+ return {
+ next() {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ return state / 0x100000000;
+ },
+ pick(arr) {
+ return arr[Math.floor(this.next() * arr.length)];
+ },
+ };
+}
+
+function getRng(opts, rng) {
+ if (rng) return rng;
+ return createSeededRng(opts && opts.seed);
+}
+
+function randInt(rand, min, max) {
+ return min + Math.floor(rand.next() * (max - min + 1));
+}
+
+function randPick(rand, arr) {
+ if (rand.pick) return rand.pick(arr);
+ return arr[Math.floor(rand.next() * arr.length)];
+}
+```
+
+`buildIsland({ seed: 123 })` must be deterministic when no external RNG is passed.
+
+## Island Grid Algorithm
+
+Implement `buildIsland(opts = {}, rng)` as the top-level generator.
+
+```javascript
+function buildIsland(opts = {}, rng) {
+ const width = opts.width ?? ISLAND_WIDTH;
+ const height = opts.height ?? ISLAND_HEIGHT;
+ const routeCount = opts.routeCount ?? ROUTE_COUNT;
+ const rand = getRng(opts, rng);
+
+ if (width !== 50 || height !== 50) {
+ throw new Error('Only 50x50 islands are supported by this layout');
+ }
+
+ const grid = allocateGrid(width, height);
+ layOutRings(grid, width, height);
+ subdivideInteriorRoads(grid, rand);
+ stitchRoadExits(grid, width, height);
+ validateConnectedRoads(grid, width, height);
+
+ const routeStart = { x: 3, y: 2, heading: 'east' };
+ const route = buildRouteOnGraph(grid, { count: routeCount, startCell: routeStart, startHeading: routeStart.heading }, rand);
+ const cells = flattenGrid(grid);
+
+ return {
+ width,
+ height,
+ grid,
+ cells,
+ roads: cells.filter(c => c.type === CELL_TYPES.ROAD),
+ buildings: cells.filter(c => c.type === CELL_TYPES.BUILDING),
+ beach: cells.filter(c => c.type === CELL_TYPES.BEACH),
+ water: cells.filter(c => c.type === CELL_TYPES.WATER),
+ route,
+ routeStart,
+ bounds: { minX: -0.5, minY: -0.5, maxX: width - 0.5, maxY: height - 0.5, width, height },
+ };
+}
+```
+
+`allocateGrid(width, height)` must return `grid[y][x]`, not `grid[x][y]`.
+
+```javascript
+function allocateGrid(width, height) {
+ const grid = [];
+ for (let y = 0; y < height; y++) {
+ const row = [];
+ for (let x = 0; x < width; x++) {
+ row.push({
+ x,
+ y,
+ type: CELL_TYPES.WATER,
+ exits: { N: false, E: false, S: false, W: false },
+ meta: null,
+ });
+ }
+ grid.push(row);
+ }
+ return grid;
+}
+```
+
+`layOutRings(grid, width, height)` must set fixed rings exactly like this:
+
+```javascript
+function layOutRings(grid, width, height) {
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
+ grid[y][x].type = CELL_TYPES.WATER;
+ } else if (x === 1 || y === 1 || x === width - 2 || y === height - 2) {
+ grid[y][x].type = CELL_TYPES.BEACH;
+ } else if (x === 2 || y === 2 || x === width - 3 || y === height - 3) {
+ grid[y][x].type = CELL_TYPES.ROAD;
+ } else {
+ grid[y][x].type = CELL_TYPES.BUILDING;
+ }
+ }
+ }
+}
+```
+
+`flattenGrid(grid)` must preserve row-major order:
+
+```javascript
+function flattenGrid(grid) {
+ const out = [];
+ for (let y = 0; y < grid.length; y++) {
+ for (let x = 0; x < grid[y].length; x++) {
+ out.push(grid[y][x]);
+ }
+ }
+ return out;
+}
+```
+
+This creates water on the outside edge, beach at `x/y = 1` and `48`, and the outer ring road at `x/y = 2` and `47`. Interior building cells initially occupy `x = 3..46` and `y = 3..46`.
+
+## Mixed Subdivision Road Algorithm
+
+The inner road network is generated by recursively splitting building rectangles. A split line becomes road. Child rectangles exclude that new road line.
+
+Coordinate meanings:
+
+| Name | Meaning |
+|---|---|
+| `x`, `y` | Top-left cell of a rectangle that currently contains only buildings or roads created by deeper splits. |
+| `w`, `h` | Rectangle size in cells. |
+| `right` | `x + w - 1`. |
+| `bottom` | `y + h - 1`. |
+| `MIN_BLOCK` | Minimum building-cell thickness on each side of a split line. |
+
+Start with the interior building rectangle:
+
+```javascript
+subdivide(3, 3, 44, 44);
+```
+
+Use this exact split eligibility rule:
+
+```javascript
+const canSplitW = w >= MIN_BLOCK * 2 + 1;
+const canSplitH = h >= MIN_BLOCK * 2 + 1;
+```
+
+The `+ 1` is the road cell used by the split. For `MIN_BLOCK = 4`, the smallest splittable rectangle is `9` cells wide or tall: `4 building cells + 1 road cell + 4 building cells`.
+
+Use this exact coordinate picker:
+
+```javascript
+function pickSplitCoord(rand, start, size) {
+ return randInt(rand, start + MIN_BLOCK, start + size - MIN_BLOCK - 1);
+}
+```
+
+Wrap the recursive splitter in `subdivideInteriorRoads(grid, rand)` so `rand` and `grid` are in scope:
+
+```javascript
+function subdivideInteriorRoads(grid, rand) {
+ function subdivide(x, y, w, h) {
+ const canSplitW = w >= MIN_BLOCK * 2 + 1;
+ const canSplitH = h >= MIN_BLOCK * 2 + 1;
+ if (!canSplitW && !canSplitH) return;
+
+ let splitType;
+ if (canSplitW && canSplitH) {
+ const r = rand.next();
+ splitType = r < 0.40 ? 'quad' : (r < 0.70 ? 'horiz' : 'vert');
+ } else if (canSplitH) {
+ splitType = 'horiz';
+ } else {
+ splitType = 'vert';
+ }
+
+ const right = x + w - 1;
+ const bottom = y + h - 1;
+
+ if (splitType === 'quad') {
+ const cx = pickSplitCoord(rand, x, w);
+ const cy = pickSplitCoord(rand, y, h);
+ drawRoadH(grid, x, right, cy);
+ drawRoadV(grid, cx, y, bottom);
+ subdivide(x, y, cx - x, cy - y);
+ subdivide(cx + 1, y, right - cx, cy - y);
+ subdivide(x, cy + 1, cx - x, bottom - cy);
+ subdivide(cx + 1, cy + 1, right - cx, bottom - cy);
+ return;
+ }
+
+ if (splitType === 'horiz') {
+ const cy = pickSplitCoord(rand, y, h);
+ drawRoadH(grid, x, right, cy);
+ subdivide(x, y, w, cy - y);
+ subdivide(x, cy + 1, w, bottom - cy);
+ return;
+ }
+
+ const cx = pickSplitCoord(rand, x, w);
+ drawRoadV(grid, cx, y, bottom);
+ subdivide(x, y, cx - x, h);
+ subdivide(cx + 1, y, right - cx, h);
+ }
+
+ subdivide(3, 3, 44, 44);
+}
+```
+
+Road drawing helpers are simple cell-type changes:
+
+```javascript
+function drawRoadH(grid, x1, x2, y) {
+ for (let x = x1; x <= x2; x++) grid[y][x].type = CELL_TYPES.ROAD;
+}
+
+function drawRoadV(grid, x, y1, y2) {
+ for (let y = y1; y <= y2; y++) grid[y][x].type = CELL_TYPES.ROAD;
+}
+```
+
+The endpoints of every split line are adjacent to an existing parent boundary road or the outer ring road. After exits are stitched, those adjacent endpoints become connected T-intersections or corners. No orphan rescue pass should be necessary.
+
+## Exit Stitching And Validation
+
+After all roads are marked, compute exits from neighbors. Do not try to maintain exits while subdividing.
+
+```javascript
+const DIRS = {
+ N: { dx: 0, dy: -1, heading: 'north' },
+ E: { dx: 1, dy: 0, heading: 'east' },
+ S: { dx: 0, dy: 1, heading: 'south' },
+ W: { dx: -1, dy: 0, heading: 'west' },
+};
+
+function stitchRoadExits(grid, width, height) {
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const cell = grid[y][x];
+ cell.exits = { N: false, E: false, S: false, W: false };
+ cell.meta = null;
+ if (cell.type !== CELL_TYPES.ROAD) continue;
+
+ for (const dir of ['N', 'E', 'S', 'W']) {
+ const nx = x + DIRS[dir].dx;
+ const ny = y + DIRS[dir].dy;
+ cell.exits[dir] = !!grid[ny] && !!grid[ny][nx] && grid[ny][nx].type === CELL_TYPES.ROAD;
+ }
+ cell.meta = classifyIntersection(cell.exits);
+ }
+ }
+}
+```
+
+`validateConnectedRoads(grid, width, height)` must do a BFS over road cells and throw if any road is unreachable or if any road has degree lower than `2`.
+
+```javascript
+function validateConnectedRoads(grid, width, height) {
+ const roads = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (grid[y][x].type === CELL_TYPES.ROAD) roads.push(grid[y][x]);
+ }
+ }
+ if (!roads.length) throw new Error('Island has no roads');
+
+ for (const cell of roads) {
+ const degree = Object.values(cell.exits).filter(Boolean).length;
+ if (degree < 2) throw new Error('Dead-end road at ' + cell.x + ',' + cell.y);
+ }
+
+ const seen = new Set();
+ const queue = [roads[0]];
+ seen.add(roads[0].x + ',' + roads[0].y);
+
+ while (queue.length) {
+ const cell = queue.shift();
+ for (const dir of ['N', 'E', 'S', 'W']) {
+ if (!cell.exits[dir]) continue;
+ const nx = cell.x + DIRS[dir].dx;
+ const ny = cell.y + DIRS[dir].dy;
+ const key = nx + ',' + ny;
+ if (!seen.has(key)) {
+ seen.add(key);
+ queue.push(grid[ny][nx]);
+ }
+ }
+ }
+
+ if (seen.size !== roads.length) {
+ throw new Error('Disconnected road graph: reached ' + seen.size + ' of ' + roads.length);
+ }
+}
+```
+
+## Route Walking Algorithm
+
+Route generation must walk the finished road graph. It must not create roads.
+
+A decision cell is any road cell with `degree >= 3` or a degree-2 corner. A degree-2 straight cell is not a decision cell.
-1. Lays out cell types (`water`/`beach`/`road`/`building`) on a fixed 50x50 grid.
-2. Builds the road graph using **Mixed Quad/Binary Subdivision**.
-3. Generates the truck's `route` by walking the finished road graph, biased toward lowest-visited exits.
-4. Returns a unified `{ grid, cells, route, bounds, water, beach, buildings, roads }` payload.
+```javascript
+function isDecisionCell(cell) {
+ if (!cell || cell.type !== CELL_TYPES.ROAD) return false;
+ const meta = cell.meta || classifyIntersection(cell.exits);
+ return meta.degree >= 3 || meta.kind === 'corner';
+}
+```
+
+Outbound choices at a decision are all road exits except the U-turn exit. There are no dead-ends, so a U-turn should only be allowed if it is the only possible exit after filtering.
+
+```javascript
+const CARD_TO_HEADING = { N: 'north', E: 'east', S: 'south', W: 'west' };
+const HEADING_TO_CARD = { north: 'N', east: 'E', south: 'S', west: 'W' };
+const OPPOSITE = { north: 'south', east: 'west', south: 'north', west: 'east' };
+
+function outboundHeadings(cell, headingIn) {
+ const back = OPPOSITE[headingIn];
+ const all = [];
+ for (const card of ['N', 'E', 'S', 'W']) {
+ if (cell.exits[card]) all.push(CARD_TO_HEADING[card]);
+ }
+ const withoutBack = all.filter(heading => heading !== back);
+ return withoutBack.length ? withoutBack : all;
+}
+```
+
+To find the next decision, start from the current road cell and walk one cell at a time in the current heading until a decision cell is reached.
+
+```javascript
+function findNextDecision(grid, fromCell, heading) {
+ let previous = fromCell;
+ let current = cellAhead(grid, fromCell, heading);
+
+ while (current && current.type === CELL_TYPES.ROAD) {
+ if (isDecisionCell(current)) {
+ return { decision: current, approach: previous };
+ }
+
+ const outCard = HEADING_TO_CARD[heading];
+ if (!current.exits[outCard]) {
+ throw new Error('Straight road ended before a decision at ' + current.x + ',' + current.y);
+ }
+
+ previous = current;
+ current = cellAhead(grid, current, heading);
+ }
+
+ throw new Error('Route left the road graph from ' + fromCell.x + ',' + fromCell.y + ' heading ' + heading);
+}
+
+function cellAhead(grid, cell, heading) {
+ const next = stepPosition(cell, heading);
+ return grid[next.y] && grid[next.y][next.x];
+}
+```
+
+Choose among outbound headings by lowest directed-edge visit count. A directed edge key is the decision cell plus the chosen heading.
+
+```javascript
+function chooseHeading(rand, visitCounts, decision, options) {
+ let lowest = Infinity;
+ let candidates = [];
+
+ for (const heading of options) {
+ const key = decision.x + ',' + decision.y + '>' + heading;
+ const count = visitCounts[key] || 0;
+ if (count < lowest) {
+ lowest = count;
+ candidates = [heading];
+ } else if (count === lowest) {
+ candidates.push(heading);
+ }
+ }
+
+ return randPick(rand, candidates);
+}
+```
+
+`buildRouteOnGraph(grid, opts, rng)` must return only route steps. It must not mutate the grid except for reading `cell.meta`.
+
+```javascript
+function buildRouteOnGraph(grid, opts = {}, rng) {
+ const rand = rng || createSeededRng(opts.seed);
+ const count = opts.count ?? ROUTE_COUNT;
+ const route = [];
+ const visitCounts = opts.visitCounts || Object.create(null);
+
+ let fromCell = opts.startCell || { x: 3, y: 2 };
+ let heading = opts.startHeading || fromCell.heading || 'east';
+ fromCell = grid[fromCell.y][fromCell.x];
+ if (!fromCell || fromCell.type !== CELL_TYPES.ROAD) {
+ throw new Error('Route start is not a road cell');
+ }
+
+ for (let i = 0; i < count; i++) {
+ const found = findNextDecision(grid, fromCell, heading);
+ const decision = found.decision;
+ const approach = found.approach;
+ const options = outboundHeadings(decision, heading);
+ const headingOut = chooseHeading(rand, visitCounts, decision, options);
+ const exit = cellAhead(grid, decision, headingOut);
+
+ if (!exit || exit.type !== CELL_TYPES.ROAD) {
+ throw new Error('Route chose non-road exit from ' + decision.x + ',' + decision.y);
+ }
+
+ const edgeKey = decision.x + ',' + decision.y + '>' + headingOut;
+ visitCounts[edgeKey] = (visitCounts[edgeKey] || 0) + 1;
+
+ route.push({
+ index: (opts.startIndex ?? 0) + i,
+ x: decision.x,
+ y: decision.y,
+ approachX: approach.x,
+ approachY: approach.y,
+ headingIn: heading,
+ headingOut,
+ move: getRelativeMove(heading, headingOut),
+ exitX: exit.x,
+ exitY: exit.y,
+ });
+
+ fromCell = exit;
+ heading = headingOut;
+ }
+
+ return route;
+}
+```
+
+`extendRouteOnGraph(islandOrGrid, existingRoute, additional, rng)` must return new steps only. It must not create new map cells.
+
+```javascript
+function visitCountsFromRoute(route) {
+ const visitCounts = Object.create(null);
+ for (const step of route) {
+ const key = step.x + ',' + step.y + '>' + step.headingOut;
+ visitCounts[key] = (visitCounts[key] || 0) + 1;
+ }
+ return visitCounts;
+}
+
+function extendRouteOnGraph(islandOrGrid, existingRoute, additional, rng) {
+ const grid = Array.isArray(islandOrGrid) ? islandOrGrid : islandOrGrid.grid;
+ if (!existingRoute.length) {
+ return buildRouteOnGraph(grid, { count: additional }, rng);
+ }
+
+ const last = existingRoute[existingRoute.length - 1];
+ return buildRouteOnGraph(grid, {
+ count: additional,
+ startCell: { x: last.exitX, y: last.exitY },
+ startHeading: last.headingOut,
+ startIndex: existingRoute.length,
+ visitCounts: visitCountsFromRoute(existingRoute),
+ }, rng);
+}
+```
+
+## Game Integration
-Renderer is rewritten to fill cells fully and use chamfered/arc'd corners on non-exit edges so T-intersections, corners, and 4-ways look clean.
+In `FireTruckScene.create()`, replace route/city setup with this flow. Use a fixed seed so browser tests and screenshots are stable.
-## Algorithm Detail
+```javascript
+this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200, seed: 20260426 });
+this.route = this.island.route;
+this.renderIsland();
+this.createTruck();
+
+this.currentStep = this.route[0];
+const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y);
+this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
+this.heading = this.currentStep.headingIn;
+this.targetRotation = this.rotationForHeading(this.heading);
+this.truck.setPosition(start.x, start.y);
+this.truck.rotation = this.targetRotation;
+```
+
+Store `this.cellSize = CELL_SIZE` for tests.
+
+Camera setup must use water as the background and clamp to the island footprint:
+
+```javascript
+this.cameras.main.setBackgroundColor('#6BC4E8');
+this.cameras.main.setZoom(1 / SCALE);
+this.cameras.main.setBounds(
+ this.island.bounds.minX * CELL_SIZE,
+ this.island.bounds.minY * CELL_SIZE,
+ this.island.bounds.width * CELL_SIZE,
+ this.island.bounds.height * CELL_SIZE
+);
+```
-### `buildIsland({ width=50, height=50, seed }, rng)`
+Route extension must reuse the finite graph:
```javascript
-1. Allocate cells[width][height], type = 'water' for all.
-2. Set up the perimeter:
- - x=0, x=49, y=0, y=49 -> 'water'
- - x=1, x=48, y=1, y=48 (inner ring) -> 'beach'
- - x=2, x=47, y=2, y=47 (inner ring) -> 'road' (This forms the Outer Ring Road)
-3. Fill the interior (x: 3..46, y: 3..46) with 'building'.
-4. Recursive subdivide(x, y, w, h):
- // x,y is the top-left of the building block, w,h is its size.
- let canSplitW = w >= MIN_BLOCK * 2 + 1; // e.g. MIN_BLOCK = 4
- let canSplitH = h >= MIN_BLOCK * 2 + 1;
- if (!canSplitW && !canSplitH) return;
+const ROUTE_EXTENSION_COUNT = 36;
- // Choose split type based on available dimensions
- let type = 'none';
- if (canSplitW && canSplitH) {
- // Mix it up: 40% quad (4-way), 30% horiz (T), 30% vert (T)
- let r = rng.frac();
- type = r < 0.4 ? 'quad' : (r < 0.7 ? 'horiz' : 'vert');
- } else if (canSplitH) type = 'horiz';
- else type = 'vert';
+extendRouteIfNeeded() {
+ if (this.routeIndex < this.route.length - 12) return;
+ const extension = FT.extendRouteOnGraph(this.island, this.route, ROUTE_EXTENSION_COUNT);
+ this.route.push(...extension);
+}
+```
- if (type === 'quad') {
- let cx = rng.integerInRange(x + MIN_BLOCK, x + w - 1 - MIN_BLOCK);
- let cy = rng.integerInRange(y + MIN_BLOCK, y + h - 1 - MIN_BLOCK);
- drawRoadH(x, x+w-1, cy);
- drawRoadV(cx, y, y+h-1);
- subdivide(x, y, cx - x, cy - y);
- subdivide(cx + 1, y, x + w - cx - 1, cy - y);
- subdivide(x, cy + 1, cx - x, h - (cy - y) - 1);
- subdivide(cx + 1, cy + 1, x + w - cx - 1, h - (cy - y) - 1);
- } else if (type === 'horiz') {
- let cy = rng.integerInRange(y + MIN_BLOCK, y + h - 1 - MIN_BLOCK);
- drawRoadH(x, x+w-1, cy);
- subdivide(x, y, w, cy - y);
- subdivide(x, cy + 1, w, h - (cy - y) - 1);
- } else if (type === 'vert') {
- let cx = rng.integerInRange(x + MIN_BLOCK, x + w - 1 - MIN_BLOCK);
- drawRoadV(cx, y, y+h-1);
- subdivide(x, y, cx - x, h);
- subdivide(cx + 1, y, x + w - cx - 1, h);
- }
+Replace every speed multiplier fallback with nullish coalescing:
- Run subdivide(3, 3, 44, 44) to fill the interior.
-5. Stitch cell exits: for each road cell, set N/E/S/W exits based on the four neighbors that are road.
- (The ring road corners will automatically become degree-2 90-degree turns).
-6. Final connectivity check: BFS from ring road; assert all road cells reachable. Throw if not.
+```javascript
+const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
```
-**Why this works:** It guarantees connectivity (every cut touches parent roads), bounds block sizes perfectly, avoids dead ends natively, and generates a natural mix of intersections. The renderer's chamfering handles the visual "rounded 90-degree corners".
+This is required because screenshot tests set `GAME_SPEED_MULTIPLIER = 0` to freeze the scene.
-### Truck route: `buildRouteOnGraph(grid, { count=200, startCell, startHeading }, rng)`
+## Test And Screenshot Hooks
+Add these methods to `FireTruckScene`.
+
+```javascript
+cellAt(x, y) {
+ return this.island && this.island.grid[y] && this.island.grid[y][x] ? this.island.grid[y][x] : null;
+}
+
+snapTruckToRouteIndex(index) {
+ if (!this.route[index]) throw new Error('Invalid route index: ' + index);
+
+ this.routeIndex = index;
+ this.currentStep = this.route[index];
+ this.heading = this.currentStep.headingIn;
+ this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
+ this.phase = 'approach';
+ this.promptDir = this.currentStep.move;
+ this.promptResolved = false;
+ this.state = 'prompting';
+ this.targetSpeed = 0;
+ this.speed = 0;
+ this.targetRotation = this.rotationForHeading(this.heading);
+
+ const pos = this.worldPoint(this.currentStep.approachX, this.currentStep.approachY);
+ this.truck.setPosition(pos.x, pos.y);
+ this.truck.rotation = this.targetRotation;
+ this.refreshDebug();
+}
```
-1. Pick start cell on the outer ring road (e.g., top-left corner, heading East) or use provided.
-2. Initialize visit counts for each directed edge.
-3. Walk forward. A step continues until we reach a Decision Point (degree >= 3 intersection, OR a degree 2 corner).
-4. At a Decision Point:
- - Identify legal outward edges (cannot U-turn unless dead-end, which we don't have).
- - Bias selection toward the edge with the lowest visit count.
- - Record { x, y, headingIn, headingOut, move, exitX, exitY } matching `game.js` expectations.
- - Increment visit count for chosen edge.
-5. Repeat for `count` steps.
+
+Keep `window.__FT_SCENE__ = this` in `create()`.
+
+## Island Renderer
+
+Rename `renderCity()` to `renderIsland()` and render all `50 x 50` cells. Keep chunked texture baking so the final texture size stays under GPU limits.
+
+Chunking rules:
+
+```javascript
+const BAKE_CELL = CELL_SIZE / SCALE;
+const CHUNK_CELLS = 16;
+const chunkCountX = Math.ceil(this.island.width / CHUNK_CELLS);
+const chunkCountY = Math.ceil(this.island.height / CHUNK_CELLS);
```
-A new helper `extendRouteOnGraph(grid, route, additional, rng)` is added so the in-game extender (`game.js:431-443`) can safely query the graph from the truck's last `(exitX, exitY, headingOut)`.
+Each cell is centered at integer coordinates. A cell footprint top-left is `(x - 0.5, y - 0.5)` in cell coordinates.
+
+When creating a chunk image for cells `cellStartX..cellEndX - 1` and `cellStartY..cellEndY - 1`, place the image at:
-### Renderer rewrite (in `game.js`)
-
-Rip out the offset-building approach. Per cell:
-
-| Cell type | Drawing |
+```javascript
+const imageWorldX = (cellStartX - 0.5) * CELL_SIZE;
+const imageWorldY = (cellStartY - 0.5) * CELL_SIZE;
+```
+
+Within the chunk texture, cell `(x, y)` starts at:
+
+```javascript
+const localX = (x - cellStartX) * BAKE_CELL;
+const localY = (y - cellStartY) * BAKE_CELL;
+```
+
+Draw order for each chunk:
+
+1. Draw every non-road cell in the chunk over its full `BAKE_CELL x BAKE_CELL` footprint.
+2. Draw every road cell in the chunk as a full asphalt rectangle over its full footprint.
+3. Apply road-corner masks for road cells.
+4. Draw lane stripes on straight road cells only.
+
+Base color helper:
+
+```javascript
+function colorForCell(cell) {
+ if (!cell) return WATER_COLOR;
+ if (cell.type === CELL_TYPES.WATER) return WATER_COLOR;
+ if (cell.type === CELL_TYPES.BEACH) return BEACH_COLOR;
+ if (cell.type === CELL_TYPES.ROAD) return ASPHALT_COLOR;
+ return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)];
+}
+```
+
+Building drawing must fill the whole cell. Do not use the old `BAKE_CELL * 0.38` offset or `BAKE_CELL * 0.76` size.
+
+```javascript
+gfx.fillStyle(colorForCell(cell), 1);
+gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL);
+if (cell.type === CELL_TYPES.BUILDING) {
+ gfx.lineStyle(2, 0x000000, 0.08);
+ gfx.strokeRect(localX + 1, localY + 1, BAKE_CELL - 2, BAKE_CELL - 2);
+}
+```
+
+Road drawing starts as a full rectangle:
+
+```javascript
+gfx.fillStyle(ASPHALT_COLOR, 1);
+gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL);
+```
+
+Corner masking rule: if both adjacent exits for a corner are missing, cover that corner with the diagonal neighbor's base color. Use a square mask of `Math.round(BAKE_CELL * 0.22)` pixels. This keeps T-intersections and corners from looking like full square plazas.
+
+| Corner | Mask condition | Diagonal color cell |
+|---|---|---|
+| NW | `!cell.exits.N && !cell.exits.W` | `grid[y - 1][x - 1]` |
+| NE | `!cell.exits.N && !cell.exits.E` | `grid[y - 1][x + 1]` |
+| SE | `!cell.exits.S && !cell.exits.E` | `grid[y + 1][x + 1]` |
+| SW | `!cell.exits.S && !cell.exits.W` | `grid[y + 1][x - 1]` |
+
+Lane stripes only go on straight cells. Do not draw stripes on corners, T-intersections, or 4-ways.
+
+```javascript
+const meta = cell.meta || classifyIntersection(cell.exits);
+if (meta.kind === 'straight') {
+ gfx.fillStyle(STRIPE_COLOR, 0.92);
+ if (cell.exits.N && cell.exits.S) {
+ gfx.fillRect(localX + BAKE_CELL / 2 - 3, localY + BAKE_CELL * 0.1, 6, BAKE_CELL * 0.8);
+ }
+ if (cell.exits.E && cell.exits.W) {
+ gfx.fillRect(localX + BAKE_CELL * 0.1, localY + BAKE_CELL / 2 - 3, BAKE_CELL * 0.8, 6);
+ }
+}
+```
+
+After rendering, `this.cityChunks` can either be renamed to `this.islandChunks` or kept as `this.cityChunks` to minimize unrelated changes. If kept, update comments so they refer to the island, not the old city.
+
+## Unit Test Requirements
+
+`tests/unit/route.test.js` should keep the existing helper-function tests and add these tests.
+
+| Test | Assertion |
|---|---|
-| `water` | Solid blue (`#1D7CF2` darkened ~20%) over its full cell footprint. |
-| `beach` | Sand color (`#F2E2B6`) over full cell. |
-| `building`| Pastel fill over full cell, optional 2-px darker inset border for visual separation. Color seeded by `(x*13 + y*17) % palette.length` (preserves existing palette). |
-| `road` | Asphalt fill over full cell, then **chamfer the non-exit corners**: for each pair of adjacent edges that don't both have an exit, fill the inside corner with the neighboring cell's color so a corner cell becomes a quarter-arc, a T becomes a clean trident, a 4-way is unmodified. Lane stripes drawn only on `straight` cells (degree-2 with opposite exits), skipping the 0.5 cell nearest each end so stripes never enter intersections. |
+| `buildIsland()` deterministic with seed | Two calls with `{ seed: 123, routeCount: 30 }` produce identical road-cell coordinates, exits, and route steps. |
+| route steps stay on graph | For every route step, decision, approach, and exit cells are roads; approach is adjacent to the decision on `OPPOSITE[headingIn]`; exit is adjacent on `headingOut`; the decision has an exit back to approach and an exit to `headingOut`; `move === getRelativeMove(headingIn, headingOut)`. |
+| route starts correctly | `routeStart` is `{ x: 3, y: 2, heading: 'east' }`; `route[0].headingIn` is `'east'`; the first approach path remains on road cells. |
+| extension continues smoothly | `extendRouteOnGraph(island, island.route, 20, rng)` returns 20 steps; first extension index is `island.route.length`; first extension starts from the previous last step's `exitX`, `exitY`, and `headingOut`. |
+
+`tests/unit/projection.test.js` should replace the old city tests with these island tests.
-**Chamfer implementation detail:** Draw the asphalt rectangle. For each of the 4 corners (e.g., North-West), check if both North and West exits are missing. If so, draw a small square (e.g., 20% of cell size) in that corner using the diagonal neighbor's color (the building/beach color at NW). This creates the visual 90-degree rounded corners.
+| Test | Assertion |
+|---|---|
+| fixed rings | Outer perimeter cells are water; `x/y = 1,48` are beach except perimeter; `x/y = 2,47` are road except where already water/beach precedence applies. |
+| interior cells | Every cell in `x = 3..46`, `y = 3..46` is either building or road. There must be no water or beach in the interior. |
+| connected graph | BFS from any road reaches exactly `island.roads.length` cells. |
+| no dead-ends | Every road cell has degree at least `2`. |
+| varied intersections | Count only decision cells for ratios. Let `decisionCount = tCount + fourCount`. Assert `decisionCount > 0`, `tCount / decisionCount >= 0.55`, and `fourCount / decisionCount >= 0.06`. Do not divide these ratios by all road cells because long straight roads make that threshold misleading. |
+| average degree | Average degree across all road cells is at least `2.0`. |
+| building count sanity | `island.buildings.length + interiorRoadCount === 44 * 44`, where `interiorRoadCount` counts road cells with `x = 3..46` and `y = 3..46`. |
-**Lane stripes detail:** existing `BAKE_ROAD` width is preserved. Stripes only on through cells. Yellow dashed center line drawn from `cell_edge + 0.1*cell` to `opposite_edge - 0.1*cell` so adjacent intersections don't get stripe leakage.
+## Browser Test Requirements
-**Camera:** `cameras.main.setBounds(islandBounds)` so the truck cannot leave the island; `setBackgroundColor(WATER_COLOR)` for the area outside the island bake.
+Keep the existing fire-truck browser tests for load, render, automatic movement, wrong arrow stop, and correct arrow resume.
-## Files to change
+Add these checks to `tests/browser/runner.js` after the fire-truck scene loads:
-| Path | Change |
+```javascript
+const info = await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ return {
+ width: s.island.width,
+ height: s.island.height,
+ cell00: s.cellAt(0, 0).type,
+ cell11: s.cellAt(1, 1).type,
+ cell22: s.cellAt(2, 2).type,
+ routeOutOfBounds: s.route.some(step => step.x < 2 || step.x > 47 || step.y < 2 || step.y > 47 || step.approachX < 2 || step.approachX > 47 || step.approachY < 2 || step.approachY > 47 || step.exitX < 2 || step.exitX > 47 || step.exitY < 2 || step.exitY > 47),
+ hasFourWayOnRoute: s.route.some(step => s.cellAt(step.x, step.y).meta.kind === 'four'),
+ };
+});
+```
+
+Assertions:
+
+| Field | Expected value |
|---|---|
-| `games/fire-truck/lib.js` | Replace `buildRoute`/`buildCity` with `buildIsland`, `buildRouteOnGraph`, `extendRouteOnGraph`. Keep `HEADINGS`, `turnHeading`, `getRelativeMove`, `stepPosition`, `classifyIntersection`, `getLegalMoves`, `pickPromptMove`, `summarizeKinds` exported. Add `CELL_TYPES` enum. |
-| `games/fire-truck/game.js` | Replace `buildRoute(64)` + `buildCity(route)` with a single `buildIsland(...)` call (see `game.js:48-49`). Rewrite `renderCity` → `renderIsland` to handle 4 cell types and chamfered corners. Update `extendRouteIfNeeded` (`game.js:431-443`) to call `extendRouteOnGraph`. Update camera to `setBounds`. Drop the `BAKE_CELL * 0.38` building offset entirely (`game.js:183-189`). |
-| `tests/unit/route.test.js` | Rewrite for new API: `buildIsland` determinism, route stays on road graph, `extendRouteOnGraph` continues smoothly from end-of-route state. |
-| `tests/unit/projection.test.js` | Rewrite for `buildIsland`: t-ratio ≥ 0.04, four-way-ratio ≥ 0.04, largest connected component = 100%, avg degree ≥ 1.5, alternate-connections ≥ route.length, no dead-ends, building cells fill the full inner rectangle minus roads. |
-| `tests/browser/runner.js` | Add fire-truck cases: (a) island bounds visible (truck cannot leave water boundary), (b) `__FT_SCENE__.cellAt(x, y)` returns expected type, (c) screenshot regression smoke. |
-| `tests/browser/screenshot.js` *(new)* | Standalone Playwright script for the implementation agent. |
-| `package.json` | Add `npm run screenshot` → `node tests/browser/screenshot.js`. |
+| `width` | `50` |
+| `height` | `50` |
+| `cell00` | `'water'` |
+| `cell11` | `'beach'` |
+| `cell22` | `'road'` |
+| `routeOutOfBounds` | `false` |
+| `hasFourWayOnRoute` | `true` |
-## Headless screenshot harness for the implementation agent
+## Screenshot Harness
-The implementation agent must be able to **see** what it built. Add a new script `tests/browser/screenshot.js` that:
+Add `tests/browser/screenshot.js`. It must be runnable with `npm run screenshot`.
-1. Boots `tests/serve.js`.
-2. Launches headless Chromium (`viewport: 1280×800`).
-3. Takes four shots to `tests/screenshots/fire-truck-*.png`:
- - `overview.png` — `cameras.main.setZoom(0.05)`, centered on island. Confirms ring road, inner network, and borders.
- - `gameplay.png` — default zoom, frozen (`GAME_SPEED_MULTIPLIER = 0.0`).
- - `intersection.png` — truck snapped to first 4-way via `snapTruckToRouteIndex(k)`.
- - `portal.png` — portal index page.
-4. Prints saved paths to stdout.
+Script behavior:
-Agent runs `npm run screenshot` and uses the `Read` tool on the PNG files to visually inspect the output.
+1. Start `tests/serve.js` on a non-conflicting port, for example `8767`.
+2. Launch Chromium with viewport `{ width: 1280, height: 800 }`.
+3. Create `tests/screenshots/` if it does not exist.
+4. Set `window.__TEST_MODE__ = true` and `window.GAME_SPEED_MULTIPLIER = 0` before loading fire-truck pages.
+5. Save these files and print each path to stdout.
-## Tunable parameters (with defaults)
+| File | Page setup |
+|---|---|
+| `tests/screenshots/fire-truck-overview.png` | Load `/games/fire-truck/`, wait for `window.__FT_SCENE__`, stop camera follow, set zoom to `0.05`, center on island center `((width - 1) / 2 * cellSize, (height - 1) / 2 * cellSize)`. |
+| `tests/screenshots/fire-truck-gameplay.png` | Load a fresh `/games/fire-truck/`, wait for scene, keep default camera, take screenshot while frozen. |
+| `tests/screenshots/fire-truck-intersection.png` | Load a fresh `/games/fire-truck/`, find the first route index whose decision cell has `meta.kind === 'four'`, call `snapTruckToRouteIndex(index)`, center camera on the truck, take screenshot. |
+| `tests/screenshots/portal.png` | Load `/`, take screenshot. |
+
+Implementation skeleton:
```javascript
-WIDTH = 50 HEIGHT = 50
-MIN_BLOCK = 4
-ROUTE_COUNT = 200
-PALETTE = existing 6-color pastel set
-WATER_COLOR = #6BC4E8
-BEACH_COLOR = #F2E2B6
-ASPHALT_COLOR = #2A2D34
-STRIPE_COLOR = #FFD23F
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+const serve = require('../serve.js');
+
+async function save(page, filePath) {
+ await page.screenshot({ path: filePath, fullPage: false });
+ console.log(filePath);
+}
+
+async function main() {
+ const outDir = path.join(__dirname, '..', 'screenshots');
+ fs.mkdirSync(outDir, { recursive: true });
+
+ const { server, url } = await serve(8767);
+ const browser = await chromium.launch();
+
+ try {
+ const fireTruckPage = async () => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await page.addInitScript(() => {
+ window.__TEST_MODE__ = true;
+ window.GAME_SPEED_MULTIPLIER = 0;
+ });
+ await page.goto(url + '/games/fire-truck/', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__FT_SCENE__ && window.__FT_SCENE__.island);
+ return page;
+ };
+
+ const overview = await fireTruckPage();
+ await overview.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ s.cameras.main.stopFollow();
+ s.cameras.main.setZoom(0.05);
+ s.cameras.main.centerOn((s.island.width - 1) / 2 * s.cellSize, (s.island.height - 1) / 2 * s.cellSize);
+ });
+ await save(overview, path.join(outDir, 'fire-truck-overview.png'));
+ await overview.close();
+
+ const gameplay = await fireTruckPage();
+ await save(gameplay, path.join(outDir, 'fire-truck-gameplay.png'));
+ await gameplay.close();
+
+ const intersection = await fireTruckPage();
+ await intersection.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ const index = s.route.findIndex(step => s.cellAt(step.x, step.y).meta.kind === 'four');
+ if (index < 0) throw new Error('No 4-way route step found');
+ s.snapTruckToRouteIndex(index);
+ s.cameras.main.stopFollow();
+ s.cameras.main.centerOn(s.truck.x, s.truck.y);
+ });
+ await save(intersection, path.join(outDir, 'fire-truck-intersection.png'));
+ await intersection.close();
+
+ const portal = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await portal.goto(url + '/', { waitUntil: 'domcontentloaded' });
+ await save(portal, path.join(outDir, 'portal.png'));
+ await portal.close();
+ } finally {
+ await browser.close();
+ server.close();
+ }
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
+```
+
+Add this script to `package.json`:
+
+```json
+"screenshot": "node tests/browser/screenshot.js"
```
-## Verification Steps for Coding Agent
+## Visual Acceptance Checklist
+
+Run `npm run screenshot`, then inspect the PNGs with the `Read` tool.
+
+The overview screenshot must show a complete water perimeter, a complete one-cell beach ring, a closed outer road ring, and interior roads that are not a uniform full grid.
+
+The overview screenshot must show no roads outside the island and no truck route outside the outer ring road.
+
+The gameplay screenshot must show buildings touching their neighboring building cells with no transparent or grass-colored alleys between them.
-1. `npm run test:unit` — verify determinism, ratios, connectivity, zero dead-ends.
-2. `npm run test:browser` — verify island bounds, cell queries, and rendering smoke.
-3. `npm run screenshot` — visually confirm via `Read` tool: closed outer ring, no phantom alleys, mix of T's/4-ways, complete water boundary.
-4. Manual smoke via `npm run dev` (if practical).
+The intersection screenshot must show a clean 4-way intersection with no duplicated asphalt overlap, no stray lane stripe through the intersection center, and no road gaps at the exits.
+
+The portal screenshot must still render normally.
+
+## Verification Steps
+
+1. Run `npm run test:unit`.
+2. Run `npm run test:browser`.
+3. Run `npm run screenshot`.
+4. Inspect `tests/screenshots/fire-truck-overview.png`, `tests/screenshots/fire-truck-gameplay.png`, `tests/screenshots/fire-truck-intersection.png`, and `tests/screenshots/portal.png` with the `Read` tool.
+5. If practical, run `node tests/serve.js` and manually smoke-test arrow prompts in the browser URL printed by the server.
+
+Do not commit if `npm test` fails.
## Critical Files
-- `games/fire-truck/lib.js` — Core logic rewrite.
-- `games/fire-truck/game.js` — Renderer overhaul & camera bounds.
-- `tests/unit/projection.test.js` — Update assertions.
-- `tests/unit/route.test.js` — Route extension tests.
-- `tests/browser/runner.js` — `__FT_SCENE__` inspection hooks.
-\ No newline at end of file
+| Path | Purpose |
+|---|---|
+| `games/fire-truck/lib.js` | Core island, road graph, route walking, and route extension logic. |
+| `games/fire-truck/game.js` | Island rendering, camera bounds, route integration, and screenshot/test hooks. |
+| `tests/unit/projection.test.js` | Graph and island invariants. |
+| `tests/unit/route.test.js` | Route continuity and helper behavior. |
+| `tests/browser/runner.js` | Browser smoke tests and `__FT_SCENE__` checks. |
+| `tests/browser/screenshot.js` | Visual verification harness for the implementation agent. |