A Per-Wheel Tire Model in JavaScript, Running at 480 Hz
Mars Rally Championship, a free browser rally game, solves tire forces at each of its four wheels separately, eight times per rendered frame — 480 Hz internal at 60 fps. 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.
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.
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 — longitudinal force resolves first, lateral force takes what is left inside the circle:
const Fmax = muEff * F_eff; // per-wheel limit
const Fy_budget = Math.sqrt(Math.max(0, Fmax * Fmax - Fx * Fx));
const Fy_desired = Fmax * peakY;
const Fy = (sy === 0) ? 0 : -Math.sign(sy) * Math.min(Fy_desired, Fy_budget);
Longitudinal priority is what real tires do, 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.
Two margins stop the budget being spent by accident. ABS caps service-brake
force at BRAKE_ABS_FRACTION = 0.90 of the circle, leaving √(1 − 0.9²) ≈ 44%
of grip for steering under a fully held pedal. Drive torque is capped at 90% of
grip by DRIVE_CAP_MARGIN, for the same reason in the other direction: full
throttle mid-slide must not zero out lateral grip, or the slide can never be
caught.
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, braking compresses the front springs and throttle squats the rears, so
load transfer is not a term anyone wrote — it is what the springs do. 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 total grip budget is µ·g ≈ 2.6 m/s², which is what makes
braking from 100 km/h take about 165 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 about three times more visible than it would be under 9.81 m/s², because the grip you are making mistakes with is a third of 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 grip ceiling; µ·g ≈ 2.6 m/s² total. 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 | 0.90 | fraction of circle | Grip reserved for steering while braking. At 1.0 the wheels lock and you steer nothing |
DRIVE_CAP_MARGIN | 0.90 | fraction of circle | Lateral headroom under power. At 1.0 a full-throttle slide can never be caught |
N_PHYSICS_SUBSTEPS | 8 | per frame | 480 Hz at 60 fps. 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 frame (60Hz × 8 =
// 480Hz internal). 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 at low FPS.
Explicit Euler on a damping term is stable while λ·dt < 2. Four substeps at 60
fps 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 — so a player on a 20 fps laptop gets a slow, ugly rover instead of an
exploding one. Frame delta is clamped at 50 ms, flooring the worst case at 160
Hz internal.
For anyone worried about cost: 8 substeps × 4 wheels × two passes is 64 wheel evaluations per frame, 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: fixed substeps, seeded noise, recorded inputs
The substep count is fixed rather than adaptive, and that is not only about
stability. A fixed substep count, a noise field seeded once with
seedNoise(42), and recorded player inputs together mean the same run replays
bit-exact. That is the property the competitive layer rests on: ghosts that are
a re-run rather than a smoothed recording, and leaderboard times a server can
check by re-simulating 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 refuse both jobs at once. Get the rover sideways and feel the peak fall away.
- Stage 1, "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.