# Substrate physics pack — AI construction rules

Physics is part of the **default medium**, not an optional library the model invents.

```
AI constructs through pack → Rapier simulates → thing() owns inputs → DevTune tunes → Keep
```

## Import

```js
import { ensurePhysicsWorld } from '../../../physics/pack.js'; // path from your gen place
import { thing, bind } from './substrate.js';

const pack = await ensurePhysicsWorld({
  thing,
  rapierUrl: /* URL to shell/vendor/rapier.mjs or pack-local vendor */
});
```

In the game loop:

```js
pack.stepPhysics(dt); // fixed internal 1/60; pass wall-clock dt
```

## Build solids (do this)

```js
// Floor — STATIC. Declare only what a fixed body can obey.
const floorTune = thing('physics.body.floor', {
  friction: 0.8, restitution: 0.05,
  sizeX: 20, sizeY: 0.5, sizeZ: 20
});
pack.staticBody('floor', {
  mesh: floorMesh,
  tune: floorTune,
  position: { x: 0, y: -0.25, z: 0 },
  shape: 'box'
});

// Crate — dynamic; Rapier owns motion
const crateTune = thing('physics.body.crateA', {
  mass: 2, friction: 0.55, restitution: 0.2,
  linearDamping: 0.04, angularDamping: 0.04,
  sizeX: 1, sizeY: 1, sizeZ: 1
});
pack.dynamicBody('crateA', {
  mesh: crateMesh,
  tune: crateTune,
  position: { x: 0, y: 4, z: 0 },
  shape: 'box'
});
bind('physics.body.crateA', crateMesh);
```

World gravity is owned as `physics.world` (`gravityX/Y/Z`) inside `ensurePhysicsWorld`.

## Do not do this

| Anti-pattern | Why |
|---|---|
| `mesh.position.y -= g*dt` on a dynamic crate | Dual ownership — fights Rapier |
| Raw `new RAPIER.World` in game files | Bypasses pack, ownership, Keep |
| Fake solidity with only a Box3 / ray | Not simulated; designer dials lie |
| Putting body props in non-literal objects | Keep cannot write (no byte range) |
| Invented ranges on dials | Use rangeless steppers unless authored |

## Designer dials (minimum)

| Thing | Keys |
|---|---|
| `physics.world` | gravityX, gravityY, gravityZ |
| `physics.body.<id>` **static** | friction, restitution, sizeX/Y/Z or radius |
| `physics.body.<id>` **dynamic** | the above **plus** mass, linearDamping, angularDamping |

⚠️ **A static body must not declare mass or damping.** `RigidBodyDesc.fixed()` never reads them, so
each one becomes a dial the designer can drag that can never do anything — "spin drag" on something
that cannot spin. Reported from play as inspector noise, and the docs taught it: this table used to
list one key set for both kinds.

Live: pack re-reads owned settings each `stepPhysics` — **static bodies included.** `applyOwnedProps`
skipped every static body until 2026-08-05, which made a floor's `size` and `friction` fake-live:
the number moved, nothing else did. Found by an outside reviewer building on the shipped 0.1.3. The
gate is now `substrate/physics/pack.test.mjs`, and it measures the solver, never the tune.

Keep: writes the **input** literals in source, not solver state.

## Character controller

Kinematic capsule + Rapier `CharacterController` live in this backend (`createCharacter` /
`characterMove`) and are consumed by the **movement pack**. Games should not open raw
controller APIs — use `createMovementPack`.

Shell Make Solid remains a separate path for stranger games (dual-world residual).

## Rung 1 — contact honesty (construction, not dials)

Contact quality is **pack construction**, not Keep-owned inputs. See `PHYSICS_RUNGS.md`.

Exported as `CONTACT` from `rapier-backend.js` / `pack.js`:

| Constant | Role |
|---|---|
| `skin` (0.05) | Collider contact skin on static/dynamic solids |
| `characterOffset` (0.08) | CharacterController gap from solids (main play-feel lever) |
| `characterMass` (80) | Impulse scale when pushing dynamics — stops infinite ball rockets |
| `autostepIncludeDynamics` (false) | Step static ledges only — not onto crates/balls (was causing capsule-in-ball) |
| `softCcd` + CCD on dynamics | Reduce tunneling on fast crates/balls |
| `solverIterations` / `internalPgs` / `maxCcdSubsteps` | Stiffer contacts, more CCD budget |
| `snapToGround` / `autostepMinWidth` | Ground stick + stair ledge policy |

**Do not** put skin/offset on the designer surface as fake dials.  
**Do not** apply contact skin on the kinematic character capsule (breaks ground queries).

### Residuals (honest)

- Geometric mesh vs skinned collider: slight visual gap is preferred over melt-through.
- Continuous raw gravity into the controller (test anti-pattern) can still sink; the movement pack zeros vertical vel when grounded — that is the supported path.

## Rung 2 — shapes match art (construction)

Mesh ≈ solid under shove. New **shape kinds** under the same `staticBody` / `dynamicBody` API.
Exported budget: `SHAPE` from `pack.js` / `rapier-backend.js`.

### Collider inset (all box/ball)

Visual mesh keeps full `sizeX/Y/Z` or `radius`. The **collider is inset** by `SHAPE.colliderInset` (= `CONTACT.skin`, currently 0.05 m) so soft contact does not melt art and rest bottoms sit flush. Not a Keep dial.

### Shape kinds

| `shape` | Kind | Notes |
|---|---|---|
| `'box'` | static/dynamic | Default. Inset half-extents from tune sizes. |
| `'ball'` | static/dynamic | Inset radius from tune. Mesh must be **unit-radius** (`SphereGeometry(1)`); pack scales by `radius` (not diameter). |
| `'compound'` | static/dynamic | `parts: [{ shape, size\|halfExtents\|radius, translation }]` — max `SHAPE.maxCompoundParts` |
| `'convex'` | static/dynamic | `vertices` (flat xyz or `[{x,y,z}]`) — max `SHAPE.maxConvexVertices` |
| `'trimesh'` | **static only** | `vertices` + `indices` — max `SHAPE.maxTrimeshTriangles`. Dynamic refused. |

```js
// Compound bench (one body, three colliders)
pack.dynamicBody('bench', {
  mesh: benchGroup,
  tune: thing('physics.body.bench', {
    mass: 5, friction: 0.65, restitution: 0.08,
    linearDamping: 0.06, angularDamping: 0.06
  }),
  position: { x: 0, y: 1.2, z: 2.2 },
  shape: 'compound',
  parts: [
    { shape: 'box', size: [1.6, 0.15, 0.4], translation: { x: 0, y: 0.35, z: 0 } },
    { shape: 'box', size: [0.15, 0.35, 0.4], translation: { x: -0.7, y: 0.1, z: 0 } },
    { shape: 'box', size: [0.15, 0.35, 0.4], translation: { x: 0.7, y: 0.1, z: 0 } }
  ]
});

// Convex from points (local space)
pack.dynamicBody('rock', {
  shape: 'convex',
  vertices: [/* x,y,z,... */],
  tune: rockTune,
  position: { x: 0, y: 2, z: 0 }
});

// Static mesh collider (terrain / detailed floor)
pack.staticBody('terrain', {
  shape: 'trimesh',
  vertices: Float32Array,
  indices: Uint32Array,
  tune: terrainTune,
  position: { x: 0, y: 0, z: 0 }
});
```

Helpers (no three import required in pack core):

- `extractMeshVertices(mesh)` / `extractMeshIndices(mesh)` — from `pack.js` for three-like meshes
- Over-budget convex: stride-sampled down to max vertices (honest residual)

### Do not

| Anti-pattern | Why |
|---|---|
| Dynamic trimesh | Unstable + tab death — pack throws |
| Raw `ColliderDesc.convexHull` in game | Bypasses budget + ownership |
| Keep-owned `colliderInset` / vertex lists as fake dials | Construction only; material keys stay mass/friction/… |

### Residuals (honest)

- Inset + skin still allow *soft* solver penetration under extreme shove — less visual melt, not zero.
- Full mesh fidelity on dynamics = convex approximation, not every triangle.
- Joints (doors/lids) = **rung 3**, not released.

## Proof scene

`substrate/gen/physics-proof/` — floor + two crates + **compound bench** (rung 2), orbit, Space tosses.  
`substrate/gen/play-pass/` — full medium stack; box/ball crates pick up inset automatically.

```bash
node adapters/serve/serve.mjs substrate --port 5430 --shell
# → /gen/play-pass/  or  /gen/physics-proof/
node bridge/bridge-server.js
```
