# Netcode

How a Voxel Party game stays in sync for 1–16 players, and how to prove it headless. Sessions
add people joining, leaving and the host changing mid-game: section 8 here, and `vp docs sessions`.

## Contents
1. The model
2. The building blocks (`Seats`, `PlayerSync`, `HostSync`, `Reckon`, `followReport`, `Reconciler`, `Predictions`, `History`)
3. Shape A: host-authoritative (sketch)
4. Shape B: per-player (sketch)
5. Shape C: discrete events (sketch)
6. Testing with `FakeRoom` and `FakeFlow`
7. Pitfalls
8. Sessions: a live roster and host changes
9. Shooters: the shooter decides what it hit
10. Shape D: deterministic lockstep (`Lockstep`), for RTS, tower defense and snakes
11. The host decides: actions, secrets and timed events (`HostSync`)

Block worlds (players placing and breaking blocks) have their own sync, `WorldSync`: `vp docs world` §7.

---

## 1. The model

- The server never runs game code. It relays messages, keeps a synced clock, collects one score
  per player and ranks them. Every client runs the game.
- Each client **owns** some seats: its own human, and on the host also every CPU. Offline and in
  `vp dev`/`vp check` there's one client, which is the host and owns every seat.
- `link.players[i]` (the same order as `ctx.players[i]`) has `control`, from this client's view:
  `'local'` (this keyboard), `'cpu'` (a bot this client runs; only the host sees these), or
  `'remote'` (someone else's).
- The golden rule: **your own actions feel instant.** You always move yourself locally and tell
  the others. Shared outcomes (who got the coin, who got hit) are decided by one authority, the host.
- `link.mode` is `'minigame'` (a board party's round: fixed roster) or `'session'` (a room that
  plays just this game: the roster changes mid-game, section 8).
- Timing: `link.now()` is the synced server clock (ms). `flow.clock` is seconds since GO on that
  clock, identical everywhere. Anything scheduled (FIRE moments, round starts, moving platforms)
  is a function of `flow.clock`, never of summed-up `dt`.
- Limits: a message is at most 16 KB and a state blob at most 8 KB. Each client may send about
  **60 messages/s (burst 120)**; over that the server drops messages silently, including scores.
  State streams are batched into one message per 20 Hz tick for all the seats you own (up to 16);
  `HostSync` adds 15/s; every `sendEvent` and `reportScore` is one more. A host's kept world
  (`link.keep`, section 8) may be up to 64 KB and goes out at most once a second.
- **What hasn't changed isn't sent.** Every message a client sends costs the room it plays in, so
  the link skips a state that's the same as the last one it sent for that player (it goes again
  once a second, so a newcomer still sees them), and `HostSync` does the same for a world at rest.
  A player standing still, a menu, a turn-based game waiting for a move: next to nothing. So a
  state is **what is true now**: no clocks, frame counters or random numbers in it (every message
  already carries when it was sent: `onState`, and events' `sentAt`), and round what doesn't need
  full precision (`r100`). Keep `sendState` and `PlayerSync.send` every frame: the skipping is the
  link's job. `vp check --long` warns about a field that keeps changing by itself.
- `link.sendEvent(type, data, to)` with `to` (a player id, or a list) goes to those players'
  clients only: the others never get the bytes. Use it for anything big meant for one player (a
  joiner's copy of the world). Players without a client of their own (CPUs, someone whose
  connection dropped) get nothing, and neither do you.

## 2. The building blocks

All from `@voxelparty/sdk/core`, three.js-free, so `rules.ts` and tests can use them.

```ts
const seats = new Seats(link);
seats.count;            // number of seats
seats.you;              // your seat index, or -1 (spectating, and in bots-only runs)
seats.role(i);          // 'local' | 'bot' | 'remote'   ('bot' = a CPU this client runs)
seats.owns(i);          // role !== 'remote': this client simulates seat i and reports its score
seats.pid(i);           // the player id
```

**`PlayerSync<T extends object, E>`**: each client streams the seats it owns (any JSON-able object type, interface or alias), with one-offs (`E`) riding along.
```ts
type NetMove = { x: number; z: number; y: number; d: number };
type Shot = { k: 'shot'; x: number; y: number; z: number; yaw: number } | { k: 'hit'; who: string; dmg: number };
const moves = new PlayerSync<NetMove, Shot>(link, { delayMs: 100, angles: ['y'] });   // keepMs: see below
moves.event(i, { k: 'shot', x, y, z, yaw });  // a one-off from a seat you own: rides along with its next send
moves.send(i, { x, z, y: yaw, d: dashes });  // every frame; the link sends the newest at 20 Hz
moves.onEvent((i, e, pid, at) => show(i, e));  // once: other clients' one-offs, each exactly once, in order per seat; `at` = when event() was called
moves.onReset((i, pid) => lastSeq.delete(pid));  // once: seat i's sender restarted (a reload, a new host for a CPU)
moves.instance(i);      // which run of its sender's game that came from: a new one means a reload
moves.latest(i);        // newest state as received (same object until a new one lands), or null
moves.smooth(i);        // state at now − delayMs, numbers interpolated (angles the short way): for drawing
moves.age(i);           // ms since latest(i) was sent (trip + wait for the next send); sentAt(i): when, or null
moves.fresh(i);         // newest state if it's new since the last call, else null
moves.counter(i, 'd');  // how much counter `d` rose since the last call (0 the first time, and after a restart)
moves.dispose();
```
- **One-offs from players** (a shot, a hit claim, a pickup): `event()` costs no messages of its
  own. Each one rides along in every state the seat sends for `keepMs` (default 400 ms, about 8
  sends: the first to leave carries it, the rest are spares; at most 64 at a time per seat), so
  keep them few and small: **one per shot with its hits inside**, as arrays (`['s', x, y, z, [victim, dmg]…]`),
  and lower `keepMs` (150–250) for guns that fire 10–20 times a second: every send carries every
  one-off still riding, and with a dozen seats that's what fills the 16 KB message. Every other
  client gets each one exactly once, in order, through `onEvent`:
  from the first state it hears from that seat on (a late joiner, or a host that reloaded, doesn't
  get the last moment again), and all of a restarted sender's new run. You never get your own.
- **Restarts.** A reloaded tab keeps its player id but its game starts over: counters from 0,
  sequence numbers from 1. `link.instance` is a new random string each time the game starts on a
  client; PlayerSync stamps it on what it sends (`$i`, `$e` and `$t` are its own keys in the state), and
  when a seat's changes it forgets that seat (counters read 0 again) and calls `onReset`. Clear
  whatever you keep per sender there. The same happens to a CPU's seat when a new host drives it.

**`HostSync<S, E>`**: the host's world at 15 Hz, with one-off events riding along.
```ts
const sync = new HostSync<Snap, Ev>(link, { valid: isSnap });   // opts: hz 15, delayMs 110, buffer 12, channel 'snap'
// host, when something happens:   sync.event({ k: 'pickup', i, id });
// host, every frame:              sync.tick(dt, () => world.snapshot(), over);  // force=true sends now (the final state)
// clients, once:                  const off = sync.onEvent((e) => apply(e));     // each event exactly once, in order
// clients, every frame, one call: const { fresh, latest, at } = sync.read();
//   fresh:  the newest snapshot if it's new since the last read, else null: apply world state once
//   latest: the newest snapshot, fresh or not (null before the first)
//   at:     { a, b, k } around now − 110 ms, for drawing positions (null before the first)
// dispose:                        sync.dispose();
```
A snapshot that's the same as the last one (and no events waiting) is only sent again once a
second, so keep what ticks by itself out of it (a countdown: send when it ends, and let clients
count with `link.now()`); clients draw a world that held still, then moved, without smearing it.
Option `keep: { save, load }`: the host also keeps a full copy of the world with the room about
once a second, and a new host loads it before it takes over (section 8). It can be up to a second
older than the newest snapshot: a snapshot may be lossy, the kept world never is.
Three more things only the host can get right have the netcode done for you: **actions** a player
sees at once and the host applies exactly once (`act`), **secrets** only one player receives
(`tell`), and **timed events** that go off at the same moment everywhere (`schedule`): section 11.
(`fresh()`, `latest()` and `sample()` still exist one at a time; `read()` is the same three in one
call, and counts as the read for `fresh`.)
Offline, `tick` does nothing and drops queued events (nobody to tell); the host has already
applied its own events.

**`Reckon({ trustMs = 100, maxMs = 250, rate = 12, snap = 2.5 })`**: draw a remote player where it
is *now* instead of `smooth()`'s ~100 ms ago, for things you aim at or dodge. Stream a velocity
with the position; one `Reckon` per remote seat:
```ts
const s = moves.latest(i);
if (s) reckon.follow(dt, s.x, s.z, s.vx, s.vz, moves.age(i));   // run on to now, glide onto it
avatar.set(reckon.x, 0, reckon.z, s.yaw);                      // reckon.guess: undrawn, for hit tests
// respawn, blink: reckon.teleport()
```
Don't run `latest()` on yourself: a hard limit on how far ahead you guess ("walk on for at most
120 ms") freezes the player whenever a report is a little late, then jumps it, and drawing the
guess as it is shows every report's correction as a jump, 20 times a second. `Reckon` fades the
velocity out instead of stopping it, and eases the drawing onto the guess (only a big move snaps).
With your own physics for the guess (a knockback that decays), work it out yourself and hand it to
`reckon.glide(dt, gx, gz, vx, vz)`. Warlock does.

**`followReport(from, report, maxSpeed, dt, slack = 0.25)`**: host side. Moves a remote player
towards where they say they are, at most `maxSpeed·dt·1.5 + slack` per frame, and ignores
non-finite reports. A bad or malicious report can't teleport anyone.

**`Reconciler({ trail = 60, dist = 1.5, time = 0.3, canSnap? })`**: client side, for your own
avatar. The host sees you a round trip late, so compare its idea of you with your *recent path*
(the last `trail` frames), not your current spot, and give in only when it has insisted you're
more than `dist` away for `time` seconds:
```ts
const rc = new Reconciler({ canSnap: (host) => isFloor(host.x, host.z) });   // optional veto: never snap into a hole
if (rc.check(dt, me, { x: snap.f[o] / 100, z: snap.f[o + 1] / 100 })) { me.x = …; me.z = …; }
rc.record(me);   // instead of check, on frames the host's view of you is known to lag (it still thinks you're falling)
rc.reset();      // after a respawn or teleport: forgets the path
```
When `canSnap(host)` says no, the reconciler keeps insisting and snaps on the first frame it's
allowed.

**`Predictions<K>(timeout = 0.6)`**: show your own pickup or shot at once; if the host doesn't
confirm it in time, undo it quietly.
```ts
pred.add(coin.id);                 // shown as taken
if (pred.confirm(e.id)) { /* host agreed: don't show it twice */ }
for (const id of pred.tick(dt)) { /* expired: draw the coin again */ }
```

**`History<S>({ maxRewindMs = 400, delayMs = 100 })`**: lag compensation, for whoever judges
something that moves (usually the host). Everyone draws the others about 100 ms in the past, and
what they do reaches the host later still; so judge a shot, a hook or a tag by what *they* saw
when they did it, not by where things are when it arrives.
```ts
const past = new History<number[]>();
past.record(link.now(), fighters.flatMap((f) => [f.x, f.z]));   // host, every frame: a fresh copy
moves.onEvent((i, e, pid, at) => {                               // PlayerSync one-offs carry when they happened
  const then = past.seen(at);             // the world on their screen then: `delayMs` before `at`, at most `maxRewindMs` back
  if (then && hits(e, then)) …;
});
link.onEvent((from, type, data, at, sentAt) => past.seen(sentAt));   // link events: the same, with sentAt
past.seen(link.now() - lag);              // something judged every frame (a touch): `lag` from onState, see below
past.at(t);                               // the world at link time t, interpolated
```
Numbers interpolate through plain objects and arrays; anything else is the earlier record's.
`maxRewindMs` caps how far back anyone's claim can reach (the server already refuses an event's
`sentAt` more than a second old). For a touch judged every frame rather than on an event, measure
how late a player's states arrive: `link.onState((pid, t) => lag.set(pid, link.now() - t))`.
Hot Potato's passes work that way.

Helpers: `lerpAngle(a, b, k)`, `r100(v)` (round to hundredths for snapshots: send `r100(x)`, read
`x / 100`), `END_EVENT` (the event `flow.end` sends), and the `GameFrame` interface (below).

## 3. Shape A: host-authoritative

For shared worlds: pickups, bumps, knockouts. This is the shape of both templates (the default standalone game and `vp init --minigame`'s Star Catch),
and `node_modules/@voxelparty/sdk/examples/coin-cascade/` is the full version (dash counters, pickup prediction, cues).

```ts
// rules.ts: the world, no three.js
export class World {
  fighters: Fighter[]; stars: Star[] = [];
  constructor(players: number, seed: number) { this.rand = mulberry32(seed ^ 0x57a2); … }
  move(f: Fighter, m: Stick, dt: number) { … }      // everyone, for the fighters they drive (Stick from /core)
  hostStep(dt: number): Catch[] { … }               // host: spawns, pickups, scores; returns one-offs
  advance(dt: number) { … }                         // clients: keep things falling between snapshots
  snapshot(shown?: (i: number) => { x: number; z: number } | null): Snap { … }  // flat arrays, ×100
  applySnap(s: Snap) { … }                          // clients: take items and scores
  scores() { return this.fighters.map((f) => f.score); }
}

// game.ts, every frame
update(dt: number, t: number) {
  const { flow, link, input } = this.ctx, you = this.seats.you, w = this.world;
  if (flow.live && you >= 0) {                        // 1. move yourself, instantly, and say where you are
    const me = w.fighters[you];
    w.move(me, input.move(), dt);                     // a Stick { x, z }, like a bot's
    this.moves.send(you, { x: me.x, z: me.z, y: me.yaw });
  }
  if (link.isHost) { if (flow.live) this.host(dt); }  // ask every frame: the host can change
  else this.client(dt);
  this.draw(dt, t);
}
private host(dt: number) {
  const w = this.world;
  w.fighters.forEach((f, i) => {
    const role = this.seats.role(i);
    if (role === 'bot') w.move(f, this.bot(i).update(dt, f, w.stars), dt);
    else if (role === 'remote') {                     // 2. follow remote players, no faster than they can run
      const p = followReport(f, this.moves.latest(i), SPEED, dt);
      f.x = p.x; f.z = p.z;
    }
  });
  for (const c of w.hostStep(dt)) { this.sync.event(c); this.showCatch(c); }   // 3. one-offs: send and show
  const over = this.ctx.flow.timeLeft <= 0;
  // Remote players move in 20 Hz steps on the host: send where the host *draws* them (Avatar.shown).
  this.sync.tick(dt, () => w.snapshot((i) => (this.seats.role(i) === 'remote' ? this.avatars[i].shown : null)), over);
  if (over) this.ctx.flow.end(w.scores(), 'TIME UP!');                          // 4. host ends it for everyone
}
private client(dt: number) {
  const w = this.world, you = this.seats.you;
  w.advance(dt);
  const { fresh, latest: newest, at } = this.sync.read();
  if (fresh) w.applySnap(fresh);
  if (!at || !newest) return;
  w.fighters.forEach((f, i) => {
    const o = i * 4;
    if (i === you) {                                  // ourselves: only snap back if the host insists
      const host = { x: newest.f[o] / 100, z: newest.f[o + 1] / 100 };
      if (this.ctx.flow.live && this.reconciler.check(dt, f, host)) { f.x = host.x; f.z = host.z; }
      return;
    }
    f.x = lerp(at.a.f[o], at.b.f[o], at.k) / 100;    // everyone else: interpolated, ~110 ms behind
    f.z = lerp(at.a.f[o + 1], at.b.f[o + 1], at.k) / 100;
    f.yaw = lerpAngle(at.a.f[o + 2] / 100, at.b.f[o + 2] / 100, at.k);
  });
}
```

Button presses: stream a **counter** (`d: input.presses('action')` or your own `dashes++`), and
on the host compare it with the last one seen (`moves.counter(i, 'd')`, which starts over when the
sender restarts), or send a one-off with `moves.event(i, …)`. A `pressed: true` flag can fall
between two 20 Hz sends and be lost.

To keep `update` testable, Coin Cascade puts the host/client logic in a three-free `core.ts`
class that takes `(link, frame: GameFrame, input: () => Intent)`. The game passes `ctx.flow`;
tests pass a `FakeFlow`. Do the same for anything beyond the template's size.

## 4. Shape B: per-player

Everyone plays their own copy of the same seeded challenge (a course, a FIRE schedule, a pattern)
and others appear as ghosts. Nobody can affect anyone else, so there are no conflicts.
Sky Hopper, Quick Draw, Memory Match and Fishing Frenzy work this way.

```ts
type Run = { x: number; y: number; z: number; ry: number; cp: number; fin: number };
// constructor
this.course = buildCourse(mulberry32(link.seed));          // identical everywhere
this.sync = new PlayerSync<Run>(link, { delayMs: 100, angles: ['ry'] });

update(dt: number, t: number) {
  const { flow, link } = this.ctx;
  for (const r of this.runners) {
    const role = this.seats.role(r.i);
    if (role === 'local' && flow.live && !r.fin) stepRunner(r, this.readInput(), dt, flow.clock);
    else if (role === 'bot' && flow.live && !r.fin) stepRunner(r, r.bot.update(dt, r), dt, flow.clock);
    else if (role === 'remote') {
      const s = this.sync.smooth(r.i);                     // a ghost, drawn 100 ms in the past
      if (s && Number.isFinite(s.x)) { r.x = s.x; r.y = s.y; r.z = s.z; r.ry = s.ry; r.fin = s.fin; }
    }
  }
  if (flow.live) for (const r of this.runners)
    if (this.seats.owns(r.i)) this.sync.send(r.i, { x: r.x, y: r.y, z: r.z, ry: r.ry, cp: r.cp, fin: r.fin });
  if (flow.live && !this.ended) this.checkEnd();
  this.draw(dt, t);
}

private checkEnd() {
  const mine = this.runners.filter((r) => this.seats.owns(r.i));
  if (!mine.every((r) => r.fin > 0) && this.ctx.flow.timeLeft > 0) return;
  this.ended = true;
  if (!mine.length) return this.ctx.flow.finish(null);     // nothing to report (a spectator)
  // Only the seats this client owns. NaN for the rest, or the host would overwrite their result.
  const scores = this.runners.map((r) => (this.seats.owns(r.i) ? (r.fin > 0 ? -r.fin : -1e6 + r.progress) : NaN));
  this.ctx.flow.finish(scores, 'GOAL!');
}
```

- Finishing early is normal: that client shows FINISH and waits (a `WaitCard` saying "Waiting for
  others…" is nice). The results arrive when every seat has a score or at the hard stop.
- Races score `-finishMs`; unfinished runners rank by progress, below every finisher.
- Moving obstacles are a pure function of `flow.clock`, so they match on every screen.
- Quick Draw's trick for a one-off result (a shot) is to stream the whole history (`a: [ms, ms, -1]`)
  in every state, so a dropped update can never lose one.

## 5. Shape C: discrete events

For choices on a schedule (Treasure Doors): pick, bid, vote, reveal.

- Rounds run on a fixed schedule of `flow.clock` (pick window, grace, reveal), identical everywhere.
- You pick locally and see it at once; send `link.sendEvent('pick', { round, choice })`. Also
  stream your current choice with `sendState`, so a timeout still counts what you were on.
- The host (whoever `link.isHost` is at resolve time) collects picks from `link.onEvent`, makes the
  CPU picks, resolves with the seeded rng, and broadcasts `link.sendEvent('reveal', {...})`.
  It applies the reveal itself too.
- Everyone animates the reveal from that one event. The host calls `flow.end(totals, headline)`
  after the last round.
- Unsubscribe `link.onEvent` in `dispose()` (it returns the unsubscribe function).

## 6. Testing with `FakeRoom` and `FakeFlow`

From `@voxelparty/sdk/test`, runs under `bun test` with no DOM.

```ts
import { describe, expect, test } from 'bun:test';
import { FakeFlow, FakeRoom } from '@voxelparty/sdk/test';

test('host and client agree, and everyone is scored', () => {
  const room = new FakeRoom({
    mg: { id: 'my-game', maxMs: 50_000 }, seed: 7,
    players: [{ id: 'p0' }, { id: 'p1' }, { id: 'p2', cpu: true }, { id: 'p3', cpu: true }],
    clients: ['p0', 'p1'],       // clients[0] is the host; p2 and p3 run on it
    latency: 100,                // one-way ms, plus up to jitter (0.5) × latency
    strictScores: true,          // a non-host reporting a seat it doesn't own throws
  });
  const frames = room.links.map((l) => new FakeFlow(l, ROUND));
  const cores = room.links.map((l, k) => new Core(l, frames[k], botDriver(k)));   // drive the humans with bots too
  room.run((dt) => cores.forEach((c) => c.update(dt)), { done: () => !!room.results });
  // The server can have every score before the host's end event reaches a client: let it arrive.
  room.run((dt) => cores.forEach((c) => c.update(dt)), { until: room.now + 1000 });
  expect(frames.every((f) => f.over)).toBe(true);
  expect(room.results!.ranking.flat().sort()).toEqual(['p0', 'p1', 'p2', 'p3']);
  expect(cores[1].world.scores()).toEqual(cores[0].world.scores());
  expect(room.stats.dropped).toBe(0);
});
```

Sessions: leave `maxMs` out of `mg` for an untimed session (`mode` 'session', `endsAt` Infinity,
no automatic results: your game ends it, or you stop the run with `until`), and change the
roster mid-run:
```ts
const room = new FakeRoom({ mg: { id: 'my-game' }, seed: 7, players: [{ id: 'p0' }], clients: ['p0'] });
const cores = [new Core(room.links[0])];
room.run(step, { until: room.now + 3000 });
const late = room.join({ id: 'p1' });          // a new client playing p1 (its link, which you drive too)
cores.push(new Core(late!));
room.join({ id: 'p2', cpu: true });            // a CPU: the host runs it
room.run(step, { until: room.now + 3000 });
room.leave('p0');                              // the host leaves: the next client becomes host
room.run(step, { until: room.now + 3000 });
expect(cores[1].world.fighters.has('p2')).toBe(true);   // the new host carried the world on
```
Every link's `players` and `onPlayers` follow a latency later, as online. A left client's link has
`gone: true` and stops sending: skip it in your step (`cores.filter((c) => !c.link.gone)`).

Dropped connections and reloads (any mode, board minigames too). The player stays on the roster
while their client is away, and the host drives them. The host is the earliest-joined player whose
client is here, as on the server, so a host that reloads is host again once it's back (with the
kept world). Test both: they're where "stuck dead", "invisible" and "the match restarted" bugs live.
```ts
room.drop('p1');                               // p1's connection is lost: the host drives p1 meanwhile
const back = room.rejoin('p1');                // p1 is back: a new client, a new `instance`, a fresh game
cores.push(new Core(back));                    // make a new core on it, as a reloaded page would
const fresh = room.reload('p0');               // drop + rejoin at once: the host reloads its tab
```
Check the exact signatures in `node_modules/@voxelparty/sdk/types/minigames/kit/loopback.d.ts`.

- `FakeRoom` options: `mg` (`{ id, maxMs? , players?, input?, mode? }`), `seed`, `players` (seat order; `cpu: true` = run by the host),
  `clients` (the pid each client plays, `null` = spectator), `latency`, `jitter`, `reorder`,
  `countdownMs` (default 0: live at once), `maxMessage`, `strictScores`, `rateLimit` ('throw' default, or 'drop').
- `room.tick(ms)`, `room.run(step, { until?, fps? = 60, done? })`, `room.now`, `room.links`,
  `room.scores` (a `Map` of pid → accepted score, first per player), `room.results` (set once everyone is scored, or at
  endsAt + 2.5 s), `room.stats` `{ states, events, bytes, maxMessage, dropped, keeps, maxKeep }`.
- Kept worlds (`link.keep`, section 8): the host's reach the room a latency later, at most one a
  second (over 64 KB throws); `room.kept` is what the room holds (`{ world, at }`, null once the run
  is over). When the host leaves, the new host's `onKept` gets it inside `room.leave`, before its
  next frame as host.
- Each `FakeLink` also has `reported` (the scores it sent) and `sent` `{ states, events, keeps, bytes, dropped }`.
- `new FakeFlow(link, round?)` is a `GameFrame` (`live`, `clock`, `timeLeft`, `end`, `finish`,
  plus `over`, `headline`, `setRound`, `dispose`). `end` relays the finish to the other
  `FakeFlow`s exactly as `Flow.end` does.
- **Promises don't settle inside `room.run`**: the loop is synchronous, so `link.results.then(...)`
  (and `FakeFlow`'s own "TIME UP!" from it) only runs after it returns. Check `room.results`
  in the loop, and end the round from your game's timer (`timeLeft <= 0`), as the real game should anyway.
- Worth asserting: a non-host's own movement changes on the first frame; host and client agree
  on scores and items; every one-off event arrives exactly once (count them on both sides); the
  round ends on both sides with the same headline; `client.reported.size === 0` in
  host-authoritative games; nothing dropped; `room.stats.maxMessage` well under 16 KB.

## 7. Pitfalls

- **Counters, not flags,** for anything pressed: `input.presses('action')`, `dashes++`. Flags
  get lost to the 20 Hz throttle.
- **State is what's true now.** No `t: link.now()`, frame numbers or `Math.random()` in a state or
  snapshot, and round positions (`r100`): an unchanged state isn't sent, and one field that always
  changes keeps an idle player at 20 messages a second. When something happened belongs in an
  event (`PlayerSync.event` and `link.sendEvent` carry their time).
- **The NaN score rule** (per-player): `flow.finish` gets a finite score only for owned seats.
  `strictScores` only catches a *non-host* breaking it; on the host a stray finite score silently
  wins, so compute scores with `seats.owns(i) ? … : NaN`. Host-authoritative games use
  `flow.end` with every score instead.
- **One finish.** `flow.end`/`flow.finish` only count the first time; guard with an `ended` flag
  so you don't spend work every frame, and stop sending state after it.
- **Rate limits.** Never `sendEvent` every frame. One-offs ride on `HostSync.event` (the host's)
  and `PlayerSync.event` (a player's); state goes through `sendState`/`PlayerSync` (batched). A
  burst of 10 `sendEvent`s in one frame is fine; 60 a second is not.
- **Seeded randomness must be consumed identically.** Every client must make the same `rand()`
  calls in the same order. Keep separate streams for separate jobs (layout, host-only spawns,
  bots, scenery) so a host-only call never shifts a shared one: `mulberry32(link.seed ^ SALT)`,
  `botRng(link.seed)`. `Island` and `arenaStage`'s pollen consume their own rng in a fixed order.
- **Clocks.** Schedule shared moments by `flow.clock`, not by adding up `dt` (frame rates differ).
  The platform already caps `dt` at `MAX_DT` (0.1 s), so a hitch doesn't teleport things through
  walls; headless cores driven by `FakeRoom` get its fixed step. Don't add your own clamp.
- **Untrusted input.** Validate everything from the network: `HostSync`'s `valid`, `Number.isFinite`
  on streamed numbers, bounds on indices. `read()`'s `latest` and `at` (and `PlayerSync`'s
  `latest`/`smooth`) are null until something arrives.
- **Spectators and bots-only runs.** `seats.you === -1`: no input, no sending for yourself, and
  the camera must frame the whole field (or a bot) instead of "me".
- **Any number of seats.** Board minigames: `vp check` plays 4-, 2- and 3-player rounds.
  Sessions: 1 up to `players.max`, changing mid-run. Size arrays by `players.length` /
  `seats.count` (or key by pid), never a hard-coded 4, and make sure the game is still a game with
  the fewest players (spawn points, an arena that isn't empty, a win condition that can trigger).
- **Last one standing:** keep the knockout order as groups of seats (players out on the same
  frame share a group) and end with `flow.end(scoresFromKnockouts(n, groups))`: survivors first,
  ties shared.
- **The host can change** mid-game when a host drops (in sessions, sooner or later it will):
  read `link.isHost` each frame rather than caching it, and have the host keep the whole world
  (`HostSync`'s `keep`) so a new host carries on exactly (section 8).
- **Show each event once, everywhere.** Put effects and sounds where the event is *applied*
  (the host's `hostStep` result, the clients' `onEvent`), not where it's detected, so every client
  sees and hears it exactly once. Coin Cascade pushes `cues` from both paths and draws only from those.
- **Other players: `smooth()` or `Reckon`**, never your own guess from `latest()` (section 2).
- **Snapshots small:** flat number arrays ×100 (`r100`), about 1 KB. Send `Avatar.shown` for
  remote players so clients get the host's smooth path.

## 8. Sessions: a live roster and host changes

In a session (`link.mode === 'session'`) people join and leave while the game runs. The full
guide is `vp docs sessions`; the netcode rules:

- **Seat indices shift.** `link.players` is replaced and `ctx.players` updated in place when the
  roster changes; a leaver's index disappears and later seats move up. Key per-player state by
  pid (`seats.pid(i)`, `link.players[i].id`) in `Map`s, and look indices up when an API needs
  one. `PlayerSync` and `HostSync` are index-based at the call (`send(i, …)`, `latest(i)`) but
  keyed by pid inside, so they stay right as long as `i` is the current index.
- **`GameStage.onPlayers(players, joined, left)`** (or `link.onPlayers`) runs on every change:
  create and remove fighters, avatars, bots. A joiner's state appears on each client when that
  client hears of the join; the host's snapshot may arrive a moment before or after: create
  unknown pids from the snapshot too, and ignore snapshot entries for pids no longer on the roster.
- **Snapshots carry ids**, not seat order: `{ ids: ['p0', 'p3'], f: [x, z, …, x, z, …] }` or
  `{ f: [['p0', x, z, hp], …] }`. Seat order differs between a snapshot's send and its arrival
  whenever someone joined or left in between.
- **Host changes.** When the host drops, the next client becomes host (`link.isHost` turns true
  there) and must carry the world on. Snapshots are built to be small, so they're lossy (HP
  rounded, only the newest shots, no bot plans), and a new host that rebuilds from one loses what
  they leave out. So the host also **keeps** a full copy with the room, and the room hands it to
  the next host (only to them; it's never broadcast):
  ```ts
  const sync = new HostSync<Snap, Ev>(link, {
    valid: isSnap,
    keep: { save: () => world.save(), load: (w) => world.load(w) },   // load validates
  });
  // host, every frame, as before: sync.tick(dt, () => world.snapshot());   (it also keeps, about 1/s)
  ```
  - `save()` returns the whole world as plain JSON, at full precision: everything the host
    decides (HP, projectiles in flight, spawn queues, round state, scores, bot targets), timers
    as `link.now()` times. At most 64 KB; aim for a few KB. No three.js objects, `Map`s or class
    instances: they don't survive JSON.
  - `load(world, at)` replaces the world with it. It runs on the new host before `isHost` reads
    true there, and on a host that reloaded mid-run soon after its game starts (it's host from its
    first frame). It came from another client: validate it and ignore junk. `at` is when it was
    kept (server ms), up to a second ago.
  - Without `HostSync`: `link.keep(world)` on the host (the latest call wins and it's sent at most
    once a second, so call it about that often) and `link.onKept((world, at) => …)`.
  - **Too big?** `link.keep` returns the world's size in characters of JSON; over `KEEP_MAX`
    (64 KB) it isn't kept, and the room keeps the last one that fitted (warned once in the
    console). Check it in a test at your biggest world (16 players, the late game), and shrink:
    numbers as whole numbers (`r100`), lists of numbers with `packInts` (`{ delta: true }` for
    sorted or slowly changing ones: ids, tiles, paths), grids with `packBytes`, and leave out
    what the new host can rebuild (paths, caches, effects). Unpacking throws on junk: it's inside
    your `load`, which validates anyway.
    ```ts
    save: () => ({ n: w.tick, hp: packInts(w.hp), tiles: packBytes(w.tiles) }),
    load: (d) => { try { w.hp = unpackInts(d.hp); w.tiles = unpackBytes(d.tiles); } catch { /* junk: ignore */ } },
    ```
    Worlds that are big by nature (an RTS late game) use `Lockstep` (section 10): every client has
    the whole world, so a new host needs no kept copy, and one that reloads gets it from the others.
    A world of blocks players build and break uses `WorldSync` (`vp docs world`): the host puts
    everyone's edits in order, your own show at once, and joiners, reloads and new hosts get the
    world in pieces the same way.
  - Without a kept world, adopt the latest snapshot when `isHost` turns true
    (`sync.read().latest`); anything it leaves out is lost at a handover.

  CPUs (role `'bot'`) move to the new host with it. Offline (`vp dev`) `onKept` never fires;
  `FakeRoom` hands the kept world over in `room.leave`, `room.drop` and `room.reload` of the host (section 6).
- **Reloads.** A player who reloads keeps their id and seat, but their game starts from scratch,
  and a host that reloads is host again. So: take the kept world when it comes (`load` above),
  never assume a counter or sequence number from a player only goes up (`PlayerSync.onReset`,
  `link.instance`), and for match setup use `Setup`, which asks the others before a returning
  host takes charge, so the match that's on carries on.
- **Disconnected players** stay on the roster for a few seconds with role `'bot'` on the host
  before they leave: your bot drives them, so nobody freezes in place.
- **Untimed runs** have `flow.timeLeft === Infinity` and `link.endsAt === Infinity`: never
  compute "time left" fractions from them. Schedule by `flow.clock` or `link.now()`.
- **Budgets with 16 players:** one state blob per owned seat per 20 Hz tick, batched; keep each
  under ~200 bytes, and host snapshots under ~4 KB (flat arrays ×100). Events stay rare.

## 9. Shooters: the shooter decides what it hit

Fast shooters (first person, twin-stick) can't wait a round trip to find out whether a shot hit:
by the time the host has checked it, the target has moved on your screen. So the one who fires
decides, from what they see, and the host keeps score. This is how Frag Island plays, and what
`vp init --fps` starts from (`vp docs fps` for the movement, camera and CPUs).

- **Everyone moves their own body** and streams it with `PlayerSync` (`{ x, y, z, yaw, pitch, l }`,
  `l` = which life it is). The host moves the CPUs. Nobody follows anyone else's position: in a
  shooter, where you are is yours.
- **Firing is local.** Trace the shot at once against the map (`grid.ray`) and the other players
  *as you draw them* (`sync.smooth(i)` for remote seats: the same positions that are on your
  screen), show the tracer, play the sound, and send it with `PlayerSync.event`: the shot
  (from, to: everyone draws the tracer) and, if it hit, the claim (victim, damage, the victim's
  life number). The template sends `{ k: 'shot', o, e }` and `{ k: 'hit', v, d, l }`; a fast gun
  should send **one event per shot with its hits inside**, as compact arrays, and a lower `keepMs`
  (section 2): one-offs cost no messages, but every state carries every one still riding. What cut
  Frag Island's biggest message in half (9.8 KB to 4.3 KB with 12 players): hits always go, but
  only every other *missed* hitscan tracer is sent to the others; nobody counts the misses.
- **Who sent it:** `sync.instance(i)` is the run of the game seat i's state came from. A new one
  means a reload, so the host can tell a reloaded player's life 1 from the old life 1.
- **The host keeps score.** It gets every claim once (`sync.onEvent`), checks it's plausible (the
  shooter is alive; the victim is alive and on that life, so a hit on a corpse or a respawned
  player doesn't count; no faster than the gun fires; within its range), applies it to HP, frags
  and deaths, and broadcasts the result: HP and scores in `HostSync` snapshots, and `dmg`, `frag`,
  `spawn` and `win` as `HostSync` events. The host's own shots and its CPUs' go the same way,
  without the network.
- **Deaths and respawns come from the host.** It picks the spawn (the one farthest from enemies)
  and sends `{ k: 'spawn', pid, s, l }`; the body's owner teleports there and streams the new life.
- **Knock-back** (a rocket's blast) goes to whoever moves the victim: the host sends it with the
  `dmg` event and the victim's owner applies it with `push`. Your own rocket's push on yourself
  you apply at once (that's what makes rocket jumps feel right).
- **Projectiles** (rockets) fly on a fixed path from `(origin, direction, time fired)`, so every
  screen draws the same flight without syncing it. Only the shooter's client decides the
  explosion; others just draw it and never apply it to themselves.
- **Restarts.** A player who reloads starts from life 0 with fresh numbers: `PlayerSync` handles
  its own (`onReset`), clear your per-sender state there (fire-rate timers). Keep the match with
  `HostSync`'s `keep` so a new or reloaded host carries on.
- **Trust.** The shooter can lie about what it hit. Friends' games accept that for the feel; the
  checks above stop the accidental cases (lag, a reload, a respawn).
- **When the host decides instead** (a hook, a melee swing, a tag, anything the shooter can't
  settle alone), judge it by what the actor saw: keep a `History` of positions on the host and look
  at `past.seen(at)`, `at` being when the one-off happened (section 2). Without it, a player with a
  slow connection has to lead every target by their ping.

## 10. Shape D: deterministic lockstep (`Lockstep`)

For games whose world is too big to snapshot but where players only give **orders**: an RTS
(build, send, sell), tower defense, snakes (turn), card and board games. Every client runs the
same simulation on the same orders at the same ticks, so only the orders travel (a few bytes),
whatever the size of the world. Castle Fight, Line Tower Wars and Worm War each built this by
hand; `Lockstep` is the shared version.

```ts
import { Lockstep, type LockstepInput } from '@voxelparty/sdk/core';
type Order = { k: 'build'; x: number; z: number; kind: number } | { k: 'start'; match: number; seed: number };

const ls = new Lockstep<World, Order>(link, {
  create: () => new World(),                        // the first host's world (lobby, no match yet)
  step: (w, inputs, tick) => w.step(inputs, tick),  // one tick, deterministic (below)
  hash: (w) => w.hash(), save: (w) => w.save(), load: (d) => World.load(d),   // load: null on junk
  valid: isOrder,                                   // every order from the network is checked
  onTick: (w) => cues.push(...w.events.splice(0)),  // the world's events, after each tick
  mayResume: () => setup.phase === 'play',          // no match on: a host with no world starts at once
});
// every frame:
ls.update();                                        // host: seal + simulate; clients: follow the seals
if (link.isHost && setup.match > (ls.world?.match ?? 0)) ls.system({ k: 'start', match: setup.match, seed: link.seed ^ setup.match });
for (const p of ls.pending) drawGhost(p.o);         // your orders the world hasn't applied yet
draw(ls.world, ls.alpha);                           // glide between the last two ticks (alpha 0..1)
// on a click:
ls.order({ k: 'build', x, z, kind });
```

`step` gets the tick's inputs in the order the host sealed them:
- `{ k: 'order', pid, o }`: a player's order (or a CPU's: the host's `ls.orderFor(pid, o)`).
- `{ k: 'system', o }`: the host's `ls.system(o)` (a match starting, a vote closing). A host change
  can lose one that wasn't sealed yet, so send them from a check you make every frame (as above:
  the setup is on match 3, the world is still on 2), and the next host sends it again.
- `{ k: 'join', pid, cpu }`, `{ k: 'leave', pid }`, `{ k: 'away', pid, away }`: the roster, sealed
  into the world (`roster: false` turns them off). `away`: a person whose connection dropped; the
  host's CPU plays for them (`orderFor`) until `away: false`. `ls.members` is who the world has.

**Determinism is the whole game.** Everything `step` reads must be in the world or the inputs:
- Whole numbers or fixed point (milli-cells, integer HP). Floats only where every browser computes
  them identically (`+ − × ÷`, `Math.sqrt`, `Math.floor`); never `Math.sin`/`cos`/`atan2`/`pow`/`exp`
  on anything that feeds back into the world (use a lookup table, or integer steps).
- Randomness from a seeded stream kept **in** the world (a `mulberry32`-style state as a number,
  advanced inside `step`), never `Math.random`, `Date.now` or `link.now()`.
- Iterate in a fixed order: arrays, or `Map`s filled in the same order on every client. Sort
  anything you build from a `Set` or object keys.
- CPUs are best inside the simulation (Line Tower Wars: their choices come from the world's rng,
  so they need no network and survive any host change), or give orders from the host (`orderFor`).
- `hash` covers everything that matters (a 32-bit FNV over the numbers); `save`/`load` round-trip
  exactly. Test both: step two worlds with the same orders and compare, and `load(save(w))` then
  step both.

**What it does for you:**
- The host seals ticks on the shared clock (`tickMs`, default 50: 20 a second) and sends them
  about ten times a second, with its world's hash every `hashEvery` ticks (20).
- Orders are stamped with the tick they're for (the next one; `ls.order(o, { tick })` for a snake's
  turn on its next cell), and the host runs `delayMs` behind the clock, learned from how late
  orders arrive (between `delay.min` and `delay.max`, default 0–250 ms), so they land on their
  tick. A late one takes the next open tick; none is lost, and none applies twice (the world
  remembers each player's last order number, per run of their game).
- Clients simulate only sealed ticks, a little behind: no rollback. Your own orders show at once
  in `ls.pending`: draw them as ghosts (a building going up, gold spent). The character you
  **steer** (a runner, a snake's head, a hero you walk with the keys) is the exception: draw it
  from `ls.predictor()` (below), never from the world, and never with a prediction of your own.
- A client whose hash differs asks for the whole world and carries on from it (`onLoad`); so do
  joiners, reloaded tabs, a client that missed a seal, and one far behind (a hidden tab). Whole
  worlds go in chunks, only to whoever asked (`sendEvent`'s `to`), paced under the rate limit, so
  size isn't a problem (up to 256 chunks of 12 KB).
- Host changes: every client has the world, so the new host simulates what was sealed and carries
  on in a new epoch, sending everyone its world. Orders the old host never sealed are sent again
  by their players. The host keeps the world with the room once a second when it fits in 64 KB
  (`stats.keepsSkipped` counts the ones that didn't); a host that reloads takes the newest of the
  room's copy and the others' worlds.
- Offline (`vp dev`): you're the host; it starts at once and simulates on the clock.

**Your own character: `ls.predictor()`.** An order lands a round trip after you give it, too late
for dodging, so what you steer is drawn predicted: the world's copy of your body stepped on to now
with your orders still on their way. Give it three small functions and it does the rest:

```ts
// sim.ts: ONE function for what an order does to a body, and ONE for a tick of its movement.
// The world's step calls them for every runner; the prediction calls them for yours.
export function steerBody(b: Body, o: Order) { if (o[0] === 'm') { b.dx = o[1]; b.dz = o[2]; } }
export function moveBody(w: World, b: Body) { /* speed, walls: read w, write only b */ }

// core.ts
this.me = ls.predictor<Body>({
  from: (w) => {                                   // a COPY of your body, or null: nothing to predict
    const r = w.runnerOf(link.you);
    return r && r.alive && !w.cpuDrives(r) ? { x: r.x, z: r.z, dx: r.dx, dz: r.dz } : null;
  },
  order: (b, o) => steerBody(b, o),
  step: (w, b) => moveBody(w, b),
});
// game.ts, every frame after ls.update():
const p = this.core.me.update();                   // { x, z, y?, body } or null
if (p) avatar.set(p.x / FP, 0, p.z / FP, yawOf(p.body.face));
else drawFromWorld(r, ls.alpha);                   // spectating, or your CPU is playing
```

What it gets right that hand-rolled prediction gets wrong (Stampede Tag's runner stood still for
half of every tick and then jumped, 20 times a second, on the host and solo):
- It draws the moment *now* on the shared clock, always past the world's last tick, gliding
  between the two predicted ticks around it: no stall-then-jump however far behind the host runs.
- Your orders apply on the tick they're stamped for, late ones on the next (where the host puts
  them), so the prediction agrees with the world; a disagreement (an order that reached the host
  late) is eased in over ~150 ms (`rate`), never a jump back, and a big one (a respawn) snaps
  (`snap`, or `me.teleport()`).
- `from` must return a new object and `step` must only read the world: it checks the world's hash
  around its first predictions and says so on the console if they changed it.

Use the same `steerBody` and `moveBody` in the world's step, or the prediction and the world
disagree every time you turn. `vp check` holds a direction with your seat and warns when anything
moves in stops and starts (testing §4).

**Test it** with `FakeRoom`: one `Lockstep` per link, `update()` every frame, record `hash(world)`
per tick in `onTick`, and assert that every pair of clients agrees at every tick both simulated,
through `join`, `leave` of the host, `reload`, `drop`/`rejoin` and latency up to 250 ms, and that
every order a player gave was applied exactly once (let the world count them). `ls.stats` has
seals, fulls, desyncs, resends and keeps for your asserts.

---

## 11. The host decides: actions, secrets and timed events (`HostSync`)

Three things every host-authoritative game ends up needing, each easy to get subtly wrong (lost
actions, doubled buys, a role that never arrives after a reload). They're all on `HostSync`, and
they all survive the host leaving, dropping or reloading, as long as the world comes back through
the `keep` option (not `link.keep` by hand alongside).

```ts
const sync = new HostSync<Snap, Fx, Act, Secret>(link, {
  valid: isSnap,
  keep: { save: () => world.snapshot(), load: (w) => void (world = World.from(w)) },
  act: (a, pid, at) => world.apply(pid, a, at),   // host: each player's action once, in order; false = turned down
  validAct: isAct,                                // anything from the network is untrusted
});
```

### Actions: instant on your screen, exactly once on the host

For what players *do* that the host must decide (grab, place, buy, open a door): the player sees it
at once, the host settles who got the tomato.

```ts
// Anyone, when the player does it (the host too, and the host for its CPUs: act(a, cpuPid)):
sync.act({ k: 'grab', cell });

// Clients, every frame: what to draw. The host's newest world with your pending actions on top.
// Made again only when a snapshot arrives or `pending` changes, so call it every frame.
const view = link.isHost ? world : sync.predict((s) => World.from(s), (v, a, at) => v.apply(me, a, at));

// Your action the host turned down (it's gone from the prediction): undo its effect, play a "nope".
sync.onReject((a) => sfx.play('nope'));
```

- The `act` option runs **only on the host**, with every player's actions exactly once and in the
  order each player did them; the host's own at once, inside `act()`. `at` is when they did it
  (link time, clamped to at most a second back): judge "was it still there then?" with it.
  Return `false` to turn it down.
- Every action is numbered. Each send carries all your unconfirmed ones, and the host applies
  each player's in order, so a host change never makes one jump the queue. Unconfirmed ones go
  again after 1.5 s; everyone keeps others' actions for a few seconds, and a new host applies what
  the old one never got to. If a new host restores an older kept world, your client sees the
  host's record go back and sends what it lost again. Nothing is applied twice.
- `sync.pending` is your unconfirmed actions, oldest first; `predict` replays them. Make each
  action say what it expects to find (`{ k: 'grab', cell, was: 'tomato' }`) so it never does
  something else by surprise on the host.
- About 60 messages a second per client: actions are for what players do, not for every frame
  (stream movement with `PlayerSync`).
- A kept world and the record of actions go together. If `keep.load` doesn't take the kept world
  (you already have a newer one), return `false` from it, so the record follows your world.
- A host that reloads should wait for its kept world before acting as host (Setup's `fresh` says
  whether one is coming).

### Secrets: one player's eyes only

Roles, hands, a hidden target. The host tells; only that player's client receives the bytes.

```ts
// Host, whenever it changes (a new round, the gun changing hands):
sync.tell(pid, { role: 'murderer', round });
sync.secretOf(pid);                          // host: what pid was told (CPUs' too: they have no client)

// Everyone: your own.
sync.onSecret((s) => showRole(s.role));      // once per change; again after a reload; now if you already have one
sync.secret;                                 // the newest, or null before the host has told you
```

Delivered once (a new version each time you `tell`), resent until the player's client says it has
it, told again when they reload, and kept with the room, so the next host knows everyone's without
telling anyone twice. Keep it plain JSON and small. Everyone else only ever sees what the
snapshot carries, so never put a secret there.

### Timed events: the same moment on every screen

```ts
// Host: a bomb that goes off at a set time, on every client and the host.
sync.schedule(link.now() + 250, { k: 'boom', x, z });

// Everyone, every frame:
for (const { e, late } of sync.due()) boom(e, late);   // late: ms past its time; catch up by that much
```

Schedule at least a latency ahead (150–250 ms) and it lands everywhere together; a client that got
it late still gets it, once, and knows by how much. It goes out at once (no waiting for a
snapshot). Late joiners don't get events scheduled before they came.

`sync.flush()` sends the queued `event()`s now instead of with the next snapshot, for a moment
that must land fast (a starting gun). The world itself follows with the next snapshot.

### Testing it

`FakeRoom` has everything these need: latency and jitter, `leave`, `drop`, `rejoin` and `reload`
(the host's too). A test that runs a shop through random host leaves, drops and reloads and checks
every client's buys land exactly once takes a few lines, and the same lines work for your game's
invariant (coins, items, scores add up; nothing pending at the end).

**`PlayerSync.onReport((i, state, sentAt, pid) => …)`**: every state from another client as it
arrives, even two in one frame (`fresh()` only sees the newest by the next frame).
