Mars Rally Championship, a free browser rally game, solves tire forces at each of its four wheels separately, eight times per 60 Hz physics tick — 480 Hz internal, whatever the display's frame rate. Every wheel carries its own normal force, slip angle, friction circle and load-sensitive friction coefficient, and all of it is plain JavaScript ES modules with no build step and no physics library. This is what the model does, which constants actually matter, and what a per-wheel car physics simulation in JavaScript buys you over a steering multiplier.
In-game screenshot: the rover loaded in a corner. Each wheel's load comes from its own spring, so the compressed outside tires carry the bigger grip budgets.
The arcade version, and why I replaced it
The first handling model I wrote was the one everybody writes. Steering input scaled a yaw rate, throttle scaled a forward velocity, and a "grip" constant decided how much lateral velocity got scrubbed off per frame. Fifteen lines, frame-rate independent, fine for about ten minutes.
The problem with a steering multiplier is that grip keeps rising with input. Turn the wheel more, get more cornering force, forever. Real tires peak at a few degrees of slip and then give some of it back, and that falling edge is the entire sensation of a car being on the limit. Without it you have a vehicle on a rail: it never gets loose, never rewards a correction, never punishes anything. A multiplier also cannot say which wheel is loaded, what happens when only two of them touch the ground, or how much grip is left for steering while you are braking.
The rewrite replaced the single lumped grip term with four independent
contacts. The old path still exists behind a USE_PER_WHEEL_TIRES flag as an
escape hatch, but per-wheel is the live path for everyone.
Slip angle and slip ratio are the two inputs
A tire model needs two numbers per wheel to be interesting. Slip angle is the angle between where the wheel points and where it is actually travelling. Slip ratio is the mismatch between the wheel's circumferential speed and the ground going past it. Lateral force comes from the first, longitudinal from the second, and both follow one shape: steep rise, peak, fall-off.
Slip angle is one line in the substep loop, computed in the wheel's own frame. The front wheels project into their steered frame, so full lock at a standstill still produces real lateral force and therefore real yaw:
const vLong = wheelVx * wForward.x + wheelVz * wForward.z;
const vLat = wheelVx * wRight.x + wheelVz * wRight.z;
const slipA = Math.atan2(vLat, Math.abs(vLong) + TIRE_MIN_SLIP_EPS);
TIRE_MIN_SLIP_EPS is 1.0 m/s — a denominator floor, and the difference
between a model that behaves at parking speed and one that divides by nearly
zero.
Why the curve has to fall off after the peak
The lateral curve peaks at PEAK_SLIP_ANGLE = 0.18 radians, about 10 degrees,
deliberately wider than the 8-degree default the model started at: gravel-spec
rally rubber is more forgiving, and a wider peak gives the player a bigger
window to sit in. The shape is a normalised rational curve, not a fitted
Pacejka Magic Formula:
const sy = slipA / PEAK_SLIP_ANGLE; // 1.0 at the peak
const sCapY = Math.min(Math.abs(sy), TIRE_CURVE_POST_PEAK_CLAMP);
let peakY = (2 * sCapY) / (1 + sCapY * sCapY); // 1.0 at s = 1
2s/(1+s²) is worth knowing if you are building one of these: exactly 1.0 at
s = 1, steep below, gently decaying above, no coefficients to fit and no lookup
table. I would not publish a tire test with it, but the felt gap between it and
a fitted curve is far smaller than the gap between either one and a
multiplier.
The real deviation is CURVE_POST_PEAK_CLAMP = 1.5, which caps normalised slip
before the curve is evaluated, so peakY never drops below about 0.92. An
earlier version let the curve decay honestly toward zero under wheelspin, and
the result was a rover pinned at about 20 km/h forever: lost grip caused more
slip, which lost more grip. Real tires plateau at a high kinetic floor, and
clamping is cheaper than modelling why. The same plateau keeps a held slide
catchable; how drifting works in racing games
covers that side of the slip angle for players.
Diagram: sideways force peaks at 0.18 rad, about 10 degrees, then holds near 0.92 of the peak past s = 1.5 instead of decaying toward zero.
Slip ratio is the honest gap in the shipped model. PEAK_SLIP_RATIO = 0.15 and
a per-wheel WHEEL_INERTIA_KGM2 = 2.5 sit in the config for the version that
integrates a real wheel-speed state. The path that ships short-circuits it:
longitudinal force is taken straight from drive and brake demand, capped at the
friction circle, and wheelSlipRatio is written as 0 with a comment saying so.
Wheelspin is a boolean flag for dust and audio rather than a runaway
integrator. I would rather ship one state variable fewer than ship one that
explodes when a wheel leaves the ground at 200 km/h.
One friction circle per wheel, spent once
This changes the feel more than any curve shape. A tire has one friction budget; braking, accelerating and cornering all draw on it. The code makes that literal — the circle's radius is friction plus regolith cohesion, and the longitudinal and lateral demands are resolved onto it in one of two ways:
const Fmax = muEff * F_eff + cohesion; // per-wheel limit
let FxD = drive - brakeDir * brake; // net pedal demand
const braking = FxD * brakeDir < 0;
const cap = braking ? Fmax * BRAKE_ABS_FRACTION : Fmax; // ABS / traction control
if (Math.abs(FxD) > cap) FxD = Math.sign(FxD) * cap;
const Fy_desired = Fmax * peakY; // from the tire curve
const Fy_budget = Math.sqrt(Math.max(0, Fmax * Fmax - FxD * FxD));
const FyL = Math.min(Fy_desired, Fy_budget); // pedal first
const sc = Math.min(1, Fmax / Math.hypot(FxD, Fy_desired)); // shared
const w = braking ? COMBINED_SLIP_BRAKE : COMBINED_SLIP_DRIVE;
const Fx = FxD + w * (FxD * sc - FxD);
const Fy = -Math.sign(sy) * (FyL + w * (Fy_desired * sc - FyL));
Longitudinal priority is what a locked tire does, and it produces behaviour I
never had to write. Lock the rears with the handbrake, Fx saturates at
−sign(vLong)·Fmax, Fy_budget goes to zero, and the back of the rover comes
around because nothing is left to hold it — not because a HANDBRAKE_YAW_KICK
constant said so. A tire held at its peak by ABS or traction control instead
follows the combined slip vector, so both demands scale onto the circle
together; COMBINED_SLIP_* blends the two ends, which coincide in a straight
line.
Diagram: the pedal shares each tire's grip circle between braking and turning; the handbrake spends the rears' circles on braking alone, so the tail comes around.
BRAKE_ABS_FRACTION ships at 1.0: ABS holds each tire at its peak, so a
straight stop takes the whole circle — about 125 m from 100 km/h — and no wheel
locks. With COMBINED_SLIP_BRAKE at 0.8 the rover turns while it slows: from
60 km/h, full brake and full lock swing its path about 24° in 3 s, against
about 3° for the pedal-first version that shipped until 22 September 2026.
DRIVE_CAP_MARGIN caps drive at 90% of each tire's own grip, so full throttle
keeps cornering grip on every surface, worn or damaged tires included, and a
slide under power can still be caught.
One force sits outside the circle on purpose: a tire sliding sideways ploughs a wedge of regolith (Bekker bulldozing), worth up to 35% more sideways grip on regolith, 20% on polygon and 6% on hardpack. It fades in from about 11° of slip and is full by about 34°, so grip-limit cornering never sees it — it is why a drift bites on loose ground.
Load sensitivity, and where the weight goes
Per-wheel normal force is not a fixed quarter of vehicle weight. It falls out of the suspension spring-damper in the first pass of each substep:
const pen = terrainY - wheelWorldY; // penetration, metres
const F = Math.max(0, WHEEL_CONTACT_K * Math.max(0, pen)
- WHEEL_CONTACT_C * vCompress);
with WHEEL_CONTACT_K = 600 and WHEEL_CONTACT_C = 270 in the loop's
unit-mass terms. Because the chassis carries real pitch and roll degrees of
freedom, sideways load transfer is not a term anyone wrote: the tires' side
force acts at the centre of gravity's height, rolls the body onto the outside
springs, and the outside wheels carry more of the load. Fore-and-aft is a
different story. Braking and throttle forces are not fed back into pitch, so
the nose dip under braking is drawn by a separate visual model and the front
tires gain no load from it. Wheel
offsets are ±1.25 m lateral, ±1.5 m longitudinal, matching the 3.0 m wheelbase.
Then load sensitivity: friction coefficient falls as you push harder on a tire, which is why a heavily loaded outside wheel does not hand you double the grip.
const loadN = Math.max(0, 1 - TIRE_LOAD_SENSITIVITY
* Math.max(0, F_eff * CAR_MASS_KG / TIRE_LOAD_REF_N - 1));
const muEff = TIRE_MU_BASE * gripDmgFactor * tireMul * loadN
* wheelSurfaceGripMul[i] * wreckedGrip;
TIRE_LOAD_REF_N = 2790 is the load at which µ equals its base value, and it
is where Mars gets interesting. That is 3,000 kg × 3.72 m/s² ÷ 4 — one quarter
of the rover's static weight on Mars, roughly two and a half times lighter
than the same corner on Earth. Everything downstream scales with it: at
MU_BASE = 0.70 the friction half of the grip budget is µ·g ≈ 2.60 m/s².
Regolith cohesion — the c in τ = c + σ·tanφ, about 0.12 m/s² per tire — does
not scale with gravity and brings the total to ≈3.1 m/s², which is what makes
braking from 100 km/h take about 125 metres. Surface multipliers move µ between
roughly 0.53 and 0.81, tire wear and suspension damage move it further, and
they all multiply into the same muEff.
Low gravity is a genuinely useful stress test. Every modelling error is more than twice as visible as it would be under 9.81 m/s², because the grip you are making mistakes with is less than half the size. A sloppy load-transfer term on Earth reads as mild understeer; here it puts you in the rocks. For the driving consequences rather than the code, see what Mars gravity does to rally driving.
The parameters that matter
| Parameter | Value | Unit | What changes if you move it |
|---|---|---|---|
MU_BASE | 0.70 | – | The friction ceiling; µ·g ≈ 2.60 m/s², ≈3.1 m/s² with cohesion. Raise it and Mars stops feeling like Mars |
PEAK_SLIP_ANGLE | 0.18 | rad (~10°) | Where lateral grip peaks. Lower is twitchier turn-in; higher is a wider forgiving window |
PEAK_SLIP_RATIO | 0.15 | – | Longitudinal peak for the wheel-speed version; unused by the shipped direct-force path |
CURVE_POST_PEAK_CLAMP | 1.5 | – | How far past the peak the curve is evaluated. Lower it and heavy wheelspin strands the car |
LOAD_SENSITIVITY | 0.05 | – | How fast µ falls with load. Higher punishes weight transfer and rewards smooth hands |
LOAD_REF_N | 2790 | N | The load at which µ = MU_BASE. Mis-set it and every corner is silently under- or over-gripped |
BRAKE_ABS_FRACTION | 1.0 | fraction of circle | Share of the circle service braking may take. At 1.0 ABS holds each tire at its peak — the shortest straight stop, no wheel locks; below 1.0 every stop lengthens |
COMBINED_SLIP_BRAKE | 0.8 | blend | How a held pedal shares the circle with cornering. At 0 it leaves no lateral force and the rover slides straight; toward 1 the rover turns while it slows |
DRIVE_CAP_MARGIN | 0.90 | fraction of tire's grip | Traction control. Drive is capped below each tire's own limit, so full throttle keeps cornering grip on every surface |
N_PHYSICS_SUBSTEPS | 8 | per 60 Hz tick | 480 Hz internal. At 4, stiff-suspension damping runs out of stability margin |
SOFT_CONTACT_K | 30 | m/s² per m | How hard the chassis is pulled onto falling terrain over a crest |
Eight substeps, and what broke at four
The contact model is stiff, and stiff springs against a 60 Hz outer loop are past what explicit Euler tolerates. The symptom is not subtle: the rover lands, the integrator over-corrects, and it bounces higher than it fell. Substepping is the fix, and the config comment records why the number is 8 and not 4:
N_PHYSICS_SUBSTEPS: 8, // physics integration substeps per fixed 60 Hz sim
// tick (60Hz × 8 = 480Hz internal, whatever the frame rate). Bumped from 4:
// explicit-Euler stability for pitch damping (λ = 4·lz²·C/I_p; ~431/s at
// C=115, ~600/s at C=160) was tight at N=4; pitch integration uses
// semi-implicit damping so λ·subDt can exceed 2 without oscillation.
Explicit Euler on a damping term is stable while λ·dt < 2. Four substeps of a
60 Hz step gives subDt = 4.17 ms, so λ ≈ 431/s lands at 1.80 — inside the
bound, with nothing left for a long frame or a damper raised during tuning.
Eight substeps halves it. Vertical, pitch and roll damping additionally
integrate semi-implicitly,
dividing by (1 + λ·subDt) rather than subtracting, which is unconditionally
stable.
The step itself no longer depends on the frame rate at all. The physics ticks at a fixed 60 Hz: a 144 Hz monitor draws poses in between ticks, a 20 fps laptop runs three ticks per frame, and subDt is 2.08 ms on every machine. Below 20 fps the simulation slows down rather than take longer steps. It used to step once per rendered frame with that frame's delta, which made a run's physics depend on the screen it was driven on.
Diagram: the physics ticks at a fixed 60 Hz with 8 substeps each, 480 Hz, whether the screen draws at 144 Hz or 20 fps.
For anyone worried about cost: 8 substeps × 4 wheels × two passes is 64 wheel evaluations per tick, each a few terrain samples and some tens of floating-point operations. It is nowhere near the frame budget. Rendering is.
The soft-contact spring is a hack, and I kept it
Mars gravity is 3.72 m/s². Crest a rise at speed and the terrain falls away faster than gravity can pull a 3,000 kg chassis onto it, so the wheels hover, normal force goes to zero, and every tire force goes with it. Brake, throttle and steering all die for a quarter of a second at exactly the moment the player is asking for them.
The fix is a weak attractive spring in a band above the terrain. Wheels between 0.18 m and 0.30 m of air gap get pulled down toward the ground:
const gap = -pen; // metres of air under the wheel
const F_soft = -(TIRE_SOFT_CONTACT_K * gap + TIRE_SOFT_CONTACT_C * vCompress);
const F_softClamped = Math.max(-WHEEL_CONTACT_K * 0.25, Math.min(0, F_soft));
SOFT_CONTACT_K = 30, damping 15. It is not a fudge factor dressed as physics:
real Mars rovers have suspension that extends downward to keep wheels on
falling ground, and this is the cheapest possible model of that reach. The
clamp stops it ever pushing up; the hard spring owns landings.
The gate is the important part. Fire this spring on every gap and it sucks the rover out of the air at the top of every jump, so it is disabled above 2.0 m/s of chassis-relative lift. On a ramp takeoff the relative velocity is large and positive, the gate closes, the arc stays ballistic. Over a downhill crest it is small, the gate stays open, and the rover tracks the slope with grip live. One threshold separates "the terrain is falling away from me" from "I jumped".
Determinism: a fixed step, a fixed world, recorded inputs
The substep count is fixed rather than adaptive, and that is not only about
stability. A fixed 60 Hz step with a fixed substep count, a world that is the
same on every load (the surface noise is seeded once with seedNoise(42), and
the rocks are placed from a per-stage seed), and every run's inputs recorded
once per tick together mean the same run replays bit-exact. The trig and
exponent functions the physics calls are its own, written with basic
arithmetic, because browsers' built-in versions don't always agree in the
last bit. That is the property the competitive layer rests on: replays
that are a re-run rather than a smoothed recording, and leaderboard times the
server checks by re-simulating the inputs instead of trusting. Any
adaptive-substep scheme would have made all of it approximate. The
determinism write-up covers the
replay pipeline properly; ghost racing is the player-facing
version.
The order of that compromise matters. Pick determinism first and let the model be simpler than you wanted, rather than building the model you wanted and finding out it cannot be replayed.
Where to try this
A physics model is worth what it feels like from the driver's seat. Brake late into a corner and feel the friction circle split one budget between stopping and turning. Get the rover sideways and feel the peak fall away.
- Stage 3, "The Opener" is 1,164 metres of timed line and the fairest place to feel the grip budget.
- Play in the browser — no download, no install, no account.
- Building the game with no build step covers the ES-module architecture these files live in.
- The damage model explains the
tireMulandgripDmgFactorterms multiplying intomuEff.