Phase 2: Lobby System¶
Overview¶
This phase implements the complete lobby lifecycle: creation, joining, leaving, host controls, chat, ready-up, spectator management, lobby browser, and garbage collection. By the end of this phase, players can create lobbies with room codes, join/leave, chat, toggle ready status, and browse public lobbies — all through the standalone WebSocket server established in Phase 1.
Prerequisites¶
Phase 1 complete (server boots, auth works, types/schemas/constants defined)
server/rmhbox/index.tsrunning on port 7676 with auth middleware
1. Lobby Manager Core¶
1.1 Create server/rmhbox/lobby-manager.ts¶
Import
nanoidwith custom alphabet fromlib/rmhbox/utils.ts(thegenerateRoomCode()function)Import server types:
RMHboxLobby,RMHboxPlayer,RMHboxSpectator,LobbySettingsImport shared constants:
ROOM_CODE_LENGTH,DEFAULT_MAX_PLAYERS,DEFAULT_MAX_SPECTATORS,CHAT_HISTORY_LENGTH,CHAT_MAX_LENGTH,LOBBY_IDLE_TIMEOUT_MS,LOBBY_ABSOLUTE_TIMEOUT_MS,LOBBY_EMPTY_TIMEOUT_MS,LOBBY_GC_INTERVAL_MSDefine
LobbyManagerclass with constructor acceptingio: ServerCreate private
lobbies: Map<string, RMHboxLobby>storageCreate private
userToLobby: Map<string, string>index (userId → lobbyId) for fast lookupVerification: Instantiate
LobbyManager— no errors, internal maps are empty.
1.2 Implement Room Code Generation¶
Implement private
generateUniqueLobbyId(): stringmethodCalls
generateRoomCode()to get a 6-char codeChecks against
this.lobbieskeys for uniquenessRegenerates on collision (max 10 attempts, then throw)
Verification: Call
generateUniqueLobbyId()100 times — all codes are unique, 6 chars, no ambiguous characters (I, O, 0, 1).
2. Lobby Creation¶
2.1 Implement createLobby(socket, payload)¶
Validate that the user is not already in a lobby (check
userToLobby)If already in a lobby, emit
rmhbox:errorwithALREADY_IN_LOBBYcode
Generate unique lobby ID via
generateUniqueLobbyId()Create default
LobbySettingsobject, merging with any provided partial settings:isPublic: false,maxPlayers: 8,maxSpectators: 20,allowMidGameJoin: true,allowSpectatorPromotion: true,autoStartThreshold: null,gameDurationOverride: null
Create
RMHboxPlayerfor the creating user:userId: socket.data.userId,userName: socket.data.userName,avatarUrl: socket.data.avatarUrlsocketId: socket.id,isConnected: true,isReady: falsescore: 0,roundScore: 0,joinedAt: Date.now(),lastSeenAt: Date.now(),role: 'player'
Create
RMHboxLobbyobject with the player as host and only memberstate: 'WAITING',chat: [],currentGame: null,matchHistory: [],roundNumber: 0
Store lobby in
this.lobbiesmapStore
userId → lobbyIdinthis.userToLobbymapJoin socket to Socket.io rooms:
lobby:{id},lobby:{id}:players,lobby:{id}:player:{userId}Build and emit
rmhbox:lobby:createdwith{ lobbyId, lobby: ClientLobbyState }to the socketVerification: Create a lobby — response contains a valid 6-char lobbyId. The internal
lobbiesmap has 1 entry. TheuserToLobbymap maps the userId to the lobbyId. The socket is in the correct rooms.
3. Lobby Join¶
3.1 Implement joinLobby(socket, payload)¶
Validate lobby exists (check
this.lobbies.get(payload.lobbyId))If not found, emit
rmhbox:errorwithLOBBY_NOT_FOUND
Validate lobby is not
DISBANDEDValidate user is not already in a lobby
If already in a lobby, emit
rmhbox:errorwithALREADY_IN_LOBBY
Determine join role (player vs spectator):
If
asSpectator === true: join as spectatorIf lobby state is
PLAYINGandallowMidGameJoin === false: force join as spectatorIf lobby state is
PLAYINGandallowMidGameJoin === true: join as spectatorIf
players.size >= settings.maxPlayers: force join as spectatorOtherwise: join as player
If joining as player:
Check
players.size < settings.maxPlayers, else emitLOBBY_FULLerrorCreate
RMHboxPlayerobject with all fields populatedAdd to
lobby.playersMapJoin rooms:
lobby:{id},lobby:{id}:players,lobby:{id}:player:{userId}
If joining as spectator:
Check
spectators.size < settings.maxSpectators, else emitLOBBY_FULLerrorCreate
RMHboxSpectatorobjectAdd to
lobby.spectatorsMapJoin rooms:
lobby:{id},lobby:{id}:spectators,lobby:{id}:player:{userId}
Update
userToLobbymapUpdate
lobby.lastActivityAtBroadcast
PLAYER_JOINEDorSPECTATOR_JOINEDgame action to all members inlobby:{id}Send full
rmhbox:lobby:state_snapshotto the joining socketAdd system chat message:
"<userName> joined"Verification:
Join an existing lobby — new player appears in the lobby’s
playersmap, all existing sockets receive thePLAYER_JOINEDaction, joining socket receives state snapshot.Try to join a full lobby — error is emitted, player is not added.
Join during a game — player is added as spectator.
4. Lobby Leave¶
4.1 Implement leaveLobby(socket, payload)¶
Find the lobby the user is in (via
userToLobby)Determine if user is a player or spectator
Remove from the appropriate map (
playersorspectators)Remove from
userToLobbyLeave all Socket.io rooms for this lobby
Handle host succession if the leaving player was the host:
Find the next player by earliest
joinedAtIf no players remain but spectators exist, promote earliest spectator to player and make them host
If nobody remains, mark lobby as
DISBANDEDand schedule cleanupBroadcast
HOST_TRANSFERREDaction if host changed
If a game is
PLAYING:Notify the active minigame handler via
handlePlayerDisconnect(userId)If player count drops below the minigame’s
minPlayers, callgameCoordinator.forceEndGame(lobbyId)
Broadcast
PLAYER_LEFTorSPECTATOR_LEFTaction to remaining lobby membersAdd system chat message:
"<userName> left"Update
lobby.lastActivityAtVerification:
Leave a lobby — player is removed, remaining players see
PLAYER_LEFTaction.Last player leaves — lobby is marked
DISBANDED.Host leaves — next earliest player becomes host,
HOST_TRANSFERREDis broadcast.Leave during game with too few remaining — game force-ends.
4.2 Implement handleDisconnect(socket)¶
Called when a socket disconnects (not an explicit leave)
Find the lobby via
userToLobbyusingsocket.data.userIdSet
player.socketId = nullandplayer.isConnected = falseBroadcast
PLAYER_DISCONNECTEDaction to lobbyStart the grace period timer (120 seconds) — store timer reference on the lobby/player for cancellation
If grace period expires without reconnect: call the same logic as
leaveLobby()Verification: Disconnect a socket — player is marked disconnected but still in the lobby. After 120s, player is fully removed.
5. Host Controls¶
5.1 Implement kickPlayer(socket, payload)¶
Validate the requesting socket is the host (
socket.data.userId === lobby.hostUserId)If not, emit
rmhbox:errorwithNOT_HOST
Validate the target user is in the lobby and is not the host themselves
Remove the target player from the lobby (similar to leave logic)
Emit
rmhbox:lobby:kickedwith{ reason: 'Kicked by host' }to the kicked player’s socketForce-disconnect the kicked player’s socket from the lobby rooms
Broadcast
PLAYER_KICKEDaction to remaining lobby membersAdd system chat message:
"<userName> was kicked by the host"Verification: Host kicks a player — kicked player receives the kicked event, is removed from the lobby, remaining players see the action.
5.2 Implement transferHost(socket, payload)¶
Validate requesting socket is the current host
Validate target user is a player in the lobby
Set
lobby.hostUserId = payload.targetUserIdBroadcast
HOST_TRANSFERREDaction with{ newHostUserId, newHostUserName }Add system chat message:
"Host transferred to <userName>"Verification: Transfer host —
hostUserIdchanges, all players receive the action.
5.3 Implement updateSettings(socket, payload)¶
Validate requesting socket is the host
Validate lobby is in
WAITINGstate (settings can only be changed when not in a game)Merge partial settings into
lobby.settingswith validation:maxPlayers: clamp between 2 and 16maxSpectators: clamp between 0 and 50Validate boolean fields are booleans
Broadcast
SETTINGS_UPDATEDaction with the new settingsVerification: Update settings — new values are applied, broadcast received by all members. Attempt to update during
PLAYING— rejected.
5.4 Implement endSession(socket, payload)¶
Validate requesting socket is the host
Transition lobby state to
SESSION_RESULTSIf a game is currently
PLAYING, force-end it firstBroadcast
rmhbox:game:session_resultswith cumulative standings and match historyStart a timer (e.g., 15 seconds), then transition to
DISBANDEDOn disband: emit
rmhbox:lobby:disbandedto all sockets, clean up all rooms, remove lobby from memoryVerification: Host ends session — all players receive session results, then the disbanded event.
6. Chat System¶
6.1 Create server/rmhbox/chat.ts¶
Define
ChatHandlerclass with constructor acceptingio: Server, lobbyManager: LobbyManagerImplement
handleConnection(socket):Register
rmhbox:lobby:chatlistener on the socket (validated withChatSchema)
Implement chat message handler:
Find the user’s lobby
Validate the user is in the lobby (player or spectator)
Sanitize chat content with
sanitizeString(content, CHAT_MAX_LENGTH)Create
ChatMessageobject with uniqueid(nanoid), userId, userName, content, timestamp, type: ‘user’Add to
lobby.chatarray (ring buffer: ifchat.length > CHAT_HISTORY_LENGTH, remove oldest)Broadcast
CHAT_MESSAGEgame action to all inlobby:{lobbyId}roomUpdate
lobby.lastActivityAt
Implement
addSystemChat(lobbyId, message): create a system ChatMessage with type: ‘system’Verification: Send a chat message — all lobby members receive it. Send >100 messages — oldest are trimmed. Send a message with HTML tags — they are stripped.
7. Ready-Up System¶
7.1 Implement Ready Toggle¶
Register
rmhbox:lobby:toggle_readylistener (validated withToggleReadySchema)Find the user’s lobby and player record
Toggle
player.isReady = !player.isReadyBroadcast
PLAYER_READY_CHANGEDaction with{ userId, isReady }Check auto-start threshold: if
settings.autoStartThresholdis set and the count of ready players >= threshold, trigger game selection flow (emit to host or auto-start vote)Verification: Toggle ready —
isReadyflips, all players see the change. Set auto-start threshold to 2 with 2 players — when both ready, auto-start triggers.
8. Spectator Management¶
8.1 Implement Spectator Promotion¶
Register
rmhbox:lobby:request_promotionlistener (validated withRequestPromotionSchema)Validate lobby is in
WAITINGorROUND_RESULTSstateValidate
settings.allowSpectatorPromotionis trueValidate
players.size < settings.maxPlayersMove the spectator from
lobby.spectatorstolobby.players:Create a new
RMHboxPlayerfrom the spectator dataRemove from spectators map, add to players map
Leave
lobby:{id}:spectatorsroom, joinlobby:{id}:playersroom
Broadcast
SPECTATOR_PROMOTEDaction with{ userId, userName }Add system chat message:
"<userName> joined as a player"Verification: Spectator requests promotion during WAITING — they become a player. Request during PLAYING — rejected. Request when lobby is full — rejected with
LOBBY_FULL.
9. Lobby Browser¶
9.1 Implement Public Lobby Browsing¶
Register
rmhbox:lobby:browselistener (validated withBrowseLobbiesSchema)Note: This event does NOT require the user to be in a lobby (can be called from the landing page)
Filter all lobbies where
settings.isPublic === trueandstate !== 'DISBANDED'Sort by
players.sizedescending (most active first)Apply pagination using cursor/limit
If cursor is provided, skip lobbies until cursor lobby is found, then return next
limitlobbiesOtherwise return first
limitlobbies
Map each lobby to
PublicLobbyInfo:lobbyId,hostName(get from host player’s userName),playerCount,maxPlayers,spectatorCount,state,currentGame(display name if playing, null otherwise),roundNumber
Emit
rmhbox:lobby:browse_resultwith{ lobbies, nextCursor }to the requesting socketVerification: Create 3 public lobbies and 2 private — browse returns only the 3 public ones. Pagination works correctly with limit=1.
10. Build Client State¶
10.1 Implement buildClientState(lobby, userId): ClientLobbyState¶
Map
lobby.playerstoClientPlayerInfo[]: includeisHost: player.userId === lobby.hostUserIdMap
lobby.spectatorstoClientSpectatorInfo[]Determine
myRole: 'player' | 'spectator'based on whether userId is in players or spectators mapBuild
ClientGameInfoiflobby.currentGameis not null:Call
currentGame.handler.getStateForPlayer(userId)orgetStateForSpectator()depending on rolePackage into
publicStateandprivateState
Assemble
ClientLobbyStatewith all fields includingseq(incrementing sequence counter per lobby)Ensure no internal server data leaks (no Maps, no socketIds, no timer references)
Verification: Build client state for a player and a spectator in the same lobby — player sees their role as ‘player’, spectator as ‘spectator’. No
socketIdor internal fields appear in the output.
11. Lobby Garbage Collection¶
11.1 Implement GC Interval¶
Create
startGarbageCollector()method onLobbyManagerRun every
LOBBY_GC_INTERVAL_MS(60 seconds)For each lobby in
this.lobbies:If
state === 'WAITING'andlastActivityAtis older thanLOBBY_IDLE_TIMEOUT_MS(15 min): disbandIf
lastActivityAtis older thanLOBBY_ABSOLUTE_TIMEOUT_MS(30 min) regardless of state: force-disbandIf ALL players AND spectators have
isConnected === falsefor more thanLOBBY_EMPTY_TIMEOUT_MS(2 min): disband
On disband:
Emit
rmhbox:lobby:disbandedwith{ reason: 'Inactive lobby' }to all remaining socketsRemove all sockets from lobby rooms
Delete lobby from
this.lobbiesmapDelete all userId entries from
this.userToLobbymapCancel any active grace period timers
Log the cleanup
Verification: Create a lobby, don’t interact for 15 minutes — lobby is garbage collected. Create a lobby, have all players disconnect for 2 minutes — lobby is cleaned up.
12. Helper Methods¶
12.1 Implement Lookup Methods on LobbyManager¶
getLobby(lobbyId): RMHboxLobby | undefinedgetLobbyByUserId(userId): RMHboxLobby | undefined— usesuserToLobbyindexgetLobbyBySocketId(socketId): RMHboxLobby | undefined— iterates players/spectators to find matching socketIdfindLobbyByUserId(userId): RMHboxLobby | undefined— alias forgetLobbyByUserIdVerification: Create a lobby, call each lookup method — correct lobby is returned. Call with nonexistent IDs — returns undefined.
12.2 Implement Broadcasting Methods on LobbyManager¶
broadcastAction(lobbyId, action: Partial<GameAction>)— auto-assignseqandtimestamp, emitrmhbox:game:actiontolobby:{lobbyId}Maintain a per-lobby
seqcounter that increments on every action
broadcastToPlayers(lobbyId, event, data)— emit tolobby:{lobbyId}:playersbroadcastToSpectators(lobbyId, event, data)— emit tolobby:{lobbyId}:spectatorssendToPlayer(lobbyId, userId, event, data)— emit tolobby:{lobbyId}:player:{userId}addSystemChat(lobbyId, message)— create system ChatMessage and broadcastVerification: Broadcast an action — all connected sockets in the lobby receive it with a valid
seqandtimestamp. Send to a specific player — only that player receives it.
13. Wire Up Event Handlers¶
13.1 Register All Lobby Events in server/rmhbox/index.ts¶
In the
io.on('connection')handler:Call
lobbyManager.handleConnection(socket)which registers all lobby event listeners on the socketCall
chatHandler.handleConnection(socket)which registers chat listeners
In
handleConnection(socket)onLobbyManager:socket.on('rmhbox:lobby:create', validated(CreateLobbySchema, (s, d) => this.createLobby(s, d)))socket.on('rmhbox:lobby:join', validated(JoinLobbySchema, (s, d) => this.joinLobby(s, d)))socket.on('rmhbox:lobby:leave', validated(LeaveLobbySchema, (s, d) => this.leaveLobby(s, d)))socket.on('rmhbox:lobby:kick', validated(KickPlayerSchema, (s, d) => this.kickPlayer(s, d)))socket.on('rmhbox:lobby:transfer_host', validated(TransferHostSchema, (s, d) => this.transferHost(s, d)))socket.on('rmhbox:lobby:update_settings', validated(UpdateSettingsSchema, (s, d) => this.updateSettings(s, d)))socket.on('rmhbox:lobby:end_session', validated(EndSessionSchema, (s, d) => this.endSession(s, d)))socket.on('rmhbox:lobby:toggle_ready', validated(ToggleReadySchema, (s, d) => this.toggleReady(s, d)))socket.on('rmhbox:lobby:request_promotion', validated(RequestPromotionSchema, (s, d) => this.requestPromotion(s, d)))socket.on('rmhbox:lobby:browse', validated(BrowseLobbiesSchema, (s, d) => this.browseLobbies(s, d)))
In the
socket.on('disconnect')handler inindex.ts:Call
lobbyManager.handleDisconnect(socket)
Verification: Connect a socket and emit each event — the correct handler is invoked. Emit with invalid payloads — Zod validation rejects with
INVALID_PAYLOADerror.
14. Integration Testing¶
14.1 End-to-End Lobby Lifecycle Test¶
Connect two authenticated sockets
Socket A creates a lobby → receives
lobby:createdwith valid lobbyIdSocket B joins the lobby → Socket A receives
PLAYER_JOINEDaction, Socket B receivesstate_snapshotSocket A sends a chat message → both sockets receive it
Socket B toggles ready → both sockets see
PLAYER_READY_CHANGEDSocket A (host) kicks Socket B → Socket B receives
kicked, Socket A seesPLAYER_KICKEDSocket B joins again → succeeds
Socket A transfers host to Socket B → both see
HOST_TRANSFERREDSocket B (now host) updates settings → both see
SETTINGS_UPDATEDSocket A leaves → Socket B sees
PLAYER_LEFT, Socket B is still hostSocket B leaves → lobby is auto-disbanded
Verification: All assertions pass. No memory leaks (lobbies map is empty after full lifecycle).
14.2 Edge Case Tests¶
Attempt to join a non-existent lobby →
LOBBY_NOT_FOUNDerrorAttempt to create a lobby while already in one →
ALREADY_IN_LOBBYerrorAttempt host actions from a non-host socket →
NOT_HOSTerrorFill a lobby to
maxPlayersthen attempt to join → overflow joins as spectator or getsLOBBY_FULLAll players disconnect → after 2 min, lobby is garbage collected
Verification: All edge cases produce the correct error codes and no server crashes.
Phase 2 Completion Criteria¶
Players can create lobbies with unique 6-character room codes
Players can join lobbies by room code (as player or spectator)
Players can leave lobbies with proper host succession
Host controls work: kick, transfer host, update settings, end session
Chat system works with sanitization and ring buffer
Ready toggle works with auto-start threshold support
Spectator promotion works between rounds
Public lobby browser works with pagination
buildClientState()produces properly sanitized client stateGarbage collection cleans up idle/empty lobbies
All events are wired through the
validated()wrapper with Zod schemasPer-socket rate limiting is applied to all lobby events
All lobby operations are tested end-to-end