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

TypeScript
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

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

  • ratio is 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.
  • steer without a feedback wheel is the road wheels' angle as a fraction of steering.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. With steering.feedback it 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:

TypeScript
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

TypeScript
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

TypeScript
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

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

TypeScript
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

TypeScript
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, live
Note

change 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:

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

Presets 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:

TypeScript
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

TypeScript
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 explosion

reset() 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.

TypeScript
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:

TypeScript
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

TypeScript
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
TypeScript
// 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).

See also

  • Physics — Shape, surface friction, contact events
  • Systems — where a driver, a gearbox and the wheel art live
  • Input — keys, gamepad axes for throttle and steer