# Wheels and vehicles

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

```json
{ "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.

```json
{ "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 }] }
```

```ts
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.**

| Quantity | Positive 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 |
| `speed` | Forward (-Z); negative while reversing |
| `throttle` | Forward acceleration |
| `brake`, `handbrake` | Unsigned 0..1; there is no negative brake |
| Slip ratio | Under drive. Negative under braking |
| `reverseGear` | A 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

```json
{
  "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:

| Field | Values | Notes |
|---|---|---|
| `input` | `auto` \| `script` | **Default `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. |
| `wheels` | entity ids | The 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

| Field | Unit | Notes |
|---|---|---|
| `mass` | kg | > 0. Plausible 80..8000. Includes occupants and cargo; they are not modelled separately. **The body is built with this mass**, whatever the `rigidbody` says. |
| `centerOfMass` | m | Local, 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. |
| `inertia` | kg·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.

| Field | Unit | Notes | Default |
|---|---|---|---|
| `radius` | m | > 0. Rolling radius of wheel and tyre, not the rim. Plausible 0.1..0.6. | none: a standalone wheel must give one (`wheel-invalid`) |
| `width` | m | > 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 |
| `tyre` | | A `tyres` block (below) for this wheel alone, over the vehicle's. | `clamp`, μ 1, slip 0.12 / 0.14 rad, rolling 0.014 |
| `inertia` | kg·m² | > 0. Spin inertia, what a `curve` tyre spins up against. | from radius and width |
| `steerable`, `driven`, `braked`, `handbrake` | | Under 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 |
| `visuals` | | Entity 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`:

```json
{ "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:

| Field | Unit | Notes |
|---|---|---|
| `suspension.restLength` | m | > 0. Spring length unloaded. |
| `suspension.travel` | m | > 0. Maximum compression from rest. Checked against ground clearance. |
| `suspension.stiffness` | N/m | > 0. |
| `suspension.dampingCompression` | N·s/m | ≥ 0. Normally the smaller of the two. |
| `suspension.dampingRelaxation` | N·s/m | ≥ 0. Normally larger than compression. |
| `suspension.maxForce` | N | > 0. Ceiling on the spring's output; what stops a stiff spring launching the chassis at a 60 Hz tick. |
| `steerable` | 0..1 | Fraction of steer input. 1 front, 0 fixed, 0.25 a mild rear-steer axle. |
| `driven` | 0..1 | Fraction 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. |
| `braked` | 0..1 | Fraction of the axle's brake torque. Multiplies with `brakes.bias`. |
| `handbrake` | bool | Whether `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

| Field | Unit | Notes |
|---|---|---|
| `engine.torqueCurve` | `[rpm, N·m][]` | Non-empty, **strictly increasing in rpm**. Linearly interpolated, clamped outside the range. |
| `engine.idleRpm` | rpm | > 0, below the redline. |
| `engine.redlineRpm` | rpm | Above idle. |
| `engine.inertia` | kg·m² | > 0. Crank and flywheel. Small is revvy, large is a diesel. |
| `gears` | ratio | First gear first, each > 0. Conventionally strictly descending — documented, not enforced. |
| `reverseGear` | ratio | > 0. |
| `finalDrive` | ratio | > 0. Typically 3..4.5. |
| `differential` | — | `open` \| `locked` \| `lsd` |
| `shift` | — | `auto` \| `manual` |
| `shiftUpRpm` | rpm | > 0, not above `redlineRpm`. **Defaults to 0.9 x redlineRpm.** Shifting earlier is what makes a van or a diesel feel like one. |
| `shiftDownRpm` | rpm | > 0, not below `idleRpm`. **Defaults to 1.6 x idleRpm.** |
| `shiftTime` | s | >= 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

| Field | Unit | Notes |
|---|---|---|
| `brakes.maxTorqueFront` / `Rear` | N·m | ≥ 0. **Per wheel**, not per axle. |
| `brakes.handbrakeTorque` | N·m | ≥ 0. Independent of the footbrake and of `bias`. |
| `brakes.bias` | 0..1 | Fraction 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.peakSlipAngle` | **rad** | > 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.loadSensitivity` | 0..1 | How 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.dragArea` | m² | ≥ 0. Frontal area. Drag = ½ · 1.225 · `frontalCd` · `dragArea` · v². |
| `aero.frontalCd` | — | ≥ 0. ~0.30 saloon, 0.35 SUV, 0.40+ van. |
| `aero.downforceFront` / `Rear` | N·s²/m² | ≥ 0. Downforce in newtons = this × v². 0 for a road car. |
| `assists.*` | 0..1 | `abs`, `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.maxAngle` | **rad** | > 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.rate` | rad/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.speedReduction` | 0..1 | How much of `maxAngle` is removed at `speedReductionAt`. **Defaults to 0.35**; 0 disables it. |
| `steering.speedReductionAt` | m/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.

| Field | Unit | Notes |
|---|---|---|
| `drift.enabled` | bool | `false` keeps the block but turns the drift off (a scene disabling a preset's). **Defaults to true.** |
| `drift.minSpeed` | m/s | > 0. **Defaults to 8.** |
| `drift.minSteer` | 0..1 | Steering needed to start. **Defaults to 0.25.** |
| `drift.rearGrip` | 0..1 | Rear lateral grip multiplier at full drift; 1 is no slide. **Defaults to 0.5.** |
| `drift.gripRampTime` | s | ≥ 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.slideAngle` | **rad** | > 0. The angle between heading and travel the yaw assist holds with the steering neutral. **Defaults to 0.5.** |
| `drift.steerAngleRange` | 0..1 | Steering into the turn raises the held angle by up to this fraction, counter-steering lowers it by the same. **Defaults to 0.5.** |
| `drift.yawAssist` | 1/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.yawDamping` | 1/s | ≥ 0. Damps that torque against the angle's rate. **Defaults to 5.** |
| `drift.chargeMinAngle` | **rad** | ≥ 0. Sliding below this angle charges nothing. **Defaults to 0.15.** |
| `drift.boostTiers` | array | `{ 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.

| Field | Unit | Notes |
|---|---|---|
| `handling.maxSlip` | **rad** | > 0. Slip angle (heading against travel) above which the guard acts. **Defaults to 0.42.** |
| `handling.antiSpin` | 0..1 | Strength of the guard past `maxSlip`; 0 is off. **Defaults to 1.** |
| `handling.maxYawRate` | rad/s | > 0. Yaw rate cap, easing to 0.83 of it at top speed. **Defaults to 2.3.** |
| `handling.turnRadius` | m | > 0. Low-speed cap: yaw rate ≤ 0.35 + speed / turnRadius, so no pivot on the spot. **Defaults to 5.2.** |
| `handling.maxReverseSpeed` | m/s | > 0. Backward speed cap. **Defaults to 4.5.** |
| `handling.landingGrace` | s | ≥ 0. After a landing the guard is up to 2.5× as strong, fading over this long. **Defaults to 0.7.** |
| `handling.driftSlip` | **rad** | > 0. `maxSlip` while the `drift` block is drifting, so a drift still slides. **Defaults to 0.62.** |
| `handling.maxRiseSpeed` | m/s | ≥ 0. Upward speed cap on its wheels; 0 is off. **Defaults to 2.5.** |
| `handling.antiRoll` | 0..1 | Keeps 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`).

| Field | Unit | Notes |
|---|---|---|
| `recovery.flippedAfter` | s | ≥ 0. Upside down (up axis below 0.35 of world up) this long; 0 is never. **Defaults to 0.7.** |
| `recovery.stuckAfter` | s | ≥ 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.stuckSpeed` | m/s | > 0. Sets the distance above. **Defaults to 2.** |
| `recovery.fellAfter` | s | ≥ 0. Falling with nothing within 8 m below this long; 0 is never. **Defaults to 0.25.** |
| `recovery.waterDepth` | m | ≥ 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.dropInSpeed` | 0..1 | Share of the spot's speed it is given back, forward; never over half its top speed. **Defaults to 0.5.** |
| `recovery.protectFor` | s | ≥ 0. After a recovery: no new one, and its spin damped. `ctx.vehicle.recovering` reads true. **Defaults to 1.2.** |
| `recovery.guard` | bool | The 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.

| Field | Unit | Notes |
|---|---|---|
| `offRoad.surfaces` | tags | Defaults to `["terrain"]`. A road's own surface is never off road unless tagged. |
| `offRoad.topSpeed` | 0..1 | The share of its top speed it keeps fully off road. **Defaults to 0.62.** |
| `offRoad.drag` | m/s² | ≥ 0. Extra deceleration off road. **Defaults to 1.2.** |
| `offRoad.overDecel` | m/s² | ≥ 0. Added while above the off-road top speed, down to it. **Defaults to 9.** |
| `offRoad.guard` | 0..1 | The `handling` guard's `maxSlip` and `maxYawRate` times this off road (no effect without `handling`). **Defaults to 0.7.** |
| `offRoad.steerBack` | m | ≥ 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.

| Field | Unit | Notes |
|---|---|---|
| `contact.glanceDeg` | **deg** | > 0. A wall hit shallower than this is a glance: it slides along the wall keeping its speed. **Defaults to 30.** |
| `contact.glanceKeep` | 0..1 | The least share of its speed a glance keeps (a shallower one keeps its cosine when that is more). **Defaults to 0.9.** |
| `contact.headOnKeep` | 0..1 | The share a head-on hit keeps, along the wall; blended from `glanceKeep` between `glanceDeg` and 90°. **Defaults to 0.4.** |
| `contact.pushOut` | m/s | ≥ 0. Pushed off the wall on a hit. **Defaults to 0.6.** |
| `contact.yawKick` | 0..1 | How 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.align` | s | ≥ 0. After a wall hit the nose turns along the wall for this long; 0 is off. **Defaults to 0.4.** |
| `contact.lipHeight` | m | ≥ 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.bump` | 0..1 | A 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.

| Field | Unit | Notes |
|---|---|---|
| `water.thrusters` | array | `{ 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.topSpeed` | m/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.reverse` | 0..1 | Reverse thrust from `brake`, as a fraction of forward. **Defaults to 0.4.** |
| `water.rudder` | object | `{ 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.keel` | object | `{ 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.planing` | object | `{ 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.righting` | 1/s² | ≥ 0. A torque toward upright while the hull is in the water. **0 (the default) is off**: a capsized hull stays capsized. |
| `water.bow` | Vec3 | Where spray starts. Absent: the middle of the front edge of the chassis collider's bottom face. |
| `water.slamSpeed` | m/s | ≥ 0. The downward speed at which the hull hitting the water is a slam. **Defaults to 2.** |
| `water.blendTime` | s | ≥ 0. The wheels-to-water handover time. **Defaults to 0.4.** |
| `water.yawDamping` | 1/s | ≥ 0. The hull's resistance to yawing in the water. **Defaults to 0.8.** |
| `water.maxYawRate` | rad/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.yawRateSpeed` | m/s | > 0. The speed at which `maxYawRate` is reached. **Defaults to 6.** |
| `water.lockAtTopSpeed` | 0..1 | The share of the steering lock (rudder, nozzle) left at `topSpeed`, falling linearly from full lock at rest. **Defaults to 0.6.** |
| `water.bank`, `water.bankStiffness` | rad, 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.trimStiffness` | rad, 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.

| | sedan | suv | pickup | van |
|---|---|---|---|---|
| mass (kg) | 1500 | 2100 | 2400 | 2600 |
| wheelbase (m) | 2.80 | 2.90 | 3.40 | 3.20 |
| track (m) | 1.55 | 1.65 | 1.70 | 1.70 |
| wheel radius (m) | 0.32 | 0.36 | 0.39 | 0.36 |
| drive | FWD | AWD 40/60 | RWD | RWD |
| gears | 6 | 8 | 6 | 6 |
| differential | open | lsd | locked | open |
| peak torque (N·m) | 205 | 385 | 480 | 420 |
| ground clearance (m) | 0.145 | 0.20 | 0.22 | 0.20 |
| steering lock (rad) | 0.60 | 0.55 | 0.52 | 0.50 |
| shift up (rpm) | 5900 | 5600 | 5200 | 4000 |

### 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):

| Ask | Block | What it does |
|---|---|---|
| Drift and boost tiers | `drift` (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 assist | `handling` | No 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 / respawn | `recovery` | Flipped, 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 assist | `contact` | A 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 road | `offRoad` | Slower 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`:

| preset | `collider.size` | `collider.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:

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

| Code | Severity | Fires when |
|---|---|---|
| `vehicle-invalid` | error | `preset` 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-invalid` | error | An 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-brake` | warning | No wheel has `steerable` above 0, or none has `braked` above 0. |
| `vehicle-needs-dynamic-body` | warning | No dynamic `rigidbody`, or no non-trigger `collider`. |
| `vehicle-geometry` | warning | A 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-implausible` | warning | A `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-absent` | warning | This 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):

| Action | Reads | Notes |
|---|---|---|
| `throttle` | button 0/1, or axis1 value | A negative value (S on a `["S", "W"]` axis) is a brake. |
| `brake` | button 0/1, or axis1 value | Held at |speed| < 0.5 with no throttle, reverses. |
| `steer` | axis1, -1..1 | **+1 right.** `["A", "D", "Left", "Right"]` binds both pairs. |
| `handbrake` | button 0/1 | Locks 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:

```ts
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:

```json
{ "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.

