Skip to content

Room lifecycle & host migration

Status
In progress · partial UIPhases, timers, grace and host migration are live. Fog-of-war hides names, grades and other players' answers, but the current question's country code still reaches every client.
Verified
against master

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 joinedAt is promoted.
  • Eviction, not disconnection, changes membership. A player who drops mid-game stays in room.members for the 10 s grace period. The host keeps the host role during that time.
  • What the client sees is a projection. toClientRoom and toClientGameQuestion strip answers and grades from every snapshot until the phase reaches results.

startGame checks the three preconditions, resets the game state to starting, broadcasts GAME_STARTING, and arms the countdown.

apps/server/src/lib/managers/game-management.ts
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,
},
});

Each question gets an endTime of timePerQuestion seconds from now, and a timer that fires 300 ms after that.

apps/server/src/lib/managers/game-management.ts
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:

apps/server/src/lib/managers/game-management.ts
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.

ConstantValueSource
MULTIPLAYER_GAME_START_COUNTDOWN_MS3000packages/shared/src/constants/game.ts
TIME_PER_QUESTION_OPTIONS10, 15, 20, 30 ssame
MULTIPLAYER_QUESTION_DELIVERY_SLACK_MS300same
MULTIPLAYER_ANSWER_GRACE_MS750same
MULTIPLAYER_RESULTS_HOLD_MS4500same
MULTIPLAYER_MIN_ANSWER_TIME_MS80 (faster answers are clamped up)packages/shared/src/constants/multiplayer-answer.ts
RECONNECT_GRACE_PERIOD_MS10000apps/server/.../websocket-management/constants.ts
MAX_ROOM_LIFETIME_MS4 hpackages/shared/src/constants/game.ts

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).

RunQuestions
Easy, all regions15
Medium, all regions30
Hard, all regions205 (every quiz country)
Europe55
Africa54
Asia50
Americas39
Middle East17
Caribbean16
Oceania14
Central America7

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.

apps/server/src/lib/managers/websocket-management.ts
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.

  • Join. Rejected once the phase is past starting unless allowJoinAfterGameStart is set, and also for a taken username, a full room (maxRoomSize, 2 to 40) or a kicked user. Each join gets a fresh playerSeatId (a UUID) and a seat ticket.
  • Leave, kick, intentional close. Evict immediately. A kicked user is added to kickedUsers and cannot rejoin that room.
  • Last member gone. evictUser stops 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_WARNING when a room is within 10 minutes of its 4 h limit, and deletes rooms past the limit with ROOM_EXPIRED.
apps/server/src/lib/utils/cleanup.ts
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.

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.

packages/shared/src/multiplayer/client-room-wire.ts
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.

With tt seconds per question and answer grace g=750g = 750 ms, the question phase lasts

Qi=min⁡ ⁣(ai,  1000 t+g)Q_i = \min\!\left(a_i,\; 1000\,t + g\right)

where aia_i is the time at which the last non-eliminated member answered, if they all did. The delivery slack s=300s = 300 ms never appears: the timer fires at 1000t+s1000t + s, and because s<gs < g it re-arms for the remaining g−s=450g - s = 450 ms.

With countdown C=3000C = 3000 ms, results hold R=4500R = 4500 ms and NN questions, a game where nobody ends a question early takes

Tgame=C+N(1000 t+g+R)T_{\text{game}} = C + N\left(1000\,t + g + R\right)

The last results hold is included because endGame runs from the results timer. Plugging in the constants:

RunttNNTgameT_{\text{game}}
Easy10 s15231 750 ms (3 min 52 s)
Easy15 s15306 750 ms (5 min 7 s)
Medium15 s30610 500 ms (10 min 11 s)
Hard15 s2054 154 250 ms (1 h 9 min)
Hard30 s2057 229 250 ms (2 h 0 min 29 s)

Fixed overhead per question is g+R=5.25g + R = 5.25 s, which is 35 % of a 15 s question.

The sweep interval is P=30P = 30 min and the lifetime is L=4L = 4 h. A room created at cc becomes eligible at c+Lc + L and is deleted at the first sweep after that, so its real lifetime is in [L, L+P)=[4 h, 4.5 h)[L,\, L + P) = [4\,\text{h},\, 4.5\,\text{h}).

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

P(warning)=1030=13P(\text{warning}) = \frac{10}{30} = \frac{1}{3}

Two Hard runs at 30 s per question take 2×7 229 2502 \times 7\,229\,250 ms ≈4\approx 4 h 11 min before lobby time. A room that plays them back to back can be deleted mid-game by the sweep.

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 1000t+g1000t + g. The grace window G=10G = 10 s is shorter than one question plus its results hold (1000t+g+R≥15.251000t + g + R \ge 15.25 s for t≥10t \ge 10), 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.code from NEW_QUESTION, or the last entry of gameState.usedCountries from 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 by joinedAt becomes host immediately.
  • Clock-based joinedAt. joinedAt is an ISO string from the server clock. Two joins within the same millisecond compare equal, and reduce keeps the earlier array entry, which is insertion order.
  • Missing joinedAt. Sorts as +∞+\infty. If every member lacked it, members[0] would be chosen.
  • Stop during countdown. stopGame returns the room to waiting and 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.

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.

ChoiceAlternativeTradeoff
Earliest joinedAt becomes hostRandom member, or host picks a successorDeterministic and explainable to players; the longest-waiting player wins. No handoff message is needed.
Host role survives gracePromote on first dropAvoids flapping the host on a short network blip. Costs up to 10 s without host controls.
Grace players block early endSkip them in the checkA returning player can still answer. Costs up to one full question of waiting.
Keep country.code on the wireSend an opaque image token insteadThe client renders the flag with FlagImage from the code. An opaque token would need a server-issued URL per question.
Fixed-interval sweep for lifetimePer-room expiry timerOne interval for all rooms; the cost is up to 30 minutes of overrun and an unreliable warning.
Fixed question count per difficultyHonour the questionCount settingRun 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.