Room lifecycle & host migration
1. Foundational mental model
Section titled “1. Foundational mental model”A room is an in-memory object on the Bun game server. It has members, a host, settings, and a gameState whose phase field drives everything the players see. Every transition is caused by a server timer or a host message; clients never advance the phase themselves.
After the 4.5 s hold, results returns to question until the last question or an elimination ends the run. STOP_GAME from starting, question, or results returns to waiting. RESTART_GAME from finished returns to starting.
Three rules hold the model together:
- The host is a member. It has no separate process or role outside the member list. When the host is evicted, the member with the earliest
joinedAtis promoted. - Eviction, not disconnection, changes membership. A player who drops mid-game stays in
room.membersfor the 10 s grace period. The host keeps the host role during that time. - What the client sees is a projection.
toClientRoomandtoClientGameQuestionstrip answers and grades from every snapshot until the phase reachesresults.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Starting a game
Section titled “Starting a game”startGame checks the three preconditions, resets the game state to starting, broadcasts GAME_STARTING, and arms the countdown.
if (room.members.length < 2) { return this.rejectGameControl( "start", roomId, userId, "At least 2 players are required to start the game" ); }
this.resetGameState(roomId);
const roomForBroadcast = roomsManager.getRoom(roomId); if (!roomForBroadcast) { return this.rejectGameControl("start", roomId, userId, "Room not found"); }
const gameStartingAt = Date.now(); this.broadcastToRoom(roomId, { type: WS_MESSAGE_TYPES.GAME_STARTING, data: { countdown: MULTIPLAYER_GAME_START_COUNTDOWN_SEC, startTime: gameStartingAt, settings: roomForBroadcast.settings, }, });Question and results timers
Section titled “Question and results timers”Each question gets an endTime of timePerQuestion seconds from now, and a timer that fires 300 ms after that.
const question: GameQuestion = { index: gameState.currentQuestionIndex + 1, country: questionData.currentCountry, options: questionData.options, correctAnswer: questionData.currentCountry.code, startTime: Date.now(), endTime: Date.now() + room.settings.timePerQuestion * 1000, };When that timer fires, endQuestion checks the answer grace window first and re-arms itself if the window is still open:
if (fromQuestionTimeout) { const question = room.gameState.currentQuestion; const graceEndsAt = question.endTime + MULTIPLAYER_ANSWER_GRACE_MS; const now = Date.now(); if (now < graceEndsAt) { const remainingMs = graceEndsAt - now; logger.debug("Deferring question end for answer grace", { roomId, questionIndex: question.index, remainingMs, }); const questionTimer = this.questionTimers.get(roomId); if (questionTimer) { clearTimeout(questionTimer); } const timer = setTimeout(() => { this.endQuestion(roomId, true); }, remainingMs); this.questionTimers.set(roomId, timer); return; } }So the 300 ms delivery slack and the 750 ms answer grace do not add up. The question closes at endTime + 750. submitAnswer uses the same boundary: anything received after endTime + 750 is rejected with AFTER_TIMEOUT.
The question also ends early when every non-eliminated member has answered (checkAndAdvanceIfAllAnswered). After endQuestion, the room holds results for MULTIPLAYER_RESULTS_HOLD_MS and then calls nextQuestion, which calls endGame once the index reaches totalQuestions.
| Constant | Value | Source |
|---|---|---|
MULTIPLAYER_GAME_START_COUNTDOWN_MS | 3000 | packages/shared/src/constants/game.ts |
TIME_PER_QUESTION_OPTIONS | 10, 15, 20, 30 s | same |
MULTIPLAYER_QUESTION_DELIVERY_SLACK_MS | 300 | same |
MULTIPLAYER_ANSWER_GRACE_MS | 750 | same |
MULTIPLAYER_RESULTS_HOLD_MS | 4500 | same |
MULTIPLAYER_MIN_ANSWER_TIME_MS | 80 (faster answers are clamped up) | packages/shared/src/constants/multiplayer-answer.ts |
RECONNECT_GRACE_PERIOD_MS | 10000 | apps/server/.../websocket-management/constants.ts |
MAX_ROOM_LIFETIME_MS | 4 h | packages/shared/src/constants/game.ts |
Question counts
Section titled “Question counts”getQuestionCountForRun sets the length of a run. With regionFilter: "all" it uses difficulty; with any region it uses that region’s pool size and ignores difficulty. Values below were computed by calling the function against the current pool (205 quiz countries).
| Run | Questions |
|---|---|
| Easy, all regions | 15 |
| Medium, all regions | 30 |
| Hard, all regions | 205 (every quiz country) |
| Europe | 55 |
| Africa | 54 |
| Asia | 50 |
| Americas | 39 |
| Middle East | 17 |
| Caribbean | 16 |
| Oceania | 14 |
| Central America | 7 |
Host migration
Section titled “Host migration”evictUser removes the member, deletes the seat, and promotes a new host if the evicted user was the host and anyone is left. The choice is the earliest joinedAt; a member without one sorts last.
selectNewHost<T extends { id: string; joinedAt?: string }>(members: T[]): T { return members.reduce((earliest, member) => { const earliestTime = earliest.joinedAt ? new Date(earliest.joinedAt).getTime() : Number.POSITIVE_INFINITY; const memberTime = member.joinedAt ? new Date(member.joinedAt).getTime() : Number.POSITIVE_INFINITY; return memberTime < earliestTime ? member : earliest; }, members[0]); }promoteNewHost then sets room.host, updates the seat role to host, sets isAdmin on the user and the live socket, and broadcasts HOST_CHANGED { newHost }.
If Ana reconnects within the 10 s, consumePendingDisconnect clears the timer, the room broadcasts PLAYER_RECONNECTED, and nothing about the host changes. The WebSocket gateway simulator plays this sequence with the real constants.
Joining, leaving and cleanup
Section titled “Joining, leaving and cleanup”- Join. Rejected once the phase is past
startingunlessallowJoinAfterGameStartis set, and also for a taken username, a full room (maxRoomSize, 2 to 40) or a kicked user. Each join gets a freshplayerSeatId(a UUID) and a seat ticket. - Leave, kick, intentional close. Evict immediately. A kicked user is added to
kickedUsersand cannot rejoin that room. - Last member gone.
evictUserstops the game and deletes the room at once. - Cleanup sweep. Runs every 30 minutes while any room exists. It force-disconnects users inactive for 5 minutes, deletes empty rooms older than 10 minutes, broadcasts
ROOM_TTL_WARNINGwhen a room is within 10 minutes of its 4 h limit, and deletes rooms past the limit withROOM_EXPIRED.
for (const room of expiredRooms) { try { if (room.gameState?.isActive) { gameManager.stopGame(room.id); } webSocketManager.broadcastToRoom(room.id, { type: WS_MESSAGE_TYPES.ROOM_EXPIRED, data: { roomId: room.id, expiredAt: now }, }); roomsManager.delete(room.id); } catch (error) { logger.error("Failed to cleanup expired room", error, { roomId: room.id }); } }The CleanupConfig interface comments inactiveUserTimeout as seconds, but getInactiveUsers takes minutes. The value 5 means 5 minutes.
Fog-of-war
Section titled “Fog-of-war”toClientRoom is applied to every room snapshot the server sends (AUTH_SUCCESS, USER_JOINED, USER_LEFT and so on). toClientGameQuestion is applied to the question in NEW_QUESTION.
export function revealCorrectAnswers(phase: GamePhase): boolean { return phase === "results" || phase === "finished";}
function stripCountryNameForWire<T extends GameQuestion["country"]>(country: T): T { return { ...country, name: "" };}
export function toClientGameQuestion( question: GameQuestion, phase: GamePhase): ClientGameQuestion { if (revealCorrectAnswers(phase)) { return question; }
const { correctAnswer: _correctAnswer, country, ...rest } = question; return { ...rest, country: stripCountryNameForWire(country), };}The player’s own answer for the current question comes back separately as ownQuestionAnswer, so a reconnecting client can restore its selection without seeing anyone else’s.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Question phase length
Section titled “Question phase length”With seconds per question and answer grace ms, the question phase lasts
where is the time at which the last non-eliminated member answered, if they all did. The delivery slack ms never appears: the timer fires at , and because it re-arms for the remaining ms.
Whole-game length
Section titled “Whole-game length”With countdown ms, results hold ms and questions, a game where nobody ends a question early takes
The last results hold is included because endGame runs from the results timer. Plugging in the constants:
| Run | |||
|---|---|---|---|
| Easy | 10 s | 15 | 231 750 ms (3 min 52 s) |
| Easy | 15 s | 15 | 306 750 ms (5 min 7 s) |
| Medium | 15 s | 30 | 610 500 ms (10 min 11 s) |
| Hard | 15 s | 205 | 4 154 250 ms (1 h 9 min) |
| Hard | 30 s | 205 | 7 229 250 ms (2 h 0 min 29 s) |
Fixed overhead per question is s, which is 35 % of a 15 s question.
Room lifetime and the sweep
Section titled “Room lifetime and the sweep”The sweep interval is min and the lifetime is h. A room created at becomes eligible at and is deleted at the first sweep after that, so its real lifetime is in .
The warning fires only if a sweep lands inside the 10-minute window before expiry. With one sweep per 30 minutes, a room receives at most one warning, and with the sweep phase uniform relative to room creation the chance of receiving one is
Two Hard runs at 30 s per question take ms h min before lobby time. A room that plays them back to back can be deleted mid-game by the sweep.
Grace and early ends
Section titled “Grace and early ends”Players in grace remain in room.members, so they count in the “everyone answered” check. A question with one player in grace cannot end early and runs to . The grace window s is shorter than one question plus its results hold ( s for ), so one disconnect can hold open at most two question phases: the one it started in and the next.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Answer on the wire. A player who opens DevTools can read
question.country.codefromNEW_QUESTION, or the last entry ofgameState.usedCountriesfrom any room snapshot. The opaque CDN path (see Opaque flag tokens) does not help because the code arrives in the socket payload. Server-side scoring is unaffected; only fairness between players is. - Host in grace blocks control. While the host is in grace nobody can stop or restart the game. The phases keep advancing on timers, so the game does not stall.
- Host drops in the lobby. No grace in
waiting, so the host is evicted at once and the next member byjoinedAtbecomes host immediately. - Clock-based
joinedAt.joinedAtis an ISO string from the server clock. Two joins within the same millisecond compare equal, andreducekeeps the earlier array entry, which is insertion order. - Missing
joinedAt. Sorts as . If every member lacked it,members[0]would be chosen. - Stop during countdown.
stopGamereturns the room towaitingand clears the replay recorder; nothing from that run is saved. - Expiry during a game. The sweep stops the game, sends
ROOM_EXPIRED, and deletes the room with no replay save. See the lifetime math above. - Server restart. Rooms, seats and timers are all in memory and are lost.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”Keep room state on the game server when it changes on a timer or needs sub-second fan-out: phases, answers, host role.
Push data elsewhere when it must outlive the process: finished replays go to Convex, match stats to SQLite.
The code does not record why these choices were made. The tradeoff column is this chapter’s analysis.
| Choice | Alternative | Tradeoff |
|---|---|---|
Earliest joinedAt becomes host | Random member, or host picks a successor | Deterministic and explainable to players; the longest-waiting player wins. No handoff message is needed. |
| Host role survives grace | Promote on first drop | Avoids flapping the host on a short network blip. Costs up to 10 s without host controls. |
| Grace players block early end | Skip them in the check | A returning player can still answer. Costs up to one full question of waiting. |
Keep country.code on the wire | Send an opaque image token instead | The client renders the flag with FlagImage from the code. An opaque token would need a server-issued URL per question. |
| Fixed-interval sweep for lifetime | Per-room expiry timer | One interval for all rooms; the cost is up to 30 minutes of overrun and an unreliable warning. |
| Fixed question count per difficulty | Honour the questionCount setting | Run length follows from difficulty and region alone. The code gives no reason; the setting is currently inert. |
Non-goals: persisting rooms across restarts, spectator mode, and hiding the answer from a determined client. The last would need the flag image itself to be served per question without naming the country.