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

Mars Rally Championship in-game screenshot: the rover cornering hard on a Mars stage, body rolled toward the outside wheels, whose suspension is visibly compressed.

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: the lateral tire force curve, 2s over 1 plus s squared, against slip angle in radians, peaking at 0.18 rad and held flat at about 0.92 beyond the clamp, with the unclamped curve dashed as it keeps falling.

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: two top-down views of the rover braking into a left turn, a friction circle around each wheel sized by its load and an arrow for each tire's force; with the handbrake the rear arrows point straight back and the tail swings out.

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

ParameterValueUnitWhat changes if you move it
MU_BASE0.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_ANGLE0.18rad (~10°)Where lateral grip peaks. Lower is twitchier turn-in; higher is a wider forgiving window
PEAK_SLIP_RATIO0.15–Longitudinal peak for the wheel-speed version; unused by the shipped direct-force path
CURVE_POST_PEAK_CLAMP1.5–How far past the peak the curve is evaluated. Lower it and heavy wheelspin strands the car
LOAD_SENSITIVITY0.05–How fast µ falls with load. Higher punishes weight transfer and rewards smooth hands
LOAD_REF_N2790NThe load at which µ = MU_BASE. Mis-set it and every corner is silently under- or over-gripped
BRAKE_ABS_FRACTION1.0fraction of circleShare 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_BRAKE0.8blendHow 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_MARGIN0.90fraction of tire's gripTraction control. Drive is capped below each tire's own limit, so full throttle keeps cornering grip on every surface
N_PHYSICS_SUBSTEPS8per 60 Hz tick480 Hz internal. At 4, stiff-suspension damping runs out of stability margin
SOFT_CONTACT_K30m/s² per mHow 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: a 50 millisecond timeline with three fixed 60 Hz physics ticks of 8 substeps each, above the frames of a 144 Hz monitor drawing between ticks and a 20 fps laptop running three ticks per frame.

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.

Questions, answered


What is a slip angle?

Slip angle is the angle between the direction a wheel is pointing and the direction it is actually travelling. It is what generates cornering force: a tire makes almost none at zero slip angle, rises steeply as slip grows, peaks at a few degrees, then falls away. In this game the peak sits at 0.18 radians, about 10 degrees.

Do you need Pacejka's Magic Formula for a game?

No. The Magic Formula is a curve-fitting tool for real tire test data, with coefficients you probably do not have. A game needs the right shape, not the right coefficients: something that rises to a peak at a chosen slip value and falls off after it. This game uses a simple rational curve, and the difference from a fitted formula is far smaller than the difference from a grip multiplier.

How fast does vehicle physics need to run?

Faster than your frame rate, if the suspension is stiff. This game runs eight physics substeps per 60 Hz tick, 480 Hz, because explicit integration of a stiff spring-damper goes unstable once the damping rate times the timestep exceeds about 2. If your car bounces higher after a landing than the height it fell from, that is the symptom.

Why do deterministic physics matter for a racing game?

Because a deterministic simulation lets the same inputs reproduce the same run exactly. That makes a replay a genuine re-run rather than a smoothed recording of positions, and it lets a leaderboard time be verified by re-simulating the inputs instead of trusting whatever the client reported, which is how this game's board works. A fixed step, a seeded world and maths that every browser computes the same way are what buy that property.

Does the tire model change on different surfaces?

Yes. Each wheel carries its own surface grip multiplier feeding the same effective friction coefficient as tire wear and suspension damage. Hardpack multiplies base grip by 1.15 and cracked polygon ground by 0.75, so the working friction coefficient spans roughly 0.53 to 0.81 rather than sitting at the 0.70 base value.

Keep reading


LOG · September 6, 2026Tech & Engineering

Simulating Vehicle Damage in a Browser Game Without Killing the Frame Rate

A vehicle damage simulation a game can afford in a browser tab: four HP pools, an energy-based impact curve, and damage wired into handling.

Read the dispatch
LOG · October 14, 2026Browser Gaming

Best Racing Games With Leaderboards You Can Trust

Racing games with leaderboards, judged on trust: who checks a run before it posts, whether you can watch it or race its ghost, and if you need an account.

Read the dispatch
LOG · October 14, 2026Browser Gaming

Best Drift Simulator Games: Realistic Physics Compared

The best drifting simulator games compared on tire model, surfaces, assists, wheel support and scoring, from Assetto Corsa to free browser options.

Read the dispatch