kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

commit 9ea773b1f20332f6913365c151f29e2e4c56eaf7
parent 0088c092a8534fe30ee0d630f84ccba4b648fe42
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sun, 26 Apr 2026 14:38:23 -0700

5.5 update

Diffstat:
Msee-screenshot-of-current-sorted-flute.md | 1038+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
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. |