Race simulation
The race is a pure simulation in src/game/sim/: no Phaser, no three.js, no clock, no randomness.
The game, the garage analyzer, the career balance tests and the online server (party/) all run
the same code, so a race given the same car, tune and input log always ends in the same frame. It
started as a port of the 2004 Flash game Drag Racer v3 (docs/drag-racer-v3.md); that port is
gone. The race now runs a drag physics model in sim/physics/: a torque curve, real gear ratios,
tyre grip with weight transfer, air drag and rolling resistance. Its design and the decisions
behind it are in docs/2026-11-07-drag-physics-design.md.
Files at a glance
Section titled “Files at a glance”| File | Role |
|---|---|
sim/clock.ts |
SIM_HZ, DT, ticks(), nthRoot, mph to m/s |
sim/types.ts |
CarStats, Tune, RaceSetup, RacerState, StepInput |
sim/specs.ts, sim/upgrades.ts |
car base plus parts to CarStats (see tuning, parts) |
sim/physics/types.ts |
PhysicsCar, PhysicsState, PhysicsInput; engine types, drive layouts, body styles |
sim/physics/torqueCurve.ts |
engine shapes per type, the torque curve from peak HP and TQ |
sim/physics/drivetrain.ts |
gear ratios, final drive, rpm at a speed, drive force |
sim/physics/traction.ts, sim/physics/forces.ts |
tyre grip with weight transfer; air drag and rolling resistance |
sim/physics/buildCar.ts |
car stats plus its PhysicsSpec to a PhysicsCar |
sim/physics/shiftPoints.ts |
the best shift rpm per gear |
sim/physics/stepPhysics.ts |
one physics tick, the clutch drop on green |
sim/initRace.ts |
CarStats + Tune to a RaceSetup: the tuned PhysicsCar, gear top speeds, shift points |
sim/raceSetup.ts |
initRace plus the car’s best launch rev: the setup every live race uses |
sim/step.ts |
stepRacer, one tick; shiftUp, shiftDown, holdNitrous, startNitrous |
sim/engine.ts |
rev ratio, redline, heat and blowing up |
sim/launch.ts |
staging in neutral, revving, launch grade and grip |
sim/roll.ts |
roll starts |
sim/shiftZone.ts, sim/shiftLog.ts, sim/runGrades.ts, sim/shiftPlan.ts |
grading launches and shifts |
sim/bestRun.ts, sim/perfectRun.ts |
the best run (launch rev search) and the analyzer run |
sim/aiDriver.ts, sim/aiRun.ts |
AI driver mistakes, simulated AI races |
sim/inputLog.ts, sim/inputRecorder.ts |
input event log, replay, network batching |
sim/opponentPredictor.ts, sim/predictedDriving.ts |
the remote car in online races |
sim/finishGrace.ts, sim/distances.ts |
finish grace period, race distances |
The tick
Section titled “The tick”The sim advances in fixed steps of DT = 1 / SIM_HZ seconds (sim/clock.ts); SIM_HZ is 120. A
fixed step is what makes a race reproducible: the result depends only on the inputs per tick, never
on the frame rate of the machine that ran it. That is what lets the server replay a client’s race
and get the same finish.
Every rate in the sim is written per second and multiplied by DT, and every duration is written in
seconds and converted with ticks(seconds). So SIM_HZ can change without retuning anything.
decayPerTick() turns a “share taken off per 30 Hz frame” into the per tick share that decays
equally fast; the online race’s drawn-opponent correction (net/NetRaceSession.ts) uses it.
The original ran at 30 Hz. docs/2026-11-06-sim-tick-rate.md explains why that was raised: at 30
Hz every input snapped to a 33 ms tick, and one tick of shift timing in first gear could cost over
100 ms of finish time. At 120 Hz a tick is about 8 ms. That doc also lists a possible next step
(sub-tick input timestamps) that is not built.
Units are SI: speed v in m/s, distance d in metres, engine speed in rpm. Speeds shown in mph
(roll speeds, the HUD) convert with METRES_PER_SECOND_PER_MPH (0.44704) in sim/clock.ts;
toDisplaySpeed and formatSpeed in core/unitSettings.ts take m/s.
Setup: from car and tune to a physics car
Section titled “Setup: from car and tune to a physics car”initRace (sim/initRace.ts) runs once per race and computes everything that does not change
during it. It builds setup.physics, a PhysicsCar, with buildPhysicsCar
(sim/physics/buildCar.ts) from the car’s HP, TQ (lb-ft), WT (kg) and its physics spec:
engine type, drive layout (fwd, rwd, awd) and body style. Each car model sets that spec in
player/carModels.ts.
- Torque curve (
sim/physics/torqueCurve.ts): the engine type gives idle, peak torque, peak power, redline and limiter rpm (ENGINE_SHAPES). Torque is 60% of peak at idle, flat atTQup to the rpm where it makesHP, then falls to 92% of that at the redline and 85% at the limiter. An engine that would make itsHPonly past the redline makes it at the redline. - Gearing (
sim/physics/drivetrain.ts): six geometric ratios from 3.4 to 0.85, so every upshift drops the rpm by the same share. The final drive is set so the stock car reaches its top speed (where drag and rolling resistance eat all its power) at the redline in sixth; parts never change it (CarStats.finalDrive). - Body (
BODIESinsim/physics/buildCar.ts): drag area, centre of gravity height, wheelbase, rear weight share, tyre radius and tyre grip per body style. The hypercars (Hyper R8, Hyper GX) have their ownhyperbody: mid-engine (rear share 0.62) on the widest tyres (grip 1.5), or a maxed one in RWD would spin all the way down the strip and lose to the AWD supercars. - Drivetrain efficiency 0.85 for FWD and RWD, 0.8 for AWD; shift time 0.25 s.
- Parts (
CarStats.parts,sim/partEffects.ts): tyre grip,squat(weight transfer towards the driven axle), efficiency, shift time and a turbo whose torque multiplier rises from 1 to1 + turboGainbetween 45% and 65% of the redline. See tuning. - Tune: each gear’s ratio is divided by its slider,
ratio / GEARn. Above 1 a longer gear, below 1 a shorter one.CAMADJUSTshapes the torque curve andBOOSTscales the turbo. Tune values are clamped toTUNE_MIN..TUNE_MAX.
From the physics car initRace also derives top[gear], the speed (m/s) at the limiter in each
gear, and the shift points (below), and baseTemp from AIRINTAKE and (with a turbo) BOOST.
How the sliders trade off is on the tuning page; car stats are on /reference/cars/.
raceSetup (sim/raceSetup.ts) wraps initRace and adds the best run’s launch
rev and the rev climb at each shift (below). Both are always found over a quarter mile, whatever the
race distance, because they are a property of the car, not the race.
One step
Section titled “One step”stepRacer(setup, state, input) in sim/step.ts advances one racer by one tick. In order:
- Staged (gear 0, on the line): run the launch logic (
stepStaged, below), update heat, stop. - Roll race before green (
input.green === falsewhile in gear): cruise at the current speed, stop. - AI controls for AI racers: shift and fire nitrous (see AI driver).
- Physics (
stepPhysicsinsim/physics/stepPhysics.ts) with full or no throttle and the nitrous torque, if any:- Engine torque from the curve at the current rpm, none during a shift or at or past the limiter (fuel cut). Without gas there is no torque and drag slows the car.
- Drive force at the tyres: torque times gear ratio, final drive and efficiency, over the tyre radius.
- Traction (
sim/physics/traction.ts) limits that force, then air drag and rolling resistance (sim/physics/forces.ts) are taken off anda,vanddare integrated overDT. - The rpm follows the wheels (
lockedRpm), or the slipping launch clutch, never below idle.
- Track the run: top speed so far, and
shiftPointTicks(ticks the rev has been at or past this gear’s shift point, used for grading). - Heat (
updateHeat).
Shifting and the player’s nitrous are not inside stepRacer: callers apply them to the state before
the tick with shiftUp, shiftDown and holdNitrous. A shift (startShift) cuts the drive for the
car’s shift time and ends a launch clutch slip.
Traction
Section titled “Traction”The driven axle’s load holds the drive: the front axle for FWD, the rear for RWD, and the whole car
for AWD (its centre differential sends drive to the axle that grips). Acceleration moves weight from
front to rear by mass * a * cgHeight / wheelbase, so RWD gains grip as it pulls and FWD loses it.
Tyres that hook up hold drive up to static grip times that load. Above it they spin and give only
sliding grip (87% of static) until the demand drops below the sliding limit. state.spinning holds
whether they spin; isWheelspinning in sim/launch.ts reads it for the view.
Every car has traction control (tractionControl in physics/traction.ts, standard, not a part): it
caps the drive force at 0.999 times the grip the tyres have now (static grip while hooked up, sliding
grip while spinning, so they hook up the next tick). Power past the grip is wasted instead of
spinning the tyres. The cap ignores the launch’s grip scale (below), so a launch above the perfect
zone still spins for its 1.5 s and a launch too low still bogs. Before it (2026-11-08), a car with
more power than grip spun through each gear, the flare chain-shifted the gearbox into a tall gear
and a higher stage could be slower than the one before (docs/2026-11-08-stage-traction-design.md).
npm run stages:check holds the promise: every uniform stage quicker on every car and distance, and
full Stage 5 the quickest build.
Spinning tyres let the engine rev above road speed: the drive force the road does not take spins up
the engine and driven wheels (SPIN_INERTIA in physics/wheelSlip.ts) and slipRpm holds the
extra revs, so the tach and engine sound flare and the limiter comes early. Once the tyres hook up
the revs fall back to road speed within HOOK_UP_TIME. A shift scales slipRpm by the ratio change
(rescaleSlip in physics/stepPhysics.ts, on the first tick in the new gear), as the same tyre
speed turns the engine slower in a taller gear. Before that, a dual-clutch gearbox (Drivetrain Stage 4
and 5) upshifted straight through the gears on the flared revs and made a car slower. Shift windows
are sized on the rev at road speed (roadRev in sim/bestRun.ts), so a spin rushes the needle
through them.
Rev ratio
Section titled “Rev ratio”The rev the tach, the launch and the shift points use is revRatio = rpm / limiterRpm
(sim/engine.ts), or the free rev while staged. redlineRev(setup) is the car’s redline on that
scale; it differs per engine type. The HUD’s tach (race/view/Hud.ts) shows it as real rpm, revRatio x limiterRpm, on a scale up to the limiter rounded up to the next 1000.
Heat and blowing up
Section titled “Heat and blowing up”The engine only heats above redlineRev(setup) in gears 1 to 5 (in sixth there is no shift left to miss,
and a built car on its stock final drive reaches the limiter there): from REDLINE_HEAT per second at the redline up
to LIMITER_HEAT at the limiter (a downshift past the limiter heats like the limiter). Below it, it
cools at COOL_RATE per second back to the setup’s baseTemp (set by boost and air intake). At
ENGINE_BLOW_TEMP the engine blows and the race is lost. Heat runs on the line too, so revving
against the limiter while staged costs temperature.
Nitrous
Section titled “Nitrous”CarStats carries the nitrous kit (nitrous, 100 when installed), reserve bottles and a Dual-Port
flag (sim/specs.ts derives them from installed parts). Every bottle is one shot: once fired it runs
for NITROUS_SHOT_FRAMES (1.2 s) and cannot be stopped, and the tank (nitrousTank) drains with it.
When a shot ends the next reserve bottle loads. While it runs and gas is held, it adds 1.5 times the
engine’s peak torque (NITROUS_TORQUE_SHARE, 1.5 times that again with Dual-Port) in any gear. Like a real kit’s
throttle and clutch switches, the shot pauses with the gas off and during a shift. On the limiter it
runs on without any push (the engine gives nothing there), so a late shift wastes it. The extra torque goes through
traction control like any other, so a shot in first or second gear on a traction-limited car is
mostly wasted: on most cars a shot pays most from third or fourth, and grip parts are what make a
shot count. No shot fires below second gear (NITROUS_MIN_GEAR): on a phone the nitrous button sits next to the gas, and a player tapping gas for the launch would otherwise fire it by accident. The best run fires once a shift is done.
There are two ways to fire it:
holdNitrous(state, held): the player, called every tick. A press fires one shot; keeping the button down does not fire the next bottle (nitrousHeldremembers last tick).startNitrous(state): fires a shot when none runs, used by the AI and the best run.
Launch and the start tree
Section titled “Launch and the start tree”A race starts with both cars staged in gear 0 (stageRacer in sim/launch.ts). The countdown is
STAGING_FRAMES ticks (four lamps of 700 ms); frame 0 is the first tick after green, so frames are
negative during the countdown.
On the line, gas revs the free engine (REV_RISE_PER_TICK) and releasing it lets the rev fall back
to idle. Hitting the limiter bounces the rev back. On the first green tick the clutch drops by
itself at the held rev: there is no jump start and no reaction time skill. While the clutch slips
it pulls the engine toward the wheels’ rpm (CLUTCH_ENGAGE_TIME, 0.5 s, in
sim/physics/stepPhysics.ts), so the launch rev sets the pull for the first moments. Too low a rev
bogs and too high a rev spins the tyres, by the physics alone.
Each car has its own best launch rev, setup.launchRev, found by the best run. The launch is graded
against it (revZone), and the grade also scales the tyres’ grip for LAUNCH_GRIP_TIME (1.5 s):
Zone (revZone) |
Rev at green | Grip (LAUNCH_GRIP) |
|---|---|---|
perfect |
within PERFECT_ZONE_HALF_WIDTH of setup.launchRev |
1 |
near |
up to NEAR_ZONE_WIDTH below that |
0.9 |
low |
lower | 1 (it already bogs) |
over |
above the perfect zone | 0.6 (wheelspin, even on a weak car) |
One exception: a player sitting at idle with no gas at green stays put until gas is pressed, then launches bogged. That is the only way a late start costs time.
While the launch clutch slips (state.clutchRpm > 0) the rev is the engine’s, not the wheels’, so
nothing makes a shift decision on it: not the AI, the online prediction, the shift timing count or
the best run. Shifting out of the slip by hand is allowed and ends it.
Once launched the player’s gas is held for them (RaceControls.gasDown in race/RaceControls.ts
returns true whenever the car is not staged). In a race the player controls three things: the rev
on the line, the shifts, and nitrous.
RaceClock (race/RaceClock.ts) draws the lamps from elapsed milliseconds (LIGHT_INTERVAL_MS),
but the sim’s green is the tick count, not the clock: the session passes green: frame >= 0 into
every step.
Roll starts
Section titled “Roll starts”In a roll race (sim/roll.ts) both cars start already moving at setup.rollSpeed (in mph,
converted to m/s; ROLL_SPEED for career roll events) with no staging, launch or rev. lineUp puts each car in rollGear, the
lowest gear whose shift point is above the speed (the one that pulls hardest), and places it
behind the roll point by exactly the distance it covers during the countdown. During the countdown
the car cruises (step 2 above), so it passes d = 0 on green and the race measures from there.
lineUp falls back to stageRacer when rollSpeed is 0, so callers use it for both kinds.
Shift points and grading
Section titled “Shift points and grading”Shift points
Section titled “Shift points”Each gear’s best shift point is computed, not searched: bestShiftRpms
(sim/physics/shiftPoints.ts) walks the rpm up from idle in 25 rpm steps until the next gear, at the
same speed, pulls at least as hard as this one, and stops at the redline at the latest (a flat torque
curve would otherwise put it on the limiter’s fuel cut). It assumes the tyres hook up. initRace
stores the result as rev ratios in setup.shiftPoints (index 1..5; sixth is 1).
The best run
Section titled “The best run”simulateBestRun (sim/bestRun.ts) finds the fastest way to drive a car with a given tune: every
upshift at its shift point, and a search for the best launch rev. The launch rev is tried from idle
to the redline every COARSE_STEP (0.1), then every FINE_STEP (0.02) around the best of those,
without nitrous. Then nitrous is tried at that launch from each gear in NITROUS_FROM_GEARS (1 to
5), held only while the tyres hook up, and the fastest is kept: on a traction-limited car,
nitrous in the low gears is wasted. A roll race has no launch to
search. While the launch clutch slips the run does not shift.
It records the launch rev, each gear’s shift point and how fast the rev was climbing there
(shiftRevRates), the gear the nitrous started in (nitrousFromGear) and how long the tyres spun
(spinFrames, for the parts shop’s warning). The search runs the race some 20 times, so results are cached by car, tune,
distance and roll speed (MAX_KEPT_RUNS); treat a cached result as read-only.
This best run is what the player is measured against: raceSetup copies its launchRev (also the
AI’s starting launch rev) and shiftRevRates into every live RaceSetup. The older
simulatePerfectRun (sim/perfectRun.ts) drives with the AI shift rule instead; it shifts early.
The tests use it to check the AI never beats the best run. MAX_RACE_FRAMES in sim/perfectRun.ts caps every simulated race.
Shift windows
Section titled “Shift windows”A shift is graded by when it lands, not by its rev (sim/shiftZone.ts). Each gear’s ideal
moment is the tick the rev reaches that gear’s best shift point. A shift within
PERFECT_SHIFT_WINDOW seconds of it, before or after, is perfect. Later is over. Earlier is
near if the rev is within NEAR_ZONE_WIDTH of the shift point, else low.
Why time, not rev: the rev climbs at very different speeds per gear. A rev band gave first gear a
window of a few milliseconds (luck) and fourth gear over a second (free). A time window makes every
gear equally hard. The measurements are in docs/2026-11-06-sim-tick-rate.md.
shiftTiming finds the distance to the ideal moment: after it, from shiftPointTicks; before it,
by stepping a copy of the car forward on full gas until it reaches the point. revBand converts the
window back to a rev range for the tach colours (approximate, from the best run’s rev rates).
The same zone feeds the live shift callout (race/view/ShiftCallout.ts) and the results screen.
Run grades
Section titled “Run grades”After a race, gradeRun (sim/runGrades.ts) replays the player’s input log and records the launch
zone and every upshift (gradeUpshift in sim/shiftLog.ts). It also puts a cost in seconds on each
mistake with what-if replays (planFinish in sim/shiftPlan.ts):
- The run is re-driven with upshifts triggered by rev (plus how long the player sat there) instead of by frame, so this base replay matches the real run.
- Then once per shift, with only that shift moved to the ideal point, and once with an ideal launch. The difference in finish time is the time that mistake lost.
Roll races report no launch loss. race/raceResult.ts calls gradeRun and simulateBestRun to
build the results screen.
The AI driver
Section titled “The AI driver”AI racers hold gas and drive themselves inside stepRacer: they shift up a reaction time (setup.aiShiftDelayTicks) after the rev at road speed (roadRev,
without the flare of spinning tyres) passes their shift point for the current gear
(setup.aiShiftPoints), and fire nitrous shots. In a gear where the rev climbs fast the reaction
time overshoots further. They also shift once the real rev (with the flare) reaches the redline, where
the engine starts to heat: a powerful car spinning its tyres would otherwise sit on the limiter until
the engine blew. Their mistakes
are decided once per race, outside the sim, by rollAiDriver in sim/aiDriver.ts, which writes
them into the setup. The sim itself never calls a random function.
rollAiDriver(setup, sloppiness, random, precise) takes a sloppiness from 0 to 1 from the career
event (see career and /reference/career/):
| Normal driver | Precise driver (bosses) | |
|---|---|---|
| Launch rev | setup.launchRev +- a miss from MIN_LAUNCH_MISS up to MAX_LAUNCH_MISS |
setup.launchRev +- MAX_LAUNCH_MISS * sloppiness, no floor |
| Shift points | the best shift point times (0.8 + aiShiftOffset) / 0.95, the original AI’s early share (stronger cars shift later), +- MAX_SHIFT_MISS * sloppiness per gear |
the car’s best shift point +- the same spread |
| On the line | blips the engine for show, then in the last second holds gas until its launch rev | eases the rev onto its launch rev with a fine throttle in the last second |
| Reaction | leaves the line REACTION_PER_SLOPPINESS * sloppiness s (1 s for a rookie) after green, +- 25% |
the same, so near 0 for a boss |
| Shift reaction | SHIFT_DELAY (0.03 s) plus up to SHIFT_DELAY_PER_SLOPPINESS * sloppiness (0.12 s at 1) |
the same |
| Nitrous | from third gear, only in a strong car; a gear early with chance EARLY_NITROUS_PER_SLOPPINESS * sloppiness |
from the best run’s gear (setup.aiNitrousGear), the same chance of a gear early |
Shift points are clamped between LOWEST_SHIFT (0.6) of the redline rev and the limiter. Like the
best run, the AI makes no shift while its launch clutch slips, and it fires a shot only once a
shift is done and its tyres grip. A precise driver near
sloppiness 0 is close to the best run. Reaction is most of the dial: on a traction-limited car a
missed launch rev or shift point costs little, so a rookie at sloppiness 1 or 2 ran the same time
before it.
simulateAiRun (sim/aiRun.ts) runs a full AI race outside the game. seededAiRunFrames and
averageAiRunFrames use a seeded generator (mulberry32) so balance checks such as bracket dial-ins
(player/career/bracketDial.ts) and the career balance tests give the same numbers on every run.
The live race passes Math.random, except a career race the race server judges: it races with the
seed from the server’s ticket (SessionOptions.random, race/RaceSession.ts), so the server can run
the same opponent again (race/replayEventRace.ts, below).
Determinism, input logs and replays
Section titled “Determinism, input logs and replays”Because the sim is fixed-step and pure, a run is fully described by its car, tune, distance and a
list of input events (sim/inputLog.ts):
InputEventis{ f: frame, k: kind }with kindsgasOn,gasOff,up,down,nos,nosOff. Gas and nitrous are held states, so only their on/off edges are logged. An event at framefis applied before tickf.EventCursorwalks a sorted log and applies it to a state, the same way the race scene applies live input.replayRunreplays a log over a distance and returns the fractional finish frame (interpolated inside the crossing tick, so two cars finishing in the same tick still have an order) or blown.InputRecorder(sim/inputRecorder.ts) turns live input into that log and batches it for the network, with a heartbeat (HEARTBEAT_FRAMES) so the other side knows which frames are complete.
Every race records its log, solo races included: gradeRun grades from it. Online, the server
never trusts a client’s claimed time. party/src/judge.ts replays each runner’s log with
replayRun and judges on that, and party/src/inputGuard.ts rejects malformed or out-of-order
events (isInputEvent). Client and server must run the same sim, so clients send a SIM_VERSION
(net/simVersion.ts) and the room turns away a mismatch. On the client, OpponentPredictor
(sim/opponentPredictor.ts) replays the remote car exactly up to its last reported frame and
predicts beyond it with predictedGas (sim/predictedDriving.ts): hold the last gas and nitrous,
shift at the best shift point, lift past the redline where a shift cannot help. Details on the
online page.
This is why the sim has no Math.random, no wall clock and no Phaser: any of those would make the
server’s replay disagree with the client.
The finish
Section titled “The finish”A car finishes when d reaches the race distance (sim/distances.ts: eighth, quarter and half
mile in metres). A blown engine ends its run. Once one car is over the line, the other has
finishGraceFrames(distance) ticks to finish too (sim/finishGrace.ts), longer for races over a
quarter mile, or it counts as not finished. The online server applies the same grace
(applyFinishGrace in party/src/judge.ts). MAX_RACE_FRAMES is the hard cap for any run.
Race results: timeslip and charts
Section titled “Race results: timeslip and charts”The race records nothing extra while it runs. buildRaceResult (race/raceResult.ts) replays it
with sim/telemetry.ts, which steps a car tick by tick and samples time, distance, speed, rpm,
gear, acceleration in g, wheelspin and nitrous:
playerTrace: the player from the input log, the same replay the judge and the grades use.opponentTrace: the AI from theRaceSetupit raced with (its driver rolled once at the start), timed from its own green, so a bracket race’s head start does not count.perfectTrace: the best run, driven byperfectControlsandstartRunfromsim/bestRun.ts.
Tests check each replay ends on the live finish. sim/timeslip.ts turns a trace into a timeslip
(60, 330, 660, 1000 and 1320 ft, cut at the race’s own line, speed at the eighth and the line) and
the gap to the perfect run by distance.
The results screen (race/view/resultTabs.ts) puts tabs left of the card: RESULT (the card),
TIMESLIP (timeslipView.ts), SPEED (speed of both cars and the player’s rpm, shifts marked), GRIP
(acceleration with wheelspin and nitrous shaded) and VS PERFECT (seconds behind the perfect run
along the strip), all in resultCharts.ts on the small lineChart.ts. A tab is built the first
time it opens.
How the race scene drives the sim
Section titled “How the race scene drives the sim”The architecture page covers the scene and module layout; here only the sim side.
RaceSession (race/RaceSession.ts) holds both cars’ setups and states for one solo race, with no
Phaser and no input of its own. Its constructor builds both setups with raceSetup (the opponent
on STOCK_TUNE), rolls the AI driver, and lines both cars up. Options cover a precise (boss)
opponent, a bracket race’s head start (opponentStart: the frame the opponent’s green comes on; the
player’s is always frame 0) and a roll speed. tick(gas) steps the player, then the opponent, then advances frame.
checkEnd() reports finish, blown or timeout (grace or MAX_RACE_FRAMES). finishOpponent() runs
the opponent to the line off screen when the player finished first, because its time is still
needed for the result.
RaceClock (race/RaceClock.ts) is the fixed-step accumulator. Each render frame advance() adds
the frame’s delta (capped by MAX_FRAME_DELTA_MS, so a background tab does not fast-forward the
race), runSteps() runs one tick per whole 1000 / SIM_HZ ms and keeps the remainder, and
stepAlpha() returns how far the render frame is between two ticks. The view interpolates
positions and wheel angles with that alpha, so motion is smooth at any display rate while the sim
stays at SIM_HZ.
The Race scene (race/scenes/Race.ts) ties them together. Ticks start with the first lamp, so the
player can rev in neutral. Each tick it reads gas and nitrous, logs their edges into the event’s
InputRecorder, applies holdNitrous, and calls session.tick(gas). Shift keys apply
shiftUp/shiftDown straight away and log the shift at the current frame, which is the next tick
to run, so replay applies it at the same point. The shift callout grades an upshift just before it
lands. At the end, race/raceResult.ts grades the log and compares against the best run.
Online races use net/NetRaceSession.ts and net/NetRaceClock.ts instead, with the opponent coming
from OpponentPredictor rather than the AI.
Checking the sim
Section titled “Checking the sim”npx tsx scripts/physics-et.tschecks every stock car’s best quarter mile on the drag model (no nitrous, best launch rpm searched, shifting where the next gear pulls harder). On a prepped strip (1.5 times the tyres’ grip), cars the Huntington formulaET = 5.825 * (lb / hp)^(1/3)puts at 12 s or slower must land within 5% of it. On the street (plain grip), the cars with a real counterpart (McLaren F1, Porsche Carrera GT, Skyline GT-R R34) must land within 10% of its road test. It exits 1 when a check fails.docs/drag-racer-v3.md(the original’s formulas from the decompiled SWF) is history: the drag model replaced that sim, and its reference port andsim:checkare gone.npm testruns the Vitest suite intests/, which covers the drag model (physics.test.ts), the sim (sim.test.ts,launch.test.ts,shiftZone.test.ts,runGrades.test.ts,roll.test.ts,predictedDriving.test.ts,finishGrace.test.ts,aiDriver.test.ts) and the career balance built on it.