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

```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"]`). `steer` +1 (D) turns **right**. `brake` held at a standstill **reverses**. An action the active context does not bind reads 0 — while a menu owns input, the car sits still.
- **The wheel entities are drawn where the simulation has the wheels**: moved with the suspension, turned by the steering and spun about their own axle as the car rolls, on top of the rotation you gave them. A wheel modelled in parts beside an empty mount names them in its `visuals` (§2).
- **The preset sets everything else** — mass, the wheels' size, springs and roles, engine, brakes, tyres, steering lock. `sedan`, `suv`, `pickup`, `van` (§3). The preset's wheel on the same corner fills each `wheel` (§3); a field on the `wheel` component wins. The box above is the one the sedan preset was tuned against. Override any vehicle field: `"steering": { "maxAngle": 0.5 }`, `"chassis": { "mass": 1300 }`.
- **The chassis mass is the vehicle's**, whatever the `rigidbody`'s `mass` says: the springs and the drive are tuned against it.
- **It is the player** if the scene names no `spawn { for: "player" }` and has no `controller`: device input and an entity-less `POST /input` go to it.
- **A chase camera** is the engine's own third-person rig, from the game's behaviour: `ctx.camera.follow(ctx.entity)` and `ctx.camera.set("third-person", { distance: 7, pitch: -0.25, eyeHeight: 1.2, recenterDelay: 0, lookAhead: 0.3 })`. `recenterDelay: 0` swings the view back behind the car as it turns.
- **A behaviour reads it** through `ctx.vehicle` (`speed`, `steer`, `gear`, `wheelsOnGround`; §6), and a tester through `GET /entities?fields=vehicle` (§7).

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

### A cart from bare wheels

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

```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 400..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.15..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`. 0 is off. |
| `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.

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

### model

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

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

---

## 3. Presets

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

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

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

### The chassis collider each preset assumes

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

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

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

### How a preset resolves

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

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

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

---

## 4. How it drives

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

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

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

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

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

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

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

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

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

### `ctx.wheel`

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

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

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

---

## 7. What a tester reads

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

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

