# Excitebike — build plan Living plan doc. Survives context clears: **read this file first**, pick the next unchecked item, update the checkboxes and the Status line as work lands. ## Status **Complete 2026-07-29, through Wave 5 and two rounds of playtest fixes.** Ten tracks — the five original NES courses transcribed from their track maps, plus five new ones. SELECTION A, SELECTION B and DESIGN all implemented. `node tools/verifyExcitebike.js` → **620 checks green**. Playtested and signed off by Brian for feel. He has retuned `TUNE` himself (turbo boost, nose-down landing tolerance) — **do not "restore" those values**; after any physics change, re-run `node tools/genExcitebikeTracks.js --force` because qualifying times are measured, then the harness. ## Decisions taken up front | Question | Answer | |---|---| | Scope | Single player, faithful to the 1984 NES original | | Modes | SELECTION A (solo vs the clock), SELECTION B (vs rivals), DESIGN — the original's three | | Rivals | Anonymous NES bikes in the alternate palette. No names, no opponent roster | | Tracks | The 5 originals, transcribed from the NES maps, plus 5 new | | Presentation | True 256×240 NES frame at integer ×4, centred in a cabinet bezel | | Overlay | Full `ArcadeCRTOverlay` over bezel and all; `m6x11` for the non-NES chrome | | Slug / category / icon | `excitebike` / `arcade-console-pc` / iconFrame **91** (painted) | ## Files | File | Role | State | |---|---|---| | `src/games/excitebike/ExcitebikeNES.js` | NES palette, index grids, 8×8 tiles, screen geometry | done | | `src/games/excitebike/ExcitebikeArt.js` | Tile bank, themes, 5×7 font, parametric bike/rider/dust | done | | `src/games/excitebike/ExcitebikeTrack.js` | Hurdles A–S, terrain compiler, sampling, validation | done | | `src/games/excitebike/ExcitebikeLogic.js` | 60Hz sim: throttle, heat, jumps, crashes, rivals | done | | `src/games/excitebike/ExcitebikeAuto.js` | Reference riders + `probeTrack` | done | | `src/games/excitebike/ExcitebikeRaster.js` | Index grids and terrain → canvases | done | | `src/games/excitebike/ExcitebikeGame.js` | Phaser scene: viewport, screens, HUD, audio | done | | `src/games/excitebike/ExcitebikeDesign.js` | DESIGN mode screen | done | | `src/games/excitebike/ExcitebikeDesignData.js` | Node-safe half of DESIGN, so the harness can pin it | done | | `src/games/excitebike/sprites.md` | Drop-in art spec | done | | `assets/gamedata/excitebike/` | `tracks.json` + `track-01..10.json` | done | | `tools/genExcitebikeTracks.js` | Builds the bank, measures the qualifying times | done | | `tools/readExcitebikeMaps.js` | Transcribes the five originals from their map PNGs | done | | `tools/lib/png.js`, `tools/lib/canvasStub.js` | Zero-dependency PNG reader; headless canvas | done | | `tools/verifyExcitebike.js` | The harness | done | Wiring is in place across the six touchpoints: `gamesRegistry.js`, `main.js` (import + scene array), `GameRoomScene.js` `slugDispatch`, `assetManifest.js` (music only — every pixel is generated), `soundtrack.js` (`excitebike: 'nintendo'`). `PreloadScene` needed no change: there is no artwork JSON because there is no artwork to fetch. --- ## Wave 0 — NES foundation — ✅ DONE 2026-07-29 Palette, tile bank, raster stage, the masked ×4 viewport, CRT overlay, all wiring. - [x] 64-entry NES master palette; everything drawn samples only from it - [x] Art as **index grids** of subpalette slots, not pixels, so Node can assert on it - [x] 8×8 tile bank as string art; 5×7 font in 8×8 cells via Phaser `RetroFont` - [x] One container at NES coordinates, `setScale(4)`, geometry-masked to a 1024×960 window - [x] Cabinet bezel drawn inside the scene so the CRT curve wraps it too ### Wave 0 findings 1. **The art has to be data to be testable.** Grids of slot indices cost nothing and let the harness prove every tile stays inside four colours and every sprite inside its subpalette — with no canvas and no dependency. `ExcitebikeRaster.js` is the only module that touches the DOM, and `tools/lib/canvasStub.js` stubs enough of it (`createImageData`/`putImageData`/`getImageData`) to run that module under Node as well. Both halves are checked. 2. **Draw the bike parametrically, not by hand.** 17 pitch angles × 4 wheel phases is 68 frames of the same machine; hand-authoring them gets you 68 subtly different machines. Drawing into an index grid keeps the NES constraints while giving smooth animation. 3. **Containers ignore child depth.** Draw order inside the viewport container is insertion order. Bikes are sorted per frame with `bikeLayer.sort('y')` so nearer lanes paint last. 4. **Integer scale only.** 1080/240 is 4.5, and half the rows would be five pixels tall while the rest were four. On a scrolling track that shimmer is very visible. ×4 in a bezel is correct. 5. **`RetroFont` is not used anywhere else in this repo** — the config shape was verified against the Phaser 3.90 bundle before committing to it (`ParseRetroFont`: `image`, `width`, `height`, `chars`, `charsPerRow`, `offset`, `spacing`; `xAdvance` is `width`, so text centres on `len * 8`). ## Wave 1 — Ride it — ✅ DONE 2026-07-29 Full bike physics, heat, jumps, crashes, SELECTION A. - [x] Fixed 60Hz accumulator, seeded RNG, deterministic from a seed + input trace - [x] A accelerates and releasing brakes; B is turbo and heats the engine; cool zones dump heat - [x] Landing judged on `|pitch − groundAngle|`: clean, hard, or down - [x] Crash → tumble → run on foot mashing A → remount - [x] `M:SS:hh` clock, `3RD` target, heat meter, wall BEST — the original's HUD ### Wave 1 findings 1. **Feathering turbo was free speed.** With a slow bleed off the top end, tapping B held you at turbo pace while the meter hovered. `turboBleedRate` makes speed above the normal top end fall away fast, so sustained turbo has to be paid for in heat. 2. **Pitch control needs weight.** The first cut integrated pitch velocity eight times too fast and the nose could be swung end to end in a fifth of a second, which made every landing trivial and made the skill dial do nothing. At ~0.3s level-to-full-up, jumps have to be planned on the way up. 3. **Landing tolerance must narrow with impact speed.** A fixed window meant a passive rider never crashed: gravity alone left them inside it. `landSpeedSqueeze` makes the big jumps the ones that punish a lazy attitude, which is what the ramps are for. 4. **A crash cost three seconds and there were six a race.** Recovery dominated everything. Shorter tumble, a shorter slide, and a shove on the remount instead of a dead stop brought it to about two. ## Wave 2 — The five NES courses — ✅ DONE 2026-07-29 All 19 hurdles, and the originals transcribed rather than guessed. - [x] `tools/lib/png.js` — PNG decode on `node:zlib`, no dependency - [x] `tools/readExcitebikeMaps.js` — palette, extent, ramps, holes, straight off the images - [x] Hurdle catalogue reshaped to the heights and footprints the originals actually use - [x] Playfield geometry corrected to the original's own bands ### Wave 2 findings 1. **The maps carry a legend panel.** The first 256–512px of each image is a black/navy panel with the track's stats on it, not track. Reading the image width as the course length is wrong by up to 512px. The real lengths are **5888 / 5393 / 6416 / 6528 / 5752**. 2. **Lanes are 12 pixels.** The playfield splits crowd 0–39, sky and wall 40–63, infield 64–127, four 12px lanes 128–175, apron 176–191. The first build guessed 24px lanes in a 56–152 band and was wrong about every proportion. A lane is a tile and a half deep, so lane dividers land mid-tile — that is the real geometry, not a rounding slip. 3. **Almost nothing in the original is a ramp with a cliff on the end.** They are *hills*. Of Track 1's 22 raised features, 19 rise and come straight back down. Only the short steep ones (h16 w15) end high. The catalogue was rebuilt around that: A/B/C/D/H/R are mounds, E and S are the launchers. 4. **So hills have to launch you by curvature.** You leave a crest when `v² · curvature > g`. That one line is what turns the original's rolling terrain into jumps at speed and leaves it as bumps at a crawl. Without it every course in the bank is a flat road with decoration on it. 5. **A launch edge is two pixels wide and the bike moves four.** Testing the crest at exactly the bike's position stepped straight over every ramp. The sim now tests the stretch it is about to cross, scaled by speed. 6. **Each course has a rare highlight colour** used only on lit ramp faces. A material threshold tuned on the common colours misses it, which loses small ramps — and a missed ramp in front of a hole turns a jump into an unclearable wall. That was the one validation failure in the transcription. ### Known gap, deliberate Cool zones and mud are **not** transcribed. Both are drawn as texture inside the lane band, and on these maps that texture is not separable from the shading on a ramp body — the detector fires on every ramp. Rather than emit hurdles the images do not support, cool zones are placed into the long clear stretches on a rule and mud is left out. This is the one part of the five courses that is not the original's. Worth revisiting if the maps can be read better. ## Wave 3 — Racing — ✅ DONE 2026-07-29 - [x] Rival bikes, gap avoidance, lane preference, traffic awareness - [x] The manual's contact rule: catch a leader from behind and *you* go down - [x] Qualify → main race → next track, best times and progress in localStorage ### Wave 3 findings 1. **Contact needs a closing speed.** Crashing whoever was behind on any overlap wiped the pack out on every straight. Below `contactKnockdownSpeed` the trailing bike is simply held up. 2. **The reference rider has to avoid traffic too**, or SELECTION B measures a bot flaw rather than the race: it rear-ended the pack all afternoon and never won. 3. **Rivals must be as fallible as a player.** Give them exact landing angles and they ride perfectly, the race has no attrition, and nobody holding a controller can win. They now misjudge and react late on the same terms the `human` probe does. ## Wave 4 — DESIGN mode — ✅ DONE 2026-07-29 - [x] `ABCDEFGHIJKLMNOPQRS CL END LP` strip, bike-as-cursor, 50-hurdle cap, laps 1–9 - [x] `PLAY MODE A / PLAY MODE B / DESIGN / SAVE / LOAD / RESET`, one save slot - [x] A designed track is priced by the same reference rider the shipped ones are ### Editor contract, pinned by the verifier `ExcitebikeDesign.js` imports Phaser and cannot run under Node, so the parts the editor rests on live in `ExcitebikeDesignData.js` and §9 of the harness asserts them: the palette strip is the 19 hurdles plus `CL`/`END`/`LP`, a blank design is a legal track, a designed track survives a save/load round-trip and can be ridden to the finish, and the 50-hurdle and 9-lap caps hold. ## Retune after first playtest — ✅ DONE 2026-07-29 Brian played it. Three things were wrong, all of them fidelity misses rather than bugs. 1. **The throttle was too slow and produced no heat at all.** On the original, holding A alone brings the meter up to about halfway and leaves it there — it can never stall you. Heat now chases a *target* set by the throttle (`heatAccelTarget` 0.5 for A, 1.0 for B) rather than rising only on turbo. Base speed 150 → **175**. 2. **Turbo overheated in about three seconds.** It should take a solid six to eight. `heatRiseRate` is now 1/7 per second, and turbo is **275** against a 175 base — a 57% boost worth reaching for. 3. **Crashing on landing was far too easy.** This was the real miss: the tolerance was *symmetric*. On the original, coming down rear-wheel-first is how you are meant to land and barely ever puts you down; going over the bars is what hurts. The rule is now lopsided — `landCleanBack` 0.45 / `landHardBack` 1.20 against `landCleanNose` 0.22 / `landHardNose` 0.42 — and only the nose-down side narrows with impact speed. Crash rates across the bank fell from 1.3–5.9 per 1000px to 0.2–3.0. ### Findings 1. **A renamed constant went NaN in the countdown.** The dash-start charge still referenced `TUNE.heatRate` after it became `heatRiseRate`, so `temp` was NaN before the flag dropped. The harness's blanket "nothing ever goes NaN" sweep caught it; nothing else would have. 2. **The new heat model rewards bursts, and the bots were feathering.** With heat chasing a target, the sustainable turbo duty is ~58%, but the reference riders toggled B around a single threshold and spent the race accelerating without ever reaching turbo speed. They now use hysteresis (`heatOff` / `heatOn`), which is how a person plays it, and the gap between a good rider and a careless one opened right back up. 3. **The qualifying target needs a floor over the expert.** The human probe is jittered and can beat the expert on a given course by luck, which left a perfect ride no margin at all. The target is now `max(human × 1.06, expert × 1.12)`. 4. **The soak bands had to be re-based, and that is a trap.** Forgiving landings mean the pack crashes less and nobody runs away with it, so the old bands were simply measuring the old physics. Widening bands until they pass is how a regression guard rots, so a **monotonic ordering** check went in alongside them: expert > human > steady > naive on podium rate, always. That holds regardless of how the absolute numbers drift. Control mapping, for the record — it was misremembered in the playtest report: **X or SPACE = throttle** (the A button), **Z or SHIFT = turbo** (the B button). The bezel hint and the title screen now say so explicitly, and the title adds `LAND REAR WHEEL FIRST`. ## Playtest fixes — ✅ DONE 2026-07-29 Two reports, both of which turned out to be bugs rather than tuning. **"Riding into mud or grass instantly crashes, and you crash again after getting up."** The "grass" is a hole — Wave 5 changed gaps to render as the infield showing through, which is how the maps draw them. The second half was the real fault: the bike slides only 12-60px when it goes down, and a hole is 80-440px wide, so a rider remounts **still inside it** and crashes on the next frame, forever. Bad ground now only puts you down above `surfaceCrashSpeed` (110); below that you bog to a 46px/s crawl. The crawl is capped *under* the crash speed on purpose — that is the mechanism, not a number: once in bad ground you cannot build up enough pace to be thrown off it again, so you can always get out. Mud was never in this path; it caps speed and has never crashed anyone. **"The 45-degree jump crashes me and the AI constantly."** Three compounding faults, all from the steep ramp being only 15px wide: 1. The 4px slope window and 6px curvature window both **straddle its cliff**, so mid-climb the ground reads as a −1.28 rad (−73°) wall. 2. That made the bike **launch early**, while still on the face at height 13.5 rather than at the lip. 3. Vertical position is resolved *before* the horizontal move, so after launching the bike ended the frame **buried inside the ramp** and "landed" on the very next one — against that imaginary −73° wall, giving a relative angle of +2.17 rad and an instant crash. Fixes: a launch now lifts the bike clear of the highest ground it is crossing; launch pitch is clamped to `pitchMax`; and a landing is judged against a **clamped** ground angle (`landGroundAngleMax` 0.5) — anything steeper is a cliff, not a surface you touch down on. The ramp's geometry is untouched. The harness now drives **every ramp in the catalogue at seven speeds** with a rider who does nothing but hold the throttle, and none of them may crash. That is the check that would have caught this. ## Wave 5 — Polish — ✅ DONE 2026-07-29 - [x] iconFrame **91** — already painted by Brian (a motocross helmet). Nothing to do. - [x] Ramps given a readable silhouette - [x] Scenery: the infield and apron shrub rows, off the maps - [x] Snow and night themes retinted for contrast - [ ] Cool-zone and mud transcription — **investigated and rejected, see below** ### Ramps now read as terrain They were the same dirt as flat ground with one lit pixel on top. Anything standing proud of a lane now gets its own packed-earth hatch (`rampBody`), a dark stroke around its silhouette, a lit crest, and a full-height vertical cut on genuinely sheer faces. The sheer test needed a **3px** threshold — the first cut fired on any step at all, and a ramp climbs more than a pixel per column, so the whole face was being stroked as if it were a cliff. Checking this needed *looking* at it, which meant a PNG encoder (30 lines on `node:zlib`, kept in the scratchpad). Reading the pixels back as ASCII was useless — four overlapping lane copies. Three more faults were obvious the moment there was an image: 1. **Gaps were flat grey blocks.** They now show the infield through the lane with vertical cut edges at each end, which is how the maps draw them. Horizontal lips per lane read as a ladder, not a hole. 2. **Lane dividers were drawn light**, so they vanished on the lighter of the two alternating lane fills. Dark now, as on the maps. **Cool-zone chevrons had the identical bug** and were invisible on every other lane; markings now pick whichever shade contrasts with the lane they sit on. Any fixed shade painted onto alternating lanes will half disappear — worth remembering. 3. **Snow was grey-on-white and night was dark-on-dark.** Neither had enough contrast to see a ramp coming. Snow is now churned earth through a pale snowfield; night is floodlit amber on dark grass. ### Scenery The infield and apron were bare. The maps show the original plants a row of shrubs along the far side of the infield (8px tall, every other tile — `bush` is transcribed pixel for pixel off Track 1, y112-119) and a larger row across the apron. They are painted in the **grass subpalette's slot 1**, which the grass fill tiles never touch — that is what lets a shrub be a different colour from the field it stands in without breaking the four-colour rule, and the harness now asserts that slot stays free. ### The markings gap: investigated, still open Masking out the columns a ramp stands on does fix the false positives — the old detector put a mud pit under every ramp because a ramp body is full of the shade colour and its crest is full of the highlight. With ramps masked, what is left on flat ground is: - **cool zones: none at all, on any of the five courses.** They are not drawn in the highlight colour. - **mud: 18-23px marks on alternating lane pairs** (0&2, 1&3). Too narrow to be the 64px mud pits the game needs, and the alternating-lane pattern does not match any hurdle in the catalogue. These are more likely distance markers or small decoration. So the masking improvement is kept, but **nothing new is emitted**. Cool zones stay rule-placed and mud stays out. Anyone picking this up again should know the colour approach has been tried and does not separate them; the next thing worth trying is periodicity — chevrons repeat on a fixed pitch and churned ground does not. ## How difficulty is decided One place: `tools/genExcitebikeTracks.js`. Qualifying times are **measured, never guessed** — the `human` probe in `ExcitebikeAuto.js` drives each course over five seeds and the target is the median plus 6%. The expert is deliberately *not* the yardstick: with perfect information and no reaction time it is barely slowed by hazards at all, and a target priced off it would be unreachable. The harness asserts both ends and the gradient across ability: | rider | wins | podium | |---|---|---| | expert | 50–100% | 85–100% | | human | 0–35% | 20–70% | | steady | 0–20% | 0–35% | | naive | 0–2% | 0–10% | A change that makes the pack trivial or impossible breaks one of those bands. ## Sources for original-game behaviour - Official Nintendo manual (NES Classic edition), `CLV-P-NAAHE_en.pdf` — controls, HUD, the three modes, cool zones, the rival contact rule, DESIGN mode's menu and caps - The NES instruction manual's hurdle list — the 19 letters A–S and their names - nesmaps.com full-course track maps for Tracks 1–5 — the source for every length and hurdle position