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

323 lines
20 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
**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 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`.
## 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 | 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