ADR-0012 ยท accepted
Co-op runs in delay-based lockstep over a host star โ
Amended on 2026-11-06:
GameDriveris gone;makeGameEngine'sattachis therequestAnimationFrameadapter, see the note on ADR-0010.Since ADR-0019: the signalling worker is the kit's (
arcadekit/net/worker), and the demo's Worker is a few lines over it. The demo'sCoopClientis the kit's Co-op client (makeCoopClient,arcadekit/net/browser), with the lobby, the content hash and the worker-timed ticker behind it. A genre's per-player step input isPlayerInputs(arcadekit/runtime).
#25 makes survivors a co-op game for 2 to 4 players in different browsers. The kit already has what lockstep needs: a deterministic step with all randomness from the run's Rng, step arithmetic that agrees across JavaScript engines (ADR-0011), one plain input value per step, and a DOM-free GameLoop whose frame(input, { steps }, nowMs) runs exactly the steps it is given (ADR-0010). What was missing is the decision on how peers agree on each step's input, and what happens when they can't.
Delay-based lockstep. Each peer sends only its input, never positions or state. Every peer runs step N once it has every player's input for step N, so all runs stay identical. A peer samples its local input for step N + inputDelay, a fixed input delay of a few steps, so the input usually arrives before step N is due and stepping doesn't stall. The delay is a setting, tuned by measurement; 2 to 6 steps is 33 to 100 ms at 60 Hz. There is no prediction and no rollback: a step whose inputs haven't all arrived waits.
A host star. One peer, the host, runs the lobby and is the hub; every other peer connects only to the host. Each peer sends its inputs to the host. The host decides when a step's inputs are complete, merges them and relays the merged input for that step to every peer. Every peer, the host included, steps on merged inputs only. The host is the one authority on who is in the run, so it is the only peer that decides a step without someone's input (see drop-out, below).
A new module: @gamekit/net. It holds the lockstep engine and its transports.
- The lockstep engine knows no transport. Its interface takes a
Transportthat carries messages between the host and the peers. The seam is thatTransportinterface, and it has two adapters: an in-memory transport for tests, with simulated latency, jitter and loss (createMemoryNetwork), and WebRTC data channels for the browser (linksTransportin@gamekit/net/browser). The browser entry's signalling client (hostRoom,joinRoom) speaks@gamekit/net/signallingover a WebSocket and turns a join code into one WebRTC link per pair in the star. - The lockstep channel is unreliable and unordered. The session already resends, on every tick, every input, step and hash the other side hasn't acknowledged, and ignores duplicates and stale messages, so a lost message is covered by the next one a few milliseconds later. A reliable, ordered channel would hold every later message back until a lost one is retransmitted (head-of-line blocking), stalling steps for a round trip or more. Each link also has a reliable, ordered channel for the app's few messages around the run (the lobby's hero picks and its start), which have no resend of their own.
- The engine's merged inputs reach
GameLoopas a plainStepInput, a buffer of confirmed inputs thatread(stepIndex)indexes by step number. The engine callsloop.frame(confirmed, { steps: ready }, nowMs), wherereadycounts the steps whose merged input has arrived. It steps on a timer, not onrequestAnimationFrame, and a sibling ofGameDriverdraws onrequestAnimationFrameonly. That sibling is the survivors demo'sCoopClientfor now, the only lockstep app; it ticks the session on a timer and again on each animation frame before drawing, since a main thread busy drawing runs timers late, and a tick per frame keeps input flowing. The timer runs in a dedicated Web Worker that posts each tick to the main thread (workerTicker), because browsers throttle a hidden tab's main-thread timers to about once a second (an open data channel already spares the page Chrome's harsher intensive throttling) and run no animation frames for it, while a worker's timers keep their rate. So a hidden tab on a desktop browser, which keeps running hidden pages, keeps stepping, and sending its input, at full rate, and draws nothing. Only the timer is in the worker: the session, the simulation and WebRTC stay on the main thread, whose message and data channel events a hidden tab doesn't throttle. A page the browser freezes or suspends (a mobile browser in the background, a frozen tab) stops its worker too, and is a stalled peer for the drop rule below. Single-player keeps pausing in a hidden tab, as a solo game should. This is the third stepping adapter onGameLoop.framethat ADR-0010 left room for, and no contract in@gamekit/runtimechanges for it. @gamekit/netmay depend on@gamekit/runtime,@gamekit/coreand Effect. Nothing lower imports it. Its root entry is DOM-free, like the other packages' (ADR-0002); the WebRTC transport lives in its browser entry.- Signalling is a small hosted worker that swaps WebRTC offers and answers behind a join code: a Cloudflare Worker with a Durable Object per join code, defined and deployed with Alchemy (TypeScript infrastructure as code). For the survivors demo it is the demo's own Worker, which also serves the site, so the client finds it on the page's origin, and
alchemy devruns both locally (apps/survivor-demo/worker). Its protocol is@gamekit/net/signalling, beside the client that speaks it, so the net package depends on no app. Peers use free public STUN servers. There is no TURN server in the first version, so two peers behind symmetric NATs can't connect yet. The worker trusts whoever holds a join code and has no rate limits yet; its README lists what it needs before it is public.
A run starts in step. Everyone is in the lobby when the run starts; there is no late join. The host sends the run's seed and the content hash with the start message. A peer whose content hash differs refuses to start, with an error naming both hashes. Every peer then calls start with that seed and its own config, which must be the same as everyone's: the lobby agrees on it (the hero, say), and nothing checks it at the start yet, so a config that differs shows only as a desync, and only if the state hash covers what differs.
Anything that changes the run is a step-scheduled input. In single-player, the survivors demo picks an upgrade with engine.modify((world) => chooseUpgrade(world, 0, index)) (hero 0's pick; ADR-0013 added the slot), and pausing calls setPaused. Both run at whatever step the click lands on, which differs per peer. In a lockstep run, an upgrade pick, a pause and a player leaving are part of some step's input, scheduled like any other input, so every peer applies them at the same step. The lockstep engine offers no modify and no local pause. GameLoop.modify and setPaused stay for single-player, and their docs no longer offer modify for "a network message".
A desync stops the run. Every N steps, each peer hashes its state and sends the hash to the host with its input; the host compares them and relays its own. A mismatch stops the run for everyone, with an error that names the step and every peer's hash. There is no resync. The hash comes from the genre, as the replay tests' hash does today; where it sits in the interface is decided with @gamekit/net's first implementation.
A drop-out waits, then removes the player. A disconnected or closed peer stops sending input, and one too slow to keep up (a frozen tab, a tab ticked only by throttled main-thread timers) sends it in bursts too late. A hidden tab on a desktop browser is neither, since its ticks come from a worker (above); a page the browser freezes or suspends, as mobile browsers do in the background, is a stalled peer. Either way stepping stalls on the missing input, and every peer shows "waiting for X". After about 10 s of stalling on that player (each wait on one step of a quarter second or more, until a second passes without one), the host removes them: their removal is an input scheduled for the next undecided step, so their hero leaves at the same step on every peer, and the run continues without them. A removed player can't rejoin that run. With no host migration, a host that drops out ends the run for everyone, since no other peer can decide a step.
Survivors' co-op rules are the genre's, not this ADR's. One shared team XP pool, a level-up that pauses everyone until each player has picked, and N players in World follow in #25's genre work. Push-puzzle stays single-player.
Considered Options โ
- Rollback netcode. Each peer predicts the missing inputs, steps on, and rewinds and re-simulates when a real input differs. It feels better at high latency. Rejected: it needs snapshot and restore of a run's state, which ADR-0007 rules out, and re-simulating several steps per frame would multiply the cost of a survivors step with hundreds of enemies. A co-op horde game tolerates 50 to 100 ms of input delay; a fighting game would not.
- A full mesh. Every peer sends its input to every other peer, and each peer decides on its own when a step is complete. It saves one hop of latency for non-host peers. Rejected: it needs up to six connections for 4 players, each through NAT traversal, and no single peer is the authority on who is in the run. Removing a dropped player would then need the peers to agree on the step it happens at, which is a consensus problem the star avoids. The star also gives desync checks a natural referee.
- Resync from the host's state after a desync. Needs state serialization, which ADR-0007 doesn't provide, and would hide arithmetic bugs that ADR-0011 wants found. Stopping with a clear error is enough while the step is exact.
- A hosted broker such as PeerJS, or copy-pasted offers. A broker is a third-party dependency on the join path; copy-paste is only fit for a prototype. A worker of our own behind a join code is small and stays in the repo as code.
- Keep
modifyand broadcast each call. A closure can't be sent, and even a described change would land at a different step on each peer. Scheduling it as input is the same mechanism the step already has.
Consequences โ
- ADR-0003 is amended. A lockstep run doesn't use the fixed clock's
{ seconds }path. The lockstep engine passes{ steps }budgets, which never clamp or drop, and a step can only wait for its inputs, never be skipped. A hidden tab on a desktop browser doesn't pause a lockstep run: it keeps stepping from its worker's timer. A peer that really stalls (closed, frozen or suspended by the browser, or ticking too slowly) stalls everyone until the host removes it. - ADR-0004 is amended, and ADR-0010's open question on
endFrameis settled. On a lockstep peer, the local device source is not theStepInputthatGameLoopreads. The engine samples it on its own schedule, once per step it schedules, calls itsendStepafter each sample and itsendFrameonce per sampling tick, then sends the sample. So one press still reaches exactly one step's input, and every peer applies it once.GameLoopreads the buffer of merged inputs, whoseendStepandendFramedon't touch a device. - ADR-0007 stands. Delay-based lockstep restores no earlier state, and a desync stops the run instead of resyncing.
- ADR-0010 holds without a contract change.
GameLoop.framewith a{ steps }budget is the seam the lockstep engine plugs into,stepIndexnumbers the steps it hashes, andStepInput.read(stepIndex)is how merged inputs reach a step. Its open question about makingmodifyand pause into step-scheduled inputs is settled above. - ADR-0011 is what makes this work across browsers. Lockstep depends on every engine computing the same step, and the desync check is the backstop if one doesn't. The cross-browser Playwright check stays later work in #25.
- A genre's input becomes per-player. A lockstep step's input holds every player's input, plus the scheduled actions (picks, pauses, removals). Survivors'
SurvivorsInputandWorldchange for N players in #25's genre work. - Two engines in one process is the test setup.
@gamekit/net's tests run a host and a peer, each with its ownGameLoop, over the in-memory transport, and check that their hashes match under latency, jitter and loss, that a desync is reported with its step, and that a silent peer is removed after the timeout. - The signalling worker is infrastructure in the repo. Deploying it needs a Cloudflare account; the join flow fails with a clear error when the worker can't be reached. Adding TURN later changes only the transport's configuration.