What this is: The core real-time game loop for the most fundamental match format. The Room state machine, problem selection, Blitz advancement cycle, and the Reconciliation Worker all live here. This is the single most important stage — every later stage extends it.
Owner: Someone who can think clearly about state machines and race conditions under concurrency.
Tasks
-
Room creation: POST /api/contests/rooms
- Admin (or the system for tournament bracket rooms) creates a room.
- Validate preset or custom config.
- Run problem selection:
- Retrieve
solved:<cfHandle> SET for each participant.
SUNIONSTORE all solved sets into a temporary exclusion key.
- Query MongoDB problem pool within the rating band, excluding solved problems.
- Randomly sample
problemCount problems.
- Write ordered problem array to
room:<id>:problems.
- Write stub
ContestRoom to MongoDB (status: 'waiting').
- Write stub
ContestProblemSet to MongoDB.
- Set
room:<id>:state Hash: { status: 'waiting', type: 'blitz', currentProblem: 0, startTime: null, timeLimit: N, contestId }.
- Write
room:<id>:teams Set with both teamId values.
- Write
team:<teamId>:meta and team:<teamId>:users for each team.
- Add
roomId to contest:<contestId>:rooms Set.
- Return
roomId.
-
SSE room subscription
- When a client connects to
GET /api/events, the server checks if the user belongs to any active room.
- If yes: subscribe the SSE connection to
events:room:<roomId>.
- Immediately publish
room.state_sync to events:user:<userId> with the full current room state snapshot (including current problem and scores).
- Reconnect path: same flow — the reconnecting client re-receives
room.state_sync and reconstructs state from it.
-
Ready check
- Participants signal ready via
POST /api/contests/rooms/:id/ready.
- Server tracks readiness in
room:<id>:state (e.g. a readyCount field).
- On all participants ready: transition to active (task 4).
- On 60s timeout without all ready: cancel room, notify creator. Tournament walkover logic is Stage 6A.
-
Room start
- Set
room:<id>:state: status = 'active', startTime = now.
- Reveal
problem[0]: set revealedAt = now in room:<id>:problems.
- Publish
room.state_sync to events:room:<roomId> with full state including the first problem.
- Enqueue a BullMQ delayed job on
reconciliation_queue for the room time limit: { roomId, contestId, trigger: 'timeout' }.
-
Blitz advancement cycle (triggered by Stage 2's sync.detected internal event)
- Receive
sync.detected.
- Validate that
currentProblem in room:<id>:state matches the solved problem index — guard against stale or replayed events.
- Award points:
ZINCRBY room:<id>:scores <points> <teamId>.
- Record solve time:
ZADD room:<id>:solve_times <solveMs> <teamId> where solveMs = cfTimestamp - startTime.
- Advance:
HINCRBY room:<id>:state currentProblem 1.
- If
currentProblem == problemCount: trigger room end (task 6).
- Else: reveal next problem (set
revealedAt = now). Publish room.advance to events:room:<roomId>: { solvedBy: { userId, teamId }, problemIndex, nextProblem }. Publish room.score to events:room:<roomId>: { scores: Record<teamId, number> }.
- Append to
room:<id>:submissions stream.
-
Room end & Reconciliation Worker
- Triggers: all problems solved OR the time limit BullMQ job fires.
- Set
room:<id>:state.status = 'completed'.
- Publish
room.end to events:room:<roomId>: { winner: teamId, finalScores, duration }.
- Enqueue a job on
reconciliation_queue: { roomId, contestId, trigger: 'completed' | 'timeout' }.
reconciliation_queue worker:
- Read
room:<id>:scores ZSET. Determine winner: higher score wins; on tie, lower total solve time from room:<id>:solve_times.
- Open MongoDB transaction: write final
ContestRoom (scores, winner, endTime, trigger).
- Write all
ContestSubmission records from room:<id>:submissions stream.
- Finalise
ContestProblemSet (solvedBy, solvedAt per problem).
- If
contestId is a knockout Contest: call the bracket advancement hook (wired in Stage 6B).
DEL all room:<id>:* keys.
-
Disconnect / auto-forfeit (completing Stage 1's stub)
- Keyspace notification fires when
room:<id>:presence:<userId> TTL expires.
- Set forfeit flag in
room:<id>:state.
- Enqueue job on
reconciliation_queue: { roomId, contestId, trigger: 'forfeit', forfeitedUserId }.
- Publish
room.player_forfeited to events:room:<roomId>: { userId, teamId }.
-
Edge case: insufficient problems
- Problem selection returns fewer than
problemCount eligible problems.
- Return
{ error: 'insufficient_problems', minimumRatingRange } to the creator.
- No room document is created.
Testing Gate
Handoff Contract
What this is: The core real-time game loop for the most fundamental match format. The Room state machine, problem selection, Blitz advancement cycle, and the Reconciliation Worker all live here. This is the single most important stage — every later stage extends it.
Owner: Someone who can think clearly about state machines and race conditions under concurrency.
Tasks
Room creation:
POST /api/contests/roomssolved:<cfHandle>SET for each participant.SUNIONSTOREall solved sets into a temporary exclusion key.problemCountproblems.room:<id>:problems.ContestRoomto MongoDB (status: 'waiting').ContestProblemSetto MongoDB.room:<id>:stateHash:{ status: 'waiting', type: 'blitz', currentProblem: 0, startTime: null, timeLimit: N, contestId }.room:<id>:teamsSet with bothteamIdvalues.team:<teamId>:metaandteam:<teamId>:usersfor each team.roomIdtocontest:<contestId>:roomsSet.roomId.SSE room subscription
GET /api/events, the server checks if the user belongs to any active room.events:room:<roomId>.room.state_synctoevents:user:<userId>with the full current room state snapshot (including current problem and scores).room.state_syncand reconstructs state from it.Ready check
POST /api/contests/rooms/:id/ready.room:<id>:state(e.g. areadyCountfield).Room start
room:<id>:state:status = 'active',startTime = now.problem[0]: setrevealedAt = nowinroom:<id>:problems.room.state_synctoevents:room:<roomId>with full state including the first problem.reconciliation_queuefor the room time limit:{ roomId, contestId, trigger: 'timeout' }.Blitz advancement cycle (triggered by Stage 2's
sync.detectedinternal event)sync.detected.currentProbleminroom:<id>:statematches the solved problem index — guard against stale or replayed events.ZINCRBY room:<id>:scores <points> <teamId>.ZADD room:<id>:solve_times <solveMs> <teamId>wheresolveMs = cfTimestamp - startTime.HINCRBY room:<id>:state currentProblem 1.currentProblem == problemCount: trigger room end (task 6).revealedAt = now). Publishroom.advancetoevents:room:<roomId>:{ solvedBy: { userId, teamId }, problemIndex, nextProblem }. Publishroom.scoretoevents:room:<roomId>:{ scores: Record<teamId, number> }.room:<id>:submissionsstream.Room end & Reconciliation Worker
room:<id>:state.status = 'completed'.room.endtoevents:room:<roomId>:{ winner: teamId, finalScores, duration }.reconciliation_queue:{ roomId, contestId, trigger: 'completed' | 'timeout' }.reconciliation_queueworker:room:<id>:scoresZSET. Determine winner: higher score wins; on tie, lower total solve time fromroom:<id>:solve_times.ContestRoom(scores, winner, endTime, trigger).ContestSubmissionrecords fromroom:<id>:submissionsstream.ContestProblemSet(solvedBy, solvedAt per problem).contestIdis a knockout Contest: call the bracket advancement hook (wired in Stage 6B).DELallroom:<id>:*keys.Disconnect / auto-forfeit (completing Stage 1's stub)
room:<id>:presence:<userId>TTL expires.room:<id>:state.reconciliation_queue:{ roomId, contestId, trigger: 'forfeit', forfeitedUserId }.room.player_forfeitedtoevents:room:<roomId>:{ userId, teamId }.Edge case: insufficient problems
problemCounteligible problems.{ error: 'insufficient_problems', minimumRatingRange }to the creator.Testing Gate
POST .../ready→ room starts with a problem neither has solved → Player A submits on CF, clicks Sync →sync.detectedinternal event fires →room.advanceSSE event received on both clients → repeat until all problems solved →room.endfires with correct winner.room.state_syncwith current state, room resumes without interruption.room.player_forfeitedfires, MongoDBContestRoomshows correct forfeit outcome.room:<id>:solve_times.ContestRoomandContestSubmission— all fields populated. Allroom:<id>:*Redis keys deleted.Handoff Contract
SSE_EVENTS.mdcommitted to the repo: event name, direction, channel, full payload shape for every event in the catalogue above.reconciliation_queueworker is running as a BullMQ worker process.sync.detectedinternal event is consumed here. The handshake is clean and documented.