---
name: incanto-multiplayer
description: Incanto multiplayer over a pluggable NetworkTransport — NetworkManager, owner replication via scene-JSON network blobs, NetworkSpawner, the built-in Loopback transport, the @agent8/gameserver adapter, and custom transports for any backend. Use when adding multiplayer to a game.
---

# Multiplayer with Incanto

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

## Mental model

Incanto multiplayer is **transport-agnostic**: the engine speaks one small
`NetworkTransport` interface, and ANY backend plugs in behind it —

| Transport | When |
|---|---|
| `LoopbackTransport` (built in) | offline dev, tests, local split-screen — built-in protocol only, zero infra |
| `LocalGameServer` (built in) | **PREVIEW**: run your REAL `server/src/server.ts` (custom rules + `$roomTick`) in memory — no cloud, no auth |
| `createAgent8Server()` (built in) | the agent8/Verse8 platform (`@agent8/gameserver`) — production |
| your own implementation | any server: Socket.IO, Colyseus, Supabase Realtime, custom WebSocket… |

Three rungs, same game code: **Loopback** (movement/co-presence, no server logic) →
**LocalGameServer** (your actual server-authoritative `Server` class, run locally) →
**Agent8** (that same `Server` deployed live). You climb rungs by swapping ONE line —
the transport — never the game or the server file.

The replication model is **data-authoritative, not simulation-authoritative**: clients own
their state; the backend stores/relays it (cadence ≥ tens of ms). Design for **casual
sync** — co-presence, turn-based, racing-with-tolerance. Fast-paced fairness (fighting
games, physics duels) needs a simulation-authoritative server, which is out of scope.

Authority rules:
- movement/cosmetics → **owner-authoritative** (the replication below)
- score/economy/inventory → **server-function-authoritative** (extend `server.js`;
  `$sender.account` is trustworthy, args are NOT; guard with `$lock`)

  > **`$lock` is NOT reentrant.** Taking one key inside another's callback —
  > `$lock('clock')` inside `$lock('match')`, one string apart in a
  > read-modify-write — DEADLOCKS. The preview server runs every client's calls
  > one at a time, so one stuck call wedges the whole match: every
  > `setMyState` stops, new players can never join, and `local.tick()` never
  > resolves. It says so now (`a call has been running for over 5s and every
  > other client is blocked behind it`) instead of hanging in silence, and
  > `incanto-multiplay` prints its verdict instead of exiting 13 with nothing.
  > Take one lock, do the whole read-modify-write inside it, and release.
- physics/AI run client-side; one-simulator needs (NPC waves) use the host-client pattern
- a SHARED physics object (a ball, a crate, a door) → **owner-handoff**: one
  client simulates it at a time and the server decides which — the recipe is
  "A shared physics object" below, and `examples/soccer-mp-3d` is the composition

## Scene JSON

```json
"multiplayer": { "room": "auto" },
"root": { ..., "children": [
  { "name": "Player", "type": "CharacterBody2D",
    "network": { "mode": "owner", "sync": ["position", "Skin.animation"], "throttleMs": 50 },
    "children": [ ... ] },
  { "name": "Remotes", "type": "NetworkSpawner",
    "props": { "source": "users", "scene": "remote-player" } }
] }
```

**The `network` block is validated at LOAD.** It used to be the one node-level
block the loader cloned without looking at, and its typos are invisible at
runtime — a string `sync`, a capitalised `mode`, the plural `syncs` each mean
"nothing replicates and nothing says so", and produce a report identical to a
working game's. All three are hard `BAD_FORMAT` errors now, and
`bunx incanto check` catches them before you ever open a browser:

```
[BAD_FORMAT] "network.sync" must be an ARRAY of prop names, not "position"
             — write ["position"] (on 'Player')
```

Valid keys: `mode` (`owner` — the only one the engine reads; `observer` was a
sketch that shipped in the validator's list and was never implemented, so it is
now refused by name), `sync` (array of prop names),
`throttleMs` (number). An `owner` with an empty `sync` is an error too — it is
a half-finished edit that behaves exactly like a broken one.

- **ONE owner node per player.** Its `sync` keys are relative to ITSELF
  (`position` = own prop, `Skin.animation` = child path + prop). A change to ANY
  of them sends ALL of them, once per throttle window — the payload is the whole
  set, deliberately: backends shallow-merge one level down, so a partial patch
  would REPLACE the stored `sync` object and erase every key that had stopped
  changing (a team colour, a skin, a name). Players already in the room would
  never notice — they applied it once and kept it — while the next joiner
  rendered the default forever. An idle owner still sends nothing until the
  keyframe (`keyframeMs`, default 2 s) comes round, which is also what heals a
  send that never reached the wire. Spawned entities (bullets,
  pickups) go through **collections**, never extra owner nodes.
- **`NetworkSpawner`** (register with `registerNodesNet()`): **`scene` is REQUIRED** —
  the key you registered the remote's scene under. Without one the spawner used to
  return from every frame and no remote ever appeared, on a scene that loaded,
  audited and played clean. A spawner in a game with no multiplayer at all says so
  after two seconds, by name. `source: "users"` spawns one
  instance of the registered scene per OTHER account (self skipped); the flat `sync` patch
  applies onto the spawned scene's root; `position` lerps when `interpolate: true`
  (remote entities render slightly in the past — that's correct). **A remote is
  made from its FIRST patch, where that patch says it is** — not at the join,
  at the origin, sliding to its place: the kernel lists an account the moment it
  joins with nothing in it, and an instance built then stood on the kick-off
  spot for a few frames (with a body on the remote scene it kicked the ball
  into the player beside it). So a remote scene with a `CharacterBody3D` root is
  fine, and is how the ball bounces off the OTHER player in `soccer-mp-3d`. Emits
  `spawned(node, key)` / `despawned(node, key)`. `source: "collection:<id>"` mirrors a
  room collection by `__id` — a collection ENTITY applies as your server wrote it
  (`addCollectionItem('coins', { position: [x, y] })` lands on the spawned node;
  the `{sync: {…}}` envelope an owner state uses is accepted too). A key whose
  node PATH resolves to nothing (`Skin.animation` on a scene with no `Skin`) is
  reported once rather than dropped — that value replicates to nowhere.

## Boot

```ts
import { registerNodes2D } from 'incanto/2d';   // or registerNodes3D for a 3D game
import { NetworkManager, registerNodesNet, LoopbackHub } from 'incanto/net';

// REGISTRARS COMPOSE and you need BOTH: `registerNodesNet()` adds NetworkSpawner
// and nothing else, so a 2D scene loaded after it alone dies on its own root
// node (`Unknown node type 'Node2D'`).
registerNodes2D();
registerNodesNet();
// `{ engine }` on a MANUAL boot, always: `onReady` runs during the load, and a
// behaviour that reads `this.engine`/`this.rng` there (Wander draws its first
// heading in it) throws TREE_VIOLATION without it.
const scene = loadScene(json, { engine });
engine.setScene(scene);

// DEV / offline / split-screen: zero infrastructure, built-in protocol only
const hub = new LoopbackHub();
const manager = await NetworkManager.create(engine, { transport: hub.createClient('p1') });

// PREVIEW with YOUR server logic (custom rules + $roomTick), still no cloud:
// const local = createLocalGameServer({ server: Server });   // see "Preview" below
// const manager = await NetworkManager.create(engine, { transport: local.createClient('p1') });

// LIVE on agent8 (install the optional peer dep `@agent8/gameserver`):
// const manager = await NetworkManager.create(engine);          // verse/account/auth auto-resolve
// …and run the server/ init flow below ("Going live on agent8 v2").

// ANY OTHER BACKEND: implement the NetworkTransport interface (see below) and
// pass it the same way: NetworkManager.create(engine, { transport: myTransport }).

manager.registerScene('remote-player', remotePlayerSceneJson);
engine.start();
```

`NetworkManager` API:
- ROOM signals `roomState`, `allUserStates`, `userJoined/Left`, `message(type)`,
  `collection(id)`; snapshots `latestUserStates`/`latestRoomState`/`latestCollection(id)`.
- GLOBAL signals `globalState`, `globalMyState`, `asset` (the local account's `$asset`
  ledger — wire a HUD straight to it, no polling), `globalCollection(id)`,
  `globalMessage(type)`; snapshots `latestGlobalState`/`latestGlobalMyState`/
  `latestAsset`/`latestGlobalCollection(id)`. These fire only on a transport with a
  global tier (LocalGameServer, the agent8 adapter); they stay quiet on raw Loopback.
- Outbound `setMyState`, `patchRoomState`, `addEntity/updateEntity/removeEntity`,
  `sendEvent(type, payload)`; **`call(fn, ...args)`** to invoke a CUSTOM server function
  (your `server/src/server.ts` method — roomId is prepended automatically, e.g.
  `manager.call('claimCoin', id)` → server `claimCoin(roomId, id)`, returns the server's
  result). Global state is server-authoritative — the client READS it via the signals
  above and WRITES it only through `call(...)`. `dispose()` leaves the room.

The agent8 adapter (`createAgent8Server`) deep-imports the framework-free
`GameServer` class from `@agent8/gameserver` (skipping its React/zustand index) and
converts the v2 wire shapes to incanto's: `subscribeRoomAllUserStates` arrives as a
delta-merged ARRAY `[{...state, account, __updated}]` → an account-keyed Record;
`subscribeRoomCollection` arrives as `{items:[{__id,…}], changes}` → a `__id`-keyed
Record. It also re-issues every subscription after a reconnect.

**When the socket drops it keeps trying** — 0.5s, 1, 2, 4, then 8 up to eight
attempts — and SAYS so on the first failure and again if it gives up:

```
[incanto] the multiplayer connection dropped and the server would not take it
back (ECONNREFUSED). Retrying — other players cannot see this one until it
comes back.
```

There used to be one attempt and no catch, so a server that was still
restarting rejected it, the rejection escaped an unawaited `void reconnect()`,
the re-issue loop never ran and nothing scheduled a second try: **the session
never came back, and nothing said a word.** A `connect()` that resolved `false`
was taken for success and re-issued every subscription onto a dead socket.

**A replication send that fails now reaches `stats().errors`.** It was
`void this.setMyState(…)`, so the rejection went to the host's
unhandled-rejection channel — not `engine.log`, not the error count. A client
whose socket died kept playing perfectly on its own screen while everyone else
watched it frozen, and the run reported `errors: 0`.

**A transport that REFUSES the connection now throws** `NETWORK_UNAVAILABLE`
out of `NetworkManager.create`, instead of returning a fully-formed manager
with `connected === false` that had joined nothing.

## Writing a custom transport (any backend)

Implement `NetworkTransport` (exported from `incanto/net`): `connect`/`disconnect`,
`remoteFunction(fn, args)` answering the room protocol
(`joinRoom`/`leaveRoom`/`setMyState` shallow-merge/`patchRoomState`/
`addEntity`/`updateEntity`/`removeEntity`/`sendEvent`), and the subscription methods
(`subscribeRoomState`, `subscribeRoomMyState`, `subscribeRoomAllUserStates`,
`subscribeRoomCollection`, `onRoomMessage`, `onRoomUserJoin/Leave`). `LoopbackTransport`'s source is the reference
implementation — anything that behaves like it works with the whole engine.

## Preview: run your REAL server locally (no cloud, no auth)

`LoopbackHub` answers only the FIXED built-in protocol — it cannot run your
server-authoritative rules (`claimCoin`, `castSpell`) or your `$roomTick`. To
PLAY the whole game — client + the actual `server/src/server.ts` `Server` class —
in dev, use `LocalGameServer`:

```ts
import { createLocalGameServer, NetworkManager, registerNodesNet } from 'incanto/net';
import { Server } from '../server/src/server';   // your deployable v2 Server class

const local = createLocalGameServer({ server: Server });   // runs Server in memory
const manager = await NetworkManager.create(engine, { transport: local.createClient('p1') });
// …a second client: local.createClient('p2') — split-screen on one page.

// $roomTick is server-DRIVEN: pump it from the engine ONCE (clamp dt so a
// backgrounded tab can't fast-forward the match):
engine.updated.connect((dt) => void local.tick(Math.min(dt, 0.05) * 1000));
```

### Split-screen preview in one call (`createSplitScreen`)

Testing multiplayer alone means playing BOTH sides. `createSplitScreen` wires
the whole N-panel harness — one LocalGameServer, one engine + NetworkManager
per player — so you only supply the per-panel renderer/input:

```ts
import { registerNodes2D } from 'incanto/2d';   // the dimension your scene uses
import { createSplitScreen, registerNodesNet } from 'incanto/net';

registerNodes2D();
registerNodesNet();

const canvases = [document.getElementById('p1'), document.getElementById('p2')];
const { players, server, dispose } = await createSplitScreen({
  scene: gameJson,                       // shared scene (each panel gets its own copy)
  server: Server,                        // your v2 Server class (optional)
  scenes: { 'remote-player': remoteJson }, // NetworkSpawner scenes, every panel
  accounts: ['p1', 'p2'],                // one panel per account (default)
  setup: ({ engine }, i) => {            // finish wiring each panel
    new Renderer2D({ canvas: canvases[i], engine });
    engine.input.attachKeyboard(i === 0 ? window : canvases[i]); // split inputs!
    engine.start();
  },
});
```

Each panel gets physics on the same terms `createGame2D` gives it (`'auto'`:
Rapier when the scene has bodies; `physics: false` opts out) — without it a
`CharacterBody2D` never moves. The first panel's clock pumps `server.tick` (so
`$roomTick` runs) — don't add your own. Every panel joins ONE room: a shared scene usually says
`multiplayer: { room: "auto" }`, and "auto" means a server-ASSIGNED room, so the
harness pins panels 1..N to the room panel 0 got (`room: 'lobby'` overrides).
`dispose()` tears every panel down. Going live is unchanged: ONE
client per browser with `createAgent8Server()` as the transport.

It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
`$room`/`$lock`) are injected per call, a FRESH `Server` instance runs per request
(so `this.*` never persists), and calls are serialized (no global leaks across
`await`s). `$roomTick(deltaMS, roomId)` runs only while a room has users.

**Verifying a whole match**: `playMultiplayer` from `incanto/test` runs N clients
against one in-memory server for a fixed number of simulated seconds and reports
what they ended up sharing — rooms, who saw whom, what each `NetworkSpawner`
materialised, per-client frame errors, the final room state, and **whether the
clients hold the same VALUES**.

That last part is the one that matters and the one that used to be missing.
"Saw p2" only ever meant "p2 is in the room" — the kernel puts an empty entry
there at join — so a game whose replication was completely dead reported exactly
what a working one did. After the match quiesces, every key in every owner's
`network.sync` is read on the sender and on each other client's spawned copy and
compared:

| | |
|---|---|
| `missing` | that client never materialised the account at all |
| `absent` | the key path does not resolve on the spawned scene — a renamed child |
| `shape` | different array lengths; a componentwise lerp calls that "already correct" |
| `mismatch` | the values differ beyond the interpolation tolerance |
| `erased` | the live clients agree, and a LATE JOINER never got it |

`erased` is why the harness brings one more client in after everything settles.
Players already in the room applied a value once and kept it on their node, so
they agree with each other while the authoritative snapshot is already wrong —
a two-browser test cannot see it, and the next person to join sees the default
forever. Set `lateJoin: false` to skip that half; `seed` makes the whole match
reproducible.

**What it does not prove.** One in-memory server means no latency, no loss, no
reordering: this measures the apply path and the protocol shape, never the live
wire. A reconnect that never re-joins, a throttle that discards a payload, a
batch dropped on a closed socket — none of those are in reach, and the report
says so on its own summary line.

See `incanto-verifying-your-game.md`.

**Driving one by hand**: server calls are QUEUED and only run when the event loop
turns, so a synchronous frame loop enqueues a thousand ticks that never execute —
the match clock stands still and every `call()` result arrives after your
assertions.

```ts
for (let f = 0; f * 16.7 < ms; f++) {
  for (const p of players) p.engine.tick(f * 16.7);
  if (f % 6 === 0) await new Promise((r) => setTimeout(r, 0)); // let the server run
}
```

It is a FUNCTIONAL emulator, NOT the platform: no isolated-vm sandbox, no rate
limits, and no DURABLE persistence (global state lives only for the preview process
— it is not saved across runs, and rooms still clear when empty). It proves your
game logic; deploy for the rest. Omitting `server` (`createLocalGameServer()`) gives
the built-in kernel only — identical to a raw `LoopbackHub`.

The preview injects `$sender`, `$room`, `$lock`, `$global`, and `$asset`, so
SERVER-side economy and persistence logic runs locally:
- `$room` — full room state/user-state/collections: `getMyState`/`updateMyState`,
  `getRoomState` (always carries the `roomId` + `$users` defaults) / `updateRoomState`,
  `getUserState`/`updateUserState`, `getAllUserStates` (array, each with `account`),
  `countUsers`, room collections (`add`/`get`/`getCollectionItems`/`countCollectionItems`/
  `delete`/`deleteCollection`), and `broadcastToRoom`.
- `$global` — `joinRoom`/`leaveRoom`, process-lifetime global state (`getGlobalState`/
  `updateGlobalState`), per-account global user state (`getMyState`/`updateMyState`/
  `getUserState`/`updateUserState`), global collections (`add`/`update`/`delete`/
  `get`/`getCollectionItems`/`countCollectionItems` with `filters`/`orderBy`/`limit`
  query options), and room management (`countRoomUsers`/`getAllRoomIds`/
  `getRoomUserAccounts`/`getRoomState`/`updateRoomState`/…).
- `$asset` — a per-account currency ledger: `mint`/`burn`/`has`/`get`/`getAll`/
  `transfer` (`burn`/`transfer` throw on an insufficient balance, and all amounts
  must be non-negative & finite — surfacing economy bugs just as live would).

Client-side GLOBAL subscriptions ARE emulated: the preview client reacts to
`globalState`/`globalMyState`/`asset`/`globalCollection`/`globalMessage` (surfaced as
NetworkManager signals above), and `$global.broadcastToAll`/`sendMessageToUser` deliver
to `onGlobalMessage` (account-targeted for the latter).

Still NOT emulated (use these only LIVE): `$room.sendMessageToUser` targeted ROOM
delivery (the preview room message bus isn't account-scoped — a callable no-op;
`$room.broadcastToRoom` DOES deliver) and system handlers like `$onItemPurchased`
(VX shop). And the platform's hard constraints (isolated-vm, real concurrency, rate
limits, durable persistence) are still not enforced — a game green in preview must be
re-tested live.

Runnable reference: `examples/arena-preview` (Coin Dash — server-authoritative
`$lock`'d coin claim + `$roomTick` match clock, two clients split-screen). Going
live is the one-line transport swap below.

## A shared physics object (a ball two players kick)

The replication above is per PLAYER: one owner node, its own state. A 1v1
soccer game has one thing both players act on — the ball — and a ball is a
physics body no data-authoritative server can integrate at 60 Hz (`$roomTick`
runs every 100–1000 ms; a ball at 10 m/s moves a metre or ten between ticks).
The shape that works, measured in `examples/soccer-mp-3d`:

- **Every client runs its own Rapier ball.** It bounces off boards and off
  the other player's body locally; nothing is a ghost.
- **One client at a time is the ball's OWNER**, and the server says which
  (`room.ballOwner`). The owner publishes position + velocity at the owner
  throttle (a custom server function that DROPS a publish from anyone else —
  that is the authority) into a room collection entity; the others MIRROR it:
  set the local ball's velocity to the published one plus a position-error
  gain (`6/s`), so it chases the truth while still colliding, and teleport
  when the gap is wide (`2.5 m`).
- **Touching the ball takes it over** (`claimBall`, `$lock`'d) — the kick
  applies its impulse to the local ball AT ONCE and the claim makes that
  simulation the room's; a player WALKING at the ball (the move stick, not a
  shove) claims it too, which is what makes dribbling possible. A player the
  ball hits does not claim it, so a shot off a standing opponent bounces as the
  shooter's simulation says, and two players standing on the ball do not hand
  it back and forth every window.
- **A `seq` on the entity says "snap, do not chase".** The server bumps it on
  every reset and every handoff, and writes the entity itself when it puts the
  ball on the spot — every client snaps to a new seq, except the client that
  wrote it.
- **The server is the referee** in `$roomTick`: sides, the clock, a GOAL read
  from the replicated ball (it is caught in the net, so a slow tick cannot miss
  it), the reset, and who kicks off. Clients paint the score from room state
  and never call a goal.

What it is NOT: fair under latency. The kicker's client is right by
definition, so a laggy opponent's tackle lands late — the "casual sync" the
model is built for, stated on purpose. Two things the engine changed for it:
an impulse now composes with a velocity written in the same frame (the
mirror's write used to erase the kick a step later), and a remote spawns
where its first patch says it is.

## An online race: one physics body PER owner

The ball above is one body two players fight over. A race is the other shape —
a body EACH, nothing to hand off — and `examples/race-mp-3d` ("Apex Duel") is
the composition:

```json
{ "name": "Car", "type": "RigidBody3D",
  "props": { "mass": 900, "linearDamping": 0.3, "angularDamping": 1.5,
             "collider": { "shape": "box", "size": [1.9, 0.7, 4.2] } },
  "groups": ["car", "player"],
  "network": { "mode": "owner",
               "sync": ["position", "rotation", "Wheel0.rotation", "Wheel1.rotation"],
               "throttleMs": 50 },
  "script": { "name": "Racer" },
  "children": [
    { "name": "Drive", "type": "Vehicle3D",
      "props": { "enabled": false, "engineForce": 4200, "wheels": [
        { "position": [-0.85, -0.15,  1.45], "radius": 0.38, "steer": true },
        { "position": [ 0.85, -0.15,  1.45], "radius": 0.38, "steer": true },
        { "position": [-0.85, -0.15, -1.45], "radius": 0.38, "drive": true },
        { "position": [ 0.85, -0.15, -1.45], "radius": 0.38, "drive": true } ] } }
  ] }
```

- **The other car is a kinematic box**, not a vehicle: `remote-car.scene.json`
  is a `CharacterBody3D` root with the same meshes and `Wheel<i>` nodes (so the
  synced `Wheel0.rotation` path resolves on the copy). The spawner poses it
  from the wire — position interpolated, rotation applied outright — and the
  local car BUMPS it, because a kinematic body pushes a dynamic one.
- **The lights hold the car, not the input.** `Vehicle3D.enabled` is false
  until the room says `race`; parked, the car still settles on its springs and
  still reads its actions, so green is a real launch. Offline (no
  NetworkManager) the behaviour enables it at once.
- **The server counts gates, IN ORDER.** `passGate(i)` is `$lock`'d and
  accepted only for the gate that car is due (`next[account]`); gate 0 after
  the last one is a lap; the lap that reaches `LAPS` sets `phase: 'done'` and
  `winner`. The client never counts online — it reads `laps`/`next` off the
  room state and turns a change into its `gate`/`lap` signals. A cut across
  the infield therefore counts nothing, on the server, where cutting is decided.
- **Gates are `Area3D` sensors driven THROUGH** (the wheel rays ignore them),
  routed by `connections` with `"filter": { "group": "player" }` so the remote
  car — group `remote`, never `player` — cannot trip a gate on this client.
- **Who is ahead** is laps, then gates this lap, then distance to the next
  gate — computable on every client from the room state plus the two car
  positions, so the HUD's `1st`/`2nd` needs no server field.
- **No frame before the manager.** `createGame3D` starts the loop at once —
  and ticks two warm-up frames before it even returns — while
  `NetworkManager.create` takes a moment to join; every frame in between runs
  the game OFFLINE. The race's `Racer` grids itself to slot 0 when it finds no
  manager (the right thing for `incanto verify` and the editor's ▶ play), and
  in the browser that offline pose went out as the client's FIRST replicated
  state, on top of the other player's car, which the kinematic copy then lifted
  off the ground. Boot with `autoStart: false` and call `game.engine.start()`
  once the manager exists (`examples/race-mp-3d/src/App.tsx`):

  ```ts
  const game = await createGame3D({ canvas, scene, behaviors, autoStart: false });
  const manager = await NetworkManager.create(game.engine, { transport });
  manager.registerScene('remote-car', remoteCarJson);
  game.engine.start(); // the first frame is a networked one
  ```

## Going live on agent8 (gameserver-sdk v2)

The agent8 platform exposes NOTHING client-callable by default — every function
NetworkManager calls (`joinRoom`/`leaveRoom`/`setMyState`/`patchRoomState`/
`addEntity`/`updateEntity`/`removeEntity`/`sendEvent`) must be defined in YOUR
server code. incanto ships that body for both server styles:

**Structured project (v2, recommended).** Server code lives in `server/src/server.ts`.
NEVER hand-create `server/package.json`, `server/tsconfig.json`, or `server/src/server.ts`
— the init command generates them:

```bash
npx -y @agent8/gameserver-node init        # only if server/ does not exist yet
# then replace server/src/server.ts with the incanto kernel body:
#   node_modules/incanto/templates/agent8-server.ts   (export class Server { … })
npx -y @agent8/gameserver-node test        # write + run server tests
npx -y @agent8/gameserver-node build       # generates server/dist/server.js
# then DEPLOY = push to the repository — the platform auto-builds + deploys.
```

**Legacy (root `server.js`).** Only for projects that already ship one. Body:
`node_modules/incanto/templates/agent8-server.js` (`class Server`, NO `export`).

The client side is unchanged — `await NetworkManager.create(engine)` uses the
built-in `createAgent8Server()` transport (verse/account/auth auto-resolve via the
SDK). Develop against `LoopbackHub`, then drop the `{ transport }` option to go live.

> **Cross-links — defer server-code specifics to the service skills, don't duplicate them here:**
> - **`gameserver-sdk-v2`** — the authoritative server AND client reference: the
>   `$global`/`$room`/`$sender`/`$asset`/`$lock` contexts, `$roomTick`, collection/state
>   APIs, the init/test/build/deploy flow, the isolated-vm limits, AND client connection +
>   auth/identity (`useGameServer`, `server.connect`, `$sender.isGuest`/`isFollower`/
>   `isSubscriber`, Verse8 identity). Read it before editing `server/src/server.ts` and for
>   how the live client connects.
> - **`gameserver-sdk`** — legacy v1 single-file `server.js` reference.

How incanto's model maps onto v2 rooms:
- **`network: {mode:'owner', sync:[...]}`** → throttled `setMyState(roomId, {sync:{…}})`
  → server `$room.updateMyState(patch)` (shallow merge) → surfaces to every other
  client through v2's `subscribeRoomAllUserStates` (account-keyed after the adapter).
- **`NetworkSpawner` `source:'users'`** consumes `manager.latestUserStates` (the
  account-keyed snapshot), spawning one instance per OTHER account; `onRoomUserJoin/Leave`
  drive `userJoined`/`userLeft`. A user leaving simply disappears from the next
  all-user-states array, so the spawner despawns them.
- **`source:'collection:<id>'`** consumes `addEntity`→`$room.addCollectionItem` etc.,
  mirrored by v2's `subscribeRoomCollection` (keyed by `__id` after the adapter).

Kernel constraints (agent8 platform facts — don't fight them):
- a NEW Server instance per request: `this.*` never persists; use `$global`/`$room`,
  and `$roomTick` (100–1000ms) for timed logic — NO setTimeout/setInterval/Node builtins/fetch
- `updateMyState`/`updateRoomState` are SHALLOW merges — keep state maps flat; you can't delete by omission
- room data is ephemeral — persist to `$global` before rooms empty
- never expose unthrottled per-frame remoteFunction (rapid calls are rejected); guard score/economy with `$lock`

## Changing scene in a multiplayer game

A manager is bound to the ROOM the scene it was created for declares.

- **Same room, new scene** (level 2 of one match, a map vote): nothing to do.
  The manager rebinds to the incoming tree and keeps broadcasting; the new
  owner node re-sends its full state.
- **Different room** (lobby → match, leaving for another arena): create a NEW
  manager for the new room. Broadcasting stops on purpose — leaking one room's
  state into another is exactly what the binding prevents — and the engine logs
  an error saying which room it is bound to and which the scene asked for.

Dispose the old handle whenever you like, before or after creating the new one:
disposing a handle that a later `create()` already superseded leaves the live
manager alone.

## State rules

- JSON-safe by construction: vectors as arrays, no `undefined`/`NaN`/typed arrays
- Reserved names (never use as keys): `$users`, `roomId`, `account`, `__id`, `__roomId`,
  `__updated`, `__leaved`, anything `$`-prefixed
- `LoopbackHub` implements the SAME room protocol as the agent8 server templates,
  so passing locally proves your game LOGIC + data flow are right — necessary, but
  NOT sufficient. The preview does NOT enforce the platform's hard constraints, so a
  game that's green locally can still misbehave live; you MUST re-test live for:
  - **isolated-vm limits** — `setTimeout`/`setInterval`/`fetch`/Node builtins run
    fine in preview but are REJECTED live (timed logic must be `$roomTick`);
  - **concurrency** — the preview serializes every call, so a FORGOTTEN `$lock`
    still passes locally; live, parallel requests race (double-award, last-write-wins);
  - **rate limits** — unthrottled per-frame `remoteFunction` passes locally, throttled live;
  - **persistence/ephemerality** — preview state lives forever in a Map; live, room
    data is cleared when the last user leaves (persist to `$global` before then);
  - **wire shapes & auth** — array/delta conversions and `$sender.account` trust
    exist only on the live adapter.
- Runnable references: `examples/arena-loopback` (two clients on one `LoopbackHub`)
  and `examples/arena-preview` (the real `server/src/server.ts` run locally via
  `LocalGameServer` — server-authoritative scoring + `$roomTick`). Going live = the
  client transport swap (for a normal single-client game, just omit `{transport}`;
  a split-screen demo like arena-preview also drops its local-only scaffolding) PLUS
  deploying `server/` (the "Going live on agent8" section above) — not literally one line.


## The `agrees` rung runs YOUR server

`incanto-multiplay` loads `server/src/server.ts` (or `--server FILE`) and runs
the match against it. It did not until 0.67: every match ran on a bare loopback
kernel with no remote functions, so `roomState` came back `{}` instead of
`{"matchMs": 5983.3}`, a `manager.call('claimRelic')` threw
`Loopback kernel has no remote function 'claimRelic'`, and the report still said
`ok=true errors=0 problems=[]`. Moving `server/src/server.ts` out of the tree
changed nothing — a ✓ that could not have been a ✗.

If your server cannot be loaded the run REFUSES rather than quietly measuring a
game without its own rules. It is looked for one and two directories above the
scene (`src/game.scene.json` and `src/scenes/game.scene.json` both put the
project root there) and in the working directory; the report's first line
names the one it loaded — `· server: …/server/src/server.ts (Server)` — or
says `no server loaded`, and the `--json` report carries `server` and
`serverNote`. Before this it looked two levels up only, found nothing for a
scene at `src/`, said nothing, and ran every online example's `agrees` rung on
the bare kernel with an empty room state.

**Pass `--behaviors src/behaviors.ts` too.** A sync key is very often written by
game TypeScript (`player.firing = input.isPressed('fire')`), and without the
behaviors the harness cannot tell a DEAD sync key from one whose writer was
never loaded — so it says nothing about them at all. With the behaviors there,
a key the owner cannot read is reported as what it is:

```
✗ p1 declares `firing` in its `network.sync` and cannot READ it — it is never
  sent, so no client will ever see it change.
```

That is the most complete failure of replication there is, and the rung used to
grade it PASS: an unreadable key was dropped from the comparison, so
every client agreed perfectly about a value nobody ever sent.

**`collection:` spawners are compared too.** The account-keyed half of the check
cannot see them — a bullet has no account — so a spawner that took a hundred
entities off the wire and materialised none of them used to be entirely silent.
Now it says which spawner, and how many entities it dropped.

**A body still moving is allowed one send window.** The rung quiesces for
sixty frames — the driver's held inputs are dropped first — and then compares
the owner's truth with what the copies last RECEIVED. A character stops; a car
does not (released, a raycast vehicle coasts at constant speed), so its copies
are honestly `speed × (throttleMs + the server's turn)` behind. That much slack
is granted on `position` and `rotation` keys, scaled by the owner's own
`linearVelocity` / `angularVelocity` — a dead sync still fails, its gap grows
with the match — and the report names who was still moving:

```
· p1, p2 still moving when compared — a copy may trail by one send window, and that is not a disagreement
```

`rotation` is compared as an ORIENTATION (the angle between the two), because
Euler XYZ writes one attitude two ways near the gimbal lock and wraps at ±180°
— `[177, 88, -177]` and `[-180, 88, 180]` are a degree apart, not 357°. And
`incanto multiplay` drives a physics owner (one with a `linearVelocity`) along
its own forward at 3 m/s rather than teleporting it — a car thrown through the
kerbs every frame is the instrument's doing, not the game's.
