fertig-classic-games/docs/excitebike-build-plan.md

245 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 04 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 AS, 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 256512px 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 039, sky and wall 4063, infield 64127, four
12px lanes 128175, apron 176191. The first build guessed 24px lanes in a 56152 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 19
- [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.35.9 per 1000px to 0.23.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`.
## 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 | 50100% | 85100% |
| human | 035% | 2070% |
| steady | 020% | 035% |
| naive | 02% | 010% |
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 AS and their names
- nesmaps.com full-course track maps for Tracks 15 — the source for every length and hurdle position