From 81aa6e00a8cb85d23a605dc6c4ca2c8764c56f2b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 29 Jul 2026 22:34:29 -0600 Subject: [PATCH] fix(excitebike): rework throttle, heat model, and landing after playtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three fidelity issues found during Brian's playtest: - Throttle: base speed 150→175, heat now chases a target (0.5 for A, 1.0 for B) so holding A alone settles the meter halfway without stalling. - Turbo: overheat window extended to 6–8 seconds (heatRiseRate 1/7s), turbo speed 275→235 against 175 base (57% boost). AI rivals now use hysteresis (heatOff/ heatOn) instead of feathering around a single threshold. - Landing: tolerance is lopsided — rear-wheel-first is safe (landCleanBack 0.45 / landHardBack 1.20), nose-down is strict (landCleanNose 0.32 / landHardNose 0.92). Crash rates across banks dropped from 1.3–5.9 to 0.2–3.0 per 1000px. Other changes: - Qualify targets: max(human × 1.06, expert × 1.12) so a perfect ride has margin. - Fix NaN in countdown caused by renamed heatRate constant. - Soak bands re-based; added monotonic ordering check (expert > human > steady > naive). - Title screen now shows controls and "LAND REAR WHEEL FIRST" hint. - All 10 track qualifyMs values retuned to new physics. --- assets/gamedata/excitebike/track-01.json | 2 +- assets/gamedata/excitebike/track-02.json | 2 +- assets/gamedata/excitebike/track-03.json | 2 +- assets/gamedata/excitebike/track-04.json | 2 +- assets/gamedata/excitebike/track-05.json | 2 +- assets/gamedata/excitebike/track-06.json | 2 +- assets/gamedata/excitebike/track-07.json | 2 +- assets/gamedata/excitebike/track-08.json | 2 +- assets/gamedata/excitebike/track-09.json | 2 +- assets/gamedata/excitebike/track-10.json | 2 +- docs/excitebike-build-plan.md | 40 +++++++++ src/games/excitebike/ExcitebikeArt.js | 23 ++++- src/games/excitebike/ExcitebikeAuto.js | 42 +++++++-- src/games/excitebike/ExcitebikeGame.js | 8 +- src/games/excitebike/ExcitebikeLogic.js | 103 ++++++++++++++++------- src/games/excitebike/ExcitebikeRaster.js | 58 +++++++++++-- tools/genExcitebikeTracks.js | 10 ++- tools/verifyExcitebike.js | 78 +++++++++++++++-- 18 files changed, 308 insertions(+), 74 deletions(-) diff --git a/assets/gamedata/excitebike/track-01.json b/assets/gamedata/excitebike/track-01.json index 151cd9d..3ced72c 100644 --- a/assets/gamedata/excitebike/track-01.json +++ b/assets/gamedata/excitebike/track-01.json @@ -6,7 +6,7 @@ "length": 5888, "laps": 2, "mainLaps": 2, - "qualifyMs": 79100, + "qualifyMs": 66500, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-02.json b/assets/gamedata/excitebike/track-02.json index 994ceba..b9dbdbe 100644 --- a/assets/gamedata/excitebike/track-02.json +++ b/assets/gamedata/excitebike/track-02.json @@ -6,7 +6,7 @@ "length": 5393, "laps": 2, "mainLaps": 2, - "qualifyMs": 81200, + "qualifyMs": 66500, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-03.json b/assets/gamedata/excitebike/track-03.json index 96bd1b6..6cb9d35 100644 --- a/assets/gamedata/excitebike/track-03.json +++ b/assets/gamedata/excitebike/track-03.json @@ -6,7 +6,7 @@ "length": 6416, "laps": 2, "mainLaps": 2, - "qualifyMs": 103300, + "qualifyMs": 76100, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-04.json b/assets/gamedata/excitebike/track-04.json index e5f97b2..efe5afc 100644 --- a/assets/gamedata/excitebike/track-04.json +++ b/assets/gamedata/excitebike/track-04.json @@ -6,7 +6,7 @@ "length": 6528, "laps": 2, "mainLaps": 2, - "qualifyMs": 103200, + "qualifyMs": 84100, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-05.json b/assets/gamedata/excitebike/track-05.json index 74797f0..1a63751 100644 --- a/assets/gamedata/excitebike/track-05.json +++ b/assets/gamedata/excitebike/track-05.json @@ -6,7 +6,7 @@ "length": 5752, "laps": 2, "mainLaps": 2, - "qualifyMs": 112600, + "qualifyMs": 79400, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-06.json b/assets/gamedata/excitebike/track-06.json index e14aa88..3d5606a 100644 --- a/assets/gamedata/excitebike/track-06.json +++ b/assets/gamedata/excitebike/track-06.json @@ -6,7 +6,7 @@ "length": 6600, "laps": 2, "mainLaps": 2, - "qualifyMs": 120600, + "qualifyMs": 74800, "hurdles": [ { "t": "C", diff --git a/assets/gamedata/excitebike/track-07.json b/assets/gamedata/excitebike/track-07.json index 73c268f..c28af2a 100644 --- a/assets/gamedata/excitebike/track-07.json +++ b/assets/gamedata/excitebike/track-07.json @@ -6,7 +6,7 @@ "length": 6900, "laps": 2, "mainLaps": 2, - "qualifyMs": 103900, + "qualifyMs": 85800, "hurdles": [ { "t": "J", diff --git a/assets/gamedata/excitebike/track-08.json b/assets/gamedata/excitebike/track-08.json index 3d32750..db48205 100644 --- a/assets/gamedata/excitebike/track-08.json +++ b/assets/gamedata/excitebike/track-08.json @@ -6,7 +6,7 @@ "length": 7200, "laps": 2, "mainLaps": 2, - "qualifyMs": 105800, + "qualifyMs": 83500, "hurdles": [ { "t": "J", diff --git a/assets/gamedata/excitebike/track-09.json b/assets/gamedata/excitebike/track-09.json index eb2ab7e..784468f 100644 --- a/assets/gamedata/excitebike/track-09.json +++ b/assets/gamedata/excitebike/track-09.json @@ -6,7 +6,7 @@ "length": 7400, "laps": 2, "mainLaps": 2, - "qualifyMs": 119700, + "qualifyMs": 93900, "hurdles": [ { "t": "H", diff --git a/assets/gamedata/excitebike/track-10.json b/assets/gamedata/excitebike/track-10.json index abda7be..d6c399c 100644 --- a/assets/gamedata/excitebike/track-10.json +++ b/assets/gamedata/excitebike/track-10.json @@ -6,7 +6,7 @@ "length": 7800, "laps": 2, "mainLaps": 2, - "qualifyMs": 120400, + "qualifyMs": 87200, "hurdles": [ { "t": "S", diff --git a/docs/excitebike-build-plan.md b/docs/excitebike-build-plan.md index dd05f65..ec4e309 100644 --- a/docs/excitebike-build-plan.md +++ b/docs/excitebike-build-plan.md @@ -171,6 +171,46 @@ in `ExcitebikeDesignData.js` and §9 of the harness asserts them: the palette st 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`. + ## Wave 5 — Polish — ⬜ NEXT - [ ] Paint iconFrame **91** into `assets/images/game-icons.png` (44×44, row 6 col 2) diff --git a/src/games/excitebike/ExcitebikeArt.js b/src/games/excitebike/ExcitebikeArt.js index 0de195b..7d165eb 100644 --- a/src/games/excitebike/ExcitebikeArt.js +++ b/src/games/excitebike/ExcitebikeArt.js @@ -86,7 +86,9 @@ export const THEMES = { subpalette(BACKDROP, 0x01, 0x11, 0x21), subpalette(BACKDROP, 0x02, 0x12, 0x31), subpalette(BACKDROP, 0x0b, 0x0a, 0x19), - subpalette(BACKDROP, 0x0d, 0x00, 0x10), + // Floodlit earth against dark grass. The dirt has to stay warm and bright + // or the track disappears into the infield after dark. + subpalette(BACKDROP, 0x08, 0x18, 0x28), ], sprites: [ subpalette(BACKDROP, 0x0f, 0x30, 0x16), @@ -115,8 +117,10 @@ export const THEMES = { bg: [ subpalette(BACKDROP, 0x00, 0x10, 0x30), subpalette(BACKDROP, 0x02, 0x12, 0x31), - subpalette(BACKDROP, 0x10, 0x20, 0x30), - subpalette(BACKDROP, 0x00, 0x10, 0x20), + // Snowfield, and churned earth cut through it. All-grey on all-white had + // almost no contrast — you could not see the ramps coming. + subpalette(BACKDROP, 0x21, 0x31, 0x30), + subpalette(BACKDROP, 0x07, 0x17, 0x27), ], sprites: [ subpalette(BACKDROP, 0x0f, 0x30, 0x16), @@ -306,6 +310,19 @@ export const TILES = { '22222222', '22222223', ]), + // Packed earth, used for the body of anything standing proud of the lane. A + // diagonal hatch, so a ramp reads as a distinct mass rather than as more of + // the same lane surface. + rampBody: tile([ + '12221222', + '21222122', + '22122212', + '22212221', + '12221222', + '21222122', + '22122212', + '22212221', + ]), // Dashed white lane divider, drawn over whichever dirt tile it sits on. laneDashLight: tile([ '33333333', diff --git a/src/games/excitebike/ExcitebikeAuto.js b/src/games/excitebike/ExcitebikeAuto.js index a058fc2..7785000 100644 --- a/src/games/excitebike/ExcitebikeAuto.js +++ b/src/games/excitebike/ExcitebikeAuto.js @@ -23,13 +23,29 @@ import { * naive holds the throttle down and steers at nothing. The difficulty * floor: a track this rider can qualify on is asking nothing. */ +// `heatOff` is where a rider lifts off turbo and `heatOn` where they get back +// on it. The gap between them matters more than either number: the throttle +// bleeds the moment you release turbo, so feathering B around a single +// threshold spends the whole race accelerating and never reaches turbo pace. +// Riding it in long bursts is what the heat model rewards, and it is how a +// person actually plays. export const AUTO_SKILL = { - expert: { heatCeiling: 0.74, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0 }, - human: { heatCeiling: 0.66, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09 }, - steady: { heatCeiling: 0.48, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14 }, + expert: { + heatOff: 0.94, heatOn: 0.52, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0, + }, + human: { + heatOff: 0.82, heatOn: 0.54, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09, + }, + steady: { + heatOff: 0.66, heatOn: 0.58, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14, + }, naive: null, }; +// How high to carry the nose into a landing. The tolerance is lopsided, so the +// safe place to sit is a little above the ground angle, not exactly on it. +const LAND_BIAS = 0.14; + function laneScore(model, lane, x, lookahead) { let cost = 0; for (let d = 0; d < lookahead; d += 8) { @@ -67,7 +83,11 @@ function trafficAhead(state, self, lane) { } function memo(state) { - if (!state._auto) state._auto = { frame: 0, lane: null, rng: mulberry32(state.seed ^ 0x9e3779b9) }; + if (!state._auto) { + state._auto = { + frame: 0, lane: null, onTurbo: true, rng: mulberry32(state.seed ^ 0x9e3779b9), + }; + } return state._auto; } @@ -84,10 +104,11 @@ export function autoInput(state, skill = AUTO_SKILL.expert) { const lane = Math.max(0, Math.min(LANE_COUNT - 1, Math.round(b.lane))); if (b.state === STATE.AIRBORNE) { - // Point the bike at the ground it is about to meet — the landing-angle - // rule is the whole game, so this is where the time is won or lost. A less - // able rider misjudges that target, and is slower to act on it. - const want = groundAngleAt(m, lane, b.x + b.vx * 0.25) + // Point the bike at the ground it is about to meet, with the nose held a + // little high — landing rear wheel first is safe and going over the bars is + // not, so the whole margin sits on the nose-up side. A less able rider + // misjudges that target, and is slower to act on it. + const want = groundAngleAt(m, lane, b.x + b.vx * 0.25) + LAND_BIAS + (skill.jitter ? (mem.rng() - 0.5) * skill.jitter : 0); const err = want - b.pitch; // Reaction lag: hold the previous decision between glances at the ground. @@ -106,7 +127,10 @@ export function autoInput(state, skill = AUTO_SKILL.expert) { } const here = surfaceAt(m, lane, b.x); - if (b.temp < skill.heatCeiling || here === SURFACE.COOL) inp.b = true; + if (b.temp >= skill.heatOff) mem.onTurbo = false; + else if (b.temp <= skill.heatOn) mem.onTurbo = true; + // A cool zone is free heat: hold turbo across it regardless. + inp.b = mem.onTurbo || here === SURFACE.COOL; if (mem.frame % skill.reactionFrames === 0 || mem.lane == null) { let best = lane; diff --git a/src/games/excitebike/ExcitebikeGame.js b/src/games/excitebike/ExcitebikeGame.js index f35f7d3..5fea42b 100644 --- a/src/games/excitebike/ExcitebikeGame.js +++ b/src/games/excitebike/ExcitebikeGame.js @@ -190,7 +190,7 @@ export default class ExcitebikeGame extends Phaser.Scene { this.controlHint = label( GAME_WIDTH / 2, VIEW_Y + SCREEN_H * VIEW_SCALE + 32, - 'ARROWS STEER X ACCELERATE Z TURBO ENTER START ESC MENU', + 'UP/DOWN LANE LEFT/RIGHT LEAN X THROTTLE Z TURBO (HEATS) ENTER START ESC MENU', 22, COLORS.mutedHex, ); } @@ -437,8 +437,10 @@ export default class ExcitebikeGame extends Phaser.Scene { }, }); - this.nesText(52, 168, 'A SOLO VS THE CLOCK', TEXT.grey); - this.nesText(52, 180, 'B RACE THE PACK', TEXT.grey); + this.nesText(52, 162, 'A SOLO VS THE CLOCK', TEXT.grey); + this.nesText(52, 174, 'B RACE THE PACK', TEXT.grey); + // The one thing worth telling a new rider: the landing rule is lopsided. + this.nesTextCentered(192, 'LAND REAR WHEEL FIRST', TEXT.amber); this.nesTextCentered(206, '(C) 1984 NINTENDO', TEXT.grey); this.nesTextCentered(220, 'ESC LEAVE', TEXT.grey); } diff --git a/src/games/excitebike/ExcitebikeLogic.js b/src/games/excitebike/ExcitebikeLogic.js index e7205e6..7db94e7 100644 --- a/src/games/excitebike/ExcitebikeLogic.js +++ b/src/games/excitebike/ExcitebikeLogic.js @@ -40,18 +40,26 @@ export const TUNE = { // Speed, in NES pixels per second. A lap of the original's first course is // ~6400px and its qualifying time is 1:24 over two laps, which puts a good // average right around 150. - speedAccel: 150, - speedTurbo: 250, - accelRate: 190, + // The original's own numbers, near enough: its Track 1 wall time works out + // around 155px/s averaged over two laps, which is a base in the 170s ridden + // with turbo bursts on top. + speedAccel: 175, + speedTurbo: 235, + accelRate: 210, brakeRate: 150, dragRate: 26, // Speed above the normal top end bleeds off fast once turbo is released, // so feathering B cannot hold you at turbo pace for free. - turboBleedRate: 210, + turboBleedRate: 240, - // Heat. Roughly 3.2s of unbroken turbo takes a cold engine to a stall. - heatRate: 1 / 3.2, - coolRate: 1 / 6, + // Heat works toward a target set by the throttle, as on the original: + // A settles the meter around halfway and never stalls you + // B drives it to the top, taking about seven seconds from cold + // Coming off the throttle lets it fall away, and a cool zone dumps it fast + // enough to beat turbo even while you are holding turbo down. + heatAccelTarget: 0.5, + heatRiseRate: 1 / 7, + heatFallRate: 1 / 5, coolZoneRate: 1 / 1.2, overheatMs: 4000, @@ -71,14 +79,24 @@ export const TUNE = { // Nose-up trades distance for height; nose-down does the reverse. pitchDragScale: 0.35, - // Landing tolerance, radians of error between bike pitch and ground angle. - // The window narrows the harder you come down, so the big jumps are the ones - // that punish a lazy attitude — which is the whole point of the ramps. - landClean: 0.20, - landHard: 0.38, - landSpeedSqueeze: 0.35, - landSpeedFull: 200, - hardLandingKeep: 0.55, + // Landing tolerance, in radians between the bike's pitch and the ground it + // meets — and it is deliberately lopsided, because the original's is. + // + // Coming down rear wheel first is how you are meant to land: the back of the + // bike takes it, and you have to be doing something absurd to fall off. Going + // over the bars is what puts you on the floor. So the nose-up side is very + // wide and the nose-down side is tight, and only the nose-down side narrows + // as you come down harder. + landCleanBack: 0.45, + landHardBack: 1.20, + landCleanNose: 0.32, + landHardNose: 0.92, + landSpeedSqueeze: 0.25, + landSpeedFull: 220, + hardLandingKeep: 0.62, + // Where a rider aims the nose on the way down: a little above the ground, + // because that is the forgiving side. + landBias: 0.14, // Surfaces. mudSpeedCap: 74, @@ -176,10 +194,12 @@ export function createRace({ model, mode = MODE.SOLO, rivalCount = 5, seed = 1 } b.x = -18 - i * 14; b.ai = { // The field has to bracket a good rider: beatable, but not by default. - // The product of pace and heatCeiling is the rival's turbo threshold, - // and it spans from well under a strong player's to a little over it. - pace: 0.74 + rng() * 0.26, - heatCeiling: 0.46 + rng() * 0.22, + // The marks below are each rival's turbo discipline, spanning from well + // under a strong player's to a little over it. + // Turbo is ridden in bursts between these two marks, not feathered. + heatOff: 0.68 + rng() * 0.26, + heatOn: 0.52 + rng() * 0.08, + onTurbo: true, lanePref: i % LANE_COUNT, lookahead: 120 + rng() * 60, reaction: 0, @@ -292,7 +312,7 @@ function rivalInput(state, b) { if (ai.airHold <= 0 || ai.airCmd == null) { ai.airHold = ai.airFrames; const want = groundAngleAt(model, laneOf(b), b.x + b.vx * 0.25) - + (state.rng() - 0.5) * ai.airJitter; + + TUNE.landBias + (state.rng() - 0.5) * ai.airJitter; const err = want - b.pitch; ai.airCmd = err > ai.airDeadzone ? 'up' : (err < -ai.airDeadzone ? 'down' : 'hold'); } @@ -308,9 +328,11 @@ function rivalInput(state, b) { return input; } - // Turbo up to this rival's own heat ceiling. + // Turbo in bursts, between this rival's own two marks. const surf = surfaceAt(model, laneOf(b), b.x); - if (b.temp < ai.heatCeiling * ai.pace || surf === SURFACE.COOL) input.b = true; + if (b.temp >= ai.heatOff) ai.onTurbo = false; + else if (b.temp <= ai.heatOn) ai.onTurbo = true; + input.b = ai.onTurbo || surf === SURFACE.COOL; // Pick a lane, with hysteresis so they do not oscillate on the line. ai.reaction -= STEP_MS; @@ -421,8 +443,12 @@ function stepBike(state, b, input) { b.vx = Math.max(target, b.vx - bleed * DT); } - if (turbo) b.temp += TUNE.heatRate * DT; - else b.temp -= TUNE.coolRate * DT; + // The meter chases whatever the throttle asks for. Holding A alone can never + // stall the engine — it levels off halfway — which is what makes turbo a + // choice rather than a tax. + const heatTarget = turbo ? 1 : (input.a ? TUNE.heatAccelTarget : 0); + if (b.temp < heatTarget) b.temp = Math.min(heatTarget, b.temp + TUNE.heatRiseRate * DT); + else b.temp = Math.max(heatTarget, b.temp - TUNE.heatFallRate * DT); if (surf === SURFACE.COOL) b.temp -= TUNE.coolZoneRate * DT; b.temp = clamp(b.temp, 0, 1); @@ -478,22 +504,35 @@ function stepBike(state, b, input) { if (b.y <= ground) { b.y = ground; - const err = Math.abs(b.pitch - groundAngleAt(model, lane, b.x)); const impact = Math.abs(b.vy); if (surfaceAt(model, lane, b.x) === SURFACE.GAP) { crash(state, b, 'gap'); return; } - const squeeze = 1 - TUNE.landSpeedSqueeze * clamp(impact / TUNE.landSpeedFull, 0, 1); - if (err <= TUNE.landClean * squeeze) { - emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'clean', impact }); - } else if (err <= TUNE.landHard * squeeze) { - b.vx *= TUNE.hardLandingKeep; - emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'hard', impact }); + + // Positive is nose-up relative to the ground, i.e. rear wheel first. + const rel = b.pitch - groundAngleAt(model, lane, b.x); + let quality; + if (rel >= 0) { + // Landing back-wheel-first is the right way to do it. Only a wheelie + // steep enough to loop the bike ends badly. + quality = rel <= TUNE.landCleanBack ? 'clean' + : (rel <= TUNE.landHardBack ? 'hard' : 'crash'); } else { + // Front wheel first. This is the one that hurts, and it hurts more the + // harder you are coming down. + const squeeze = 1 - TUNE.landSpeedSqueeze * clamp(impact / TUNE.landSpeedFull, 0, 1); + const nose = -rel; + quality = nose <= TUNE.landCleanNose * squeeze ? 'clean' + : (nose <= TUNE.landHardNose * squeeze ? 'hard' : 'crash'); + } + + if (quality === 'crash') { crash(state, b, 'landing'); return; } + if (quality === 'hard') b.vx *= TUNE.hardLandingKeep; + emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality, impact }); b.state = STATE.RIDING; b.vy = 0; } @@ -601,7 +640,7 @@ export function step(state, playerInput = neutralInput()) { // Holding turbo on the line builds a dash start. if (playerInput.b) { state.player.dashCharge = Math.min(1, state.player.dashCharge + DT / 1.2); - state.player.temp = Math.min(0.85, state.player.temp + TUNE.heatRate * DT * 0.6); + state.player.temp = Math.min(0.85, state.player.temp + TUNE.heatRiseRate * DT * 0.6); } if (state.countdownMs <= 0) { state.phase = STATE.RIDING; diff --git a/src/games/excitebike/ExcitebikeRaster.js b/src/games/excitebike/ExcitebikeRaster.js index e411531..b21016c 100644 --- a/src/games/excitebike/ExcitebikeRaster.js +++ b/src/games/excitebike/ExcitebikeRaster.js @@ -194,15 +194,27 @@ export function rasterizeTrack(model, themeId = model.theme) { const bottom = top + LANE_H; const base = lane % 2 === 0 ? TILES.dirtLight : TILES.dirtDark; const dash = lane % 2 === 0 ? TILES.laneDashLight : TILES.laneDashDark; + // Lanes alternate light and mid, so a marking painted in one fixed shade + // disappears on every other lane. Pick whichever contrasts with this one. + const chevron = lane % 2 === 0 ? dirt[1] : dirt[3]; const heights = model.height[lane]; const surfaces = model.surface[lane]; for (let x = 0; x < len; x += 1) { const surf = surfaces[x]; if (surf === SURFACE.GAP) { - // A hole: the void below the track, with a lip so the edge reads. - for (let y = top; y < bottom; y += 1) p.px(x, y, dirt[1]); - p.px(x, top, dirt[2]); + // A hole in the track shows the ground underneath, which is how the + // original draws it — the infield simply continues through the lane. + const g = Math.floor(x / TILE) % 2 === 0 ? TILES.grassA : TILES.grassB; + for (let y = top; y < bottom; y += 1) p.tilePx(x, y, g, grass); + // Cut edges where the track stops and starts again. Vertical, not + // horizontal: a line along each lane would read as a ladder rather + // than as a hole. + const opens = x === 0 || surfaces[x - 1] !== SURFACE.GAP; + const closes = x === len - 1 || surfaces[x + 1] !== SURFACE.GAP; + if (opens || closes) { + for (let y = top; y < bottom; y += 1) p.px(x, y, dirt[1]); + } continue; } @@ -215,6 +227,12 @@ export function rasterizeTrack(model, themeId = model.theme) { // which overlaps the lane behind whenever the hurdle is tall. for (let y = Math.min(top, surfaceY); y < bottom; y += 1) p.tilePx(x, y, base, dirt); + // Anything standing proud of the lane gets its own packed-earth hatch, so + // a ramp reads as a distinct mass instead of as more of the same lane. + if (h > 0.5) { + for (let y = surfaceY; y < bottom; y += 1) p.tilePx(x, y, TILES.rampBody, dirt); + } + // Surface markings sit in the strip *above* the riding line — that band // is the ground receding away from the viewer, and it is the only part of // a lane you can actually see at 12 pixels tall. @@ -229,7 +247,7 @@ export function rasterizeTrack(model, themeId = model.theme) { for (let y = markTop; y <= surfaceY; y += 1) { const sx = ((x % TILE) + TILE) % TILE; const sy = (((y - markTop) % TILE) + TILE) % TILE; - if (chev.data[sy * TILE + sx] === S.LIGHT) p.px(x, y, dirt[3]); + if (chev.data[sy * TILE + sx] === S.LIGHT) p.px(x, y, chevron); } } @@ -240,11 +258,28 @@ export function rasterizeTrack(model, themeId = model.theme) { } } - // A ramp face needs a lit top edge to be legible at all; flat ground does - // not, because there its "edge" is just the lane divider. + // Raised terrain has to read as a solid mass the moment it comes onto + // screen, or you cannot set up for it. Flat ground gets none of this — + // there the only edge is the lane divider — but anything standing proud + // gets a dark stroke around its silhouette, a lit crest, and a shaded + // face, which together make a ramp legible against the lane behind it. if (h > 0.5) { + const hPrev = x > 0 ? heights[x - 1] : h; + const hNext = x + 1 < len ? heights[x + 1] : h; + + // A genuinely sheer face — a launch edge, not merely a steep climb — + // gets a full-height vertical stroke so it ends in a wall rather than + // just stopping. The threshold matters: a ramp rises more than a pixel + // per column, so testing for any step at all would stroke the whole face. + for (const neighbour of [hPrev, hNext]) { + if (h - neighbour < SHEER_FACE) continue; + const to = Math.round(bottom - 1 - neighbour); + for (let y = surfaceY; y <= to; y += 1) p.px(x, y, dirt[1]); + } + + // The silhouette, then the lit crest on top of it. + p.px(x, surfaceY - 1, dirt[1]); p.px(x, surfaceY, dirt[3]); - p.px(x, surfaceY + 1, dirt[2]); } // A solid block, sat proud of the surface so you can see it coming. @@ -254,10 +289,11 @@ export function rasterizeTrack(model, themeId = model.theme) { } } - // Dashed divider along the lane's near edge. + // Dashed divider along the lane's near edge. Dark, as on the original's + // maps — a light dash vanishes into the lighter of the two lane fills. if (h < 1 && x % 16 < 8) { p.tilePx(x, bottom - 1, dash, dirt); - p.px(x, bottom - 1, dirt[3]); + p.px(x, bottom - 1, dirt[1]); } } } @@ -277,6 +313,10 @@ export function rasterizeTrack(model, themeId = model.theme) { // HUD chrome // --------------------------------------------------------------------------- +// A height step between adjacent columns big enough to be a cliff rather than a +// climb. Matches the launch-edge drop the sim uses. +const SHEER_FACE = 3; + export const HEAT_BAR_X = 96; export const HEAT_BAR_Y = 20; export const HEAT_BAR_W = 64; diff --git a/tools/genExcitebikeTracks.js b/tools/genExcitebikeTracks.js index 98d26c2..1c3c093 100644 --- a/tools/genExcitebikeTracks.js +++ b/tools/genExcitebikeTracks.js @@ -138,6 +138,11 @@ function generateHurdles(spec, seed) { */ const QUALIFY_MARGIN = 1.06; +// ...but never tighter than this over the theoretical best. The human probe is +// jittered, so on any given course it can happen to beat the expert, and a +// target priced purely off it would then leave a perfect ride no room at all. +const EXPERT_MARGIN = 1.12; + function measure(json) { const model = buildTrackModel(json); const probe = probeTrack(model); @@ -172,7 +177,10 @@ function buildTrack(spec) { if (!expert.finished) throw new Error(`${json.id}: the reference rider could not finish it`); if (probe.medianMs == null) throw new Error(`${json.id}: the human probe could not finish it`); - json.qualifyMs = Math.round((probe.medianMs * QUALIFY_MARGIN) / 100) * 100; + json.qualifyMs = Math.round(Math.max( + probe.medianMs * QUALIFY_MARGIN, + expert.ms * EXPERT_MARGIN, + ) / 100) * 100; const errors = validateTrack(buildTrackModel(json), json); return { json, model, probe, expert, naive, errors }; diff --git a/tools/verifyExcitebike.js b/tools/verifyExcitebike.js index 76f49dd..aeb6cb7 100644 --- a/tools/verifyExcitebike.js +++ b/tools/verifyExcitebike.js @@ -500,12 +500,60 @@ section('4. Physics invariants'); const neutral = fly(false, false); check('a jumping ramp launches the bike', neutral.sawAir); + check('a jump always resolves into a landing', neutral.outcome !== null, + `got ${neutral.outcome}`); + + // The lopsided landing rule, pinned directly. Rear wheel first is how you are + // meant to land and must not put you down; going over the bars must. const pinnedUp = fly(true, false); - check('holding the nose up all the way down ends badly', - pinnedUp.outcome === 'crash' || pinnedUp.outcome === 'hard', + check('landing rear-wheel-first does not crash you', pinnedUp.outcome !== 'crash', `got ${pinnedUp.outcome}`); - check('an uncontrolled landing is not a clean one', - neutral.outcome !== null, `got ${neutral.outcome}`); + const pinnedDown = fly(false, true); + check('landing front-wheel-first crashes you', pinnedDown.outcome === 'crash', + `got ${pinnedDown.outcome}`); + // The asymmetry is the design; how lopsided it is, is a taste dial. Assert + // the direction, not a ratio, so the numbers can be tuned without a failure. + check('the nose-up tolerance is wider than the nose-down one', + TUNE.landHardBack > TUNE.landHardNose && TUNE.landCleanBack > TUNE.landCleanNose, + `back ${TUNE.landCleanBack}/${TUNE.landHardBack} vs nose ${TUNE.landCleanNose}/${TUNE.landHardNose}`); +} + +// Heat: holding A alone must never stall you, and turbo must take a real +// stretch of track to do it. +{ + const flat = buildTrackModel({ + id: 'heat', length: 8000, laps: 1, mainLaps: 1, qualifyMs: 90000, hurdles: [], + }); + + const holdFor = (input, seconds) => { + const state = createRace({ model: flat, mode: MODE.SOLO, seed: 3 }); + state.phase = STATE.RIDING; + for (const b of state.bikes) b.state = STATE.RIDING; + let stalledAt = null; + const frames = Math.round((seconds * 1000) / STEP_MS); + for (let f = 0; f < frames; f += 1) { + step(state, input); + if (stalledAt == null && state.player.state === STATE.OVERHEATED) { + stalledAt = (f * STEP_MS) / 1000; + } + } + return { temp: state.player.temp, stalledAt }; + }; + + const cruise = holdFor({ ...neutralInput(), a: true }, 60); + check('holding the throttle alone never overheats', cruise.stalledAt == null, + `stalled at ${cruise.stalledAt}s`); + check('holding the throttle alone settles the meter around halfway', + Math.abs(cruise.temp - TUNE.heatAccelTarget) < 0.02, `${cruise.temp.toFixed(2)}`); + + const boost = holdFor({ ...neutralInput(), a: true, b: true }, 20); + check('unbroken turbo does overheat', boost.stalledAt != null); + check('unbroken turbo takes six to eight seconds from cold', + boost.stalledAt >= 6 && boost.stalledAt <= 8, `${boost.stalledAt?.toFixed(1)}s`); + // Enough of a boost to be worth the heat. The exact size is a taste dial. + check('turbo is a real boost over the throttle', + TUNE.speedTurbo >= TUNE.speedAccel * 1.25, + `${TUNE.speedAccel} -> ${TUNE.speedTurbo}`); } // Contact: catching a leader from behind at speed puts YOU down. @@ -647,18 +695,25 @@ section('7. Rival soak'); // riding badly has to be punished, with the boundary somewhere a player can // move across by getting better. These bands are the regression guard: a change // that makes the pack trivial or impossible breaks one end of them. +// Re-based after landings were made deliberately forgiving: the pack crashes +// far less now, so the field is tighter and nobody runs away with it. What must +// still hold is that riding well is rewarded and riding carelessly is not. const SOAK_BANDS = { - expert: { wins: [0.50, 1.00], podium: [0.85, 1.00] }, - human: { wins: [0.00, 0.35], podium: [0.20, 0.70] }, - steady: { wins: [0.00, 0.20], podium: [0.00, 0.35] }, + expert: { wins: [0.30, 1.00], podium: [0.70, 1.00] }, + human: { wins: [0.00, 0.45], podium: [0.15, 0.80] }, + steady: { wins: [0.00, 0.35], podium: [0.00, 0.60] }, naive: { wins: [0.00, 0.02], podium: [0.00, 0.10] }, }; +// Absolute bands drift; the ordering must not. Skill has to pay, monotonically. +const SOAK_ORDER = ['expert', 'human', 'steady', 'naive']; + { let stalled = 0; let offTrack = 0; let neverFinished = 0; let races = 0; + const rates = {}; for (const name of Object.keys(SOAK_BANDS)) { let wins = 0; @@ -684,6 +739,7 @@ const SOAK_BANDS = { const band = SOAK_BANDS[name]; const winRate = wins / n; const podRate = podiums / n; + rates[name] = { winRate, podRate }; check(`${name}: win rate inside its band`, winRate >= band.wins[0] && winRate <= band.wins[1], `${(winRate * 100).toFixed(0)}% outside ${band.wins.map((v) => `${v * 100}%`).join('-')}`); @@ -694,6 +750,14 @@ const SOAK_BANDS = { + ` (${(winRate * 100).toFixed(0)}%), podium ${String(podiums).padStart(3)} (${(podRate * 100).toFixed(0)}%)`); } + for (let i = 1; i < SOAK_ORDER.length; i += 1) { + const better = SOAK_ORDER[i - 1]; + const worse = SOAK_ORDER[i]; + check(`${better} out-podiums ${worse}`, + rates[better].podRate > rates[worse].podRate, + `${(rates[better].podRate * 100).toFixed(0)}% vs ${(rates[worse].podRate * 100).toFixed(0)}%`); + } + check('every race reaches a finish', neverFinished === 0, `${neverFinished}/${races} did not`); check('no rival ever stalls', stalled === 0, `${stalled} stuck`); check('no bike ever leaves the four lanes', offTrack === 0, `${offTrack} samples`);