kgames

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

commit 0088c092a8534fe30ee0d630f84ccba4b648fe42
parent 8a8590ed8f5b2c7af20880b373500d63452f2594
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sun, 26 Apr 2026 14:24:50 -0700

Next iteration

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