commit 0088c092a8534fe30ee0d630f84ccba4b648fe42
parent 8a8590ed8f5b2c7af20880b373500d63452f2594
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sun, 26 Apr 2026 14:24:50 -0700
Next iteration
Diffstat:
1 file changed, 105 insertions(+), 92 deletions(-)
diff --git a/see-screenshot-of-current-sorted-flute.md b/see-screenshot-of-current-sorted-flute.md
@@ -15,69 +15,96 @@ The user wants:
- 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.
+## Critique of Original Approach
+
+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).
+
+**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.
+
## Approach (high level)
Replace `buildRoute` + `buildCity` with a single deterministic generator `buildIsland(opts, rng)` that:
-1. Lays out cell types (`water`/`beach`/`road`/`building`).
-2. Builds the road graph using **recursive rectangular subdivision** (subdivision → connectivity is structurally guaranteed; matches the regular look of the reference; no orphan-rescue pass needed).
-3. Adds variety with **probabilistic T-cuts** (some splits stop short of the far parent, producing T-intersections) and a small number of **chord spurs** (short segments that must start and end on existing road — never spikes).
-4. Generates the truck's `route` by walking the finished road graph, biased toward degree ≥ 3 intersections.
-5. Returns a unified `{ grid, cells, route, bounds, water, beach, buildings, roads }` payload.
+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.
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.
-## Algorithm detail
+## Algorithm Detail
### `buildIsland({ width=50, height=50, seed }, rng)`
+```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;
+
+ // 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';
+
+ 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);
+ }
+
+ 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.
```
-1. Allocate cells[width][height], type = 'building' for all.
-2. Mark perimeter ring cells as 'road' with N/E/S/W exits forming a closed loop.
- (Corner ring cells are 90° corners; mid-edge ring cells are 'straight'.)
-3. Recursive subdivide(rect):
- // rect is an axis-aligned rectangle of building cells bounded by road on all 4 sides.
- if min(rect.w, rect.h) < MIN_BLOCK (default 4): return.
- pick split axis = the longer side (random tiebreak).
- pick split position uniformly in [2 .. side-2] (so each child rect is ≥ 2 thick).
- draw straight road along that line, connecting the two parent roads.
- with probability T_CHANCE (default 0.30): stop the cut 1 cell short of one parent
- → creates a T-intersection at the parent that survives, dead-pull at the other end is
- avoided by ensuring the truncated end aligns with an existing perpendicular cell
- (we just don't extend that last cell, and the parent road absorbs no new exit there).
- recurse into both child rects.
- Run subdivide on the inner rectangle (interior to the outer ring).
-4. Stitch cell exits: for each road cell, set N/E/S/W exits based on the four neighbors that are road.
-5. Cleanup pass: any road cell with degree 1 (a true dead-end produced by a T-cut going wrong)
- gets its terminal edge clipped (cell reverted to 'building') OR extended to the nearest road,
- whichever is shorter. This pass is small because subdivision rarely produces dead-ends; it
- only catches the truncated-T case.
-6. Optional chord spurs (CHORD_COUNT default 6): pick random road cell, pick perpendicular
- direction with empty 1st neighbor, walk straight until hitting another road cell. If
- walk exceeds MAX_SPUR (default 8) without hitting road, **discard**. Apply min-spacing
- ≥ 2 perpendicular cells from any parallel road. (Pure chord rule — no spikes ever.)
-7. Final connectivity check: BFS from ring; assert all road cells reachable. Throw if not
- (subdivision guarantees this; the throw is a regression sentinel, not a fallback).
-```
-
-**Why subdivision over growth:** connectivity is guaranteed by construction; block sizes are bounded; no rejection-storm starvation at high density. Plan-agent critique flagged growth's failure modes (orphan clusters, parallel-road thinness, branch-spike connectivity violations) — subdivision sidesteps all of them.
-**Why include T-chance + chord spurs:** pure subdivision produces almost-all 4-ways (every cut meets two parents). T-chance and the cleanup pass introduce T-intersections; chord spurs add organic variation so the output doesn't look like a strict gridiron.
+**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".
### Truck route: `buildRouteOnGraph(grid, { count=200, startCell, startHeading }, rng)`
```
-1. Pick start cell on the outer ring (or use provided startCell).
-2. Walk along current heading until reaching an intersection (degree ≥ 3) or corner (degree 2 turn).
-3. Choose a legal move biased toward the move that leads to the lowest-visit-count exit.
-4. Record { x, y, headingIn, headingOut, move, exitX, exitY } using the same shape game.js
- already consumes. Enforce step-gap ≥ 2 by walking at least 2 cells before recording the
- next intersection (skip degree-2 straight cells; only count true intersections/corners).
-5. Repeat for `count` steps. No revisit-thrash defense beyond visit-count bias because
- subdivision guarantees a connected graph with many cycles.
+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.
```
-A new helper `extendRouteOnGraph(grid, route, additional, rng)` is added so the in-game extender (`game.js:431-443`) can keep working from the truck's last `(exitX, exitY, headingOut)`. No more `buildRoute(36)` from `(0,0,east)`.
+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)`.
### Renderer rewrite (in `game.js`)
@@ -90,7 +117,7 @@ Rip out the offset-building approach. Per 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. |
-The chamfer is what gives the rounded-90° look in the reference sketch. Implementation: draw the asphalt rectangle, then for each of the 4 corners check if both adjacent cell-edges are non-exits — if so, paint a small quarter-disc (or square chamfer for cheaper) of the corner-diagonal cell's color over that corner. This is baked once per cell into the chunk texture, so it's free at runtime.
+**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.
**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.
@@ -102,66 +129,51 @@ The chamfer is what gives the rounded-90° look in the reference sketch. Impleme
|---|---|
| `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 step-gap ≥ 2, 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% (subdivision guarantees), avg degree ≥ 1.5, alternate-connections ≥ route.length, no dead-ends, building cells fill the full inner rectangle minus roads. |
+| `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 — see next section. |
+| `tests/browser/screenshot.js` *(new)* | Standalone Playwright script for the implementation agent. |
| `package.json` | Add `npm run screenshot` → `node tests/browser/screenshot.js`. |
## Headless screenshot harness for the implementation agent
-The implementation agent should be able to **see** what it built without a human running the browser. Add a new script `tests/browser/screenshot.js` that:
-
-1. Boots the same `tests/serve.js` server used by the test runner.
-2. Launches Playwright headless Chromium with `viewport: 1280×800`, `deviceScaleFactor: 1`.
-3. Runs four shots and saves them to `tests/screenshots/fire-truck-*.png`:
- - `fire-truck-overview.png` — set `window.__FT_SCENE__.cameras.main.setZoom(0.05)` and centre on the island so the entire 50×50 map fits in the viewport. Confirms ring road, water, beach, and the inner road network in one frame.
- - `fire-truck-gameplay.png` — default camera zoom, captured 1.5 s after load, with `GAME_SPEED_MULTIPLIER = 0.0` (truck frozen) so the frame is deterministic.
- - `fire-truck-intersection.png` — programmatically position the truck at the first 4-way along its route (`__FT_SCENE__.snapTruckToRouteIndex(k)` helper added in `game.js`) so the screenshot always shows a 4-way + a T + a corner in one frame.
- - `fire-truck-portal.png` — portal index page, for sanity.
-4. Prints the saved paths to stdout so the agent's tool result includes them.
+The implementation agent must be able to **see** what it built. Add a new script `tests/browser/screenshot.js` that:
-The script must be runnable headless under the existing `playwright` install (`npm install` already ships Chromium). No new deps. Implementation agent runs `npm run screenshot` and then uses the `Read` tool on the PNG files to inspect — `Read` accepts image files and presents them to the model.
+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.
-Add `tests/screenshots/` to `.gitignore` (these are debugging artifacts).
+Agent runs `npm run screenshot` and uses the `Read` tool on the PNG files to visually inspect the output.
-## Tunable parameters (with defaults that should pass tests)
+## Tunable parameters (with defaults)
-```
+```javascript
WIDTH = 50 HEIGHT = 50
-MIN_BLOCK = 4 T_CHANCE = 0.30
-MAX_SPUR = 8 CHORD_COUNT = 6
-ROUTE_COUNT = 200 ROUTE_GAP = 2
+MIN_BLOCK = 4
+ROUTE_COUNT = 200
PALETTE = existing 6-color pastel set
-WATER_COLOR = #6BC4E8 (lighter than primary blue, kid-friendly)
+WATER_COLOR = #6BC4E8
BEACH_COLOR = #F2E2B6
-ASPHALT_COLOR = #2A2D34 (existing)
-STRIPE_COLOR = #FFD23F (existing)
+ASPHALT_COLOR = #2A2D34
+STRIPE_COLOR = #FFD23F
```
-These are exposed on `lib.js` exports so tests can override.
-
-## Verification
-
-Run in this order — each must pass before moving on:
-
-1. `npm run test:unit` — `route.test.js` + `projection.test.js` cover determinism, intersection ratios, connectivity, dead-end absence, and route step-gap.
-2. `npm run test:browser` — existing fire-truck browser cases plus new island-bounds + screenshot smoke.
-3. `npm run screenshot` — generates the 4 PNGs. **Implementation agent must `Read` `tests/screenshots/fire-truck-overview.png` and confirm visually:** outer ring road is closed, no phantom alleys inside blocks, mix of T's / 4-ways / corners visible, water surrounds the island, beach is a 1-cell strip.
-4. Manual smoke (optional, if dev server is running): `npm run dev` and play for 30s — truck never leaves the island, prompts still fire at intersections, no console errors.
-
-## Critical files (open these first when implementing)
+## Verification Steps for Coding Agent
-- `/home/kyle/gits/kgames/games/fire-truck/lib.js` — full rewrite of generator section; keep helpers.
-- `/home/kyle/gits/kgames/games/fire-truck/game.js` — replace `renderCity` (~line 132–220), the `buildRoute`/`buildCity` call site (~line 48-49), the building-draw block (~line 183-189), and `extendRouteIfNeeded` (~line 431-443). Camera `setBounds` near `create()` (~line 89-109).
-- `/home/kyle/gits/kgames/tests/unit/projection.test.js` — current assertions to preserve (or strengthen): t/four ratios, largest-CC, avg degree, alt-connections.
-- `/home/kyle/gits/kgames/tests/unit/route.test.js` — current step-gap ≥ 2 assertion (line ~56-60) must still hold.
-- `/home/kyle/gits/kgames/tests/browser/runner.js:212-267` — `__FT_SCENE__` shape that browser tests assert on; preserve `truckX, truckY, debugDistancePx, promptDir, state, segmentEnd, truck, targetSpeed, speed, lastStepTime, promptResolved` and add `cellAt`, `snapTruckToRouteIndex`.
-- `/home/kyle/gits/kgames/tests/serve.js` — reused by the new screenshot script.
+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).
-## What stays unchanged
+## Critical Files
-- All movement logic in `game.js` (approach/exit phases, speed/accel, prompt UI, fail/resume) — the new generator emits the same `route` shape.
-- `index.html`, audio helpers, prompt overlay, FPS HUD.
-- The shared color palette and Fredoka font.
-- The CDN-loaded Phaser/Tone — no build tooling change.
+- `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