# Netcode

Rollback netplay runs on a local network or a tailnet (November 2026): every machine runs the whole
simulation, only inputs cross the network, and a relay that never runs the game keeps everyone in step. Internet
hosting (QUIC, NAT) and lag compensation are not built yet; see "Not built yet" below.

The first section is how to run a session; the rest are the rules scripts and the host run under in one.

## Running a session

Every machine needs the same build of the engine and a **byte-identical** copy of the scene folder: the scene's
SHA-256, the engine's version and its determinism digest, the tick rate and the seed are the session's identity,
and a peer whose identity differs is refused (`net-identity-mismatch`). Build the scene once and copy the folder.

1. **Seats.** Player N of the session is the entity whose `spawn { for: "player", player: N }` says N (1 to 1024).
   A session has as many seats as `--players` says; give every seat an entity, each with its own position. Players
   1 to 4 are also the local co-op seats outside a session, exactly as before; a number above 4 means nothing
   outside one. `meta.players` (couch co-op) is not used by a session.
2. **The relay**, on one machine: `argus relay --players N [--bind 127.0.0.1:47900] [--tick-rate 60] [--grace 3]
   [--start-after S] [--stats-http 127.0.0.1:47913] [--stats-csv FILE]`. It seats peers in arrival order (or the
   seat a peer asks for), starts the clock when every seat is taken (or `--start-after S` seconds after the first
   peer, empty seats standing still), and sends every tick's input changes to everyone. It never runs the game.
   `--stats-http` serves a live load page (`/`) and its numbers (`/stats.json`). On a machine whose tailnet runs in
   userspace mode, bind `127.0.0.1` and reach it by the machine's tailnet address.
3. **Each player's machine**: `argus run <scene> --window --net-join ws://<relay>:<port>` (with `--seconds S`, the
   window closes once S seconds of ticks are confirmed and prints the result line a headless peer prints). The world loads with the
   networked script host (`HostConfig::networked()`), runs no prewarm ticks, and plays as the seat the relay gives:
   its keyboard, mouse and pad drive only its own player; every player's input reaches their entity; it rolls back
   and re-simulates as inputs arrive, predicting the 20 players nearest its own.
4. **Load-test bots**: `argus net-bot --relay ws://... --count N` take seats and send neutral input, simulating
   nothing.
5. **Headless test peers**: `argus run <scene> --net-join ws://... --seconds S` (no window) walk their player with a
   scripted walk until S seconds of ticks are confirmed, then print one JSON line: the seat, the confirmed tick, the
   digest of the last asked tick and any desync. A test across machines compares those digests.

The relay compares the digests every machine reports (every 30 confirmed ticks) and names the players whose state
differs (`Desync`); a machine logs `net-desync` when told.

A machine shows the tick the relay is about to seal and runs its own clock between bundles: a little fast while it
is behind its lead over the last bundle (the relay's grace plus 2 ticks), a little slow while ahead, within 10 %,
so it neither jumps nor waits. A headless peer reports how often the clock still had to jump forward
(`clockSnaps`) or wait (`clockStalls`); in a 30 s run across two machines, each peer jumped once (at the start)
and never waited. Between ticks the picture is interpolated from the machine's own clock; a correction from a
rollback shows as a step of the corrected entity (no smoothing yet).

### Cameras and HUDs: each machine draws its own player's

In a session, `ctx.views` takes view numbers up to 1024: view N is player N's camera (view 1 is `ctx.camera` as
ever), and each machine draws only its own player's view, once a script has set it. A behaviour on player N's
entity writes its camera with `player === 1 ? ctx.camera.follow(...) : ctx.views.follow(player, ...)`. Outside a
session a view above 4 is still `views-out-of-range`. Every player's view rig is advanced each tick on every machine
(a first-person eye stands at its height, and an `inputBasis: "camera"` stick turns by its own player's view).

HUDs work the same way: `ctx.ui.push(name, props, { player: N })` pushes a screen as player N's, and
each machine draws the screens pushed without a player and its own player's only (on one machine, in a local
split screen, each player's screen is drawn in their own view instead). Every machine pushes
and updates every player's HUD alike (the scripts run the same everywhere); only the drawing differs. A
script never asks which player this machine is: a script that branched on it would desync the session.

A first-person player's own body: `ctx.views.hide(N, entity)` stops `entity` and everything under it being
drawn in view N, so on player N's machine only; every other player still sees it. Run it alike on every
machine (for each seat, `ctx.views.hide(player, self)`). Drawing only, outside the state hash. Outside a
session the views of a split screen share one set of meshes for now: hiding from view 1 hides in every
view, and hiding from views 2 to 4 does nothing, until per-view drawing comes with per-viewport layout.

### Driving a window from a program (`POST /input`)

A window in a session (`--window --serve`) drives its OWN player from the query API: a held action, axis,
`look` or `move` joins the input this machine samples and sends, so every peer applies it on the same
tick, whichever entity the request names. A key (`{"type":"key","code":"KeyW","down":true}`) and a mouse
delta go in as the window's own devices do. A `steer` hold (which moves a controller directly) and a
seat hold cannot cross the wire and are dropped, with one log line. An unattended test drives each
window this way and captures it (`POST /capture` shows that machine's own player).

### Tools beside a session (a DIS gateway)

- **Observer**: `argus run <scene> --headless --net-join ws://... --net-observe` takes no seat and sends no input; it
  simulates from the relay and reports digests like a player.
- **Confirmed state out**: `--state-stream 127.0.0.1:47912` (with `--net-join`) serves each confirmed tick's moving
  entities (every dynamic or kinematic body) to WebSocket clients, one JSON line a tick:
  `{"tick", "entities": [{"id", "position", "rotation" (x, y, z, w), "velocity"}]}`, in scene coordinates.
- **Foreign entities in**: `argus net-bridge --relay ws://...` reads lines on stdin,
  `{"seat": N, "axes": {"posXZ": [x, z], "posY": y, ...}, "held": ["flag", ...]}`, and sends each as seat N's input
  from the next tick the relay has not sealed. Axes cross as raw `f32`: no clamping, deadzone or rounding. A proxy
  behaviour on player N's entity reads `ctx.input.axis("posXZ")` and places itself, so every machine moves it alike.

### Tick rate

A scene's `meta.tickRate` (1 to 240, default 60) is the session's tick rate too; give `argus relay --tick-rate` the
same number. Below 15 warns (`tick-rate-low`). Large sessions run comfortably at 30.

### Predictions and corrections: when and how they are shown

Each machine shows the present before every input of it is known: it guesses that another player keeps doing
what they last did. When a player's input changes and the change reaches a machine after the tick it applies to
(a key pressed, a stick turned, the mouse moved), that machine guessed wrong; it rolls back and re-runs, and the
other player's entity moves to where they really are. Determinism makes every machine end up the same, not the
guesses right. On a local network or tailnet with the default input delay, a change is usually there in time;
each one that is not is a correction. A headless peer reports how many ticks it ran on a guess (`guessed`), how
many of those were wrong (`mispredicted`), the corrections they caused (`corrections`) and the largest
(`largestCorrection`, metres). In the engine's test, a player turning every quarter second was mispredicted on 10
of 150 ticks; drawn with the default smoothing, the largest step on screen was 0.08 m, against 0.34 m snapped.

A game chooses, in its scene (`meta.netplay`), and any machine can override (`argus run` flags):

| | `meta.netplay` | flag | default |
|---|---|---|---|
| **When** other players are corrected | `remotes`: `"predicted"` (shown live, corrected when wrong) or `"confirmed"` (shown as of the last tick every input is known for: a few ticks behind, never corrected) | `--net-remotes` | `predicted` |
| **How** a correction is drawn | `smoothMs`: blended away over this many milliseconds, 0 to 1000; 0 snaps | `--net-smoothing` | 100 |
| ...except a jump | `snapAbove`: a correction larger than this many metres snaps | `--net-snap-above` | 2 |

Drawing only: the options move what is drawn (render-only transforms on every player's entity, and everything
under it), never what is simulated, so machines with different options still agree on every digest. A training
scenario that must never show a trainee somewhere they were not uses `"confirmed"`; an action game uses the
default.

### Joining late, dropping out and rejoining

A machine that joins after the clock started (`--net-join` as ever) is seated in a free seat (or the one
`--net-seat N` names, player N) and joins from a peer's state: the relay asks a machine already playing (the
donor) for its world after its last confirmed tick, sent as the engine's full snapshot (`World::snapshot_state`,
zstd-packed: 1 KB for a two-player test scene, about a fifth of the JSON in general), and forwards it with every
tick sealed since. The joiner restores it, catches up through those ticks, and plays on; its digests are
judged with everyone's. The relay keeps 20 s of ticks for this (`HISTORY`); a donor that does not answer in 10 s
is passed over for the next, and with no machine to ask (only bots) the joiner is refused (`net-no-donor`).

A machine that drops out stands still from the next tick, and its seat is free again: the same player starts
the game again with `--net-seat N` and rejoins as above. The donor keeps playing throughout (it reads its
confirmed state and puts its world back), so nobody else notices a join beyond a moment's work on one machine.

### Measured (3 November 2026)

- 2 machines (Intel Mars, AMD Phoebes) over the tailnet: one digest at tick 600, no desync.
- 10 headless clients on 4 machines (Mars, Phoebes, Ares, Triton) and 100 bots, 110 seats, 20 s at 60 Hz: one
  digest at tick 1200 across all 10, no desync, 7 late inputs in 25 s (each applied on the next tick everywhere);
  the relay used 2.6 % of one core and sent 56 kB/s.
- A training scenario (110 seats, TypeScript behaviours) with 3 clients and 107 bots: one digest at tick 900.

### Not built yet

- Lag compensation, and predicted sounds and particles cancelled by a correction (a corrected entity is smoothed: "Predictions and corrections" above).
- Internet hosting: QUIC, encryption, NAT; exported games online (the export engine has no `checkpoint`).
- The browser: a web peer has views 1 to 4 only, and does not join a session yet.

## Mixed sessions

A match may have browser peers and desktop peers together.

1. **Physics agrees.** The browser build steps the same world bit for bit as the shipped desktop engine.
   - `crates/physics-digest` compares the two tick by tick, and both run Rapier's deterministic maths.
2. **Scripts agree** when every peer runs `HostConfig::networked().with_web_peers()`.
   - The preset counts calls, limits call depth to 256, refuses promise jobs, removes `SharedArrayBuffer` and gives
     scripts a `Math` that is the same bits on every engine and OS (`script_math.js`; `**` is refused, use `Math.pow`).
   - `with_web_peers()` gives desktop peers only the `ctx` members the browser serves.
   - Peers compare their identity in the handshake (`argus::PeerIdentity`): the engine and its version,
     `determinism_digest()`, the scene (the SHA-256 of its file's canonical JSON), the tick rate and the seed. The
     first that differs refuses the session with its `peer-*-mismatch` code (docs/RUNTIME-CODES.md, "Peers"). The
     digest alone is not enough: two peers on one build with different seeds pass it, then draw different
     `Math.random` numbers. Every peer loads the scene from its file: a world built from a struct is hashed by its
     parsed scene, which a file-loaded peer does not match.
   - A peer's first message is its hello (`argus::PeerHello`), the identity under a protocol number:
     `{"type":"hello","proto":1,"engine":"argus","version":"0.7.0","determinismDigest":"e3e6e2812b7d3f33","scene":"Arena","sceneSha256":"…","tickRate":60,"seed":"12345"}`.
     The seed and the digest are strings (decimal, 16 hex digits): a u64 does not fit a JS number exactly. On join,
     `PeerHello::accept` reads `proto` first and refuses another protocol, or a message that is not a whole protocol-1
     hello, with `peer-protocol-mismatch` before it compares any field; then it compares the identity. Each side checks the hello it received, and both refuse with the same code. A hello's fields change only
     with a new protocol number, so an older peer meets a clean refusal naming the protocol (or, when `proto` is not a
     whole number, saying the message is no hello it can read), never a half-read hello. A hello is read only in the
     form a peer writes it: lower-case hex, and a seed with no sign or leading zero.
3. **The cost is the browser's gap.** A behaviour that calls a member the browser does not serve throws
   `WebUnsupportedError` on every peer.
   - The members a desktop peer loses, and when each comes to the browser, are the generated table in `docs/API.md`
     (the networked-session section); `crates/host/src/web/served.json` is its source.
   - Every member the web host serves shrinks it.
4. **Sound is presentation.** `ctx.audio.play` gives the same voice numbers everywhere, and the browser plays it through
   WebAudio (kira's cpal backend). A browser holds sound until the player's first click, key or touch on the page, so a
   browser peer is silent until then; the voices and their numbers run all the same.
5. **Where it stands (20 Oct 2026).** Every sample (hop-lane, kart-starter, lantern-hollow, pebblewind-heights,
   twin-trail) and `games/duel-yard` runs in the browser with no script errors, on the web host the merge queue holds.
   - The browser serves 186 of the 270 handle members. The 84 left are in groups no sample's own code calls: joints,
     navigation, tilemaps, most of the camera's screen maths, the environment's readers, the audio mixer's bus and
     music controls, part of the animator, players and views, the world's spawn and grab queries, labels, render
     parameters, input rebinding and `entity.setParent`.
   - A behaviour that calls one of them still throws `WebUnsupportedError` on every peer of a mixed session.

