Talos docs

Wheels and vehicles

Since 0.6

Two components.

Plain text

Two components. wheel, on a child entity of a dynamic body, is one raycast wheel: a spring, a damper and a tyre pushing that body. vehicle, on the body, composes the wheels under it into a drivable car (or van, or kart) that the input actions drive with no script. A wheel with no vehicle over it is driven by a behaviour through ctx.wheel — a cart, a trailer, a robot on wheels. This file is the one place their units, axes and sign conventions are written down; if a comment elsewhere disagrees with it, this file is right.


0. A car from primitives

A box for the body with the vehicle component on it, and four child entities, each a cylinder mesh with a wheel component:

{ "id": "car", "name": "car", "tags": ["player"], "transform": { "position": [0, 0.05, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
  "components": [
    { "type": "mesh", "asset": "cube" },
    { "type": "collider", "shape": "box", "size": [1.8, 1.45, 4.7], "center": [0, 0.87, 0] },
    { "type": "rigidbody", "kind": "dynamic" },
    { "type": "vehicle", "preset": "sedan" }
  ] },
{ "id": "car-wheel-fl", "name": "car-wheel-fl", "tags": [], "parent": "car",
  "transform": { "position": [-0.775, 0.32, -1.4], "rotation": [0, 0, 0.7071, 0.7071], "scale": [0.64, 0.22, 0.64] },
  "components": [{ "type": "mesh", "asset": "cylinder" }, { "type": "wheel" }] }

(and the same for car-wheel-fr at [0.775, 0.32, -1.4], car-wheel-rl and car-wheel-rr at z = 1.4), with "cube" and "cylinder" declared in assets as { "kind": "glb", "path": "builtin:cube" } and builtin:cylinder. The built-in cylinder is 0.5 m in radius and 1 m tall along Y, so a wheel of radius 0.32 and width 0.22 is scale [0.64, 0.22, 0.64] turned 90° about Z. What that gets you:

  • The wheel entity is the wheel. Its position is where the wheel's centre sits at rest, and the way its -Z points (after its rotation) is the way it rolls; its own scale is only its mesh's. The vehicle finds every wheel below its body, in scene order; list them in the vehicle's wheels only to choose an order or a subset.
  • It drives with no script. Every tick the engine reads the input actions throttle, brake, steer and handbrake — from the keyboard, a pad, or POST /input — so declare them in meta.input: throttle and brake as buttons (or axis1), steer as one axis1 (["A", "D", "Left", "Right"]). steer +1 (D) turns right. brake held at a standstill reverses. An action the active context does not bind reads 0 — while a menu owns input, the car sits still.
  • The wheel entities are drawn where the simulation has the wheels: moved with the suspension, turned by the steering and spun about their own axle as the car rolls, on top of the rotation you gave them. A wheel modelled in parts beside an empty mount names them in its visuals (§2).
  • The preset sets everything else — mass, the wheels' size, springs and roles, engine, brakes, tyres, steering lock. sedan, suv, pickup, van (§3). The preset's wheel on the same corner fills each wheel (§3); a field on the wheel component wins. The box above is the one the sedan preset was tuned against. Override any vehicle field: "steering": { "maxAngle": 0.5 }, "chassis": { "mass": 1300 }.
  • The chassis mass is the vehicle's, whatever the rigidbody's mass says: the springs and the drive are tuned against it.
  • It is the player if the scene names no spawn { for: "player" } and has no controller: device input and an entity-less POST /input go to it.
  • A chase camera is the engine's own third-person rig, from the game's behaviour: ctx.camera.follow(ctx.entity) and ctx.camera.set("third-person", { distance: 7, pitch: -0.25, eyeHeight: 1.2, recenterDelay: 0, lookAhead: 0.3 }). recenterDelay: 0 swings the view back behind the car as it turns.
  • A behaviour reads it through ctx.vehicle (speed, steer, gear, wheelsOnGround; §6), and a tester through GET /entities?fields=vehicle (§7).

To drive it from a script instead — an AI car, a cutscene — set "input": "script" and call ctx.vehicle.drive({ throttle, brake, steer }) (§6).

A cart from bare wheels

Leave the vehicle off and give each wheel a radius: the wheels hold the body up on their springs, and a behaviour drives them.

{ "id": "cart", "name": "cart", "tags": [], "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
  "components": [
    { "type": "collider", "shape": "box", "size": [1, 0.3, 1.6], "center": [0, 0.5, 0] },
    { "type": "rigidbody", "kind": "dynamic", "mass": 200 },
    { "type": "script", "module": "cart" }
  ] },
{ "id": "front-left", "name": "front-left", "tags": [], "parent": "cart",
  "transform": { "position": [-0.5, 0.25, -0.6], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
  "components": [{ "type": "wheel", "radius": 0.25 }] }
update(ctx) {
  for (const w of ctx.body!.wheels) {
    w.setMotorTorque(40)                                       // N·m, held until the next call
    if (w.entity.id.startsWith("front-")) w.setSteer(0.1)      // radians, positive right
  }
}

A trailer is the same thing on a joint to the car that pulls it.


1. Units, axes and signs

SI throughout. Metres, kilograms, seconds, newtons, newton-metres, radians, radians per second. No degrees, no millimetres, no pounds.

The two exceptions are both at a boundary, and both are deliberate:

  • Engine speed is rpm where a person or an agent writes or reads it — the torque curve, idleRpm, redlineRpm, telemetry. Inside the solver it is rad/s. Nothing in between.
  • Slip angle is radians, not degrees, even though degrees are what tyre data sheets use. peakSlipAngle: 0.15 is about 8.6°.

Axes. Right-handed, Y up, -Z forward, +X right: the engine's own convention (docs/CONVENTIONS.md), the way a camera, a controller and lookAt all face. A car's front wheels are at negative z.

Signs.

QuantityPositive means
steer input action (meta.input, POST /input, ctx.vehicle.drive)Right — the D key, the way a stick reads
steer angle as reported (ctx.vehicle.steerAngle, GET /entities steerAngle)Right — the action's sign
steer angle inside the model (argus-vehicles state and telemetry types)Left — a positive rotation about +Y
speedForward (-Z); negative while reversing
throttleForward acceleration
brake, handbrakeUnsigned 0..1; there is no negative brake
Slip ratioUnder drive. Negative under braking
reverseGearA ratio, always positive. The direction is the solver's, not a sign on the field

The one that gets got wrong twice

A wheel entity's position is the wheel's CENTRE AT REST — the suspension sitting at restLength — composed through any entities between it and its body (an axle entity is fine), in the body's frame, in metres, before the body's scale, exactly like a collider's center.

It is not the suspension's attachment point. The solver derives that itself as position + up * suspension.restLength. This spelling was chosen because it is what a model file hands you (the wheel node's translation) and what an author sees in a DCC tool.

A wheel rolls along its composed -Z, flattened onto the body's floor. A cylinder turned 90° about Z to lie on its side still rolls along -Z; a wheel turned about Y rolls that way, and its steering adds to that.

Where the origin sits

Every preset is written assuming the entity origin is at ground level — a base-centred asset, which is what the importer produces with baseCenter: true. Two useful consequences follow, and the validation rules lean on both:

  • A wheel's position.y is exactly its radius.
  • The chassis collider's own min.y is the vehicle's ground clearance.

You do not have to author this way. If you do not, the geometric rules still work — they compare wheels against the collider, not against zero — but the numbers stop being readable at a glance.


2. The component

{
  "type": "vehicle",
  "preset": "suv",
  "chassis": { "mass": 2400 }
}

Name a preset and override what differs, or write every part out. A component with neither is vehicle-invalid.

Two fields are about the game rather than the car, and one is about which wheels:

FieldValuesNotes
inputauto | scriptDefault auto: the engine drives it from the actions throttle, brake, steer, handbrake every tick. script: a behaviour drives it with ctx.vehicle.drive, and the actions do nothing to it.
wheelsentity idsThe wheels it drives, in its order. Absent: every wheel below this body, in scene order. A listed id that is not a wheel below this body is vehicle-invalid (§5); a wheel below the body that the list leaves out is a standalone wheel that a behaviour drives.

The entity must also carry a rigidbody of kind dynamic and a non-trigger collider. The vehicle is that body; the collider is its bodywork, and it is what the geometric rules measure wheels against. Without them you get vehicle-needs-dynamic-body.

Every field is optional. That is forced rather than lax: a preset override has to be able to say chassis: { mass: 2400 } and nothing else, so a required centerOfMass would make that unspellable. Completeness is a rule on the resolved vehicle, not a shape requirement.

Full field-by-field descriptions with units live in packages/contract/src/schema.ts and are carried through to schema.json and the rustdoc on crates/contract/src/gen.rs, so an agent reads them wherever it is standing. What follows is the shape and the units, not a second copy of those descriptions.

chassis

FieldUnitNotes
masskg> 0. Plausible 400..8000. Includes occupants and cargo; they are not modelled separately. The body is built with this mass, whatever the rigidbody says.
centerOfMassmLocal, before scale, from the entity origin. The strongest single handling number in the component. Honoured by where the wheel forces are applied (§4): every wheel force turns the car about this point. The body's own centre, which a collision pushes on, is still its collider's.
inertiakg·m²[Ixx, Iyy, Izz] about the centre of mass — roll, yaw, pitch. Optional even after resolution. Not applied yet: the body's inertia comes from its collider.

The wheel component

On its own entity, below the body it carries. Every field is optional; what fills a missing one is, in order, the vehicle preset's wheel on the same corner (under a vehicle with a preset), then the default in the last column.

FieldUnitNotesDefault
radiusm> 0. Rolling radius of wheel and tyre, not the rim. Plausible 0.15..0.6.none: a standalone wheel must give one (wheel-invalid)
widthm> 0. Sizes the contact patch, and is the lateral margin the outside-chassis rule allows.0.6 × radius
suspension.*As below.rest 0.2, travel 0.12, 40,000 N/m, damping 2,500 / 4,000, max 40,000 N
tyreA tyres block (below) for this wheel alone, over the vehicle's.clamp, μ 1, slip 0.12 / 0.14 rad, rolling 0.014
inertiakg·m²> 0. Spin inertia, what a curve tyre spins up against.from radius and width
steerable, driven, braked, handbrakeUnder a vehicle, as below; ignored on a standalone wheel, which a behaviour drives directly.front (z < 0): steers, not driven; rear: driven, handbrake; all braked
visualsEntity ids drawn as part of this wheel besides its own entity (see below).none: only the wheel entity turns

The wheel's id is its entity's id: the telemetry key, and what wheels lists. Where it is (its centre at rest) and which way it rolls come from its transform (§1), never from a field.

A wheel drawn from more than one entity: visuals

The wheel entity is drawn turning, so the simplest wheel is a cylinder mesh on the wheel entity itself. A kart built from primitives, or one cut from a model, often has the wheel in parts beside an empty mount: a tyre, a hub cap on its outside face, a brake disc. Name those in the wheel's visuals:

{ "id": "wheel-fl", "parent": "kart", "transform": { "position": [-0.42, 0.14, -0.55], ... },
  "components": [{ "type": "wheel", "visuals": ["tyre-fl", "hub-fl"] }] },
{ "id": "tyre-fl", "parent": "kart", "transform": { "position": [-0.42, 0.14, -0.55], "rotation": [0, 0, 0.7071, 0.7071], ... },
  "components": [{ "type": "mesh", "asset": "cylinder" }] }

Every tick each visual takes the wheel's own motion: it drops and rises with the suspension, turns with the steering, and spins about the wheel's axle through the wheel's centre. That motion is applied on top of the pose the scene gave the visual, so its rotation and scale are kept. A hub cap 0.14 m outboard of the tyre stays on the turned axle, and a part off the axle orbits it.

  • By id, never by name. Nothing is matched from a name like wheel-fl or tyre. A prefab's ids are rewritten on expansion and on ctx.world.spawn({ prefab }) exactly as vehicle.wheels is (kart-1/tyre-fl).
  • What a visual may be (wheel-invalid otherwise, at visuals/<k>): an entity in the scene, below the same dynamic body as its wheel, without a rigidbody of its own. It may not be the wheel or anything above it. It may not be below the wheel either, because a child already turns with its parent and would be turned twice. And one entity turns with one wheel only.
  • Cost. A wheel with no visuals costs nothing extra. Each visual is one pose write per tick, and up to 32 poses per vehicle (wheels and visuals together) are written without allocating. Past that the rest are not drawn turning, which never affects the simulation.
  • A part that should steer but not spin, such as a mudguard or a caliper, is not covered yet; parent it to an unspinning mount of its own.

The suspension, the roles, and what each number means:

FieldUnitNotes
suspension.restLengthm> 0. Spring length unloaded.
suspension.travelm> 0. Maximum compression from rest. Checked against ground clearance.
suspension.stiffnessN/m> 0.
suspension.dampingCompressionN·s/m≥ 0. Normally the smaller of the two.
suspension.dampingRelaxationN·s/m≥ 0. Normally larger than compression.
suspension.maxForceN> 0. Ceiling on the spring's output; what stops a stiff spring launching the chassis at a 60 Hz tick.
steerable0..1Fraction of steer input. 1 front, 0 fixed, 0.25 a mild rear-steer axle.
driven0..1Fraction of drive torque. RWD is 0,0,1,1; 40/60 AWD is 0.2,0.2,0.3,0.3. Ratios matter, not the sum.
braked0..1Fraction of the axle's brake torque. Multiplies with brakes.bias.
handbrakeboolWhether handbrakeTorque reaches this wheel.

Fractions rather than flags is the point: rear-wheel drive, an AWD split and torque vectoring are all one field, and adding a sixth wheel needs no new concept — it is one more entity.

There is no assumption of four wheels, and a wheel's role comes from its fractions, not from its index.

drivetrain

FieldUnitNotes
engine.torqueCurve[rpm, N·m][]Non-empty, strictly increasing in rpm. Linearly interpolated, clamped outside the range.
engine.idleRpmrpm> 0, below the redline.
engine.redlineRpmrpmAbove idle.
engine.inertiakg·m²> 0. Crank and flywheel. Small is revvy, large is a diesel.
gearsratioFirst gear first, each > 0. Conventionally strictly descending — documented, not enforced.
reverseGearratio> 0.
finalDriveratio> 0. Typically 3..4.5.
differential—open | locked | lsd
shift—auto | manual
shiftUpRpmrpm> 0, not above redlineRpm. Defaults to 0.9 x redlineRpm. Shifting earlier is what makes a van or a diesel feel like one.
shiftDownRpmrpm> 0, not below idleRpm. Defaults to 1.6 x idleRpm.
shiftTimes>= 0. Seconds with no drive during a change. Defaults to 0.25; 0 is an instant shift.

Wheel torque is engine torque × gear × finalDrive.

brakes, tyres, aero, assists

FieldUnitNotes
brakes.maxTorqueFront / RearN·m≥ 0. Per wheel, not per axle.
brakes.handbrakeTorqueN·m≥ 0. Independent of the footbrake and of bias.
brakes.bias0..1Fraction to the front. 0.6 is a normal front-biased road car.
tyres.model—clamp | curve. Both run as the arcade tyre today (§4).
tyres.peakSlipRatio—> 0, typically 0.08..0.20. Ignored by clamp, and so by the arcade model.
tyres.peakSlipAnglerad> 0, typically 0.10..0.20. Ignored by clamp, and so by the arcade model.
tyres.muLong / muLat—> 0. About 1.0 dry asphalt, 0.7 wet, 0.3 gravel.
tyres.loadSensitivity0..1How far grip falls as load rises. 0 is the textbook idealisation. Ignored by clamp, and so by the arcade model.
tyres.rollingResistance—>= 0. Retarding force = this x vertical load; it is what stops a vehicle coasting for ever. ~0.010 car tyre on asphalt, 0.015 heavier, 0.05+ soft ground. Defaults to 0.014.
aero.dragAream²≥ 0. Frontal area. Drag = ½ · 1.225 · frontalCd · dragArea · v².
aero.frontalCd—≥ 0. ~0.30 saloon, 0.35 SUV, 0.40+ van.
aero.downforceFront / RearN·s²/m²≥ 0. Downforce in newtons = this × v². 0 for a road car.
assists.*0..1abs, tractionControl, stabilityControl, steeringAssist. 0 is off.
steering.maxAnglerad> 0. Steering lock at a fully steered wheel; 0.6 rad is about 34 degrees. Defaults to 0.6. This is what decides the turning circle — author it per class.
steering.raterad/s> 0. How fast the steer angle may change; stops a key or a stick snapping to full lock in one tick. Defaults to 3.
steering.speedReduction0..1How much of maxAngle is removed at speedReductionAt. Defaults to 0.35; 0 disables it.
steering.speedReductionAtm/s> 0. Where the reduction is fully applied; below it, scaled in proportion. Defaults to 40 (about 90 mph).

drift

An optional drift object makes a vehicle slide on demand, the way an arcade racer does. Absent, the feature does not exist: the vehicle drives bit for bit as it did before it was added and the feature costs nothing. Present, the handbrake input is the drift button (it applies no brake torque any more). Holding it while the speed is at least minSpeed and the steering is at least minSteer starts a drift in the steer's direction; releasing it, or slowing below 60% of minSpeed, ends it.

FieldUnitNotes
drift.enabledboolfalse keeps the block but turns the drift off (a scene disabling a preset's). Defaults to true.
drift.minSpeedm/s> 0. Defaults to 8.
drift.minSteer0..1Steering needed to start. Defaults to 0.25.
drift.rearGrip0..1Rear lateral grip multiplier at full drift; 1 is no slide. Defaults to 0.5.
drift.gripRampTimes≥ 0. The rear grip follows a smoothstep to rearGrip over this long, and back on release. Defaults to 0.25.
(release)When the button is let go the same assist keeps the slide angle at zero while the rear grip ramps back, so a car released at a large angle straightens instead of spinning on tyres that have only just caught. No setting.
drift.slideAnglerad> 0. The angle between heading and travel the yaw assist holds with the steering neutral. Defaults to 0.5.
drift.steerAngleRange0..1Steering into the turn raises the held angle by up to this fraction, counter-steering lowers it by the same. Defaults to 0.5.
drift.yawAssist1/s²≥ 0. Stiffness of the yaw torque (per unit of yaw inertia, per radian of error) that holds the angle. 0 is none. Defaults to 14.
drift.yawDamping1/s≥ 0. Damps that torque against the angle's rate. Defaults to 5.
drift.chargeMinAnglerad≥ 0. Sliding below this angle charges nothing. Defaults to 0.15.
drift.boostTiersarray{ time, speed, duration } each > 0, time ascending (seconds of charge; extra top speed in m/s; seconds of boost). Replaced whole by a scene, never merged. Absent or empty: no boost.
drift.boostThrust—≥ 1. Multiplier on the drive force while boosting. Defaults to 2.

The charge is the time spent sliding at or above chargeMinAngle; on release the highest tier it reached starts its boost (the extra top speed fades over the last 30% of the duration). A game reads the state from ctx.vehicle (§6) to drive sparks and sounds.

aero, assists and steering are the parts that are never required. For aero and assists, absent means none of either. For steering, and for tyres.rollingResistance and the three drivetrain shift fields, absent means the documented default above — a vehicle that says nothing about steering still steers. Defaults are applied once, at resolution, so a solver never invents one of its own and two solvers cannot disagree about what "absent" meant.

model

arcade (the default when absent) or simulation. A scene names parts, never a solver's internals, so changing this field changes nothing else.

Today both run the arcade model (§4). The simulation model — engine speed through the gearbox, a differential, a slip-curve tyre with load sensitivity, the assists — has its pure parts in crates/vehicles (drivetrain.rs, tyre.rs) and is being moved onto the same raycast wheel; until it lands, a simulation vehicle drives exactly as an arcade one with the same parts, and nothing pretends otherwise. tyres.peakSlipRatio, loadSensitivity and the assists are read by nothing until then; tyres.peakSlipAngle, muLong, muLat and rollingResistance are read now.


3. Presets

Five presets ship as data in crates/vehicles/presets/: the road classes sedan, suv, pickup, van, and kart, a small light arcade vehicle with drift (see "drift" in §2). kart: 180 kg, 1.1 m wheelbase, 0.14 m wheels, 0.1 m of short stiff suspension, tyres with μ about 1.5, 0.5 rad of quick steering lock, about 26 m/s flat out, and a drift block with three boost tiers.

These are plausible round numbers for a class. They are not any real vehicle's published figures, and must not be presented as such. Reference targets for real vehicles are a separate, sourced thing and are not in this repo yet.

sedansuvpickupvan
mass (kg)1500210024002600
wheelbase (m)2.802.903.403.20
track (m)1.551.651.701.70
wheel radius (m)0.320.360.390.36
driveFWDAWD 40/60RWDRWD
gears6866
differentialopenlsdlockedopen
peak torque (N·m)205385480420
ground clearance (m)0.1450.200.220.20
steering lock (rad)0.600.550.520.50
shift up (rpm)5900560052004000

The chassis collider each preset assumes

Three of the validation rules measure wheels and the centre of mass against the entity's own collider, so a preset is only meaningful beside one. These are the boxes the presets were tuned against, and fixtures/contract/valid/vehicle.json is all four of them in one scene, regenerated and revalidated by pnpm gen:fixtures:

presetcollider.sizecollider.center
sedan[1.8, 1.45, 4.7][0, 0.87, 0]
suv[1.95, 1.8, 4.85][0, 1.1, 0]
pickup[2, 1.9, 5.7][0, 1.17, 0]
van[2.05, 2.4, 5.4][0, 1.4, 0]

A markedly different collider is fine — just expect the geometric warnings to have opinions about it.

How a preset resolves

preset first, then the component's own fields over the top:

  • Objects merge field by field. chassis: { mass: 2400 } keeps the preset's centerOfMass and inertia.
  • gears and torqueCurve are replaced whole, never merged element-wise. Interpolating between two curves would be a third engine, not an override.
  • The wheels are the scene's wheel entities; the preset's wheels are defaults by corner. A wheel ahead of the body's origin (z < 0) is a front wheel, one at x < 0 a left one; each takes the preset wheel on its corner (else one on its axle) for every field it leaves out. So three wheels, six, or a kart's four at a different track are all just entities, and a preset still sizes them.
  • A component with no preset resolves to itself and must be complete on its own; its wheels fall back to the wheel defaults (§2).

The presets are the source of truth in crates/vehicles/presets/*.json. pnpm gen:rust reads them, and emits packages/contract/vehicle-presets.json (committed bytes both languages agree with), packages/contract/src/vehicle-presets.ts and VEHICLE_PRESETS_JSON in crates/contract/src/gen.rs — the same arrangement input-default.json has, so the two mirrors cannot drift.


4. How it drives

A raycast wheel, on the physics forces API. Each tick, before the physics step, each wheel casts one ray down from its suspension's hard point (position + up * restLength), ignoring the chassis and anything riding on it. Where the ray lands gives the spring's length; the spring and damper give the wheel's load; the tyre turns that load into grip — a drive or brake force along the wheel, a cornering force across it (rising with slip angle to peakSlipAngle, then held), both inside the friction ellipse muLong/muLat × load. Every force goes to the chassis with add_force_at_point, exactly as a behaviour's ctx.body.addForceAtPoint would, and acts on the step that follows. A grip force is never more than would stop its slide in one tick, which is what holds a parked car still instead of letting it shiver.

Arcade. The drivetrain collapses into one drive force: the engine's peak torque through first gear and the final drive, fading once the engine's peak power runs out, and none past the speed where the engine reaches its redline in top gear (topSpeed). Reverse is weaker and slower. Brake torque becomes brake force at the tyre, split front and rear by bias. Steering is rate-limited by steering.rate and its lock shrinks with speed by steering.speedReduction.

The grip cap is measured at the contact patch. "What would stop the slide in one tick" is the body's mass as the patch feels it: a push at the ground, below the centre of mass, also turns the body, so a light, tall body gives way at a fraction of its mass. The cap uses that effective mass, split over the wheels on the ground. Measured by the mass alone, a 200 kg cart turning and braking overshot its slide a little more every tick and rolled over.

A standalone wheel (no vehicle over it) is the same raycast wheel with nobody between it and a behaviour: it holds the motor torque, brake torque and steer angle a behaviour last set, and its forces turn the body about its real centre of mass. A curve tyre spins its wheel on its own, in a fixed 8 sub-steps a tick, so torque past the grip spins it up and brake past it locks it; a clamp tyre rolls at road speed. The arcade vehicle always runs its wheels on clamp.

What the physics backend is asked for: a filtered raycast, the body's pose, velocity and mass properties, and add_force_at_point — nothing a behaviour cannot ask for too. So wheels add nothing to the physics checkpoint. Their own few numbers — a vehicle's smoothed steering and telemetry history, each wheel's spin — ride in the app's checkpoint beside the solver's bytes (opaque version 2 when a scene has vehicles, 3 when it has standalone wheels), so a snapshot restored mid-turn replays the uninterrupted run bit for bit. CCD is on for every chassis.

A hot reload re-resolves every vehicle and wheel from the new document, whichever entity the edit touched: a wheel given a new radius is re-tuned in place, still spinning; a wheel moved, added or removed rebuilds its body's wheels on the body as it stands.

What a model consumes is never the raw component, which is all optional:

let vehicle = argus_contract::vehicle::resolve(&scene.entities, body)?;   // ResolvedVehicle
let wheel = argus_contract::vehicle::resolve_wheel(&scene.entities, i)?;   // ResolvedWheel, standalone

ResolvedVehicle has no Option on it except chassis.inertia and each wheel's spin inertia; each ResolvedWheel carries its mount position and its rolling direction forward. resolve succeeds only on a vehicle that passes every error-severity rule; its error carries the same RuleCode the validator would have reported. TypeScript has the same functions: resolveVehicle(scene, body) and resolveWheel(scene, i).


5. Validation rules

Seven codes, mirrored byte for byte in packages/contract/src/rules.ts and crates/contract/src/rules.rs. Each covers one kind of problem and the message names the exact field; the fixtures under fixtures/contract/invalid/vehicle-*.json and wheel-*.json hold one case of each kind.

CodeSeverityFires when
vehicle-invaliderrorpreset names no preset; a part or field is missing with no preset to fill it; the vehicle has no wheels; wheels lists an id twice, or one that is not a wheel entity below this body; a number is non-finite, non-positive where it must be positive, or a fraction outside 0..1, or idleRpm is not below redlineRpm; torqueCurve is empty or not increasing in rpm; gears is empty; no wheel has driven above 0.
wheel-invaliderrorAn entity has two wheel components; a wheel has no dynamic rigidbody above it; a wheel's own number is out of range; a standalone wheel has no radius; an entity in visuals is missing, is the wheel or above it, is below it, has a rigidbody, is on another body, or is already drawn by another wheel.
vehicle-cannot-steer-or-brakewarningNo wheel has steerable above 0, or none has braked above 0.
vehicle-needs-dynamic-bodywarningNo dynamic rigidbody, or no non-trigger collider.
vehicle-geometrywarningA wheel centre is outside the collider's bounds (plus its radius, and its width on x); a wheel's travel exceeds the ground clearance under the chassis; centerOfMass.y is above the collider's top.
vehicle-value-implausiblewarningA radius outside 0.1..0.6 m or a mass outside 80..8000 kg — most likely a unit mistake. (The lower ends admit a go-kart; they were 0.15 m and 400 kg.)
vehicle-capability-absentwarningThis build has no vehicle capability. Reported by the runtime, never by a validator — see below. Every engine with a physics flavour (critic, deploy) has it, and argus --info says "vehicles": true.

One code no validator ever emits

vehicle-capability-absent (warning) is reported by the runtime at load, not by validateScene: a build of the engine compiled without the vehicles capability loads the scene and tells you the vehicle is not there, rather than ignoring the component in silence.

It is a rule code, and not a channel of its own, because these strings are the vocabulary every layer uses to report scene problems — the critic included — and a second vocabulary would just be a second place to look. But a scene has to validate identically everywhere or the schema stops being one contract, so both mirrors resolve and validate a vehicle component exactly the same way whether or not the binary in front of them can run one. CODES_NOT_EMITTED_BY_VALIDATION in codes.ts (and RuleCode::NOT_EMITTED_BY_VALIDATION in codes.rs) names it, so the "every code has a fixture" test can subtract it by name instead of being loosened.

Why the others are warnings and not errors: parseScene throws on errors only, so a hard error is a scene the critic never gets to see, let alone offer a fix for, and a car whose wheels are placed a little outside its box still drives. This follows the resolution packages/contract/src/codes.ts already records for dynamic-needs-collider.

What the geometric rules actually compare

All three need the entity's own collider and stay silent without one, or when it is a trimesh or hulls — the contract holds no mesh data, and an asset's bounds are in the file's units before the import transform, which these mirrors deliberately do not apply. A missing collider is reported by vehicle-needs-dynamic-body instead.

The collider's local AABB, before scale: a box is center ± size/2, a sphere is center ± radius, a capsule is center ± (radius, height/2, radius).

  • Outside the chassis — the wheel centre must lie inside that AABB widened by radius on every axis, and by a further width on x only, because a track wider than the bodywork is normal on a real car while a wheel a metre past the bumper is not.
  • Travel past the clearance — clearance is colliderMin.y − (position.y − radius): how far the chassis can drop before its collider reaches the contact patch. Fires when travel exceeds it.
  • Centre of mass above the roof — centerOfMass.y above colliderMax.y.

Every rule judges the resolved vehicle, so a bad number inside a preset is caught at the scene that uses it. A violation points at what the author can edit: a wheel's position at its entity's transform, a wheel's own field at its wheel component, and a value that came from the preset at the preset field.


6. Driving input, and ctx.vehicle

input: "auto" (the default) reads four actions by these names, from the vehicle's own input — which is the player's when the car is the player (§0):

ActionReadsNotes
throttlebutton 0/1, or axis1 valueA negative value (S on a ["S", "W"] axis) is a brake.
brakebutton 0/1, or axis1 valueHeld atspeed< 0.5 with no throttle, reverses.
steeraxis1, -1..1+1 right. ["A", "D", "Left", "Right"] binds both pairs.
handbrakebutton 0/1Locks the wheels whose handbrake is set; on a vehicle with a drift block it is the drift button instead and brakes nothing.

The built-in map (a scene with no meta.input) has none of them, so a driving scene declares them in its own meta.input, usually in its gameplay context.

input: "script" reads nothing; a behaviour drives it:

update(ctx) {
  const v = ctx.vehicle!
  v.drive({ throttle: v.speed < 12 ? 1 : 0, steer: 0.3 })   // held until the next drive
}

ctx.vehicle is present while the entity is a running vehicle and null otherwise: drive({ throttle, brake, steer, handbrake }) (steer +1 right; values clamped; a NaN throws vehicle.not-finite), reset() (back on its wheels, upright and still, where every wheel last touched the ground), and input, speed (m/s, negative reversing), steerAngle (the wheels' angle, radians, positive right like the action), throttle, brake, gear (1 or -1) and wheelsOnGround. On a vehicle with a drift block (§2), also drifting (a drift is held), driftAngle (radians, positive right, 0 when not drifting; its magnitude is how sideways it travels), driftCharge (seconds of boost-earning slide), driftTier (0 none, 1.. the boost tier the charge has reached) and boosting (a drift boost is pushing); all false or 0 on any other vehicle. (API since 0.6.0). drive on an auto vehicle changes nothing and is counted on GET /perf → unwired.vehicle_ignored.

ctx.wheel

On a wheel entity's own behaviour, ctx.wheel is its wheel; from the body's behaviour, ctx.body.wheels lists every wheel on the body and ctx.vehicle.wheels the vehicle's. Each is:

  • Writes, held until the next call: setMotorTorque(nm) (positive rolls it forward), setBrakeTorque(nm) (0 or more), setSteer(radians) (positive right). A NaN throws wheel.not-finite. On a wheel of an input: "script" vehicle, each field written takes that wheel over from the vehicle's command until the vehicle's next ctx.vehicle.drive — torque vectoring, one wheel spun up. A wheel of an auto vehicle ignores all three, counted on GET /perf → unwired.vehicle_ignored: the engine sets every wheel from the actions each tick.
  • Reads: drivenBy ("script" or "vehicle"), grounded, contact ({ point, normal, entity }, null in the air), slip ({ long, lat }: slip ratio, 0 on a clamp tyre, and slip angle in radians), rpm, load (N on the spring), compression (0 at rest length or longer, 1 at the bump stop), steerAngle (positive right), motorTorque, brakeTorque, radius, and entity.

Wheels and pedals (the input devices) are a later milestone. When that lands, the extension goes in three places: GamepadBinding gains raw axis spellings, InputAction gains invert, range, curve and a device selector, and axis_of in crates/app/src/input/gamepad.rs stops dropping Axis::Unknown.


7. What a tester reads

GET /entities?fields=vehicle keeps each entity's vehicle block (and its id); only a running vehicle has one:

{ "id": "car", "vehicle": {
    "input": "auto",
    "actions": { "throttle": { "action": "throttle", "kind": "button" },
                 "brake": { "action": "brake", "kind": "button" },
                 "steer": { "action": "steer", "kind": "axis1" } },
    "speed": 8.2, "forward": [0.27, 0.0, -0.96], "steerAngle": 0.21,
    "maxSteer": 0.6, "wheelbase": 2.8, "minTurnRadius": 4.37, "brakeDecel": 8.7, "topSpeed": 80.1,
    "throttle": 1.0, "brake": 0.0, "gear": 1, "wheelsOnGround": 4 } }
  • input says who owns the movement: auto, the engine from the actions (a behaviour's ctx.vehicle.drive is ignored); script, a behaviour (holds on the actions move nothing).
  • actions names each role's action with its kind, only where the scene's gameplay context binds it; a role left out reads 0.
  • speed is along forward (the car's -Z), after this tick's step. steerAngle is the wheels' angle now, positive right like the steer action; maxSteer is full lock at a standstill.
  • minTurnRadius is the tightest circle at its speed now (wheelbase / tan(lock), with the lock the steering's speed reduction leaves): what a path follower needs to know whether a turn can be made. brakeDecel is what full brake gives on the level, for stopping on a mark.

A tester drives it only through POST /input holds of the named actions — the same path a player's keys take — and a lockstep run is deterministic.


8. Deferred, said out loud

The simulation model on the raycast wheel (§2 model). Applying chassis.inertia and the centre of mass to the body itself (needs a mass-properties setter on the physics backend). A driving-scenario battery in the critic (0-60, braking distance, skidpad) and argus vehicle setup from an imported model. Force feedback. Damage and deformation. Tracked and two-wheeled vehicles. A full Pacejka tyre. AI traffic. Anything networked.

A behaviour's ctx.world.spawn of a body with wheels: the clone is not in the document, so its wheels are not composed or driven until the next reload; a vehicle cloned that way is refused with the contract's no-wheels message.

Counter-steering rear axles are also not expressible: steerable is 0..1, so a rear axle cannot turn the other way. Widening that range is the additive change to make when it is wanted.