Phase 3: Game Lifecycle, State Synchronization & Reconnection¶
Overview¶
This phase implements the complete minigame lifecycle state machine, the game coordinator that orchestrates game start/end, the voting system for minigame selection, state synchronization (action-based deltas + periodic heartbeats), the reconnection protocol with grace periods, duplicate session handling, spectator state delivery, the join-in-progress framework, and timer synchronization. By the end, lobbies can transition through all phases (WAITING → VOTING → INSTRUCTIONS → PRELOADING → COUNTDOWN → PLAYING → ROUND_RESULTS → WAITING), players can reconnect mid-game, and spectators receive the correct state.
Prerequisites¶
Phase 1 complete (server, auth, types, schemas, constants)
Phase 2 complete (lobby system fully operational)
1. Vote Manager¶
1.1 Create server/rmhbox/vote-manager.ts¶
Define
VoteManagerclass with constructor acceptingio: Server,lobbyManager: LobbyManager,gameCoordinator: GameCoordinatorDefine
ActiveVoteinterface:candidates: VoteCandidate[],votes: Map<string, string>(userId → minigameId),timer: NodeJS.Timeout,startedAt: number,endsAt: numberCreate private
activeVotes: Map<string, ActiveVote>(lobbyId → vote session)Verification: Instantiate VoteManager — no errors, empty internal state.
1.2 Implement Vote Initiation¶
Implement
startVote(socket, payload)handler forrmhbox:game:start_voteValidate the requesting socket is the host
Validate lobby is in
WAITINGstateValidate there are at least
MIN_PLAYERS(2) players in the lobbyQuery
getEligibleMinigames(playerCount)from the minigame registry to get games eligible for current player countRandomly select
VOTE_CANDIDATE_COUNT(5) candidates from the eligible pool (or all if fewer than 5)Create
ActiveVotewith the candidates, empty votes map, and a timer set forVOTE_DURATION_SECONDS(30s)Store in
activeVotesmapTransition lobby state to
VOTINGBroadcast
rmhbox:game:vote_startedwithVoteStartedPayload:candidates: array of{ minigameId, displayName, description, category, icon, playerRange }durationSeconds: 30endsAt: Date.now() + 30000
Broadcast
STATE_CHANGEDaction with{ newState: 'VOTING' }Start the vote timer — when it expires, call
resolveVote(lobbyId)Verification: Start a vote with 4 players — 5 candidates are selected (or fewer if not enough eligible), lobby transitions to VOTING, all players receive the vote_started event.
1.3 Implement Vote Casting¶
Implement
castVote(socket, payload)handler forrmhbox:game:cast_voteValidate lobby is in
VOTINGstateValidate user is a player (not a spectator)
Validate the
minigameIdis one of the active vote’s candidatesStore/overwrite the vote:
activeVote.votes.set(userId, minigameId)Compute current tallies: count votes per candidate
Broadcast
rmhbox:game:vote_updatewithVoteCastPayload:userId(who voted — but NOT what they voted for, to preserve suspense)tallies:{ [minigameId]: count }— shows totals but not who voted for whattotalVoters: count of unique voterstotalPlayers: total player count
If all players have voted, cancel the timer and call
resolveVote(lobbyId)immediatelyVerification: Cast a vote — tally is updated, broadcast received. Vote again — previous vote is overwritten. All players vote — vote resolves immediately without waiting for timer.
1.4 Implement Vote Resolution¶
Implement
resolveVote(lobbyId)methodClear the vote timer
Count votes per candidate, determine the winner (highest count)
On tie: break randomly (use
Math.random())Broadcast
rmhbox:game:vote_resultwithVoteResultPayload:winnerId: winning minigameIdwinnerName: display nametallies: final countswasUnanimous: boolean (all voted for the same game)
Remove from
activeVotesmapCall
gameCoordinator.startGameFlow(lobbyId, winnerId)to begin the game lifecycleVerification: Vote resolves — winner is correctly determined, broadcast received. Tie is broken randomly. After resolution,
activeVotesno longer has the entry.
1.5 Implement Host Force-Skip During Vote¶
In the
forceSkip(socket, payload)handler:If lobby is in
VOTINGstate: immediately resolve the vote (tallies as-is, even if no votes cast, random selection in that case)
Verification: Host force-skips a vote with 0 votes cast — a random game is selected. Force-skip with partial votes — current tallies determine the winner.
2. Game Coordinator¶
2.1 Create server/rmhbox/game-coordinator.ts¶
Define
GameCoordinatorclass with constructor acceptingio: Server,lobbyManager: LobbyManager,stateSyncService: StateSyncServiceImport
BaseMinigameand aMINIGAME_SERVER_REGISTRYthat maps minigameId → server-side game class (stub for now — populated when minigames are implemented in Phases 5-8)Verification: Instantiate GameCoordinator — no errors.
2.2 Implement startGameFlow(lobbyId, minigameId)¶
This is the main orchestration method that drives the lifecycle from INSTRUCTIONS → PRELOADING → COUNTDOWN → PLAYING → ROUND_RESULTS → WAITING.
Validate minigame exists in registry
Validate player count meets minigame’s min/max requirements
If validation fails, broadcast an error action and return lobby to
WAITING
Step 1: INSTRUCTIONS Phase¶
Transition lobby state to
INSTRUCTIONSBroadcast
STATE_CHANGEDactionBroadcast
rmhbox:game:instructionswithInstructionsPayload:minigameId,title,description,rules[],tips[],controls[]durationSeconds: from minigame definition’sinstructionDurationSeconds(default 15)estimatedGameDuration: from minigame definitionplayerCount: { min, max, current }teams: boolean
Start instruction timer (15 seconds default)
When timer expires OR host force-skips: proceed to PRELOADING
Verification: Instructions phase starts — all clients receive instructions payload with correct data. After 15 seconds, moves to preloading.
Step 2: PRELOADING Phase¶
Transition lobby state to
PRELOADINGBroadcast
STATE_CHANGEDactionBroadcast
rmhbox:game:preload_startwith{ manifest: PreloadManifest }from the minigame definitionTrack which players have emitted
rmhbox:game:ready_to_renderin aSet<string>Register a temporary listener for
rmhbox:game:ready_to_render:Add userId to the ready set
Broadcast
rmhbox:game:preload_progresswith{ players: [...], allReady: boolean }If
allReady: cancel timeout, proceed to COUNTDOWN
Start preload timeout (30 seconds):
When timeout expires: force-mark all unready players as ready, add system chat warning, proceed to COUNTDOWN
Verification: All players send ready_to_render — moves to countdown immediately. One player doesn’t send it — after 30s, force-proceeds.
Step 3: COUNTDOWN Phase¶
Transition lobby state to
COUNTDOWNBroadcast
STATE_CHANGEDactionBroadcast
rmhbox:game:countdownwith{ seconds: 3 }Wait 3 seconds (via setTimeout)
Proceed to PLAYING
Verification: Countdown phase lasts exactly 3 seconds, then transitions.
Step 4: PLAYING Phase¶
Transition lobby state to
PLAYINGBroadcast
STATE_CHANGEDactionBroadcast
rmhbox:game:startedwith{ minigameId }Instantiate the minigame handler:
Look up the class in
MINIGAME_SERVER_REGISTRYCreate
MinigameContextwith all the broadcasting functions, lobbyId, player snapshot, etc.Wrap in try-catch for fault isolation
Set
lobby.currentGame = { minigameId, handler: gameInstance, startedAt: Date.now() }Call
gameInstance.start()
Register
rmhbox:game:inputhandler for this lobby:Validate user is a player (not spectator)
Route action/data to
gameInstance.handleInput(userId, action, data)Wrap in try-catch (fault isolation — game errors don’t crash the server)
Configure
MinigameContext.onCompletecallback → callshandleGameComplete(lobbyId, results)Configure
MinigameContext.onErrorcallback → callshandleGameError(lobbyId, error)Verification: Game starts — lobby state is PLAYING, game handler is instantiated, players can send game inputs that reach the handler.
2.3 Implement handleGameComplete(lobbyId, results)¶
Step 5: ROUND_RESULTS Phase¶
Clean up the game handler (call
cleanup())Increment
lobby.roundNumberUpdate player scores: add each player’s round score to their cumulative session score
Transition lobby state to
ROUND_RESULTSBroadcast
STATE_CHANGEDactionBroadcast
rmhbox:game:round_resultswithRoundResultsPayload:minigameId,rankings(sorted by rank),awards[],roundNumbersessionStandings[]: cumulative scores sorted by total
Add match summary to
lobby.matchHistoryFire async persistence:
persistMatchResults(...)(Phase 4 will implement this — for now, log a placeholder)Start results display timer (
RESULTS_DISPLAY_SECONDS= 10 seconds)When timer expires OR host force-skips: transition back to WAITING
Set
lobby.currentGame = nullReset all players’
isReady = falseandroundScore = 0Broadcast
STATE_CHANGEDwith{ newState: 'WAITING' }
Verification: Game completes — results are broadcast, scores are updated, lobby returns to WAITING after 10 seconds.
2.4 Implement handleGameError(lobbyId, error)¶
Log the error with
console.errorClean up the broken game handler (call
cleanup()in try-catch)Set
lobby.currentGame = nullTransition lobby state to
WAITINGBroadcast
STATE_CHANGEDaction with{ newState: 'WAITING', reason: 'GAME_ERROR', message: 'The game encountered an error and was ended. Sorry about that!' }Add system chat message about the error
Verification: Simulate a game error — lobby returns to WAITING, error message is broadcast, no server crash.
2.5 Implement Host Direct Select (No Vote)¶
Implement
selectGame(socket, payload)handler forrmhbox:game:selectValidate requesting socket is the host
Validate lobby is in
WAITINGstateValidate minigameId exists in registry and is eligible for current player count
Call
startGameFlow(lobbyId, minigameId)directly (skip voting)Verification: Host selects a game directly — instructions phase begins immediately without voting.
2.6 Implement handleConnection(socket)¶
Register game-related event listeners:
rmhbox:game:select→selectGamermhbox:game:input→ route to current game handlerrmhbox:game:ready_to_render→ handled during preloadingrmhbox:game:force_skip→ handle phase skipping
Verification: All game events are registered on connection.
2.7 Implement Host Force-Skip for All Phases¶
In
forceSkip(socket, payload):VOTING: resolve vote immediately (handled by VoteManager)INSTRUCTIONS: cancel instruction timer, proceed to PRELOADINGPRELOADING: cancel timeout, force-mark all as ready, proceed to COUNTDOWNCOUNTDOWN: (don’t skip — it’s only 3 seconds)ROUND_RESULTS: cancel results timer, proceed to WAITING
Validate only the host can force-skip
Verification: Host force-skips each phase — transition occurs immediately.
2.8 Implement handleDisconnect(socket) on GameCoordinator¶
When a player disconnects during
PLAYING:Notify the active minigame handler via
handler.handlePlayerDisconnect(userId)If remaining connected player count < minigame’s
minPlayers: callforceEnd()
Verification: Player disconnects during game — handler is notified. Too few players remain — game force-ends.
3. State Synchronization Service¶
3.1 Create server/rmhbox/state-sync.ts¶
Define
StateSyncServiceclass with constructor acceptingio: Server,lobbyManager: LobbyManagerVerification: Instantiate — no errors.
3.2 Implement Heartbeat¶
Implement
startHeartbeat()methodCreate a
setIntervalrunning everyHEARTBEAT_INTERVAL_MS(10 seconds)On each tick, iterate all lobbies:
If lobby state is
PLAYING:For each connected player: build per-player
ClientLobbyStatevialobbyManager.buildClientState(lobby, userId), emitrmhbox:lobby:state_snapshotto their personal roomlobby:{lobbyId}:player:{userId}For each connected spectator: build spectator state, emit
rmhbox:lobby:state_snapshotto their personal room
Verification: During gameplay, confirm heartbeat snapshots arrive every ~10 seconds to each connected client.
3.3 Implement Phase Transition Sync¶
On every lobby state transition (WAITING → VOTING, VOTING → INSTRUCTIONS, etc.):
Send a full
ClientLobbyStatesnapshot to every connected member (same logic as heartbeat, but triggered immediately)
This ensures all clients are fully synchronized at phase boundaries
Verification: Transition from WAITING to VOTING — every client receives a full state snapshot (not just the action delta).
3.4 Implement Action Sequence Counter¶
Maintain a
seqCounter: Map<string, number>(lobbyId → current seq)Every time
broadcastActionis called, increment and attach the seq numberClient-side: actions with
seq <= lastSeqare ignored (duplicate protection)Verification: Send 10 actions in sequence — each has a seq 1 higher than the previous. Client receiving an old seq action ignores it.
3.5 Implement Timer Tick Broadcasting¶
Create a utility
startTimerBroadcast(lobbyId, durationSeconds, onComplete):Emit a
TIMER_TICKaction every 1 second with{ timeRemaining: seconds }When
timeRemaininghits 0, callonComplete()Return a cancel function
Use this utility in all timed phases: voting (30s), instructions (15s), preloading timeout (30s), countdown (3s), results (10s)
Verification: Start a 5-second timer — 5 TIMER_TICK actions are broadcast (5, 4, 3, 2, 1, then onComplete fires).
4. Reconnection Protocol¶
4.1 Create server/rmhbox/reconnection.ts¶
Define
ReconnectionHandlerclass with constructor acceptingio: Server,lobbyManager: LobbyManager,stateSyncService: StateSyncServiceMaintain
gracePeriodTimers: Map<string, NodeJS.Timeout>(userId → timeout)Verification: Instantiate — no errors.
4.2 Implement attemptReconnect(socket)¶
Called at the START of every new connection (before lobby handlers)
Extract
userIdfromsocket.data.userIdSearch for the user in any lobby via
lobbyManager.findLobbyByUserId(userId)If not found: return (normal fresh connection, not a reconnect)
If found:
Get the player or spectator record
Handle duplicate session: if there’s already a connected socket for this user:
Get the old socket via
io.sockets.sockets.get(existingSocketId)Emit
rmhbox:errorwith{ code: 'DUPLICATE_SESSION', message: 'Connected from another device' }to old socketForce disconnect old socket:
oldSocket.disconnect(true)
Update the member record:
socketId = socket.id,isConnected = true,lastSeenAt = Date.now()Cancel any active grace period timer for this user
Re-join the socket to all appropriate Socket.io rooms:
lobby:{lobbyId}lobby:{lobbyId}:playersorlobby:{lobbyId}:spectatorslobby:{lobbyId}:player:{userId}
Send full
rmhbox:lobby:state_snapshotto the reconnecting socketIf lobby is
PLAYINGand user is a player:Call
currentGame.handler.getStateForPlayer(userId)and emitrmhbox:game:state_snapshotCall
currentGame.handler.handlePlayerReconnect(userId)to notify the game
If lobby is
PLAYINGand user is a spectator:Call
currentGame.handler.getStateForSpectator()and emitrmhbox:game:state_snapshot
Broadcast
PLAYER_CONNECTEDaction to lobby:{ userId, userName }Add system chat message:
"<userName> reconnected"
Verification:
Disconnect a player mid-game → reconnect within 120s → player resumes with full state, game state is intact, grace timer is canceled.
Connect from a second device while already connected → first socket is disconnected with DUPLICATE_SESSION, second takes over.
4.3 Implement handleDisconnect(socket) on ReconnectionHandler¶
Extract userId from
socket.dataFind the user’s lobby
If not in a lobby: return
Mark the member as disconnected:
socketId = null,isConnected = falseBroadcast
PLAYER_DISCONNECTEDaction to lobbyStart grace period timer (
DISCONNECT_GRACE_PERIOD_MS= 120s):Store in
gracePeriodTimersmapOn expiry: call
lobbyManager.leaveLobby()logic (permanent leave)
Verification: Player disconnects → PLAYER_DISCONNECTED action is broadcast. Wait 120s without reconnect → player is fully removed from lobby.
5. Spectator State Delivery¶
5.1 Implement Spectator-Specific State¶
In
buildClientState():When building state for a spectator (userId is in
spectatorsmap):Set
myRole: 'spectator'If game is active: call
currentGame.handler.getStateForSpectator()to get omniscient viewSet
ClientGameInfo.privateState = {}(empty — spectators have no private state)
Ensure spectators receive
rmhbox:game:actionbroadcasts (they’re in thelobby:{id}room)Verification: Connect a spectator and a player to a game — spectator sees omniscient state (e.g., all roles revealed in social deduction games), player sees masked state.
5.2 Implement Input Gating for Spectators¶
In the
rmhbox:game:inputhandler:Before routing to the minigame handler, check that
socket.data.userIdexists inlobby.players(not inspectators)If the user is a spectator: silently drop the input (no error emitted — just ignore)
Verification: Spectator sends a game input → it is silently ignored. Player sends the same input → it reaches the game handler.
6. Join-in-Progress Framework¶
6.1 Implement JIP Policy Enforcement¶
When a player joins a lobby that is in
PLAYINGstate:Look up the active minigame’s
joinInProgressPolicyfrom the registryspectate_only: player becomes a spectator (handled in Phase 2’s join logic)join_next_subround: player becomes a temporary spectator, add to apendingJoinPlayerslistThe minigame handler should check this list at sub-round boundaries and add the player
join_immediately: add the player to the game immediatelyCall
currentGame.handler.handlePlayerJoin(userId)(new method on BaseMinigame — default implementation is no-op)
Add
handlePlayerJoin(userId): voidtoBaseMinigamewith a default no-op implementationAdd
getPendingJoinPlayers(lobbyId): string[]to LobbyManager for games that supportjoin_next_subroundVerification: Join a
spectate_onlygame → become spectator. Join ajoin_immediatelygame → appear in the active game instantly.
7. Wire Up Services in server/rmhbox/index.ts¶
7.1 Instantiate All Services¶
Create service instances in the correct dependency order:
lobbyManager = new LobbyManager(io)stateSyncService = new StateSyncService(io, lobbyManager)gameCoordinator = new GameCoordinator(io, lobbyManager, stateSyncService)voteManager = new VoteManager(io, lobbyManager, gameCoordinator)chatHandler = new ChatHandler(io, lobbyManager)reconnectionHandler = new ReconnectionHandler(io, lobbyManager, stateSyncService)leaderboardService = new LeaderboardService()(stub for Phase 4)
In
io.on('connection', (socket) => { ... }):Call
reconnectionHandler.attemptReconnect(socket)(FIRST — before any other handler)Call
lobbyManager.handleConnection(socket)Call
gameCoordinator.handleConnection(socket)Call
voteManager.handleConnection(socket)Call
chatHandler.handleConnection(socket)
In
socket.on('disconnect'):Call
lobbyManager.handleDisconnect(socket)Call
gameCoordinator.handleDisconnect(socket)Call
reconnectionHandler.handleDisconnect(socket)
Start periodic tasks:
stateSyncService.startHeartbeat()lobbyManager.startGarbageCollector()
Verification: Server starts with all services initialized. Console logs confirm each service is ready.
8. Integration Testing¶
8.1 Full Game Lifecycle Test (with Stub Minigame)¶
Create a test minigame class
TestGame extends BaseMinigamethat:On
start(): sets a 5-second timer, then callscontext.onComplete()with dummy resultsOn
handleInput(): logs the inputOn
getStateForPlayer(): returns{ test: true }On
getStateForSpectator(): returns{ test: true, spectator: true }On
computeResults(): returns 2 fake rankings
Register
TestGameas'test-game'inMINIGAME_SERVER_REGISTRYConnect 3 sockets, create a lobby, have 2 join
Host starts a vote → vote phase works
All vote for test-game → instructions phase starts
All send ready_to_render → countdown starts → game starts
After 5 seconds → round results appear → lobby returns to WAITING
Verification: All lifecycle phases transition correctly. All event payloads match expected shapes.
8.2 Reconnection Test¶
Player A disconnects during PLAYING
Wait 10 seconds, reconnect Player A
Player A receives full lobby state + game state
Grace timer is canceled
Other players see PLAYER_DISCONNECTED then PLAYER_CONNECTED actions
Verification: All assertions pass, game state is preserved.
8.3 Duplicate Session Test¶
Player A connects from Tab 1
Player A connects from Tab 2 (same auth token)
Tab 1 is force-disconnected with DUPLICATE_SESSION error
Tab 2 takes over the player slot
Verification: Only one socket per user is active.
8.4 Force-End Test¶
Start a game with 3 players (minPlayers = 2)
2 players disconnect permanently (grace period expires)
1 player remains but below minPlayers → game force-ends, lobby returns to WAITING
Verification: Game ends gracefully, remaining player sees error state.
Phase 3 Completion Criteria¶
Voting system works: start vote, cast votes, resolve winner, ties broken, host force-skip
Host can directly select a game (bypassing vote)
Game lifecycle orchestrates all phases: INSTRUCTIONS → PRELOADING → COUNTDOWN → PLAYING → ROUND_RESULTS → WAITING
Instructions phase broadcasts correct payload, waitable/skippable
Preloading phase tracks ready_to_render from all clients, with 30s timeout
Countdown phase broadcasts 3-2-1 and transitions to PLAYING
Game handler is instantiated inside try-catch for fault isolation
Game inputs are routed through the coordinator to the active handler
Game completion triggers score updates and round results broadcast
Game errors are caught and lobby returns to WAITING safely
Heartbeat sends full state snapshots every 10 seconds during gameplay
Phase transitions trigger immediate full state sync to all members
Action sequence numbers prevent duplicates and enable ordering
Timer tick broadcast utility works for all timed phases
Reconnection protocol identifies users by userId, re-maps socket, cancels grace timer
Duplicate session handling disconnects old sockets
Grace period (120s) removes players who don’t reconnect
Spectators receive omniscient game state, not player-specific state
Spectator game inputs are silently dropped
Join-in-progress framework supports all 3 policies
All services wired in correct dependency order in index.ts
End-to-end test passes with a stub minigame through the full lifecycle