# Sessions: standalone multiplayer games

A **session** is a party playing one game. Someone presses **Play** on a game (or opens its link,
`https://voxelparty.io/?play=<hash>`) and lands in its **lobby**: the game, the invite
link, each player's name, colour and character, CPUs, and who's ready. They press **Invite** and
send the party's link. The host presses **Start** (or, once every guest is ready, a 3-second
countdown starts it). People come and go while it runs. When a run ends, the party votes: **Play
again** (the next run starts by itself with everyone still in) or **New game** (everyone picks a
game in the store and one of the picks is drawn). A player can **Sit out** a round and wait in the lobby.
This is how every game is played outside Board Night, and the only way a **standalone** game
(no `board` block in `game.json`) is played.

## Contents
1. The manifest
2. The life of a session
3. The roster is live: joins and leaves
4. Host changes
5. Rounds inside a session, and the scoreboard
6. Bots in a session
7. Testing: FakeRoom joins and leaves, `vp check`
8. Checklist

---

## 1. The manifest

```json
{ "id": "sky-charge", "name": "Sky Charge", "players": { "min": 1, "max": 10 }, "input": ["mouse", "pointerLock", "keyboard"] }
```
- `players.min`: the room tops up with CPUs to this many at the start of each run. `1` means you
  can play alone; `2` guarantees an opponent (a CPU) when you're on your own.
- `players.max`: up to 16. Further people who open the link watch as spectators. Pick what the
  map and the game really hold: an arena for 4 feels empty with 1 and a mess with 16.
- `input`: what you need beyond the standard actions (see `vp docs input`): `mouse` (pointer,
  buttons, wheel), `pointerLock` (mouse look; the page lets the sandbox lock the pointer),
  `keyboard` (raw keys through `input.keys`). Default `[]`.
- No `board` block: the game is **untimed** in a session. With one (a board minigame, `vp docs
  board`), sessions play timed runs of it instead: fixed roster per run, joiners wait for the next.

## 2. The life of a session

| | Untimed (standalone) | Timed (has `board`) |
|---|---|---|
| Title card | "Click to play" (`SOLO` / `N PLAYERS`) | "Click to play", then 3-2-1-GO |
| Starts | on your click, for you: no countdown, no timer | for everyone at GO |
| `flow.timeLeft` | `Infinity` | seconds left |
| `link.endsAt` | `Infinity` | the run's hard stop |
| Starts (the first run) | the host's **Start** in the lobby, or 3 s after every guest is ready (alone: Start) | same |
| Someone opens the link mid-run | the lobby, then **Jump in!** joins the running game (drop-in) | the lobby, then **Play next round** |
| A run ends | when the game ends it: `flow.end(scores)` / every player `flow.finish`ed | same, or at `board.maxMs` |
| Then | results card (a ranking, no coins) and the party's vote (10 s): Play again → the **next run** by itself, a new `create()` and a new `link.seed`, with everyone who's still in on the roster (more than `players.max`: they take turns). New game → your game is disposed while the party picks another | same |
| A match ends inside the run | `flow.matchOver(scores)`: the party votes while your game carries on (section 5) | same |

The click on "Click to play" is a user gesture: that's when the platform locks the pointer for
`defineGame({ pointerLock: true })` games, and when `flow.onPlay(cb)` runs.

A game instance is one run. Everything that should survive a run (nothing, usually) doesn't:
each run starts clean from `create()`. Only this player's own things carry over, in `storage`
(personal bests, stats, unlocks, settings: `vp docs api` §16). For a game that never "ends", simply never call
`flow.end`: the session runs until everyone leaves.

## 3. The roster is live: joins and leaves

`link.players` (who plays) and `ctx.players` (how they look) are the **current roster**,
index-aligned: `ctx.players[i]` is `link.players[i]`. When someone joins or leaves:
- `link.players` is replaced by a new array; `ctx.players` is the **same array, updated in
  place** (so `ctx.players.length` and `seats.count` are always right);
- **indices shift**: a leaver's slot disappears and everyone after them moves up one;
- your `GameStage.onPlayers?(players, joined, left)` hook runs, and `link.onPlayers(cb)` listeners
  (same arguments; `players` is `link.players`).

So in a session game, **key everything per player by id, never by index**:

```ts
class Fighter { constructor(readonly pid: string, public x = 0, public z = 0, public hp = 100, public score = 0) {} }

// rules.ts: the world keeps a Map
readonly fighters = new Map<string, Fighter>();
sync(ids: readonly string[], spawn: (pid: string) => Fighter) {
  for (const id of ids) if (!this.fighters.has(id)) this.fighters.set(id, spawn(id));
  for (const id of [...this.fighters.keys()]) if (!ids.includes(id)) this.fighters.delete(id);
}

// game.ts
constructor(ctx: GameContext) { … this.world.sync(ctx.link.players.map((p) => p.id), (pid) => this.world.spawn(pid)); }
onPlayers(players: readonly LinkPlayer[], joined: readonly LinkPlayer[], left: readonly LinkPlayer[]) {
  this.world.sync(players.map((p) => p.id), (pid) => this.world.spawn(pid));
  for (const p of joined) this.addAvatar(p.id);          // look: ctx.players[players.indexOf(p)].look
  for (const p of left) this.removeAvatar(p.id);         // dispose its meshes, labels
  for (const p of joined) this.popups.show(…, `${p.name} joined!`);
}
```
- Everything that was an array per seat (avatars, bots, HUD stats you cache) becomes a
  `Map<pid, …>`. Look up the index only when an API wants one: `flow.hud.setStat(i, …)`,
  `seats.role(i)`, `moves.send(i, …)`: `const i = link.players.findIndex((p) => p.id === pid)`.
- Spawn joiners somewhere safe and fair: not on top of someone, not in the line of fire, with a
  second of protection if the game has damage.
- Snapshots from the host carry ids, not seat order: `{ f: [[pid, x, z, hp, score], …] }` (or
  flat arrays plus a `ids: string[]` list). A client that sees an id it doesn't know yet creates it;
  one missing from the snapshot is gone. Clients hear about the roster from the platform at about
  the same time the host does, but either can come first: handle both orders.
- Someone who closes the tab stays on the roster for a few seconds, disconnected, with the host
  controlling them (their role becomes `'bot'` on the host), then leaves. Your bot code drives them
  meanwhile, so an empty seat never freezes the game.
- `flow.hud` follows the roster by itself (up to 16 chips, compact above 4). Its stat lines are
  kept per player, so just set them when they change.

## 4. Host changes

The host (`link.isHost`) runs CPUs and, in host-authoritative games, the world. In a session
the host **will** leave sooner or later (or reload the page), and someone else becomes host
mid-run. Read `link.isHost` every frame (never cache it). Snapshots are small and lossy, so the
host also **keeps** a full copy of the world with the room, which hands it to the next host:

```ts
// rules.ts: the whole world as plain JSON, and back (validate: it came from another client)
save(): Save { return { creeps: this.creeps.map((c) => ({ ...c })), shots: this.shots.map((s) => ({ ...s })), wave: this.wave, nextWaveAt: this.nextWaveAt }; }
load(w: unknown) { if (isSave(w)) this.restore(w); }

// game.ts: one option on HostSync. Its tick() keeps the world about once a second; a new host loads it.
this.sync = new HostSync<Snap, Ev>(link, { valid: isSnap, keep: { save: () => this.world.save(), load: (w) => this.world.load(w) } });
```
- **What to keep:** everything the host decides, at full precision: HP, projectiles in flight,
  spawn queues and wave timers, scores, pickups, round state, bot targets. Timers as absolute
  `link.now()` times (or `flow.clock`), not countdowns in host memory. Not what every client has
  anyway (the level built from the seed). At most 64 KB of JSON; a few KB is typical.
- **How often:** about once a second. `HostSync` does it; by hand, `link.keep(world)` about once a
  second (the latest call wins, and it's sent at most once a second anyway).
- **Restoring:** `load` runs on the new host before `isHost` turns true there, so it just
  replaces the world, and the next frame carries on as host. A host that reloaded mid-run is host
  from its first frame and gets it a moment later: replace the world then too. It's up to a second
  old (`load(world, at)`: `at` is when it was kept); players' own positions come back through
  their streams within a tick. Without `HostSync`: `link.onKept((world, at) => …)`.
- **A host that reloaded mid-match must not reset anything first.** Its game starts from scratch
  while everyone else is mid-match, and `Setup` hands it the match that's on (phase 'play', the
  same match number) as soon as another client answers. If your game starts a new match when
  `setup.match` differs from its own, that fires on the reloaded host before `onKept` has given it
  the scores, and it wipes them. Wait for the kept world before starting or resetting a match
  (Frag Island waits up to 4 s, and sends no snapshots meanwhile), and test it:
  `room.reload(hostPid)` in `FakeRoom`, whose new game gets `onKept` before its first frame.
- Without a kept world, a new host can only adopt the latest snapshot (`sync.read().latest` when
  `isHost` turns true), and whatever it leaves out is lost.
- Bots move to the new host automatically (`seats.role(i) === 'bot'` there now); give their
  state (target, think timer) a sensible default when it's created on the fly.

## 5. Rounds inside a session, and the scoreboard

Two ways to structure play:

- **One endless run with its own rounds** (deathmatch, drop-in arenas, sandboxes). The game
  never calls `flow.end`. It keeps its own score, announces "ROUND 2" with `flow.hud.banner`,
  resets positions, and shows the standings itself. Best for drop-in: a joiner is playing a
  second after pressing Jump in!, without waiting for anyone else's round to end. Keep round state in the host's snapshot (section 4).
  **When a match is decided** (the frag limit, the last round), call `flow.matchOver(scores)` (SDK
  3.4, `scores[i]` for `link.players[i]` as with `flow.end`). That's the party's natural stopping
  point. Everyone gets a Play again / New game vote over your game, which carries on underneath
  (on your own between-match screen, say). Calling it on every client that sees the match end is
  fine: the room takes the first. A game on `Setup` gets it for free: `setup.reopen()` (back to
  the setup after a match) calls it, without scores. A game that does neither (and never ends a
  run) only gets a vote when someone asks for one.
- **One match per run** (a race, a tournament, a match to 10 kills). The host calls
  `flow.end(scores, 'RED WINS!')` when it's decided: the platform shows the ranking, then the next run
  starts with everyone who's still in. `flow.end` takes one score per current roster index
  (`scores[i]` for `link.players[i]`). Per-player games use `flow.finish` (NaN for seats you don't
  own), and the run ends when every roster player has a score.

A scoreboard: the HUD chips show one stat line per player (`flow.hud.setStat(i, `${kills} ⚔`)`).
For more (kills, deaths, ping-style columns) use `ui.board({ at: 'side' })` and redraw it when
something changes, or show it while a key is held (`input.keys.down('Tab')`, needs `keyboard`).
Sort by score, mark `me: true` on your own row, and keep it readable at 8+ rows.

## 6. Bots in a session

- CPUs fill seats up to `players.min` at the start of each run; they're ordinary roster players
  with role `'bot'` on the host. The host removes them again when humans take their place in a
  later run.
- The host can also add or remove CPUs from the room (up to `players.max`); in an untimed game
  they join and leave like anyone else.
- A game can have its own non-player enemies (zombies, turrets, targets): those are world
  objects the host simulates and snapshots, not roster seats.
- A 1-player session should still be fun: give a solo player something to do (targets, waves,
  a CPU rival via `players.min: 2`, or a personal best).
- **`players.min` is how many seats the room fills, not how many must play.** Nothing makes a
  game use every roster player: a CPU seat can sit a match out. So set `min` for the solo
  experience you want (4: a lone player gets a 2v2 with three CPUs), and let the host pick
  smaller matches in `Setup`. With two people and `min: 4`, the room adds two CPUs; if the host
  picks 1v1, bench them (host, at the match start):
  ```ts
  const seats = new Seats(link);
  const people = link.players.filter((_, i) => seats.role(i) !== 'bot');
  const cpus = link.players.filter((_, i) => seats.role(i) === 'bot');
  const playing = setup.options.size === '1v1' ? [...people, ...cpus].slice(0, 2) : link.players;
  // send `playing.map((p) => p.id)` with the match start; everyone else watches this match
  ```
  Only the host can tell a CPU from a person (`seats.role(i) === 'bot'` there, and a person
  whose connection just dropped reads `'bot'` too for a few seconds), so decide on the host and
  sync who plays (with the match start, as a `Setup` team, or through `Lockstep`, whose `join`
  input says `cpu`). Show benched players as watching, and still report a score for them when
  the run ends (an untimed run is over when everyone on the roster has one).

## 7. Testing: FakeRoom joins and leaves, `vp check`

Prove joins, leaves and host changes headless before drawing anything. `FakeRoom` runs
sessions (`mode: 'session'`, untimed when `mg.maxMs` is absent) and can change its roster mid-run;
read `vp docs netcode` §6 for its options, and check the exact method names in
`node_modules/@voxelparty/sdk/types/minigames/kit/loopback.d.ts`. Worth asserting:
- a player who joins mid-run gets a fighter on every client, and one who leaves is removed
  everywhere, with no errors and no stale index lookups (the classic bug: seat 2 leaves and
  seat 3's state is applied to the wrong player);
- after the host leaves, the new host carries the world on (scores and items survive); with
  `keep`, its world equals `room.kept.world` right after `room.leave(host)`;
- 1 player alone works, and so does `players.max`.

`vp check` plays standalone games as sessions in a muted headless browser: one starting with 1
player, one with 2, each with CPUs joining one by one up to 4 (or `players.max`) and then one
leaving, about 20 s of play each, with screenshots at the start, after the joins and after the
leave (a game whose `players.min` is over 4 starts with that many and gets 2 more). Then one run
with your seat on autopilot and one on a phone, and every screenshot is checked for a blank or
frozen view and HUD problems (`vp docs testing`). It fails on any error or a game that stops
responding. Look at the screenshots: do
joiners appear in sensible places, does the HUD/scoreboard follow, does the 1-player shot look
like a game?

### Your own scripts: `__vp` on the `vp dev` page

For screenshots and debugging (a headless browser over CDP, or the console), the `vp dev` page
has `window.__vp`: `__vp.join()` / `__vp.leave(pid?)` (a CPU drops in / someone leaves),
`__vp.players`, `__vp.state`, and `__vp.eval(code)`, which runs code **inside** the sandboxed
game and resolves with the answer as plain data (JSON):

```js
await __vp.eval('game.phase')                                  // an expression
await __vp.eval('game.camera.position.set(0, 30, 20); return game.camera.position')   // a body
await __vp.eval('ctx.flow.phase')                              // ctx: the GameContext your create() got
```

In scope: `game` (what your `create()` returned: your own class, fields and all), `ctx`, `link`
and `engine` (the runtime's engine). It may `await`; a throw rejects with its message. It only
works on the `vp dev` page, never on the site. So there's no need to attach to the game's frame
over CDP or leave debug globals in the game.

It plays too: `__vp.autopilot(true)` (your CPU plays your seat), `__vp.play()` (past the title
card), `__vp.hold('KeyW', 500)`, `__vp.look(200, 0)`, `__vp.click(x, y)`, `__vp.camera(pose)`,
`__vp.lint()`, `__vp.sounds()`. For pictures, use `bunx vp shot` with a script rather than your
own CDP code: it fast-forwards exactly, takes film strips and checks every picture (`vp docs testing`).

`vp check` picks a free debugging port for its browser each run; `--debug-port N` pins one.

### `vp check --long`: a whole match, fast-forwarded

Some bugs only show after 15 minutes: a list that never shrinks, snowballs never removed, frames
that get slower as the world fills up. `bunx vp check --long` does the normal checks, then plays
one session of `--minutes` (default 20) as fast as your machine goes (the template game plays
20 minutes in under 10 s; a heavier game takes longer).
The game runs on a virtual clock that only moves when vp says, one 60 Hz frame at a time, so it
sees the same `dt` as on the site (`--step 33`: 30 Hz frames, about twice as fast and coarser).
CPUs come and go all match: they fill it up to 8 (or `players.max`) over the first third, swap
in and out in the middle (someone from the middle of the roster leaves, someone joins), and thin
out at the end.

At 30 s (the baseline) and every tenth of the match it takes a checkpoint: a screenshot,
`.vp/check/long-<m>m<ss>s-<n>p.png`, and a row of the table:

```
Long run: 20 min of play, fast-forwarded in 60 Hz frames…
   time  players   sim avg   p95  worst  slow   drawn   heap MB  objects  geo/tex
   0:30        2      0.03  0.10    1.0     0     4.7      10.3       64    26/20
   2:00        4      0.03  0.10    4.1     0     4.3      10.9       75    28/23
   …
  18:00        6      0.03  0.10    0.4     0     4.5      12.2       87    37/28
  20:00        5      0.03  0.10    0.4     0     5.2      12.2       77    37/28
  20:00 of play in 6.6 s (182.9×); players 2 players, 1:20 +2 → 4, 2:40 +1 → 5, …
```

- **sim avg / p95 / worst**: real ms per frame of your game's own work since the last row
  (its timers and `update`, nothing drawn), to 0.1 ms. Budget: p95 under 8 ms; a 60 fps frame
  has about 16.7 ms and drawing needs the rest. **slow**: frames over 16.7 ms.
- **drawn**: one real drawn frame (update, draw, the GPU done), headless: a rough guide only.
- **heap MB**: the game's JS heap and array buffers right after a garbage collection. Flat, or
  following the player count, is fine; higher at every checkpoint is a leak.
- **objects**: everything in your scene. **geo/tex**: geometries and textures the renderer holds.
  Growing while the player count doesn't: things added and never removed, or never disposed.

It fails like the normal check (errors, or a game that stops: its clock not moving for 20 s),
and warns (⚠) on: p95 over 8 ms, a frame over 100 ms, drawn frames over 33 ms, frames getting
slower over the match, the heap or the scene growing steadily, and a game that ends its run
itself (the long run stops there; online the next run starts fresh). All of it, frame stats
included, is in `.vp/check/report.json` under `long`. Board minigames: `--long` plays round
after round (4, 2 and 3 players, a new seed each) until about as much play, a row per round.

Fast-forwarded isn't the same as played. Inside the game every clock is virtual (`Date.now`,
`performance.now`, timers, animation frames), so code that waits for the clock in a loop hangs
(and is reported as stopped), and whatever depends on real time (the real frame rate, uneven
`dt`, the browser's own hitches) only shows in the normal real-time runs. That's why those stay
real time and `--long` comes on top.

## 8. Checklist

- [ ] Per-player state keyed by pid; the `onPlayers` hook adds and removes avatars, bots and HUD bits.
- [ ] Joiners spawn safely and see what to do within 3 seconds.
- [ ] Snapshots carry ids; clients create unknown ids and drop missing ones.
- [ ] A new host carries on exactly: the host keeps the whole world (`HostSync`'s `keep`, ≤ 64 KB) and a new host loads it.
- [ ] Works with 1 player and with `players.max`.
- [ ] Ends runs with `flow.end` and a clear winner, or plays matches inside one run and calls
      `flow.matchOver(scores)` when each is decided (or truly never ends: a sandbox).
- [ ] `vp check` clean, screenshots looked at.
