323 lines
20 KiB
Markdown
323 lines
20 KiB
Markdown
# 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
|