ADR-0019 · accepted
The kit owns co-op from the signalling worker to the client; the screens stay in the app
ADR-0012 put the lockstep session, its transports and the signalling protocol in the kit. Everything around them stayed in the survivors demo: the signalling worker, the lobby and its messages, the content hash, the worker-timed ticker, the frame loop that draws a run, and the client that ties them together (917 lines, with about as many of tests). A second game on the kit, grimbonk, needed all of it and copied it. The two copies then drifted: grimbonk's client validates lobby picks, takes its input from the game and sends four kinds of answer; the demo's did none of that. ADR-0012 already called the demo's CoopClient the home of this code "for now".
Two games use the same code, which is what ADR-0001 asks for before the kit takes something in.
Decision
The kit owns co-op, in three modules. The worker and the client were the demo's code. The step input was each genre's own, survivors' and grimbonk's.
- The signalling worker is the entry
arcadekit/net/worker. An app's Worker extends the room class and exports thefetchhandler. - The players' step input is
PlayerInputs<PlayerInput>inarcadekit/runtime: each player's input by slot, and the slots leaving.soloInputfills it for one player andcombinePlayerInputs(arcadekit/net) from a lockstep session's confirmed steps. - The Co-op client is
makeCoopClientinarcadekit/net/browser. It runs one player's side of a Co-op game: it loads the content, opens or joins a room, runs the lobby, starts a lockstep run, ticks it on a worker's timer and on each animation frame, and draws it while attached.
A game supplies its genre half, in two values.
CoopRules<G>(arcadekit/net) is DOM-free, so a genre keeps it beside its simulation and tests it in Node. It holds what every peer must agree on (simulation,codec,hash,picks,config,maxPlayers,lockstep) and what the local player's seat needs (localInput,settled,choosing,localEvent,runEnded).Gis aCoopTypesbag of seven types the game declares once.CoopGame<G, LoadError>(arcadekit/net/browser) addscontent, an Effect run once per host or join, andpresentation, an Effect run once per attach that givescreateInputandcreateView.
The client's interface is commands and one stream of events. host, join, choose, start, answer and setPaused return at once. leave returns once the seat has closed. listen reports a seat in order: lobby events, one started, then run and waiting events, then at most one failed, which is its last. Only attach has a typed failure. Its attach and listen have the shapes of arcadekit/foldkit's AttachableEngine, which net may not import, so an app hands the client to engineEntry as it is.
The lobby is the kit's; its screens are the app's. The lobby's messages and state are plain values behind the client, in arcadekit/net's folder but not in its interface: an app reads the lobby event and calls nothing else. What a player chooses there is a lobby pick: a string id from the game's picks(content), such as a hero. At the start the host replaces an empty or unknown pick with the first, and a guest that receives a pick its own content lacks refuses the start. The lobby event carries the players with their names and picks, the game's maxPlayers and canStart, so an app's view only reads it. The Foldkit glue (Model, Messages, Commands, the Subscription) stays in each app.
The kit knows one answer. answer(value) is the one thing a player tells a run besides its device's input. The client holds it on every step's input until the game's settled says it can no longer apply. Survivors' upgrade pick is one case; grimbonk's pick, reroll, skip and banish are four values of one type. choosing says who still owes an answer, for the waiting event.
The kit combines the players' inputs. A Co-op game's simulation takes PlayerInputs<PlayerInput> as its step input, and the client passes combinePlayerInputs to the session. CoopRules has no combine member and the type bag no Input.
The view reads a live local player. createView gets a LocalPlayer whose slot and names follow the current seat, and reads it when it renders. The client never makes a view again. Survivors' views now take the local hero's slot as a function for this.
Test helpers are an entry. arcadekit/net/testing has fakeCoopRooms (the signalling worker and WebRTC in memory), manualTicker and testCoopGame (a small game with lobby picks, an offer to answer, a pause and an end). The kit tests its client against that game. A game tests its own half, and its app keeps one two-browser check.
Behaviour is ADR-0012's. No late join, the drop rule, the host star, a tick every 4 ms, "waiting" shown after 300 ms, and the last 32 hashes kept for debug. Input delay and the hash interval default to 4 and 60 steps; a game can state its own in CoopRules.lockstep, since every peer must use the same.
Considered Options
Three designs for the client's interface were written out and compared. The decision is a hybrid on the second.
- The smallest interface. One game value of nine members and four type parameters, and a client with one
send(command)besideattach,listenanddebug. The kit would own everything it could. Rejected in this shape:sendis seven command shapes behind one name, so a caller learns as much as with seven methods. It passed the run's events on unfiltered, which moves "is this my offer" into each app'supdate. It bundled the view and the input behind onepresentcall, so a view that throws after its input was made would leak the input unless every game handled it. Kept from it: the live local player,debug().statein place of a hook, and leaving on a failed attach behind the seam. - Grimbonk's client, lifted as it is (the base). DOM-free rules, a browser presentation, content loaded per seat, a method-shaped client with one ordered event stream, and explicit
localEventandrunEnded. Its seam sits where lockstep already has one. As written it had twelve type parameters, two optional predicates with two code paths each, adebughook, a prepare step with its own error type, and helper exports such ascanStartthat every app's view would call again. The decision cuts those: one type bag,settledandchoosingrequired, the presentation as one Effect per attach, andcanStarton the event. - An engine seam. Make "local player" a concept of
arcadekit/runtime, so the solo engine and the Co-op client share one run shape, and a game has one presentation for both. Rejected for now: it changesGamePresentationandGameViewfor every solo game to serve one caller, touches four entries in one change, and splits the events into two streams, which loses the order betweenstartedand the first run event. ADR-0001 says a change to the engine's contract gets its own issue. Kept from it: the type bag, the per-attach presentation Effect, and a lobby event that carries names, the limit andcanStart.
Smaller choices:
- A
combinemember on the rules. Each game would pass its own function from confirmed steps to its step input. Both games' functions were the same twenty lines over the same shape, so the kit took the shape (PlayerInputs) and the function. - A seam under the signalling worker, with a Cloudflare adapter. Rejected: there is one host, and nothing to vary. The worker's rules are already plain values behind an Effect shell, so a second host would start from them. Until one exists,
arcadekit/net/workerimportscloudflare:workersdirectly and is the only entry that may. - An offer number captured when the player answers, so the kit could tell a settled answer itself. Rejected: survivors numbers offers per run and grimbonk per hero, and a click on a stale card would behave differently from today. The game's
settledkeeps each game's rule exactly. - Rebuild the view when the slot is known. The demo's client did. A rebuild can block the main thread long enough for the host to drop the player, so the view reads the slot live instead.
- Lobby screens in the kit. Rejected: the kit has no UI of its own, and the two games' lobbies look nothing alike.
Deferred
- The engine seam. Single-player and co-op stay two paths in each game:
makeSurvivorsGameandmakeSurvivorsCoopGameboth build the genre's views. One run shape for both is its own issue, now that the client is small enough to show what is left. - Foldkit glue in the kit. Each app writes its own Commands, Messages and Subscription over the client. Two apps is the evidence so far, and their screens differ; a shared module waits until the copies show its shape.
- Shared end-to-end helpers. Each app's two-browser check starts its own dev server and reads its own DEV global, which maps
debug()to plain numbers its pages can return. The helpers stay in the apps. Two apps carry near copies, and so do the forty lines that give a client test aClockon the faked time and a microtask scheduler. Both are recorded as findings in co-op-findings.md.
Consequences
- The demo's
src/coop/is one file, the worker's URL and the STUN servers. The kit reads no environment: an app passes both tobrowserCoopRooms. - The kit owns English text. Player names ("Host", "Guest 2") and failure messages are the kit's. A
failedevent carries areasontag (content,room,leftOut,unknownPick,tooManyPlayers,hostLost,desync,contentMismatch,removed,badInput,bug), so an app can show its own words. Names have no such tag: an app's lobby can rename players from their ids, but the names a run's events carry are the kit's. - Content must be JSON data. The client hashes it to compare peers, so it must survive
JSON.stringify, and the hash is sensitive to key order. - The lobby's messages changed on the wire. They are
pick,lobby(players withpick) andstart(players, picks); the demo's saidheroandheroIds. Peers on different builds can't share a lobby, which the content hash already implied for a run. Astartlists up toMAX_ROOM_SIZE(16) players, since a worker's room size can be that large; the game'smaxPlayersis the limit that counts. - The game's
maxPlayersbounds a run on both sides. The demo's room size was its limit. Now a room can hold more than the game takes, so the host plans the run for the firstmaxPlayersplayers in lobby order: a guest that links between the host's start and the worker's word is left out, and fails withleftOut. A guest refuses a start with more players than its own game takes. Only a host on another build sends one, and it fails withtooManyPlayers. It has a tag of its own, apart fromunknownPick, because an app re-wordsunknownPickin its game's terms (a hero the player's game doesn't have), which would be wrong here. - The survivors demo's guest now refuses an unknown lobby pick. It checked nothing before, and its host replaced only empty picks. A guest whose content lacks a hero the host started with fails with
unknownPickand leaves the room; the host's run goes on without them. - A new seat waits for every seat before it.
hostandjoinclose the earlier seats fully before the new one's first event, its failure included, andleavereturns once they have all closed. The demo's client let the new lobby speak first. - Commands outside their phase do nothing.
answerandsetPausedbefore the run are dropped, and so ischooseonce it has started. The demo's client held a pause set in the lobby, so the run froze from its first step. It also reported one emptywaitingevent after a run's first tick; the kit's reportswaitingonly when who it waits on, or who is choosing, changes. - The client poses as an engine by shape only. A drift between its
attachandlistenandAttachableEngineshows in an app's typecheck, not in the kit's. - Run events gain a level. An app's
updatereads{ type: "run", event }. - Eighteen entries.
arcadekit/net/workerandarcadekit/net/testingare in thenetgroup, which lint checks as one, so neither adds an arrow (ADR-0018). - ADR-0012 and ADR-0013 keep their wording. Where they place the worker in the demo, name the demo's
CoopClient, or name survivors'combineHeroInputsandsoloInput, read the kit's. - Grimbonk moves after the release. Its client is the second copy this design was checked against; it becomes a Co-op game once this is published.