# Writing a networked-ready game

**Multiplayer is coming; it isn't in this build.** You can't turn on a networked game yet. This page
is how to write a game now so that it will run networked when it arrives. A game written this way
plays the same in single-player today: nothing here costs a single-player game anything.

The worked example is **`samples/arena-ready`**: up to four players in an arena, drones that chase
them, blasters that fire on their own, a round clock and a scoreboard. Every rule below is in it, and
the file that shows each one is named as it comes up. Its scripts are short, so read them alongside
this page. `crates/app/tests/arena_ready.rs` plays it under a rollback client against a serial
reference and requires every confirmed tick to agree to the bit.

A second mode, **`fixtures/ai-authoring/core-capture`** (capture a core, with patrol drones), was written
by an author who had only this page and `packages/api/src/index.ts`. It runs under the same harness
(`crates/app/tests/core_capture.rs`), and what it had to fix is in its `AUTHORING-NOTES.md`. Where this
page says what a thing does, that mode is the second place to look.

The full rules are in the script API reference (`docs/API.md`, the networked-session list under
"Conventions") and in [NETCODE.md](NETCODE.md).

## How a networked game runs your scripts

Every player's machine runs the whole simulation, tick by tick, 60 times a second. Only inputs cross
the network. The machines stay in step only if every tick does exactly the same thing on every machine,
given the same inputs. Two things follow, and every rule on this page comes from one of them.

1. **Ticks run more than once.** A machine doesn't wait for the other players' inputs: it guesses
   them (each player keeps doing what they last did) and runs ahead. When the real input arrives, it
   puts the world back to the last tick everyone agreed on (a **restore**) and runs the ticks since
   then again (a **re-run**), now with the real inputs. At 100 ms of round trip that is about 8 ticks
   behind, so a tick can run nine times or more before it is final. Whatever a tick does must
   come out the same however many times it runs, and anything it keeps must be put back by the
   restore. **A restore puts back `ctx.state` and nothing else your script holds.**
2. **Everything that steers a tick must be the same on every machine.** The same scene, the same
   inputs and the same code must give the same bits everywhere. So nothing a machine has on its own may
   steer a tick: its wall clock, its window, what it draws, the order its hash table happens to be in.

A tick that ran on a guess is a **predicted** tick. Once every player's input for it has arrived, it
is run once more and becomes a **confirmed** tick.

## The rules in script terms

The engine's own determinism rules are numbered 30 to 35 (`docs/SPEC.md`, "Determinism guardrails",
and the lane rules). Here is what each one means for a behaviour.

| Rule | For the engine | For your script |
|---|---|---|
| 30 | Sim maths gives the same bits on every platform | `Math` already does. Write `Math.pow`, never `**`. Ask the engine to rank by distance; don't rank in JavaScript |
| 31 | No hash order, no wall clock | Sort by `id` where order matters. Never steer a tick with `ctx.debug.wallMs()` |
| 32 | The sim reads only recorded input and the sim camera | Aim from `ctx.input`, `ctx.camera.viewYaw` and the **sim** bone pose, never from what is drawn |
| 33 | Gameplay state that changes lives in `ctx.state` | No changing variable at the top of a module |
| 34 | Side effects are requests | Play sounds and particles from the tick that decides them. Let the score, the UI and the save follow confirmed ticks |
| 35 | Read the start of the pass, queue writes to others, settle by a fixed rule | `emitTo` the entity a change is for, let it apply the change to itself, and settle ties by a fixed rule |

### A behaviour file

```ts
import type { Behavior } from "@argus/api"                       // type only: it costs nothing at run time

interface State { score: number[] }
export default {
  init(ctx) { ctx.state.score = [0, 0, 0, 0] },
} satisfies Behavior<State>                                       // Behavior<State, Props>: `ctx.props` typed too
```

That import is the one every sample uses (`samples/arena-ready/scenes/behaviors/match.ts`). The project
checks with `strict` and `noUncheckedIndexedAccess`, so `batch.states[k]`, `list[i]` and `positions[3 * k]`
are `T | undefined`: guard them (`const s = batch.states[k]; if (s === undefined) continue`) or give a default
(`?? 0`). Type a position `Vec3`, not `number[]`, so `v[0]` is a number. The engine loads a script without
type-checking it, so run `tsc` over your behaviours before you trust them.

A scene needs `meta.strings` (an empty `{}` will do) or it is refused at load; `samples/arena-ready/tools/build-scene.mjs`
shows the whole `meta`. Asset paths are relative to the scene file (`scenes/models/...`, `scenes/assets/...`).

### Rule 33: all play state in `ctx.state`

A restore puts back each behaviour's `ctx.state` and nothing else. A variable at the top of a module
is not put back, so a re-run starts from whatever the last guess left in it, and the machines part
without an error.

```ts
// Don't: the predicted ticks count too, and a restore never takes them back.
let shots = 0
export default { update(ctx) { shots += 1 } }

// Do:
export default {
  init(ctx) { ctx.state.shots = 0 },
  update(ctx) { ctx.state.shots += 1 },
}
```

A networked session warns of this at load (**`script-module-state`**, with the variable, the line
and the hook). It does the same for:
- a module-level array, `Map` or object a hook changes, including one changed through a local alias or
  through a helper it is handed;
- a class instance kept at module level whose method writes `this`;
- a counter kept in a closure that a module-level call made (`const tick = makeCounter()`).

What is fine at module level:
- **Constants** (`const RANGE = 14`) and data read once at load.
- **A variable only `init` and `dispose` set**: a course read once, a handle looked up once. A re-run
  runs neither again. The exception is a write that reads its own old value (`id++`, `list.push(x)`),
  because `init` runs again for an entity a predicted tick spawned. Check a stored entity is `alive`
  before you use it.
- **A cache built on the first miss from nothing the game holds** (`table ??= build()`). A flag
  (`ready = true`) or a value taken from a hook's arguments is state, not a cache.

The warning reads the source text, so it misses state kept on `this` of the exported object, or written
into a module collection through a loop variable. The re-sim-twice check (below) finds those.

**In `updateAll`, `ctx` is the first instance's.** `ctx.props`, `ctx.entity` and `ctx.state` are the
first instance's own. Reach every instance through `batch.entities[k]` and `batch.states[k]`. A
per-instance prop such as a speed goes into each instance's state at `init`:

```ts
init(ctx) { ctx.state.speed = ctx.props.speed },            // each instance's own
updateAll(ctx, dt, batch) {
  for (let k = 0; k < batch.count; k++) {
    const s = batch.states[k]
    step(batch.entities[k], s.speed * dt)                   // not ctx.props.speed: that is drone 1's
  }
},
```

`samples/arena-ready/scenes/behaviors/drone.ts` does this.

### Rule 35: change other entities by sending them events

**Send the change to the entity it is for with `ctx.events.emitTo`,** and let that entity's own
handler apply it. Only that entity's behaviours hear it, so a hit costs one handler call. `emit`, by
contrast, costs one call for every behaviour in the scene. A timer can do the same with `{ to }`:

```ts
ctx.events.emitTo(drone, "hit", { by: s.player, damage: 1 })     // blaster.ts
ctx.time.after(0.5, "stun-over", {}, { to: target })
```

**Merge what arrives by a rule that doesn't care about order.** Add up the things that add (damage,
knock-back). Settle anything only one player can win (a kill, a pickup) by a fixed rule on ids or
player numbers, never by which handler ran first. A drone in the sample adds up the tick's hits in its
own state and settles them on its next step. When two players finish it in the same tick, the lower
player number gets the point:

```ts
onEvent(ctx, ev) {                                                // drone.ts
  if (ev.name !== "hit") return
  const s = ctx.state, d = ev.data as { by: number; damage: number }
  s.damage += d.damage
  s.by = s.by === 0 ? d.by : Math.min(s.by, d.by)
},
```

**Order by id yourself, not by which behaviour updates first.** Behaviours update in the order they
were added: the scene's order when it loads, then spawns as they happen. That order is the same on
every machine, but a small change to the scene changes it. When a result depends on order, sort by
`id`. Ids compare as plain strings (`"crawler#10"` comes before `"crawler#2"`):

```ts
const winner = claims.reduce((a, b) => (b.id < a.id ? b : a))   // the lowest id wins
```

`match.ts` sorts the ids it sends `phase` events to, so their order doesn't depend on the scene.

**When an event arrives.** `emit` and `emitTo` share one queue, delivered **at the end of the tick** that
sent them, in send order, and an event sent while delivering is delivered in the same tick (up to 8
rounds). So the receiver's `onEvent` runs in the same tick, after every `update` has run. Whatever it
changes (a position, its own state) is first seen by the next tick's reads, by that entity's `update` and
by everyone else's. A hit takes effect one tick after the shot that caused it. A confirmed-only module's
events are delivered at the end of the confirmed tick that sent them.
`fixtures/ai-authoring/core-capture/scenes/behaviors/drone.ts` knocks a player out this way.

**Finish a tick's work by the next tick.** A hit you carry in a variable across several ticks comes
back wrong after a restore. Carry it in `ctx.state`, as the drone's `damage` is.

### Rule 30: maths and distances

In a networked game, `Math` gives the same bits on every machine and operating system. `Math.sqrt`,
`Math.abs`, `Math.floor` and `Math.min` are fine as they are. `Math.random()` is fine too: each
behaviour draws from its own stream, seeded from the game, and a restore puts the stream back.

**Write `Math.pow(x, y)`, not `x ** y`.** The `**` operator bypasses `Math`, so a module that uses
it is refused when it loads.

```ts
const falloff = Math.pow(1 - d / radius, 2)   // not (1 - d / radius) ** 2
```

**Let the engine pick "the nearest".** Two candidates whose distances differ in the last bit can tie
in the engine's f32 measure and then order differently in your JavaScript doubles. Ask the engine
instead:
- `query` with `sort: "distance"` and `limit` for one point (`blaster.ts`);
- `nearestMany` for many points at once (`drone.ts`).

Both settle ties the same way on every machine.

```ts
const nearest = ctx.world.query({ tag: "drone", near: from, radius: RANGE, sort: "distance", limit: 1 })
```

### Rule 31: no clock, no accidental order

`ctx.time.now`, `performance.now()` and `Date.now()` all read the game's own clock, so they agree on
every machine. A cooldown on `ctx.time.now` or on `dt` is fine. `ctx.debug.wallMs()` is real time, for
measuring how long your own code takes. A value from it kept in `ctx.state`, or a branch taken on it,
differs between machines.

A plain object's keys and a `Map` iterate in insertion order, which is the same everywhere only if the
insertions happened in the same order. Sort when the order came from events or from the scene.

### Rule 32: aim with what the simulation has

What a player aims with must be something every machine has: their recorded input (`ctx.input`), the
sim camera (`ctx.camera.viewYaw`, the frame the engine turns a stick by), and the **sim** pose of a
bone. Not the drawn picture, not the window's size.

**Read bones from the sim pose when the answer decides anything.** `ctx.animator.bone(name)` has two
poses:
- the **sim** pose: what the clips play, the same on every machine and in every re-run;
- the **drawn** pose: after look-at, IK and a face's turns, which are only drawn.

A re-run draws nothing. So in a networked game `bone(name)` with no pose answers the sim pose, while
single-player answers the drawn one, as before. Say which pose you mean, and the script reads the
same everywhere:

```ts
const chest = ctx.animator?.bone("chest", { pose: "sim" })      // blaster.ts: where a shot starts
const look = ctx.animator?.bone("Head", { pose: "drawn" })       // a particle at the head: drawn only
```

### Rule 34: side effects are requests

`ctx.audio.play`, `ctx.particles.emit` and `ctx.particles.play` don't play anything during your hook.
They queue a **request**, which the engine carries out at the end of the tick. A re-run of a tick
throws its requests away, so a shot is heard once however many times its tick runs. So:

- **Play a sound or an effect from the tick that decides it,** right where the shot or the hit is
  worked out (`blaster.ts`, `drone.ts`). Don't wait for confirmation: a predicted shot must sound now.
- **Keep the handle `ctx.audio.play` returns if you need it.** The engine assigns handles in tick
  order, so they are the same on every machine and in `ctx.state`.
- **Let the UI and the save follow the game; never read them back to decide anything.** The screens
  and the save file are not in the world's snapshot. A tick that pushes a screen on a guess cannot take
  it back. Write the HUD from a `confirmedOnly` module (as `match.ts` does), or set it each tick from
  `ctx.state` so a re-run writes the same text. Write the save from a `confirmedOnly` module.

### The HUD and screens

There are two ways to put live text on screen, and they are not the same thing.

- **`ctx.hud`** (`set`, `score`, `timer`, `objective`) draws the engine's stock `hud` screen. Its text values
  are keys into `meta.strings` (a missing key reads `[[key]]`), so it can't show a live score line.
  `ctx.hud.timer(seconds)` and `ctx.hud.score(n)` take numbers and are fine for a clock and a score.
- **Your own screen**, written with `ctx.ui.set("screen:node-id", "text", value)`. This is what the sample
  does: `samples/arena-ready/ui/screens/hud.tsx` is a screen named `hud`, which replaces the stock one, and
  `match.ts` writes its chips each tick. Text that is built at run time needs `data={{ literal: true }}`
  on the node. A project with no `ui/` folder can register a screen from the script instead:
  `ctx.ui.screen({ format: 1, screen: "corehud", root: ... })`, then `ctx.ui.push("corehud")`
  (`fixtures/ai-authoring/core-capture/scenes/behaviors/match.ts`). The stock `hud` is always the bottom
  of the stack, so a pushed screen sits on top of it; use `clickThrough` so it doesn't catch input.

A confirmed-only module's `init` may register and push screens: `init` runs on load and again after a
restart. The stack is not in a snapshot, so guard a push with `ctx.ui.stack().includes(name)` as in
`match.ts`. Write the HUD from a confirmed-only module, as `match.ts` does, so it only ever shows
confirmed scores.

### Seats and benching

A seat is a player number, 1 to 4, held by a device or by a remote player. The scene's `players` block sets
the limit and how players join (`join: "press"`), and each seat has a player entity with
`{ type: "spawn", for: "player", player: n }`, driven by that player's input. Nothing moves the entity when
a seat fills: the game does.

- Park every figure off the floor in the scene (the sample's `BENCH`: a platform far from the drones), put
  only player 1 on the floor, and move a figure onto it when its seat fills.
- Find out who sits where in a confirmed-only module: `ctx.players.list()` in `init`, and
  `player-joined` / `player-left` events (both samples). Compare against what you last saw, and `emitTo`
  the figure a `seat` or `unseat`; the figure's own script teleports itself to its start or back to the
  bench (`arena-ready`'s `blaster.ts`, core-capture's `seat.ts`).
- The seat table is in the snapshot (`WorldState.players`): a restore puts every seat back on the entity it
  drove, by slot and generation, so a seat never drives whatever later fills a dead entity's slot. The
  harnesses still fill every seat before the first tick; a seat change in the middle of a networked match
  is not something either sample's test exercises.
- A seat with nobody in it gets no input, so its figure stays where it was put. A figure that is seated
  but must not play (knocked out, waiting to respawn) still gets its player's stick: a controller has no
  off switch. Teleport it to the bench and again every tick it must stay there
  (core-capture's `seat.ts`), or give it `input: "script"` and drive nothing.
- A seat survives `ctx.session.restart()`; the match fills its own seat list again from `players.list()`.

### Scoring and match flow: `confirmedOnly: true`

A goal scored on a guess can be un-scored a moment later, and a shop that sells on a guess can sell
twice. Give such a module `confirmedOnly: true`. It then runs only on confirmed ticks, a few ticks
(about 130 ms) late, on every machine at the same tick, and never takes anything back.

```ts
export default {
  confirmedOnly: true,                                            // match.ts
  onEvent(ctx, ev) {
    if (ev.name === "drone-down") ctx.state.score[(ev.data as { by: number }).by] += 1
  },
}
```

Use it for:
- **Scoring:** points, kills, laps, objectives.
- **Match flow:** the countdown, round start and end, the winner, seats filled and emptied, respawn
  waves.
- **AI directors:** what spawns where and when, difficulty changes, and any decision every machine
  must make the same way.
- **Economy:** purchases, rewards, inventory and currency.
- **What the player reads as final:** the scoreboard, the results screen, the save.

How it behaves:
- On a predicted tick the module runs nothing: no `update`, no event hook, no `ctx.events` listener.
  `init` and `dispose` still run.
- An event sent to it on a predicted tick is not lost. That tick runs again once confirmed, sends the
  event again, and the module hears it then. So the drones can send `drone-down` at once and the match
  counts it when the kill is final.
- Events it sends go out on the confirmed tick, at the same tick on every machine. `match.ts` starts
  and ends the round by sending `phase` to every player and drone.
- Its `ctx.state` is saved and restored with the rest.
- **Its reads see the confirmed tick, not the newest one.** The module runs inside the re-run of
  confirmed tick *t*: the world is as it was at *t* (every player's real input in), not the predicted
  head some ticks ahead. `ctx.world.query`, `positions` and `players.list()` all answer for *t*, the same
  on every machine, so a capture zone read from `query` in `match.ts` of core-capture is judged on where
  the players really stood. The things it emits are delivered at the end of *t* and then re-predicted
  forward with the rest. A confirmed-only module that must count time counts its own `update` calls
  (`ctx.state.ticks += 1`): it runs once per tick, never twice.
- The UI is not in a snapshot, so write it so that doing it twice is harmless: `ctx.ui.set` is
  already change-gated, but a screen can be on the stack only once, so `match.ts` pushes `results`
  only when `ctx.ui.stack()` does not hold it. (The re-simulation check cannot judge the tick that
  pushes a screen: it runs the tick twice and the second run finds the screen and its focus already
  there, so it reports a UI-state mismatch that is the check's limit, not the script's. Run it over the
  play, as `arena_ready.rs` does, and not over the tick the results go up.)

**Restarting for another round.** `ctx.session.restart()` rebuilds the scene when the tick ends. Frame and
clock (`ctx.time.frame`, `ctx.time.now`) go back to 0; every behaviour's `ctx.state` is gone and its `init`
runs again; pending `ctx.time.after` timers and the event bus are emptied; controllers go back to their
spawns; the screen stack starts again with only the stock `hud` (a screen your script pushed, the results
among them, is gone, and a screen your script registered must be registered again in `init`). The seat
table and the player's key bindings are kept. A match that restarts after its results needs no cleanup
code for any of this. **Don't restart inside a rollback session yet.** A restart asked for in a re-run
waits for the next visible tick (`World::step_rerun`), which is not the same tick on every machine, so a
client parts from the reference at the restart. The rollback tests end their window before it
(`arena_ready.rs`, `core_capture.rs`); a solo test plays through it
(`a_solo_round_ends_in_results_and_restarts_clean`).

Leave movement, aiming, the camera, your own shots and hit effects as they are. They must answer at
once, and the rollback corrects them. A module with `confirmedOnly` does nothing different in
single-player.

### Many entities: `updateAll`

A re-run runs every behaviour again, so a behaviour that 100 units carry costs its `update` × 100 ×
re-runs, every frame. Give such a module `updateAll` instead. The host calls it once a tick with every
instance, and the batch reads answer for all of them at once:

```ts
updateAll(ctx, dt, batch) {                                       // drone.ts
  const at = ctx.world.positions(batch.entities)                  // Float32Array, 3 per entity
  const players = ctx.world.query({ tag: "player" })
  const nearest = ctx.world.nearestMany(at, ctx.world.positions(players), 30) // Int32Array; -1 = none
  for (let k = 0; k < batch.count; k++) {
    const s = batch.states[k]                                     // that instance's ctx.state
    // ...move batch.entities[k] towards players[nearest[k]]
  }
},
```

- Keep per-instance data in `batch.states[k]`, never in module variables.
- `nearestMany` gives equal distances to the lower index, so every machine picks the same target.
- `raycastMany` casts many rays in one call, with `excludeEach` for each shooter's own capsule.
- Read every position at the top of the call, then write. Each instance then sees the same start of
  the tick, whatever order the batch is in (rule 35).
- **The call comes at the first instance's place in attach order.** A module that updates before the
  batch's first instance sees the world as the tick started; one after it sees the batch's writes
  (`moveToward`, `teleport`) already made. Don't depend on it: read what another module wrote from
  events, or sort by `id` (rule 35).
- **A batch call takes one radius.** `nearestMany(from, to, radius)` has a single `radius`. If each
  instance has its own (`batch.states[k].hitRadius`), pass the largest, then test the distance to the
  answer against that instance's own radius in the loop. Don't pass one instance's value for all.

A behaviour on a handful of entities can keep a per-instance `update`. That's right when it reads
something only its own `ctx` has, such as its animator's bones. The sample's blaster does this for its
four players. The exact rules are in `docs/API.md` (Changes: "One call for every instance").

### Movement: let the engine drive

A controller with `input: "auto"` (the default) takes its seat's stick, jump and turn straight from
the recorded input. That movement is the engine's, it is in the snapshot, and it needs no script. The
sample's players have no movement script at all. A controller's entity position is at its **feet** (the
bottom of its capsule), so a zone test or a hit test against a player works on the ground plane, and
a point that must hit the body is about 0.9 m above the position. When a game needs its own movement (a dash, a
coyote-time jump), use `input: "script"` and drive `ctx.controller` from `ctx.input`. Keep the state
the movement needs, such as timers and the last jump, in `ctx.state`.

## Don't

**Don't use `async`, `await`, `Promise` or `queueMicrotask`.** A module that does is refused when it
loads, because a promise's code would run at a different moment on each machine. Wait on the tick
clock instead:

```ts
export default {
  init(ctx) {
    ctx.time.after(2, "fuse-done", { power: 3 })        // not: await sleep(2); explode()
  },
  onEvent(ctx, ev) {
    if (ev.name === "fuse-done") explode(ctx, (ev.data as { power: number }).power)
  },
} satisfies Behavior
```

**Don't recurse deeply.** A networked game stops any callback more than 256 calls deep, with a
`RangeError` ("call depth exceeded"). A flood fill over a 32 x 32 grid can recurse 1,024 deep. Use a
list you push to and pop from:

```ts
const todo = [start]
while (todo.length > 0) {
  const cell = todo.pop()!
  // ...visit cell, push its unvisited neighbours onto todo
}
```

**Don't change the built-ins.** `Math`, `Array.prototype` and the `ctx` handles are frozen for every
behaviour, networked or not. Patching one throws a `TypeError`.

**Don't write into another entity's state.** Send it an event (rule 35). Nothing stops a script from
setting another entity's position, but then the result depends on which behaviour ran first.

**Don't keep a handle to an entity across ticks in a module variable.** Keep its `id` in `ctx.state`
and look it up again (`ctx.world.find(id)`).

**Don't rely on a member the browser doesn't have yet** if browser players can join the match. In a
mixed session (browser and desktop players together) a desktop machine gets only what the browser
supports, and a behaviour that calls anything else stops with `WebUnsupportedError` on every machine.
The table under "Conventions" in `docs/API.md` lists what is missing and when it comes.

## Check it before it's networked

1. **Play it alone.** A networked-ready game is a single-player game first. Play the whole loop: the
   start, a round, the end, and playing again.
2. **Read the load warnings.** A networked session lists every `script-module-state` it finds at load,
   and each one names the variable and the line. Play a few seconds and read `script-state-handle` too:
   it names a `ctx.state` path that holds an entity handle (or another engine object), which a
   snapshot keeps as plain fields, so after a restore it moves nothing. Keep the id instead
   (`ctx.entity.id`) and look it up with `ctx.world.find(id)`.
3. **Run the re-sim-twice check.** `POST /desync { "resim": true }` (the `play.desync` tool's
   `resim`) runs every tick twice from the same snapshot while you play. The first time the second run
   differs from the first, it names the tick, the system, the entity and the field. It raises
   `resim-mismatch` once (`GET /events?filter=script_warning`) and keeps the report at `GET /desync`.
   - Add `"depth": 8` (the rollback depth at about 100 ms) to re-run from up to 8 ticks back. That
     also catches a value a tick reads before a later tick writes it, such as a last-hit time or a
     latch. A one-tick re-run cannot see those.
   - Play the busiest moments with it on: a fight, a round ending, a player joining.
   - `GET /desync/record` counts what it checked.
   - A clean game reports no mismatch. A mismatch in a script field means that behaviour keeps state
     outside `ctx.state`.
4. **Engine developers: the rollback harness.** `crates/app/tests/arena_ready.rs` is the full check,
   and a copy of it can drive any scene. Four players walk seeded paths behind a modelled network
   (100 ms and 250 ms of round trip, jitter, 1% loss). A client restores, re-runs and predicts as a
   networked game will, and every confirmed tick must equal a serial reference's to the bit.

### The checklist

- [ ] Everything that changes during play is in `ctx.state`; module level holds only constants, load-time data and caches.
- [ ] No `async`, `await`, promises or `**`; no deep recursion.
- [ ] A change to another entity is an `emitTo` its own handler applies; simultaneous claims settle by a fixed rule.
- [ ] "The nearest" comes from `query` (`sort`, `limit`) or `nearestMany`, not from a JavaScript sort on distances.
- [ ] Order that matters is sorted by `id`.
- [ ] No `ctx.debug.wallMs()` in anything a tick keeps or branches on.
- [ ] Bones that decide something are read with `{ pose: "sim" }`.
- [ ] Sounds and effects are played from the tick that decides them.
- [ ] Scores, match flow, directors, purchases, the save and the results are in `confirmedOnly` modules.
- [ ] A behaviour on many entities uses `updateAll`, and takes per-instance props from `batch.states[k]`.
- [ ] Behaviours pass `tsc` under `strict` and `noUncheckedIndexedAccess`; the scene has `meta.strings`.
- [ ] Seats: figures start on a bench, and the match moves one onto the floor when its seat fills.
- [ ] The re-sim-twice check at depth 8 stays clean through a full round (it can't judge the tick that pushes a screen, so end its window before the results).

## If something goes wrong

- **"module '...' uses `async` (line 12): this session refuses promises, async and await"** (or
  "uses `**`"): the message names the module and the line. Change that line as above.
- **`script-module-state`** at load: move the named variable into `ctx.state`, or make it a constant
  or a cache (rule 33).
- **`script-state-handle`** while playing: the named `ctx.state` path holds an entity handle (or
  another engine object). Store its id (`handle.id`) and look it up with `ctx.world.find(id)` where
  you use it. It is said when a snapshot is taken, so it appears within a tick of the handle being stored.
- **`resim-mismatch`** from the re-sim-twice check: the named field took a different value the second
  time. In a script, look for state outside `ctx.state`. In a sound or effect handle, look for one kept
  from a tick that was thrown away. `GET /desync/dump?system=&entity=` shows the field now.
- **"call depth exceeded"**: a recursion is too deep. Use a list, as above.
- **`WebUnsupportedError`**: the behaviour calls something browser players can't run yet. Leave it
  out of a mixed match, or wait for it to come to the browser.

