Skip to main content

WebSocket Event Protocol

Socket.IO, same origin as Next.js. Client connects with io() (no args). Server defined in server/index.js. Redis-backed for cluster-aware rooms and code/chat/test-case state persistence.

Server entry point: server/index.jsinitSocket()registerSocketHandlers() which fans out to four handler modules: gameHandlers, executionHandlers, matchmakingHandlers, inviteHandlers.


Shared Types

type Role = 'coder' | 'tester' | 'spectator';
type GameType = 'TWOPLAYER' | 'FOURPLAYER';
type Difficulty = 'EASY' | 'MEDIUM' | 'HARD';

interface Message {
id: string; // e.g. Math.random().toString(36).substring(7)
text: string; // message body (max 1000 chars)
userName: string; // sender display name
timestamp: number; // unix ms
}

type ParameterPrimitive =
| 'string' | 'number' | 'boolean'
| 'array_string' | 'array_number'
| 'array_array_string' | 'array_array_number';

interface Parameter {
name: string;
type: ParameterPrimitive;
value: string | null;
isOutputParameter?: boolean;
}

interface TestableCase {
id: number;
functionInput: Parameter[];
expectedOutput: Parameter;
computedOutput?: string | null;
}

Connection Setup

Socket.IO is initialised with a Redis adapter for cluster support. CORS origin is read from BETTER_AUTH_URL. Ping settings are configured for long-lived game sessions.

pingInterval:  5,000 ms
pingTimeout: 1,800,000 ms (30 min)

Auth middleware: A session-cookie auth middleware exists in socket/index.js but is currently commented out. Sockets are not authenticated at the transport layer; identity is established later via the register event.


Client → Server Events

register

Associate the connected socket with a user account. Must be emitted before any event that relies on socket.userId.

socket.emit('register', { userId: string });

Server behavior: Stores socket:{userId} → socketId in Redis and sets socket.userId on the socket instance. Emits matchFound if there is an active game involving the user.


joinLobby

Join the pre-game lobby room for a game. Used to receive teamUpdated broadcasts before the game starts.

socket.emit('joinLobby', { gameId: string });

Server behavior: Adds the socket to the {gameId}:lobby room and acknowledges with joinedLobby.


joinGame

Join the active game room and team room. Triggers game start logic if enough players are present.

socket.emit('joinGame', {
gameId: string,
teamId: string,
gameType: GameType,
});
FieldTypeDescription
gameIdstringThe game room identifier.
teamIdstringThe team room identifier.
gameTypeGameType'TWOPLAYER' or 'FOURPLAYER'.

Server behavior:

  1. Joins gameId and teamId rooms; leaves {gameId}:lobby.
  2. Counts sockets in gameId.
  3. Checks isGameStarted(gameId) — queries EXISTS game:{gameId}:expires in Redis.
  4. If game already started (late joiner): emits gameStarted with the current remaining TTL back to this socket only.
  5. If player count hits the threshold (2 for TWOPLAYER, 4 for FOURPLAYER): broadcasts gameStarting to the room, then after delayMs (default 3 s) calls startGameIfNeeded, which atomically sets three Redis timer keys and adds the game to activeGames. Broadcasts gameStarted to the whole room.
  6. Fetches game:{teamId}:code from Redis. If present, emits receiveCodeUpdate to this socket only.

Redis keys set by startGameIfNeeded:

KeyTTLPurpose
game:{gameId}:expiresGAME_DURATION_MS (5 min)Main game timer
game:{gameId}:roleswapGAME_DURATION_MS × rand(0.3–0.7)Role-swap trigger
game:{gameId}:roleswap:warningroleswap TTL − 60 sWarning before role swap

All three keys use SET … NX to prevent double-starts in a clustered environment.


codeChange

Send updated editor content. Emitted by the coder on every keystroke.

socket.emit('codeChange', {
teamId: string,
code: string, // max 10,000 chars
});

Server behavior:

  1. Validates payload with Zod.
  2. Persists code to Redis at game:{teamId}:code (no TTL).
  3. Broadcasts receiveCodeUpdate to all other sockets in the teamId room.

Full editor content is sent on every keystroke — no debounce or diffing.


sendChat

Send a chat message to the team room.

socket.emit('sendChat', {
teamId: string,
message: Message,
});

Server behavior:

  1. Persists message to Redis list chat:{teamId} via RPUSH, then trims to the last 50 messages.
  2. Broadcasts receiveChat to all other sockets in the teamId room.

requestChatSync

Request the full persisted chat history for a team (used on reconnect / late join).

socket.emit('requestChatSync', { teamId: string });

Server behavior: Reads chat:{teamId} list from Redis and emits receiveChatHistory back to the requesting socket only.


updateTestCases

Push the current test case set to Redis and broadcast to teammates.

socket.emit('updateTestCases', {
teamId: string,
testCases: TestableCase[],
});

Server behavior: Saves testCases to Redis at testcases:{teamId} and broadcasts receiveTestCaseSync to other sockets in the teamId room.


requestTestCaseSync

Fetch the latest persisted test cases and sync them to the whole team.

socket.emit('requestTestCaseSync', { teamId: string });

Server behavior: Reads testcases:{teamId} from Redis. If found, emits receiveTestCaseSync to both the requesting socket and the rest of the team.


requestTeamUpdate

Notify lobby sockets of the current player count for a team.

socket.emit('requestTeamUpdate', {
teamId: string,
gameId: string,
playerCount: number,
});

Server behavior: Broadcasts teamUpdated to the {gameId}:lobby room.


creatingRoomWithParty / sendGameWithParty

Party-specific relay events. Used by the party owner to coordinate room creation with their partner.

socket.emit('creatingRoomWithParty', { partyMember: string }); // userId
socket.emit('sendGameWithParty', { partyMember: string, gameId: string });

Server behavior: Looks up the party member's socketId from Redis and relays creatingRoomFromHost or createdRoomFromHost to them directly.


Matchmaking Events (Client → Server)

joinQueue

socket.emit('joinQueue', {
userId: string,
gameType: GameType,
difficulty: Difficulty,
partyId?: string | null,
lobbyId?: string | null,
});

Server behavior:

  • If the user is already queued in this queue, returns { status: 'already_queued' }.
  • TWOPLAYER + partyId: skips the queue entirely and calls _formPartyGame for an instant match.
  • Otherwise, pushes an entry to the Redis list queue:{gameType}:{difficulty} and attempts match formation via the popAndMatch.lua Lua script (atomic pop of N entries).
  • On match: creates a GameRoom in DB, calls warmVm(gameId) to pre-warm the sandbox, emits matchFound to each matched player's socket.
  • Emits queueStatus back to the caller with { status: 'queued' | 'matched' | 'already_queued', gameId? }.

The Lua script handles party entries (worth 2 players) and solo entries (worth 1) atomically, preventing partial pops.


leaveQueue

socket.emit('leaveQueue', { gameType: GameType, difficulty: Difficulty });

Server behavior: Scans the Redis list and removes the entry matching socket.userId. Emits queueStatus.


updateQueueSelection

Relays the current game type/difficulty selection to a party member's socket so their UI stays in sync.

socket.emit('updateQueueSelection', {
gameType: GameType,
difficulty: Difficulty,
partyMember: { userId: string },
});

partySearch

Relays the party owner's search state (searching/idle) to their partner.

socket.emit('partySearch', {
partyMember: { userId: string },
state: boolean,
});

Invite / Social Events (Client → Server)

Party Events

EventPayloadDescription
partyInvite{ toUserId: string }Send a party invite. Stored in Redis at party:invite:{toUserId} with 60 s TTL.
partyInviteAccept(none)Accept the pending invite; joins the party in DB.
partyInviteDecline(none)Decline and delete the invite from Redis.
partyKick(none)Owner kicks the current party member.
partyLeave(none)Member leaves the party.
partyJoinByCode{ code: string }Join a party directly by its ID/code (max 10 chars).

Friend Events

EventPayloadDescription
friendRequest{ friendCode: string }Send a friend request by friend code (max 20 chars).
friendRequestAccept{ requestId: string }Accept a pending friend request.
friendRequestDecline{ requestId: string }Decline a pending friend request.
friendDelete{ exFriendId: string, friendId: string }Remove a friendship record.

Server → Client Events

Game Events

EventPayloadDescription
joinedLobby(none)Acknowledgement after joinLobby.
teamUpdated{ teamId, playerCount }Broadcast to lobby when player count changes.
gameStarting(none)Broadcast to room when the player threshold is met. Countdown begins.
gameStarted{ start: number, _duration: number }Broadcast when timers are set. start = remaining ms; _duration = total game ms.
receiveCodeUpdatecode: stringSent to team room on codeChange, or to joining socket if code exists in Redis.
receiveChatmessage: MessageBroadcast to team room on sendChat. Excludes sender.
receiveChatHistoryMessage[]Unicast to requesting socket on requestChatSync.
receiveTestCaseSyncTestableCase[]Sent to team room (or socket) on test case update or sync.
waitingForOtherTeam(none)Sent to a FOURPLAYER team after they submit while waiting for the other.
gameEnded(none)Broadcast to room when the game concludes (submit or timer expiry).

Role Events (from ExpirationListener)

These are emitted by the server when Redis timer keys expire, not in response to a client event.

EventEmitted toDescription
roleSwapWarninggameId roomFired when game:{id}:roleswap:warning expires (~60 s before swap). Show a countdown UI.
roleSwappinggameId roomFired immediately when game:{id}:roleswap expires. Animate the transition.
roleSwapeach teamId roomFired ~2.5 s after roleSwapping, after DB roles have been updated. Clients should re-fetch their role.

Role swap DB logic (runs inside distributed lock):

CODER    → SPECTATOR
TESTER → CODER
SPECTATOR → TESTER

Matchmaking Events

EventPayloadDescription
queueStatus{ status, gameId? }Result of joinQueue or leaveQueue. status is queued, matched, already_queued, or removed.
matchFound{ gameId: string }Unicast to each matched player's socket when a full match is formed.
receiveQueueSelection{ gameType, difficulty }Unicast to party member when the owner changes their queue selection.
partySearchUpdate{ state: boolean }Unicast to party member reflecting owner's searching state.

Invite / Social Events

EventPayloadSent toDescription
partyInviteReceived{ fromUserId, fromDisplayName, fromAvatarUrl, partyOwnerId, sentAt }invitee socketInvite received.
partyJoinedowner: { userId, username, displayName, avatarUrl, joinedAt }accepting/joining socketConfirmed party membership.
partyMemberJoinedmember: { userId, username, displayName, avatarUrl, joinedAt }owner socketSomeone joined the party.
joinedPartyLeft(none)kicked member socketMember was kicked; reset party UI.
partyMemberLeft(none)owner socketMember left voluntarily.
friendRequestSentoutgoing request objectsender socketConfirms outgoing request; update list.
friendRequestReceivedincoming request objectaddressee socketNew incoming friend request.
friendRequestAcceptedfriend objectboth socketsFriendship confirmed; update friends list.
friendRequestDeclined{ requestId }requester socketNotifies requester of decline.
friendDeleted{ friendId }ex-friend socketNotifies the other party of removal.
creatingRoomFromHost(none)party member socketOwner is in the process of creating the room.
createdRoomFromHost{ gameId }party member socketOwner created the room; member should navigate to it.

Disconnect

socket.on('disconnect', async () => { ... });

On disconnect:

  1. Logs the disconnection with socket.id.
  2. Deletes socket:{userId} from Redis via cleanupSocket.
  3. Calls leaveAllQueues(userId) to remove the user from every matchmaking queue across all game types and difficulties.

Socket.IO automatically removes the socket from all rooms. No further app-level cleanup occurs.


Server-Side State (Redis)

Key patternTypeValueWritten byRead byTTL
game:{gameId}:expiresstring'1'startGameIfNeededisGameStarted, ExpirationListenerGAME_DURATION_MS (5 min)
game:{gameId}:roleswapstring'1'startGameIfNeededExpirationListenerGAME_DURATION_MS × rand(0.3–0.7)
game:{gameId}:roleswap:warningstring'1'startGameIfNeededExpirationListenerroleswap TTL − 60 s
game:{teamId}:codestringeditor contentcodeChange handlerjoinGame handlerNone
chat:{teamId}listMessage[] (max 50)sendChat handlerrequestChatSync handlerNone
testcases:{teamId}string (JSON)TestableCase[]updateTestCases handlerrequestTestCaseSync, submitCodeNone
game:{roomId}:submissionsstring (JSON){ team1?, team2? }submitCode handlersubmitCode handlerNone (deleted on game end)
socket:{userId}stringsocketIdregister eventmatchmaking, invites, game relayNone
party:invite:{toUserId}string (JSON)invite objectpartyInvite handlerpartyInviteAccept/Decline60 s
queue:{gameType}:{difficulty}listentry objectsjoinQueue_tryFormMatch (Lua), leaveQueueNone
activeGamessetgameId stringsstartGameIfNeededExpirationListenerNone
lock:game:{gameId}:roleswapstring'1'ExpirationListenerExpirationListener5 s
lock:game:{gameId}:endstring'1'ExpirationListenerExpirationListener5 s

Error Handling

ScenarioBehavior
Zod validation failure on any eventsocket.emit('error', { message: '...' }), handler returns early.
Redis down during joinGame code fetchconsole.error. Socket still joins; no code sent.
Redis down during codeChange persistconsole.error. Code still broadcast; not persisted for late joiners.
Executor service unreachableconsole.error, socket.emit('error', { message: '...' }). Game not ended.
submitCode with no matching GameResultsocket.emit('error', { message: 'Game or result not found' }).
FOURPLAYER: one team's execution failsLogs error; emits error to socket; game not finalized.
socket.userId missing on invite/matchmaking eventsHandler returns early silently.
Distributed lock not acquired (roleswap/end)Handler returns early — another cluster instance is handling it.
Game not in activeGames on expiryHandler returns early — game already ended via submitCode.
Client disconnectcleanupSocket + leaveAllQueues. Socket.IO removes from all rooms automatically.
No socket found for a userId lookupSkips room-leave or notification; logs warning.