12 KiB
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
Waves 0–4 done 2026-07-29. Ten tracks in the bank — 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 → 579 checks green. Wave 5 (art polish, icon frame 91) is
NEXT.
Never opened in a browser. Brian playtests; nothing here is signed off until he has.
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 (art still needed) |
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.
- 64-entry NES master palette; everything drawn samples only from it
- Art as index grids of subpalette slots, not pixels, so Node can assert on it
- 8×8 tile bank as string art; 5×7 font in 8×8 cells via Phaser
RetroFont - One container at NES coordinates,
setScale(4), geometry-masked to a 1024×960 window - Cabinet bezel drawn inside the scene so the CRT curve wraps it too
Wave 0 findings
- 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.jsis the only module that touches the DOM, andtools/lib/canvasStub.jsstubs enough of it (createImageData/putImageData/getImageData) to run that module under Node as well. Both halves are checked. - 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.
- 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. - 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.
RetroFontis 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;xAdvanceiswidth, so text centres onlen * 8).
Wave 1 — Ride it — ✅ DONE 2026-07-29
Full bike physics, heat, jumps, crashes, SELECTION A.
- Fixed 60Hz accumulator, seeded RNG, deterministic from a seed + input trace
- A accelerates and releasing brakes; B is turbo and heats the engine; cool zones dump heat
- Landing judged on
|pitch − groundAngle|: clean, hard, or down - Crash → tumble → run on foot mashing A → remount
M:SS:hhclock,3RDtarget, heat meter, wall BEST — the original's HUD
Wave 1 findings
- Feathering turbo was free speed. With a slow bleed off the top end, tapping B held you at turbo
pace while the meter hovered.
turboBleedRatemakes speed above the normal top end fall away fast, so sustained turbo has to be paid for in heat. - 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.
- Landing tolerance must narrow with impact speed. A fixed window meant a passive rider never
crashed: gravity alone left them inside it.
landSpeedSqueezemakes the big jumps the ones that punish a lazy attitude, which is what the ramps are for. - 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.
tools/lib/png.js— PNG decode onnode:zlib, no dependencytools/readExcitebikeMaps.js— palette, extent, ramps, holes, straight off the images- Hurdle catalogue reshaped to the heights and footprints the originals actually use
- Playfield geometry corrected to the original's own bands
Wave 2 findings
- 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.
- 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.
- 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.
- 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. - 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.
- 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
- Rival bikes, gap avoidance, lane preference, traffic awareness
- The manual's contact rule: catch a leader from behind and you go down
- Qualify → main race → next track, best times and progress in localStorage
Wave 3 findings
- Contact needs a closing speed. Crashing whoever was behind on any overlap wiped the pack out on
every straight. Below
contactKnockdownSpeedthe trailing bike is simply held up. - 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.
- 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
humanprobe does.
Wave 4 — DESIGN mode — ✅ DONE 2026-07-29
ABCDEFGHIJKLMNOPQRS CL END LPstrip, bike-as-cursor, 50-hurdle cap, laps 1–9PLAY MODE A / PLAY MODE B / DESIGN / SAVE / LOAD / RESET, one save slot- 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.
Wave 5 — Polish — ⬜ NEXT
- Paint iconFrame 91 into
assets/images/game-icons.png(44×44, row 6 col 2) - Parallax backdrops and per-track crowd colour; the themes exist but are plain
- Revisit cool-zone and mud transcription (see the Wave 2 gap)
- Playtest, then retune from what it actually feels like
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