Skip to content

Stage 3 — Blitz Room Engine (1v1) #6

Description

@KineticTactic

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

  1. 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.
  2. 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.
  3. 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.
  4. 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' }.
  5. 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.
  6. 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.
  7. 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 }.
  8. 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

  • End-to-end Blitz 1v1 with two real CF accounts: Create room → both players POST .../ready → room starts with a problem neither has solved → Player A submits on CF, clicks Sync → sync.detected internal event fires → room.advance SSE event received on both clients → repeat until all problems solved → room.end fires with correct winner.
  • Reconnect test: Player disconnects mid-room, reconnects within 90s → receives room.state_sync with current state, room resumes without interruption.
  • Forfeit test: Player disconnects, does not reconnect for >90s → room.player_forfeited fires, MongoDB ContestRoom shows correct forfeit outcome.
  • Problem filter test: CF handle with 50+ solved problems. Verify no selected problem appears in the handle's solved set.
  • Tie-break test: Engineer equal scores → verify winner is the team with lower total solve time in room:<id>:solve_times.
  • MongoDB record check: After room end, query ContestRoom and ContestSubmission — all fields populated. All room:<id>:* Redis keys deleted.

Handoff Contract

  • All testing gate items pass with real CF accounts.
  • SSE_EVENTS.md committed to the repo: event name, direction, channel, full payload shape for every event in the catalogue above.
  • reconciliation_queue worker is running as a BullMQ worker process.
  • Stage 2's sync.detected internal event is consumed here. The handshake is clean and documented.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions