Talos docs

Wheels and vehicles

Since 0.6Changed in 0.7

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. The body and its wheels arrive together, since each refuses the other's absence: one scene.patch adding all five, or the agent's entity.add with the wheels in children:

{ "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"]). brake is optional: a throttle bound as an axis1 (["S", "W"]) reads S as -1, which brakes. Give every action you declare a source (a key, a pad, mouse or touch binding): one declared with "keys": [] and nothing else is reported at load as binding no key. 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. A context that binds none of throttle, brake and steer (a new project's default map, which has move and jump) drives it from those instead: move forward is the throttle, back the brake (reversing at a standstill), left and right the steer, and jump the handbrake (which starts a drift on a kart). Bind any of the vehicle's actions and only they drive it.
  • 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 and kart (§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 unless the scene has another player: an entity with spawn { for: "player" } and other components (a bare spawn, with nothing else on it, is only a place and doesn't count), or one with a controller. When it is the player, device input and an entity-less POST /input go to it. The 3D Empty template's spawn is bare, so a kart added there is the player; the 3D Starter's walking player (spawn, controller, script) has the keys until it's deleted.
  • 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, steerAngle, 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 kart on a track, end to end

The kart-starter sample is this page put together; start a project from it (project.create with template: "kart", or Kart starter in New project), or build the same from these parts.

  • The kart. A body with { "type": "vehicle", "preset": "kart" }, a collider box of size [1.0, 0.5, 1.7] centred at [0, 0.4, 0] (the box the kart preset was tuned against, §3), a dynamic rigidbody (the preset's 180 kg wins over its mass), and four child wheel entities at [±0.42, 0.14, -0.55] (front) and [±0.44, 0.14, 0.55] (rear): wheel radius 0.14, so the built-in cylinder is scale [0.28, 0.18, 0.28] (the starter's) turned 90° about Z. It drives on throttle, brake, steer and handbrake (the drift button) with no script.
  • The track. An entity with { "type": "road", "source": "tracks/loop.road.json" } and a file with points, "closed": true, widthM, kerb, barrier and gates ({ "everyM": 40, "heightM": 5, "depthM": 2 }). docs/AGENT.md, "Roads", has every field.
  • The road's shape. The curve passes through every point and bends smoothly between them, so two far-apart points do not make a straight: in a closed loop, [0, 0, 0] to [0, 0, -80] bulges 7 m to the side halfway, and a gate there stands off the line a kart drives. Put points every 10 to 20 m along a straight.
  • Laps. The load adds one trigger box per gate, <road id>-gate-<n> in lap order, gate 0 the start line, each a trigger with the event road-gate. Each crossing is published on the event bus as "trigger"; its data is { event: "road-gate", volumeId: "<road id>-gate-<n>", otherId: "<the kart's id>", entering: true | false, volume, other } (BusTriggerEvent in packages/api). A gate fires for every collider in it, not only the kart: the road's own <road id>-surface and <road id>-barrier and the floor enter every gate on the first frame. So filter by the kart's id, as below; a counter keyed on "anything entered gate 0" counts a lap at load. Count a lap when the kart enters gate 0 having entered every other gate since: ts ctx.events.on("trigger", (e) => { const t = e.data if (t.event !== "road-gate" || !t.entering || t.otherId !== "kart") return const n = Number(t.volumeId.slice(t.volumeId.lastIndexOf("-") + 1)) // ... n is the gate's place in the lap; ctx.world.road("track").gates lists them as { id, s }. }) A gate is made by the load, so no behaviour sits on one; the kart's own behaviour hears it instead: onTriggerEnter(ctx, other) with other the gate (the entrant hears the volume), or onTrigger(ctx, ev) with the same fields as above, without the ids. The kart-starter counts laps another way, from its distance along the road (ctx.world.road("track").closest(...)): crossing the start line after halfway round is a lap.
  • A camera on it. A kart with no script has no behaviour to point the camera, so give it one, a script whose init does what the starter's does: ts export default { init(ctx) { ctx.camera.follow(ctx.entity) ctx.camera.set("third-person", { distance: 6.5, pitch: -0.28, eyeHeight: 1.1, recenterDelay: 0, lookAhead: 0.3 }) }, } as a script component on the kart ({ "type": "script", "module": "behaviors/chase" } for scenes/behaviors/chase.ts, the path from the scene's folder, as the starter names behaviors/kart). It still drives on the actions with no script of its own; this one only films it.
  • Getting back on. ctx.vehicle.reset() puts the kart back on its wheels where every wheel last touched the ground. The starter's behaviour does it on the track instead: on reset it moves the kart to ctx.world.road("track").at(s), facing along the road, at the distance s it last had on it.
  • A self-test that drives it. selftest/*.json steps hold the actions: { "axis": "throttle", "value": [1, 0], "frames": 180 }. An axis step's value is [x, y] for every axis, and a one-axis action (axis1, as throttle and steer are here) reads x: steer right is { "axis": "steer", "value": [1, 0], "frames": 30 }. A bare number is read as [value, 0] (docs/SELF-TEST.md). Steps run one after another and each holds one action, so a steer step lets go of the throttle and the kart coasts through the turn: drive, then steer, then drive again. To drive it round the track as a driver would, use { "drive": "road", "road": "track", "frames": 1800, "minDistanceM": 300 }: it holds the throttle and steers along the road every tick, and fails if the kart got less than that far.

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 80..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.1..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. Reserved: accepted and range-checked, but no solver reads any of them yet, so setting them changes nothing (see model below). To keep a car from spinning, use the handling block.
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. A behaviour can also start a boost itself with vehicle.boost(speed, seconds) — on ctx.vehicle, or on other.vehicle from a pickup's onTriggerEnter — on any vehicle; without a drift block it pushes at 1.3 × the drive force (docs/API.md).

handling

An optional handling object is a guard for the arcade model: no spins from steering, no pivoting on the spot, a reverse speed limit, no launches off a drift or a kerb, and a body kept near upright. Absent, the feature does not exist: the vehicle drives bit for bit as it did before. Present, a field left out takes its default ("handling": {} is the guard at its defaults), and every strength scales with the body's own mass and inertia, so the same block suits a kart and a truck. In calm driving (slip under 0.1 rad, yaw under the cap, not just landed) it does nothing. Between that and maxSlip it only straightens the vehicle gently, less the more it is steered. Past maxSlip it pulls the nose toward where the vehicle travels and bleeds sideways speed back into forward speed.

FieldUnitNotes
handling.maxSliprad> 0. Slip angle (heading against travel) above which the guard acts. Defaults to 0.42.
handling.antiSpin0..1Strength of the guard past maxSlip; 0 is off. Defaults to 1.
handling.maxYawRaterad/s> 0. Yaw rate cap, easing to 0.83 of it at top speed. Defaults to 2.3.
handling.turnRadiusm> 0. Low-speed cap: yaw rate ≤ 0.35 + speed / turnRadius, so no pivot on the spot. Defaults to 5.2.
handling.maxReverseSpeedm/s> 0. Backward speed cap. Defaults to 4.5.
handling.landingGraces≥ 0. After a landing the guard is up to 2.5× as strong, fading over this long. Defaults to 0.7.
handling.driftSliprad> 0. maxSlip while the drift block is drifting, so a drift still slides. Defaults to 0.62.
handling.maxRiseSpeedm/s≥ 0. Upward speed cap on its wheels; 0 is off. Defaults to 2.5.
handling.antiRoll0..1Keeps the body within 8° of the ground under its wheels (a slope is not a tilt); 0 is off. Defaults to 1.

A kart: "handling": {}. A heavier car: raise turnRadius to its turning circle's radius and lower maxYawRate. The guard's slip is in ctx.vehicle.slip (§6). A port of Sidecar Sprint's tuned kart handling, made generic.

recovery

An optional recovery object puts a vehicle back when it cannot go on: flipped, stuck, fallen out of the world, drowned, or flung by the physics. Absent, the feature does not exist (ctx.vehicle.reset() still works as before). Present, a field left out takes its default ("recovery": {} is all of it at its defaults). It keeps its own ring of good spots (every 0.4 s, on all its wheels and upright, the last 12), so it needs no track: it goes back to the newest spot at least 0.9 s old, facing the way it faced there, given back part of the speed it had. Each recovery raises vehicle-recovered { entity, why, from, position } at the top of the next tick (why: flipped, stuck, fell, water or physics).

FieldUnitNotes
recovery.flippedAfters≥ 0. Upside down (up axis below 0.35 of world up) this long; 0 is never. Defaults to 0.7.
recovery.stuckAfters≥ 0. Driven (throttle or brake held) and getting nowhere this long; 0 is never. Getting somewhere is moving more than stuckSpeed × stuckAfter / 2 from where it last did, or turning to a new heading (0.5 rad, credited up to a full turn before it has to move again), so a reverse out or a three-point turn is not stuck and rocking in place is. Not driven, it is never stuck. Defaults to 1.6.
recovery.stuckSpeedm/s> 0. Sets the distance above. Defaults to 2.
recovery.fellAfters≥ 0. Falling with nothing within 8 m below this long; 0 is never. Defaults to 0.25.
recovery.waterDepthm≥ 0. Chassis this deep under a water surface for 0.2 s; 0 is never. Never on a vehicle with a water block. Defaults to 0.6.
recovery.to—lastGood (the default), road (the nearest road point to that spot, facing along the road the way it was going when last on a road, so one that turned round off it is not put back facing the wrong way; lastGood with no road) or upright (where it is, on its wheels). Pick road for a racer on a track (a kart, a circuit car): it puts the vehicle back on the track facing along it, where lastGood can leave it off the road, side-on, where it last stood on its wheels. Keep lastGood for open ground with no road, and upright for a vehicle that should stay where it rolled over.
recovery.dropInSpeed0..1Share of the spot's speed it is given back, forward; never over half its top speed. Defaults to 0.5.
recovery.protectFors≥ 0. After a recovery: no new one, and its spin damped. ctx.vehicle.recovering reads true. Defaults to 1.2.
recovery.guardboolThe physics guard: a spin over 20 rad/s is cancelled, a speed gain over 12 m/s in one tick undone (a wall stopping it dead is not); six bad ticks in a row, or NaN, is a physics recovery. Defaults to true.

A kart: "recovery": {}. A race with a road: "recovery": { "to": "road" }. A game that wants the moment (a fade, a sound, a time penalty) listens for vehicle-recovered; ctx.vehicle.lastRecovery is { why, at } (world time, s).

offRoad

An optional offRoad object makes off road slower, not a spin. Absent, the feature does not exist. Present, a field left out takes its default. A wheel is off road when the entity under it carries one of surfaces' tags (terrain also names terrain cells); everything below scales with the share of wheels off road.

FieldUnitNotes
offRoad.surfacestagsDefaults to ["terrain"]. A road's own surface is never off road unless tagged.
offRoad.topSpeed0..1The share of its top speed it keeps fully off road. Defaults to 0.62.
offRoad.dragm/s²≥ 0. Extra deceleration off road. Defaults to 1.2.
offRoad.overDecelm/s²≥ 0. Added while above the off-road top speed, down to it. Defaults to 9.
offRoad.guard0..1The handling guard's maxSlip and maxYawRate times this off road (no effect without handling). Defaults to 0.7.
offRoad.steerBackm≥ 0. Past this far beyond a road's edge, the nose turns gently (at most 0.6 rad/s) back toward the road. 0 is off; no effect in a scene without roads. Defaults to 3.

contact

An optional contact object shapes what hits do to an arcade vehicle. Absent, the feature does not exist: the vehicle drives bit for bit as it did before. Present, a field left out takes its default ("contact": {} is all of it at its defaults). With nothing touching the chassis it does nothing.

FieldUnitNotes
contact.glanceDegdeg> 0. A wall hit shallower than this is a glance: it slides along the wall keeping its speed. Defaults to 30.
contact.glanceKeep0..1The least share of its speed a glance keeps (a shallower one keeps its cosine when that is more). Defaults to 0.9.
contact.headOnKeep0..1The share a head-on hit keeps, along the wall; blended from glanceKeep between glanceDeg and 90°. Defaults to 0.4.
contact.pushOutm/s≥ 0. Pushed off the wall on a hit. Defaults to 0.6.
contact.yawKick0..1How much of a contact's sudden yaw (over 2.5 rad/s in a tick) is taken back; 0 is off. Defaults to 1.
contact.aligns≥ 0. After a wall hit the nose turns along the wall for this long; 0 is off. Defaults to 0.4.
contact.lipHeightm≥ 0. An obstacle no higher than this above the ground under its wheels is a lip (a kerb, a step): hopped onto, not stopped at. The engine looks at the obstacle to tell, not at where the chassis touched it. 0 is off. Defaults to 0.5.
contact.bump0..1A hit from another moving body (a vehicle, a crate): the share of the sideways change kept, and never more than 3.5 m/s sideways, a 2.5 m/s shove along or 0.4 rad/s of yaw. Speed lost into something heavier is not given back (kept, it would drive the vehicle on into it). 1 is off. Defaults to 0.5.

A wall is a contact whose normal is within 60° of level; one at a kerb's height is a lip. One whose face reaches less than 0.4 m either side of where it touched is narrow (a post, a tree trunk): it is passed or stopped at, never slid along. The vehicle keeps glanceKeep of the speed it had going past it (none, square on), turned at most 9° off the way it was going, and while it touches, and for 0.25 s after, the obstacle may not move its yaw rate more than 0.5 rad/s. Without this, a post clipped off centre pivots a kart round it: 75 to 180 degrees in a second. A kart: "contact": {}.

water (boats, jet skis, amphibious vehicles)

An optional water object gives a vehicle water handling. Absent, the feature does not exist: the vehicle drives bit for bit as it did before. The vehicle floats by its own buoyancy component (on the same entity; watercraft-without-buoyancy otherwise) and is driven by the same throttle, brake and steer. Every force goes through the same add_force_at_point the wheels use, before the solve (vehicles, then water, then physics.step), and each part reads the water at its own point (World::vehicle_water in Rust; a thruster in the air pushes nothing).

  • With no wheels (a boat, a jet ski) the vehicle needs only chassis beside water: drivetrain, brakes and tyres are read by nothing and are not required.
  • With wheels it is amphibious: the water handling is blended in by the share of wheels off the ground, rate-limited over blendTime, so it takes over as the vehicle floats off a shore ramp and hands back as its wheels find the far one. The wheels themselves are left exactly as they are.
FieldUnitNotes
water.thrustersarray{ position, force, vector?, size? }: where it pushes (local, before scale), full-throttle thrust in N (≥ 0), how far the thrust turns with the steering at full lock in rad (0..1.5; 0, the default, is a fixed propeller, a jet steers with it and so only while it pushes), and the height of its wet band in m (> 0, 0.3). Thrust is along the hull's forward axis, scaled by how much of the thruster is under water.
water.topSpeedm/s> 0. Speed through the water at which the thrust has faded to nothing (it falls with the square of speed). Defaults to 15.
water.reverse0..1Reverse thrust from brake, as a fraction of forward. Defaults to 0.4.
water.rudderobject{ position, lift?, maxAngle?, size? }: a side force at its point of lift (N·s²/m² per rad, 300) × speed through the water² × the rudder angle (maxAngle rad at full steer, under 1.5, 0.6). Nothing at rest.
water.keelobject{ position?, lateral?, heave?, size? }: resistance to sliding sideways through the water, lateral N·s²/m² (400), and to heaving up and down, heave N·s/m (0; it stops a light hull bobbing on after a wave or a landing). Absent position: the middle of the chassis collider's bottom face.
water.planingobject{ position?, lift, minSpeed?, fullSpeed?, maxLift?, size? }: lift along the hull's up axis of lift (N·s²/m²) × speed², ramped (smoothstep) from minSpeed (3 m/s) to fullSpeed (9 m/s, above minSpeed), at most maxLift (0.6) of the vehicle's weight. A fast hull rises, its submerged volume falls, and so does the buoyancy's drag on it.
water.righting1/s²≥ 0. A torque toward upright while the hull is in the water. 0 (the default) is off: a capsized hull stays capsized.
water.bowVec3Where spray starts. Absent: the middle of the front edge of the chassis collider's bottom face.
water.slamSpeedm/s≥ 0. The downward speed at which the hull hitting the water is a slam. Defaults to 2.
water.blendTimes≥ 0. The wheels-to-water handover time. Defaults to 0.4.
water.yawDamping1/s≥ 0. The hull's resistance to yawing in the water. Defaults to 0.8.
water.maxYawRaterad/s≥ 0. The most yaw rate once the speed reaches yawRateSpeed; 35% of it at rest; 0 is no limit. Defaults to 1.4 (about 80°/s).
water.yawRateSpeedm/s> 0. The speed at which maxYawRate is reached. Defaults to 6.
water.lockAtTopSpeed0..1The share of the steering lock (rudder, nozzle) left at topSpeed, falling linearly from full lock at rest. Defaults to 0.6.
water.bank, water.bankStiffnessrad, 1/s²≥ 0. Lean into a turn toward the angle its sideways acceleration asks for, at most bank, held with bankStiffness. 0 (the default) is no lean; stiffness defaults to 40. With righting the lean settles part way (the two springs share it).
water.trim, water.trimStiffnessrad, 1/s²≥ 0. Bow-up trim while the hull climbs onto the plane (needs planing): most halfway up the planing ramp, 60% of it once planing, none at rest or slow. 0 (the default) is off; stiffness defaults to 30.

A behaviour reads the water handling's last tick from ctx.vehicle.water (§6): speed (through the water), planing, immersion, blend, capsized, bow, bowWet, sternWet, thrust, slam (the downward speed on the tick the hull hit the water, else 0) and slams. Out of range numbers are watercraft-invalid.

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

Six presets ship as data in crates/vehicles/presets/: the road classes sedan, suv, pickup, van; kart, a small light arcade vehicle with drift (see "drift" in §2); and arcade-kart, the kart with the arcade assists on. 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 (D8 audit §4e) 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

An arcade kart: arcade-kart

{ "type": "vehicle", "preset": "arcade-kart" } is everything an arcade kart racer asks of the vehicle, as engine config with no script: the kart (body, wheels, drivetrain, the drift block with its three boost tiers) plus four assist blocks, each at its documented defaults (§2):

AskBlockWhat it does
Drift and boost tiersdrift (from kart)A held handbrake past minSpeed slides; the slide charges through boostTiers and lets go as a mini-boost. vehicle.boost(speed, seconds) (§6) starts a boost from a pad or an item the same way.
Landing assisthandlingNo spins from steering, no pivot on the spot, no launch off a drift or a kerb (maxRiseSpeed), kept near upright (antiRoll), and a stronger guard for landingGrace s after a landing.
Recovery / respawnrecoveryFlipped, stuck or fallen, it is put back at its newest good spot (to, "road" for the nearest road), then protected for protectFor s.
Post-hit assistcontactA glancing wall hit keeps most of its speed and turns the nose along the wall; a yaw kick from a hit is taken back; kerbs up to lipHeight are hopped.
Off roadoffRoadSlower and calmer on terrain (surfaces), and the nose turns gently back toward a road.

Tune any field the usual way: "handling": { "maxYawRate": 2 } changes that one field and keeps the rest of the preset's block, and offRoad.surfaces replaces the preset's list whole (["grass", "sand"] for tagged ground). A preset block cannot be removed, as a preset wheel cannot: for a kart without one of the assists, name kart and write the blocks you want. kart itself has none of the four, so scenes built on it drive as they always did.

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 five 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]
kart[1.0, 0.5, 1.7][0, 0.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 keeps a parked car from shivering. Below about 1 m/s at the tyre the grip also builds up against a load that keeps pushing, up to the same limits (the brake and handbrake along the wheel, muLat × load across it): a car with its handbrake on stays put on a slope, side on to one it does not slide down, and a kart nosed into a wall under throttle is held at the angle it hit instead of being pivoted round on its nose until square to the wall. Held there it is still stopped: steer away from the wall, or back off it.

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
}

A driver along a road, ready-made: the built-in road-driver behaviour. A rival kart, traffic or an attract mode is an input: "script" vehicle with { "type": "script", "module": "road-driver", "props": { "road": "track" } }. It steers toward a point ahead on the road (lookAheadM, 8 m plus 0.3 m per m/s; laneOffsetM right of the centre line), takes each bend at the speed its curvature allows (cornerGrip × g sideways: 0.8 suits the kart, about 0.6 a road car; maxSpeed caps it), and when it is pinned — the throttle held for 1.5 s and either under 0.5 m/s all the while or under a metre along the road (nosed into a wall, scrubbing along one) — reverses for a second steering the other way. Its look-ahead shrinks where the road ahead bends hard (an S is followed, not cut across), and its speed is held to what both its grip and its turning circle at that speed (ctx.vehicle.minTurnRadius) allow. startOn holds it still until that event (a countdown's "go") and stopOn stops it again (the finish): it brakes while it rolls, then holds no pedal, because the brake at a standstill is reverse. After three back-outs in a row that fail it emits stuck (stuckEvent) once, with { entity, tries }, and sets its ctx.state.stuck: resetting it is the game's call, from the vehicle's own behaviour (ctx.vehicle.reset()) or with a teleport onto the road. It drives the way it faces at the start and counts no laps. Its pieces (pursuitSteer, cornerSpeed, roadCurvatureAhead, pedals, stepWedge) are exported from @argus/gameplay for a driver of your own.

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). On a vehicle with a handling block, also slip (the slip angle the guard saw, radians, positive right; 0 on any other vehicle). On a vehicle with an offRoad block, also offRoad (any wheel on an off-road surface). On a vehicle with a recovery block, also recovering (within protectFor of a recovery) and lastRecovery ({ why, at }, null before any). drive on an auto vehicle changes nothing and is counted on GET /perf → unwired.vehicle_ignored.

A drive is held for the behaviour that made it. When that behaviour stops (it threw and is disabled, the runaway guard suspended or disabled it, or it was removed), the vehicle goes neutral on its next step: no throttle, no steer, no handbrake, and the brake on while it rolls forward faster than 0.5 m/s, then off (a brake held at rest would reverse it). The held drive is dropped, so a suspended behaviour that runs again starts from neutral and drives it with its next drive.

Touchdown. Every vehicle also reads airTime (seconds since any wheel last touched the ground; 0 while one is down) and landing ({ count, fallSpeed, airTime }): the last touchdown, where count goes up by one each time all wheels were off the ground and one comes down, fallSpeed is the fastest downward speed of that flight in m/s and airTime its length in seconds. A behaviour plays a landing effect, sound or camera bump by watching landing.count change. The readout counts from the fixed sim step only (so it replays identically) and restarts from zero when a saved world is restored.

A vehicle with a water block also reads water (null on every other): the water handling's last tick, the hooks a wake, a bow spray or a splash sound reads (§2, water).

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 beyond one vehicle following a road (road-driver, §6: no overtaking, no avoiding other cars). 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.