fix(excitebike): rework throttle, heat model, and landing after playtest

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.
This commit is contained in:
Brian Fertig 2026-07-29 22:34:29 -06:00
parent 4e818ad104
commit 81aa6e00a8
18 changed files with 308 additions and 74 deletions

View File

@ -6,7 +6,7 @@
"length": 5888,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 79100,
"qualifyMs": 66500,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 5393,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 81200,
"qualifyMs": 66500,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6416,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103300,
"qualifyMs": 76100,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6528,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103200,
"qualifyMs": 84100,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 5752,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 112600,
"qualifyMs": 79400,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6600,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 120600,
"qualifyMs": 74800,
"hurdles": [
{
"t": "C",

View File

@ -6,7 +6,7 @@
"length": 6900,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103900,
"qualifyMs": 85800,
"hurdles": [
{
"t": "J",

View File

@ -6,7 +6,7 @@
"length": 7200,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 105800,
"qualifyMs": 83500,
"hurdles": [
{
"t": "J",

View File

@ -6,7 +6,7 @@
"length": 7400,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 119700,
"qualifyMs": 93900,
"hurdles": [
{
"t": "H",

View File

@ -6,7 +6,7 @@
"length": 7800,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 120400,
"qualifyMs": 87200,
"hurdles": [
{
"t": "S",

View File

@ -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.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)

View File

@ -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',

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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 };

View File

@ -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`);