Vehicle
A drivable car in a 3D scene — a chassis on wheels with suspension, tires, an engine and steering,
simulated by the physics engine (Jolt): Vehicle on the chassis node, a Wheel on each wheel's
node. A walking player is a CharacterController and a crate a plain Physics body
(physics.md).
- make a drivable car with wheels and suspension
- drive a car with the keyboard: throttle, steering, brakes and a handbrake
- shift gears: an automatic or a manual gearbox
- tune the engine, the tire grip, downforce and body roll
- make a car drift
- spin and steer the visible wheel models
- flip a car back onto its wheels or respawn it
- read the speed, rpm and drift angle for a HUD or tire marks
Platforms: iOS and Android (the full engine variant), desktop, the web player and the le.codes viewer app; not on the web (Web Lite) or in headless tests — check Vehicle.supported, and car.vehicle.id (0 = the host refused the car).
At a glance
Physics.configure({ gravity: [0, -9.81, 0] })
const scene = new Scene()
const road = Material.lit({ color: '#2b3550' })
const paint = Material.lit({ color: '#c85a3c' })
scene.add(Mesh.box({ material: road, size: [40, 1, 400], position: [0, -0.5, -100] })
.aspect(Shape, {})
.aspect(Physics, { motion: 'static', friction: 1 })) // tarmac: the tires read the GROUND's friction
const R = 0.35 // wheel radius, m
const hub = (x: number, z: number) => {
const n = new Node()
n.position = [x, R - 0.6, z] // where the wheel's centre rests on flat ground
return n.aspect(Wheel, { radius: R, width: 0.25 })
}
const chassis = new Node()
chassis.position = [0, 0.6, 0]
chassis.add(
Mesh.box({ material: paint, size: [1.8, 0.6, 4.2], position: [0, 0.05, 0] }),
hub(-0.8, -1.4), hub(0.8, -1.4), hub(-0.8, 1.4), hub(0.8, 1.4), // forward is −Z: the front axle
)
const car = chassis.aspect(Shape, { box: [0.9, 0.4, 2.1] }).aspect(Vehicle, { mass: 1200 })
scene.add(car)
class Driver extends System<'driver'> {
updateBefore() { // EARLY: this frame's physics step drives on it
const v = car.vehicle
const fwd = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
v.throttle = Math.abs(fwd) // 0..1, no sign
v.ratio = fwd > 0 ? 2.66 : fwd < 0 ? -2.9 : 0 // a gear: its SIGN is the direction, 0 = neutral
v.steer = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
for (const w of v.wheels) w.brake = Input.key('Space') && !w.steer ? 4500 : 0 // N·m: a handbrake
}
}
scene.system(Driver)
scene.open()That is the whole minimum. Vehicle finds every Wheel under the chassis, groups them into axles by
Z (front first), and the defaults fall out: the front axle steers, the rear one is driven.
Override any of it per wheel. The car has no visible wheels yet — the SDK poses nothing; see
spin and steer the wheel models.
The car is two aspects: Vehicle and Wheel
Each aspect goes on the node it describes:
| where | what it is | |
|---|---|---|
Vehicle |
the chassis node | the car as a whole: mass, engine, steering, aero — and the pedals |
Wheel |
each wheel's node | that wheel: size, suspension, tire, its brake — and its readouts |
The split follows the engine's own: Jolt keeps suspension and tire settings per wheel, so the SDK
does too. The chassis node needs a Shape (its collision body) and must not also
carry a Physics aspect — Vehicle owns its rigid body (attaching it next to one throws), the way
CharacterController owns its controller. Contacts still work: car.addEventListener('enter', …)
fires as for any body.
The SDK is the car, the driver is the game
The SDK is the car as hardware: inputs (throttle, steer, steerForce, ratio, clutch, a
brake per wheel) and sensors (rpm, speed, yawRate, driftAngle, per-wheel slip, load,
forces…). Everything a driver decides is the game's — which gear, when the clutch bites, whether
the engine may stall, how the steering tapers with speed, ABS, the handbrake, posing the wheel
models — each a few lines over those readouts. There is no gearbox in the SDK:
the gearbox is the game's.
Which way the car faces and where the wheels go
Chassis space is forward −Z, up +Y — the SDK's own convention, the one node.forward reports.
If the model faces the other way, rotate the model (a child node), never the chassis node.
A wheel node's position is where its centre rests on flat ground. Put it below the chassis origin: a wheel authored above it hangs in the air, and the car then revs against nothing. The SDK never moves the wheel node itself — where the wheel is right now is a readout.
Physics owns the chassis transform. Drive it through the inputs, not with per-frame position
writes. A position write does place the car (it reaches the chassis body like any physics node),
but it keeps the car's velocity and its roll — to put a car back on the road use
reset(p).
Drive it: throttle, steering and brakes
car.vehicle.throttle = 1 // 0..1 — the engine's pedal; no sign
car.vehicle.ratio = 2.66 // the ONE gear ratio the engine runs (default 1): negative = reverse, 0 = neutral
car.vehicle.clutch = 1 // 0..1 engagement (default 1); 0 = the engine is free of the wheels
car.vehicle.steer = -0.5 // −1..1, right-positive
car.vehicle.steerForce = 1 // 0..1 — how firmly the hands hold a `feedback` wheel (default 1)
wheel.brake = 1500 // N·m of brake torque on THIS wheel (default 0)Write them from a system's updateBefore: the vehicle hands them to the engine in the same early
pass (it ticks after the default order), so this frame's physics step drives on them. They are
sticky — a value keeps applying until you write it again — and a non-zero throttle, steer or
brake keeps the car awake (an idle car with no input sleeps). throttle, steer, steerForce and
clutch are clamped to their ranges; a negative brake counts as 0.
ratiois the whole drivetrain's choice: engine rpm = wheel rpm ×ratio× 3.42 (the final drive). Its sign is the direction, so reverse is just a negative gear.steerwithout a feedback wheel is the road wheels' angle as a fraction ofsteering.maxAngle, applied as is — a taper with speed or a rate limit (a keyboard asks for full lock in one frame) is the game's. Withsteering.feedbackit is where the driver's hands aim the wheel.- A brake is per wheel: a foot brake is a torque on every wheel, a handbrake is the rear pair, an ABS pulses them. A braking wheel is clamped at the physical tire limit.
Gear shifting: the gearbox is the game's
Picking a gear is policy, not physics — a few lines over rpm and ratio. A five-speed automatic:
const RATIOS = [2.66, 1.78, 1.3, 1.0, 0.74]
const REDLINE = 6000 // = engine.maxRpm
class Automatic extends System<'automatic'> {
static after = [Driver] // overrides the Driver's forward ratio
gear = 0 // 0 neutral, 1… forward
private wait = 0
updateBefore(dt: number) {
const v = car.vehicle
this.wait = Math.max(0, this.wait - dt)
if (this.gear === 0 && v.throttle > 0) this.gear = 1
if (this.gear > 0 && this.wait === 0) {
if (v.rpm > REDLINE * 0.67 && this.gear < RATIOS.length) { this.gear++; this.wait = 0.5 }
else if (v.rpm < REDLINE * 0.33 && this.gear > 1) { this.gear--; this.wait = 0.5 }
}
if (v.ratio > 0) v.ratio = this.gear === 0 ? 0 : RATIOS[this.gear - 1]
}
}
scene.system(Automatic)A sequential manual is a key that bumps the gear; a clutch envelope during a shift is clutch
ramped from 0 back to 1. Neither exists in the SDK on purpose.
The car as a whole: Vehicle options
node.aspect(Vehicle, {
mass?: number // kg, default 1200. Wheels are massless in this model
centerOfMass?: number // height in chassis space, m; default: halfway down the shape
driveAxle?: 'rear' | 'front' | 'all' | 'none' // default 'rear'; 'none' = a trailer, a pushed cart
differential?: 'lsd' | 'open' | 'locked' | number // default 'lsd'
antiRoll?: boolean | number // default true (0.01 of the wheel spring)
maxTilt?: number // degrees before the constraint rights the car; default 60 (180 = off)
friction?: number // the CHASSIS body's friction — not the tires; default 0.6
clutchStrength?: number // default 10
clutchCapacity?: number // N·m before the clutch slips; default 0 = no limit
engine?: { torque?, maxRpm?, idleRpm?, curve?, inertia?, braking? }
steering?: { maxAngle?, caster?, trail?, trailAtPeak?, feedback?, driver? }
aero?: { downforce?, drag? }
})Engine power and torque curve: engine
engine: { torque: 600, maxRpm: 7000, curve: [{ x: 0, y: 0.8 }, { x: 0.66, y: 1 }, { x: 1, y: 0.8 }] }torque (N·m, default 500) is the single biggest "how fast is it" knob and maxRpm (default 6000)
the redline — the engine's own clamp; a rev limiter's stutter is the game's. curve is what makes
engines feel different: points { x, y } in normalised units — x a fraction of the redline, y a
fraction of torque, x ascending. The example above is an ordinary petrol engine, peaking two thirds
up and falling away to the redline; [{ x: 0, y: 1 }, { x: 1, y: 1 }] is flat, which is what an
electric motor feels like. Left out, Jolt's own broad curve is used.
inertia (kg·m², default 0.5) is how eagerly it revs with the clutch out, braking (default 0.2) is
engine braking, and idleRpm (default 1000) the floor it never falls below. idleRpm: 0 removes the
floor: the engine can then be dragged to a standstill — a stall — and holding idle (a governor over
throttle) or cranking it is the game's.
Clutch slip and stalling: clutchStrength, clutchCapacity
clutchStrength (default 10) is how hard the fully engaged clutch couples engine and wheels; the
clutch input scales it 0..1. Small values slip, large ones weld. With clutchCapacity 0 the clutch
has no torque ceiling. Set it (a road car's is ~1.5× the engine's peak torque) and the clutch
slips at that many N·m × clutch — a dry clutch: a launch can be feathered, and a clutch dumped in
too high a gear drags the engine down instead of lurching the car. With idleRpm: 0 that drag is a
stall.
Differential and drifting: differential
lsd is limited slip (Jolt's 1.4 ratio — the sane default). open lets a lifted or spinning wheel
take all the torque. locked forces both wheels of an axle to the same speed, so the pair breaks
traction together instead of one at a time — that is the drift setup, and switching to it mid-drive
is one line (see retuning). A number is the raw max/min wheel-speed ratio
(> 1; smaller is more locked).
Downforce and top speed: aero
aero.downforce keeps a fast car turning; aero.drag gives it a top speed. Both numbers are quoted the same way — a fraction of the car's own weight at 30 m/s (108 km/h) —
and both grow with the square of speed from there, so 0.5 at 30 m/s is 2.0 at 60 m/s. One
mental model for both, and no drag coefficients or frontal areas to invent.
Tire grip is a constant, but the force a corner demands grows with v². With no downforce (the
default, 0) every car therefore runs out of road at speed — which is exactly what "it won't turn at
100 km/h" is. Downforce grows with v² too, so the two track each other: 0.6 roughly doubles a car's
cornering at 150 km/h. A road car is nearer 0.1, a race car well past 1. It presses along the
chassis' own down axis (that is what keeps a car planted over a crest) and only while a wheel is
on the ground — a car on its roof is never launched by its own wing.
drag (default 0.03) is what gives a car a top speed at all.
Body roll in corners: antiRoll
true (default) is a bar 0.01 as stiff as the wheel's own spring; a number is that ratio directly,
0 / false removes the bars. A ratio rather than N/m, so it keeps meaning the same thing when you
change mass or a wheel's suspension.stiffness.
These numbers are far smaller than they look, and the gain is steep. Jolt's bar is not a spring
in parallel with the suspension: it feeds lengthDifference × stiffness × dt as a bias into the
suspension's own soft constraint, so a ratio that reads as "1% of the spring" already matters.
Measured on a 1250 kg car, same corner:
| ratio | roll at 0.86 g | roll at ~1.5 g |
|---|---|---|
0 |
1.3° | 2.5° |
0.01 (true) |
0.6° | — |
0.04 |
— | 0.4° |
0.15 |
— | 0.2° — the body stays flat |
0.5 |
the car HOPS: the bar switches off at wheel lift and acts as a relay |
So this is the knob for how much a car visibly leans. It is not the knob for wheel lift: bars do
not change how much load transfers between the sides, only how far the body tips while it happens.
For lift, lower centerOfMass.
Steering lock and force feedback: steering
steering: { maxAngle: 35 } // default: steer = ±1 is ±35°
steering: { maxAngle: 35, feedback: true } // a steering wheel the tires push back on
steering: { feedback: { powerSteering: 0.3 }, driver: { maxTorque: 90 } }maxAngle (degrees, default 35) is what steer = ±1 means. The front axle's geometry — caster
(the mechanical trail, m, default 0.03), trail (the tire's pneumatic trail, m, default 0.025) and
trailAtPeak (what is left of it at the tire's peak slip angle, default 0.5) — is the lever of the
tires' self-aligning torque, which steerTorque reports (N·m, positive pulling the wheel to the
right): heavy at speed, light when the front lets go. It is there with or without a feedback wheel —
the signal a force-feedback steering wheel is driven by.
feedback makes the steering wheel a body the engine integrates every sub-step. Then steer is
where the driver's hands aim it and steerForce how firmly they hold it; where the road wheels end
up is physics — at speed a held key settles short of the lock, where the hands' torque balances the
tires' pull; let go (steerForce = 0) and the wheel centres itself on a grip corner, or swings into
opposite lock in a slide. feedback: true takes the defaults; an object tunes the column:
feedback |
default | |
|---|---|---|
inertia |
1 | kg·m² — wheel + column + rack |
damping |
25 | N·m·s/rad — keeps a let-go wheel from ringing about centre |
friction |
8 | N·m — the column's own dry friction |
parkingFriction |
40 | N·m while (nearly) parked, fading out by 1.5 m/s — why a parked car is hard to steer |
powerSteering |
0.5 | 0..1 — the share of the aligning torque the wheel never sees (1 = a dead rim) |
endStop |
{ band: 16, torque: 120, damping: 150 } |
rubber before the lock: degrees, N·m at the lock, N·m·s/rad × depth |
driver is the hands on a feedback wheel: maxTorque (N·m, default 130 — the ceiling on how much lock
a held key keeps against the tires), stiffness (N·m/rad, default 500), maxRate (deg/s the hands
can turn the road wheels, default 120) and rampTime (s for the hands' torque to build, default 0.1).
What the hands do — aim, hold, let go — is the game's, per frame, through steer and steerForce.
One wheel: Wheel options
An aspect on the wheel's own node. Its position is the wheel's resting position; everything else about that wheel lives here, which is where Jolt keeps it too.
hub.aspect(Wheel, {
radius?: number // m; default: measured from the node's rendered bounds, else 0.35
width?: number // m; default: measured, else 0.25
steer?: boolean // default: the front axle
driven?: number // this wheel's SHARE of the engine torque; default 1 on the driveAxle, else 0
axle?: number // axle group, front to back; default: grouped by Z
suspension?: { travel?, stiffness?, damping? } // m, Hz, ratio; default 0.4, 1.4, 0.5
tire?: Tire // default Tire.road()
})
hub.wheel.brake = 4500 // the input: N·m this frame (see Inputs)driven is a share, not a flag: an axle's share is the sum of its two wheels, the left/right split
their ratio — 0.6 / 0.4 front-to-rear is driven: 0.3 on each front wheel and 0.2 on each rear.
axle pairs the wheels for the differential and the anti-roll bar; set it when the wheels are not in
neat left/right pairs (wheels within 0.35 m of each other in Z share an axle by default).
Tire grip: Tire presets and curves
new Node().aspect(Wheel, { tire: Tire.drift() }) // a tire that can be held sideways
for (const w of car.vehicle.wheels) w.node.aspect(Wheel, { tire: Tire.scale(Tire.road(), 0.7) }) // a wet road, livechange a live car's tire by re-configuring the wheel's aspect, as above — the car
retunes before its next step. Assigning wheel.tire directly does not reach the engine until
something else retunes the car.
A Tire is numbers all the way down, the way the engine's torque curve is:
interface Tire {
side: Point[] // lateral friction over slip ANGLE: { x: degrees, y: friction }, x ascending
forward: Point[] // longitudinal friction over slip RATIO: { x: ratio, y: friction }
traction?: number // the DRIVE impulse clamp as a multiple of friction × load (1 = the physical tire)
circle?: number // 0..1 friction circle: how much lateral grip a spinning / locked wheel loses
}
Tire.road() // Jolt's stock tire: lateral peak 1.2 at 3°, 1.0 from 20°; traction 10, circle 0 — glued
Tire.arcade() // the peak held all the way out on both axes: the car does not let go
Tire.drift() // lateral grip falls to 0.7 past 25°, traction 2, circle 0.85 — power oversteer
Tire.scale(t, k) // both curves × k — a grip slider, a cold tire; a new Tire, t untouched
resolveTire(t) // what the engine gets: empty curves and a missing traction take road's, circle clamped 0..1Presets are fresh lists every call — copy one and edit it. road's traction of 10 (Jolt's own sample
value) means a driven wheel practically never spins up; anything a friction circle should bite on
wants traction near 1–2. What a wheel actually gets is combined with the ground it stands on as
sqrt(tire × groundFriction), and a body's friction defaults to 0.6 — so a road a car should
really corner on wants Physics { friction: 1 }.
Suspension travel and stiffness: suspension
The wheel's resting position is where the spring carries the car's weight, and that sag is
g / (2πf)² — it depends on stiffness alone, not on travel. Sag is also exactly how far the
wheel can still droop. So raising travel on its own only buys bump (compression) headroom; to
let the wheels reach further down — into dips, off crests, on landing — soften the spring as
well. At the defaults (travel: 0.4, stiffness: 1.4, damping: 0.5) that is 12.7 cm of droop and
11.3 cm of bump.
Spin and steer the wheel models
The SDK poses nothing: a wheel's position (chassis space, down the suspension), steerAngle and
spin are what a wheel model is built from — in a late phase, after the step. Put each model on a
node that is a sibling of its hub under the chassis:
const DEG2RAD = Math.PI / 180
const tire = Material.lit({ color: '#20242e' })
const art = new Map<Wheel, Node>()
for (const w of car.vehicle.wheels) {
const disc = Mesh.cylinder({ material: tire, radius: R })
disc.scale = [1, 0.25, 1]
disc.eulerAngles = [0, 0, 90] // the cylinder's axis is +Y; the axle is +X
const holder = new Node()
holder.add(disc)
chassis.add(holder)
art.set(w, holder)
}
class WheelArt extends System<'wheelArt'> {
private angle = new Map<Wheel, number>()
update(dt: number) { // LATE: the settled readouts
for (const w of car.vehicle.wheels) {
const a = ((this.angle.get(w) ?? 0) + w.spin * dt) % (2 * Math.PI)
this.angle.set(w, a)
const n = art.get(w)!
n.position = w.position
n.quaternion = Quat.fromAxisAngle([0, 1, 0], -w.steerAngle * DEG2RAD).mul(Quat.fromAxisAngle([1, 0, 0], -a))
}
}
}
scene.system(WheelArt)Both minus signs are the engine reporting the driver's terms — steer right-positive, spin forward-positive — in a right-handed scene whose forward is −Z. A brake caliper is the same without the spin.
Flip back upright or respawn: reset, applyImpulse
car.vehicle.reset(position?, rotation?): this // teleport upright, clear all motion
car.vehicle.applyImpulse(v): this // kg·m/s on the chassis — a boost pad, an explosionreset() with no arguments un-flips the car where it lies (it keeps the heading, drops the roll and
pitch); a position without a rotation keeps the heading too. It is the only thing that clears the
velocities, engine RPM and wheel spin — a plain position write does not. The inputs are kept and
re-sent on the next early pass.
Input.on('keydown', (e) => {
if (e.code === 'KeyR') car.vehicle.reset() // flipped: back on its wheels where it lies
if (e.code === 'KeyT') car.vehicle.reset([0, 0.6, 0], [0, 0, 0, 1]) // respawn at the start, facing −Z
})Retuning a live car
Re-configuring an attached aspect retunes it in place, which is how a drift button works:
car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })Live: differential, engine, steering, aero, maxTilt, friction, clutchStrength,
clutchCapacity, antiRoll (its stiffness, on axles that have a bar), and every wheel's tire
(re-configure that wheel: w.node.aspect(Wheel, { tire })).
Structural — baked into the constraint at attach: mass, centerOfMass, which wheels exist and
where, their suspension, the driven shares. Changing one of those means re-attaching the aspect;
a new mass on a live car warns and keeps the built one.
Speed, rpm, slip and drift readouts
car.vehicle.speed // m/s along the car's forward axis, negative in reverse (× 3.6 for km/h)
car.vehicle.rpm
car.vehicle.wheelsOnGround / .grounded
car.vehicle.velocity // Vec3, world, m/s
car.vehicle.angularVelocity // Vec3, world, RADIANS/s
car.vehicle.yawRate // deg/s about the vertical, positive turning RIGHT (the same sense as steer)
car.vehicle.slipAngle // deg between the nose and the body's travel, + travelling right of it
car.vehicle.driftAngle // deg of the SLIDE: the rear axle's travel against the drivetrain's axis
car.vehicle.steerAngle // deg, where the steered wheels actually are, right-positive
car.vehicle.steerTorque // N·m, the tires' aligning torque, + pulls right
car.vehicle.wheels // the Wheel aspects, front axle first
car.vehicle.id / .bodyId // native vehicle / chassis body ids (0 = not attached)The inputs read back too (throttle, steer, ratio, …). driftAngle is 0 on a clean corner at
any speed — unlike slipAngle, whose body travels ~20° off its nose at walking pace and full lock —
reads past 90° for a car spun round in a forward gear, and fades in over the first 1–2 m/s: it is the
number a gyro assist or a drift meter wants.
Per wheel, on the wheel:
contact |
touching the ground this step |
slip |
longitudinal slip ratio, signed: 0 rolling in sync, towards +1 spinning up, towards −1 locking |
slipAngle |
degrees, signed: the patch sliding to the tire's right is positive. Past ~3° it is letting go |
steerAngle |
degrees, positive to the right |
spin |
rad/s, forward-positive (× radius ≈ the speed the tire is laying down) |
load |
N — the suspension's force on the wheel (0 in the air) |
sideForce, forwardForce |
N — the tire's forces this step, + to its right / + pushing the car forward |
sideSlipSpeed, slipSpeed |
m/s — how fast the patch slides sideways / the tread runs over the road |
compression |
0 fully extended … 1 fully compressed |
position |
Vec3, chassis space — where the wheel's centre is right now |
// where drift smoke or tire marks go: the sliding wheels' centres, in world space
const marks: Vec3[] = []
for (const w of car.vehicle.wheels) {
if (w.contact && Math.abs(w.slipAngle) > 8) marks.push(car.worldMatrix.transformPoint(w.position))
}Where a car runs: Vehicle.supported and id
Everything here needs 3D physics with the vehicle model — guard with Vehicle.supported (false,
never a throw, without physics). Without it the aspect attaches, id stays 0 and every call is a
no-op. The web (Web Lite) and the headless renderer report it but refuse the car: the SDK warns
"the engine refused the settings", id stays 0 and the car never moves. Test a car with
lecodes render --sim or --desktop
(physics.md).
Motorcycles and tanks: not available
Motorcycles and tracked vehicles (Jolt has both controllers), and a drivetrain with no engine at all (an arcade "throttle → force" model).