Add a lobby
Goal
Nothing starts until the room is ready. Each player presses R when they are ready (again to take it back); once every connected player is ready, the round starts and the chests unlock. The host (ctx.players.host) can press G to start at once. Until then players can walk the arena, but E does nothing: no chest, no door, no talk. Everyone hears the start.
This builds on Play with friends: the game already declares multiplayer.
Files you will edit
src/game.tstests/roomHarness.ts
Steps
Declare the messages and the lobby's state, at module scope in
src/game.ts, below the imports. AdddefineMessageto the file'sgameableimport, which already bringsdefineGameandtype GameContext. The ready set is one byte per seat, sized once, so the systems below allocate nothing.tsconst isNull = (p: unknown): p is null => p === null; /** Up: R toggles this player's ready. */ const Ready = defineMessage('ready', isNull, { maxBytes: 8 }); /** Up: G, from the host, starts now. */ const Start = defineMessage('start', isNull, { maxBytes: 8 }); /** Down: the round started. */ const Started = defineMessage('started', isNull, { maxBytes: 8 }); /** * The fewest players a round starts with. One, so Play Solo (a room of * one, its authority in the page) can start; a game that needs company * raises it. */ const MIN_PLAYERS = 1; /** Seats: `features.multiplayer.maxPlayers`. */ const SEATS = 6; /** 1 for a ready seat. */ const ready = new Uint8Array(SEATS); /** The lobby is over. */ const lobby = { started: false };Send
RandGfrom the page.ts/** Client: R toggles ready; G asks to start now. */ function readyKey(ctx: GameContext): void { if (ctx.input.pressed('KeyR')) ctx.net.send(Ready, null); if (ctx.input.pressed('KeyG')) ctx.net.send(Start, null); }Keep the ready set and the start rule on the authority. A seat that is left or taken is not ready: whoever sits there next says so themselves, so read the
player-joinedandplayer-leftevents, not the seat. Only the host'sstartcounts. A guest with thesolorole (the headless tests,npm test) has nobody to wait for.ts/** Authority: the ready set, and the start once everyone is ready. */ function lobbySystem(ctx: GameContext): void { if (lobby.started) return; if (ctx.net.role === 'solo') { lobby.started = true; return; } const events = ctx.events; for (let i = 0; i < events.length; i += 1) { const e = events[i]; if (e.tag === 'player-joined' || e.tag === 'player-left') ready[e.val.player] = 0; } const readies = ctx.net.messages(Ready); for (let i = 0; i < readies.length; i += 1) { const id = readies[i].player; if (id >= 0 && id < SEATS) ready[id] = ready[id] === 1 ? 0 : 1; } let forced = false; const starts = ctx.net.messages(Start); for (let i = 0; i < starts.length; i += 1) { if (starts[i].player === ctx.players.host) forced = true; } let seated = 0; let all = true; for (let id = 0; id < SEATS; id += 1) { if (ctx.players.get(id)?.connected !== true) continue; seated += 1; if (ready[id] !== 1) all = false; } if (seated < MIN_PLAYERS || !(all || forced)) return; lobby.started = true; ctx.net.send(Started, null); }ctx.playersis read by id withget, never iterated: aMapiterator is an allocation every tick. The host is the first player to join, until they leave or their link drops; then it moves to the lowest seat whose link is up.Hold back what waits for the start, and hear it. A wrapper made once at module scope runs a system only after the lobby; locomotion keeps working, so players can walk around while they wait.
ts/** * @param system A system that waits for the lobby. * @returns The same system, idle until the round starts. */ function afterLobby(system: (ctx: GameContext) => void): (ctx: GameContext) => void { return (ctx) => { if (lobby.started) system(ctx); }; } /** Client: the start plays a sound. */ function hearStart(ctx: GameContext): void { if (ctx.net.messages(Started).length > 0) ctx.audio.play('sfx.door'); }Wire it in
defineGame. Reset the lobby ininit, and put the systems in order: the lobby first, so a start takes effect in the same tick.tsinit: (ctx) => { ready.fill(0); lobby.started = false; // ...the resets already there }, systems: [ lobbySystem, paintPlayers, locomotionSystem, afterLobby(interactSystem), afterLobby(dialogueSystem), updateHud, { run: readyKey, on: 'client' }, { run: hearStart, on: 'client' }, ],With
multiplayerdeclared, a bare function runs on the authority, solobbySystemand the wrappedinteractSystemanddialogueSystemrun there. Both are wrapped because both readE: one opens chests and doors, the other starts a talk.Ready everyone in the room tests.
tests/room.test.tsplays the adventure on a room's authority, where nobody pressesR, so the lobby would hold every chest shut. Intests/roomHarness.ts, have every seat sendreadyon the step after the joins:ts// The lobby (docs/recipes/add-a-lobby.md): every seat is ready on the second step. const readies: GameEvent[] = inputs.map((_, player) => ({ tag: 'message', val: { player, name: 'ready', payload: 'null' }, }));Put it below
joinsinbootRoom, and change the step's events toevents: frame === 0 ? joins : frame === 1 ? readies : [].
Verify
sh
npm test
npm run devOpen a room in two tabs, as in Play with friends. E does nothing, at a chest or beside the guide. Press R in one tab: still nothing. Press R in the other: both tabs play the start sound, and the chests open. Or close the tab that was not ready instead: the room holds a closed tab's seat in case it comes back, so the round starts once the seat is freed, about 30 s after the tab closes, or at once if the host starts it with G. Alone (no ?room=) it is a room of one: R starts it. The template's other tests are unchanged, because a solo guest skips the lobby.
See also
- Send a message: the round trip on its own
- Multiplayer: roles,
ctx.players, and what a client may do templates/mystery/src/systems/ready.ts: the same rule with a host who can start early, and a HUD line for every player