Skip to content

feat: implement 1v1 quiz battle with real-time sync and auto-start#153

Open
hansikareddy29 wants to merge 1 commit intoOpenLake:mainfrom
hansikareddy29:feature/1v1-quiz
Open

feat: implement 1v1 quiz battle with real-time sync and auto-start#153
hansikareddy29 wants to merge 1 commit intoOpenLake:mainfrom
hansikareddy29:feature/1v1-quiz

Conversation

@hansikareddy29
Copy link
Contributor

🚀 Real-Time 1v1 Quiz Battle System

✅ Features Implemented

1. Real-time Infrastructure

  • Integrated Socket.io with server-side handlers at /api/socket to manage:
    • Quiz room creation
    • Player joining
    • Real-time score synchronization

2. Private Lobby & Invite System

  • Users can generate a private lobby with a unique room URL (e.g., /quiz/lobby/[roomId]) to invite friends.

3. Automatic Battle Flow

  • Lobby automatically detects when a second player joins.
  • Added a synchronized 5-second countdown when both players are present.
  • Host client triggers battle start via Socket.io.
  • Both players are automatically navigated to the battle page simultaneously.

4. Battle Experience

  • Implemented battle UI showing:
    • Current question
  • Integrated Redis caching for battle questions to ensure:
    • Faster retrieval
    • Consistent questions for both players
    • Reduced database reads

🛠 Bug Fixes & Improvements

  • Fixed race condition by adding a roomId guard during lobby initialization.
  • Added optional chaining and loading states in BattlePage to prevent crashes while questions are loading.
  • Improved render safety and socket synchronization stability.

🚧 Remaining Work

Pending Features

  • Invite link sharing not fully functional yet.
  • Battle question timer not implemented.
  • Live leaderboard during quiz not implemented.
  • Final post-battle leaderboard not implemented.

UI & Technical Improvements

  • Refactor battle page UI to fully match solo quiz layout.

📌 Summary

This PR lays the foundation for the real-time multiplayer quiz system including private lobbies, automatic matchmaking flow, synchronized battles, and Redis-based question caching. Remaining work mainly involves leaderboard features, timers, and invite link fixes.

@github-actions
Copy link

🎉 Thanks for Your Contribution to CanonForces! ☺️

We'll review it as soon as possible. In the meantime, please:

  • ✅ Double-check the file changes.
  • ✅ Ensure that all commits are clean and meaningful.
  • ✅ Link the PR to its related issue (e.g., Closes #123).
  • ✅ Resolve any unaddressed review comments promptly.

💬 Need help or want faster feedback?
Join our Discord 👉 CanonForces Discord

Thanks again for contributing 🙌 – @hansikareddy29!
cc: @aviralsaxena16

@coderabbitai
Copy link

coderabbitai bot commented Mar 26, 2026

Walkthrough

The changes refactor the 1v1 quiz battle matchmaking system to use Redis queue-based operations instead of direct room creation, reorganize socket event handlers with simplified architecture, and update corresponding UI flows. The API endpoints are restructured with a new /api/quiz/questions/[roomId] replacing the old getQuestions endpoint, and the battle pages are consolidated to use consistent roomId-based routing with real-time score synchronization via socket events.

Changes

Cohort / File(s) Summary
Socket.IO Architecture & Matchmaking
src/pages/api/socket.ts, src/context/SocketContext.tsx, src/components/quiz/StartScreen.tsx
Refactored socket initialization to perform async API fetch before client creation; simplified matchmaking logic by inlining Redis queue operations (spop/sadd) directly into join_queue handler; removed custom type signatures and helper functions; changed event name from join_random_queue to join_queue and consolidated room/battle setup; updated StartScreen to emit correct queue event and register persistent match listener.
API Endpoint Reorganization
src/pages/api/quiz/questions/[roomId].ts, src/pages/api/quiz/battle/getQuestions.ts, src/pages/api/quiz/battle/create.ts
Removed legacy getQuestions endpoint; introduced new /api/quiz/questions/[roomId] for fetching stored questions; updated create.ts to change Redis key namespace from quiz:battle:room: to quiz:lobby: and store JSON metadata instead of static values.
Quiz Battle UI & Pages
src/pages/quiz/battle/[battleId].tsx, src/pages/quiz/battle/[roomId].tsx
Removed old battle page component with pull-based question fetching; added new battle page using roomId routing with pre-fetched questions, real-time opponent_update listener for score/progress tracking, and consolidated UI for active battle and results states.
Lobby Flow & Countdown
src/pages/quiz/lobby/[roomId].tsx, src/styles/lobby.module.css
Changed lobby join tracking to use hasJoinedRef guard; replaced manual start button with automatic countdown-triggered battle initiation; added waiting/countdown animation UI with radar visualization, status text, and pulse animations; removed previous players list and disabled button styling.
Firebase SDK Modernization
src/services/quizServer.ts
Migrated from admin-style Firestore API to client-side modular SDK; updated query construction and batch operations to use query, where, getDocs, and writeBatch helpers.
Build Configuration
tsconfig.json
Updated TypeScript JSX compilation mode from preserve to react-jsx.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Socket as Socket.IO Server
    participant Redis
    participant Firestore
    participant API as API Routes

    Client->>+Socket: emit join_queue
    Socket->>+Redis: spop(quiz:queue)
    Redis-->>-Socket: opponentId or null
    
    alt Opponent Found
        Socket->>+API: (internally)
        Socket->>+Firestore: getBankedQuestions('DSA','medium',5)
        Firestore-->>-Socket: questions[]
        Socket->>+Redis: set quiz:room${roomId} with questions+TTL
        Redis-->>-Socket: OK
        
        Socket->>Client: emit match_found {roomId}
        Socket->>Client: (other socket) emit match_found {roomId}
        
        Client->>+API: GET /api/quiz/questions/${roomId}
        API->>+Redis: get quiz:room${roomId}
        Redis-->>-API: questions JSON
        API-->>-Client: {questions}
        
        Client->>+Socket: emit join_battle {roomId}
        Client->>Client: Render battle UI
        Client->>Client: Answer questions, emit submit_answer
        Socket->>Socket: Track opponent_update events
    else No Opponent
        Socket->>+Redis: sadd(quiz:queue, socket.id)
        Redis-->>-Socket: OK
        Socket->>Client: (client continues waiting)
    end
Loading
sequenceDiagram
    participant Host as Host Client
    participant Joiner as Joiner Client
    participant Socket as Socket.IO Server
    participant Redis
    participant Firestore
    
    Host->>+Socket: emit join_private_room {roomId}
    Socket->>+Redis: Store player in private room
    Redis-->>-Socket: OK
    
    Joiner->>+Socket: emit join_private_room {roomId}
    Socket->>Socket: players.length >= 2, trigger countdown
    Socket->>Host: emit opponent_joined
    Socket->>Joiner: emit opponent_joined
    
    Note over Host,Joiner: Countdown timer (5 seconds)
    
    Socket->>+Firestore: getBankedQuestions('DSA','medium',5)
    Firestore-->>-Socket: questions[]
    Socket->>+Redis: set quiz:room${roomId} with questions+TTL
    Redis-->>-Socket: OK
    
    Socket->>Host: emit battle_starting {roomId}
    Socket->>Joiner: emit battle_starting {roomId}
    
    Host->>Host: Navigate to /quiz/battle/${roomId}
    Joiner->>Joiner: Navigate to /quiz/battle/${roomId}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related issues

Suggested reviewers

  • Jagath-P
  • aviralsaxena16

Poem

🐰 Queue the magic, Redis spins fast,
Matching players, connections at last!
No more pulling, the push flows so sweet,
Battle rooms bloom where two players meet! 🎮✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers key implemented features, bug fixes, and remaining work, but is missing several required template sections like testing details, documentation updates, and deployment notes. Add testing methodology section (unit/integration tests run or manual test cases), documentation updates status, and deployment considerations to align with the repository's PR template.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main change: implementing a real-time 1v1 quiz battle system with automatic synchronization and startup flow.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel
Copy link

vercel bot commented Mar 26, 2026

@hansikareddy29 is attempting to deploy a commit to the aviralsaxena16's projects Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/pages/quiz/lobby/[roomId].tsx (1)

32-37: ⚠️ Potential issue | 🟠 Major

This lobby state is inferred locally, so the countdown isn't actually synchronized.

Every browser enters this effect with players.length === 0, so both clients initially mark themselves as host. The second player also never receives a player_joined event for the first player, which leaves them stuck in waiting while only the first client sees the countdown. Send authoritative room state (hostId, playerCount, startAt) from the server and derive waiting/countdown from that instead of local guesses.

Also applies to: 41-50, 60-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/quiz/lobby/`[roomId].tsx around lines 32 - 37, The current lobby
effect infers host and player list locally (using players.length === 0,
setIsHost, setPlayers) which causes both clients to mark themselves host and
prevents proper countdown sync; instead stop guessing and request/subscribe to
authoritative room state from the server (including hostId,
playerCount/players[], startAt) when joining and on server events (e.g.
"room_state" or "player_joined"/"player_left"), update local state from that
payload and derive waiting/countdown from room.hostId === currentUser.uid and
whether startAt is set (or playerCount meets threshold), and remove the
local-only host/players initialization logic so setIsHost and setPlayers are
driven by the server state rather than players.length checks.
🧹 Nitpick comments (5)
src/pages/api/quiz/battle/create.ts (3)

10-10: Use 201 Created for successful room creation.

Line 10 currently returns 200; 201 is a better semantic match for create operations.

Suggested patch
-        return res.status(200).json({ roomId });
+        return res.status(201).json({ roomId });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/quiz/battle/create.ts` at line 10, Change the response status
for successful room creation from 200 to 201: locate the handler that returns
res.status(200).json({ roomId }) and update it to use res.status(201).json({
roomId }) so the create operation returns the proper HTTP 201 Created semantics
(leave the JSON body unchanged).

6-6: Set Allow header for 405 responses.

Line 6 returns 405 correctly, but adding Allow: POST makes the endpoint behavior explicit for clients and tooling.

Suggested patch
-    if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
+    if (req.method !== 'POST') {
+        res.setHeader('Allow', 'POST');
+        return res.status(405).json({ error: 'Method not allowed' });
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/quiz/battle/create.ts` at line 6, The 405 response branch in
the API handler that checks req.method !== 'POST' should include an Allow header
so clients know allowed methods; modify the branch in the create handler to set
the response header (e.g., res.setHeader('Allow', 'POST') or similar) before
returning res.status(405).json(...) so the endpoint explicitly advertises
allowed methods for the POST-only route.

11-12: Add minimal server-side error telemetry in the catch path.

Line 12 returns a good generic client error; logging the failure server-side will make production debugging much easier.

Suggested patch
     } catch (error) {
+        console.error('Failed to create quiz lobby', { error });
         return res.status(500).json({ error: 'Failed to create room' });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/quiz/battle/create.ts` around lines 11 - 12, The catch block in
the create room route currently returns a generic 500 but doesn't record the
error; update the catch path in src/pages/api/quiz/battle/create.ts to emit
minimal server-side telemetry by logging the caught error and contextual info
before returning res.status(500).json(...). Specifically, call your
telemetry/log utility (or console.error) with the caught error variable and a
short message referencing this handler (e.g., "quiz/battle create handler
failed") and include any relevant request identifiers or payload if available,
then keep the existing res.status(500).json({ error: 'Failed to create room' })
response.
src/pages/quiz/battle/[roomId].tsx (2)

101-103: No error state for failed question fetch.

If the API call fails, users will see "Loading battle..." indefinitely with no indication of the error. Consider adding an error state to display a retry option or error message.

💡 Suggested approach
+    const [error, setError] = useState<string | null>(null);

In the fetch catch block:

             .catch(err => {
-                console.error('Failed to fetch battle questions:', err);
+                if (err.name !== 'AbortError') {
+                    console.error('Failed to fetch battle questions:', err);
+                    setError('Failed to load questions. Please try again.');
+                }
             });

Then add error rendering:

+    if (error) {
+        return (
+            <div className={styles['battle-container']}>
+                <p>{error}</p>
+                <button onClick={() => router.push('/quiz')}>Back to Quiz</button>
+            </div>
+        );
+    }
+
     if (questions.length === 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/quiz/battle/`[roomId].tsx around lines 101 - 103, The component
currently only checks questions.length === 0 and shows "Loading battle..." so
failed API calls leave users stuck; add an error state (e.g., fetchError /
setFetchError) and set it in the questions fetch catch block inside this page
component (the existing fetchQuestions or useEffect that populates questions),
then update the render logic that uses questions.length === 0 to first check
fetchError and render an error message with a retry control that calls the same
fetchQuestions function (and clear fetchError before retry), ensuring successful
fetch clears fetchError and updates questions.

17-17: Unused user variable.

user is declared but never used in this component. This creates an unnecessary context subscription. Remove if not needed, or implement the intended functionality.

-    const user = useContext(UserContext);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/quiz/battle/`[roomId].tsx at line 17, The declared but unused
variable `user` from `useContext(UserContext)` should be removed to avoid an
unnecessary context subscription; locate the `const user =
useContext(UserContext);` line and delete it, or if the component actually needs
the current user, replace its usage by referencing `user` where required (e.g.,
in rendering or logic) so the variable is used; ensure all imports and
references remain consistent after removing or using `user`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/quiz/StartScreen.tsx`:
- Around line 57-79: After emitting 'join_queue' in handleFindMatch, ensure we
clean up abandoned matchmaking by emitting 'leave_queue' and resetting
isMatchmaking: update the useEffect that listens to socket to (1) on component
unmount return handler also emit 'leave_queue' with user.uid and
setIsMatchmaking(false), (2) add a cancel method (e.g., a new handleCancelMatch
function) that emits 'leave_queue' and sets isMatchmaking(false), and (3) listen
for socket disconnect/timeout events in the same useEffect to
setIsMatchmaking(false) and emit 'leave_queue' when appropriate; reference
socket, handleFindMatch, setIsMatchmaking, user.uid, 'join_queue' and
'leave_queue' to locate and modify the code.

In `@src/context/SocketContext.tsx`:
- Around line 13-35: The async initSocket inside the useEffect may create an
orphan socket after the effect is torn down; modify initSocket to use an
AbortController (or a local cancelled boolean) and check it after the await
fetch('/api/socket') before constructing io(), attaching listeners, or calling
setSocket/setIsConnected; also call controller.abort() (or set cancelled = true)
in the effect cleanup and ensure you disconnect any created socketInstance there
so no listeners remain after unmount.

In `@src/pages/api/socket.ts`:
- Around line 50-52: The socket handler for the 'submit_answer' event is
trusting the client's isCorrect flag; instead, accept the submitted
questionId/index and selectedOption from the client, look up the room's
server-side question set (room state or Rooms map) inside the 'submit_answer'
handler, validate the selected option against the canonical answer, compute the
score delta, enforce one submission per player per question (track submissions
in the room state or a per-player answeredQuestions set), and only then emit
'opponent_update' with the server-computed isCorrect/scoreDelta; also handle and
emit errors for invalid question ids or duplicate submissions.
- Around line 28-43: The current matchmaking flow removes an opponent from the
queue with redis.spop but if getBankedQuestions or redis.set fails the popped
opponent is lost; update the logic so after a successful spop(opponent) you wrap
the room setup (nanoid, getBankedQuestions, redis.set, emits) in its own
try/catch and if that inner block throws you re-add the popped opponent back
into the queue with redis.sadd(QUEUE_KEY, opponent) and emit an error event
(e.g., 'match_error') to both socket.id and opponent so clients aren’t left
hanging; reference redis.spop, redis.sadd, getBankedQuestions, redis.set,
ROOM_PREFIX, nanoid, socket.id and opponent when making the change.
- Around line 57-66: The server currently allows unlimited joins and any client
to trigger start_private_battle; fix by maintaining server-side per-room
metadata (host id and player count/state) in Redis (or in-memory) alongside
ROOM_PREFIX data: when handling join_private_room, if no metadata exists set
host = socket.id and count = 1 and save with TTL, otherwise fetch metadata and
reject the join (emit an error event and do not call socket.join) if count >= 2,
and if accepted increment count and save (emit player_joined to room); when
handling start_private_battle, fetch the room metadata and only proceed if
metadata.host === socket.id and metadata.count === 2 (otherwise emit a
permission or not_ready error), then atomically set the question bank into
`${ROOM_PREFIX}${roomId}` and emit battle_starting — ensure all updates use the
same room key naming and respect TTL and emit clear error events for rejected
joins/unauthorized starts.

In `@src/pages/quiz/battle/`[roomId].tsx:
- Around line 28-57: The effect in useEffect that joins the room and fetches
questions uses roomId, socket and checks isConnected but the dependency array
only lists [roomId, socket], and the fetch has no AbortController, risking stale
state updates; update the dependency array to include isConnected, create an
AbortController before fetch and pass its signal to fetch, check signal/abort in
the promise chain (or ignore setQuestions when aborted), and in the effect
cleanup call controller.abort() in addition to socket.off('opponent_update') to
ensure the fetch is cancelled and listeners removed; keep the existing
socket.on('opponent_update', ...) handler and state setters (setQuestions,
setOpponentScore, setOpponentProgress) untouched but guard them from running
after abort/unmount.
- Around line 72-79: The setTimeout started after answering (the block that
calls setIndex, setSelectedAnswer, setIsFinished) can run after the component
unmounts; store its ID (e.g., in a ref like timeoutRef) when you call setTimeout
and clearTimeout(timeoutRef.current) in a useEffect cleanup to avoid state
updates on an unmounted component. Update the handler that creates the timeout
(the function that currently calls setTimeout and updates
setIndex/setSelectedAnswer/setIsFinished) to save the ID into the ref and ensure
you clear it on unmount (and before creating a new timeout) so no stale timer
fires.

In `@src/services/quizServer.ts`:
- Around line 1-2: This server module currently imports the client web SDK (db,
collection, query, where, getDocs, writeBatch, doc) and performs reads/writes
against "quiz_bank"; change it to use the Admin SDK instance instead: import
adminDb (from src/lib/firebase_admin.ts) and replace web-SDK calls with Admin
SDK equivalents — use adminDb.collection('quiz_bank').where(...).get() for reads
(instead of query/getDocs) and use const batch = adminDb.batch(),
adminDb.doc(...) (or adminDb.collection('quiz_bank').doc(...)) and
batch.commit() for writes (instead of writeBatch/doc from the web client).
Update all usages in functions that read/write quiz_bank to reference adminDb
and its .collection/.doc/.where/.get/.batch API.

In `@src/styles/lobby.module.css`:
- Around line 194-205: The keyframe name "fadeIn" violates the project's
keyframes-name-pattern; rename the keyframe (e.g., "countdownFadeIn" or
"lobbyFadeIn") and update the reference in the .countdown-container's animation
property so the `@keyframes` block and the animation: fadeIn 0.5s ease; line use
the new name (ensure both the `@keyframes` identifier and the animation usage in
.countdown-container are changed).

---

Outside diff comments:
In `@src/pages/quiz/lobby/`[roomId].tsx:
- Around line 32-37: The current lobby effect infers host and player list
locally (using players.length === 0, setIsHost, setPlayers) which causes both
clients to mark themselves host and prevents proper countdown sync; instead stop
guessing and request/subscribe to authoritative room state from the server
(including hostId, playerCount/players[], startAt) when joining and on server
events (e.g. "room_state" or "player_joined"/"player_left"), update local state
from that payload and derive waiting/countdown from room.hostId ===
currentUser.uid and whether startAt is set (or playerCount meets threshold), and
remove the local-only host/players initialization logic so setIsHost and
setPlayers are driven by the server state rather than players.length checks.

---

Nitpick comments:
In `@src/pages/api/quiz/battle/create.ts`:
- Line 10: Change the response status for successful room creation from 200 to
201: locate the handler that returns res.status(200).json({ roomId }) and update
it to use res.status(201).json({ roomId }) so the create operation returns the
proper HTTP 201 Created semantics (leave the JSON body unchanged).
- Line 6: The 405 response branch in the API handler that checks req.method !==
'POST' should include an Allow header so clients know allowed methods; modify
the branch in the create handler to set the response header (e.g.,
res.setHeader('Allow', 'POST') or similar) before returning
res.status(405).json(...) so the endpoint explicitly advertises allowed methods
for the POST-only route.
- Around line 11-12: The catch block in the create room route currently returns
a generic 500 but doesn't record the error; update the catch path in
src/pages/api/quiz/battle/create.ts to emit minimal server-side telemetry by
logging the caught error and contextual info before returning
res.status(500).json(...). Specifically, call your telemetry/log utility (or
console.error) with the caught error variable and a short message referencing
this handler (e.g., "quiz/battle create handler failed") and include any
relevant request identifiers or payload if available, then keep the existing
res.status(500).json({ error: 'Failed to create room' }) response.

In `@src/pages/quiz/battle/`[roomId].tsx:
- Around line 101-103: The component currently only checks questions.length ===
0 and shows "Loading battle..." so failed API calls leave users stuck; add an
error state (e.g., fetchError / setFetchError) and set it in the questions fetch
catch block inside this page component (the existing fetchQuestions or useEffect
that populates questions), then update the render logic that uses
questions.length === 0 to first check fetchError and render an error message
with a retry control that calls the same fetchQuestions function (and clear
fetchError before retry), ensuring successful fetch clears fetchError and
updates questions.
- Line 17: The declared but unused variable `user` from
`useContext(UserContext)` should be removed to avoid an unnecessary context
subscription; locate the `const user = useContext(UserContext);` line and delete
it, or if the component actually needs the current user, replace its usage by
referencing `user` where required (e.g., in rendering or logic) so the variable
is used; ensure all imports and references remain consistent after removing or
using `user`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a99d1d24-6bc0-427b-bacf-8c79bfe7e498

📥 Commits

Reviewing files that changed from the base of the PR and between 3b18ef0 and 84b73c5.

📒 Files selected for processing (12)
  • src/components/quiz/StartScreen.tsx
  • src/context/SocketContext.tsx
  • src/pages/api/quiz/battle/create.ts
  • src/pages/api/quiz/battle/getQuestions.ts
  • src/pages/api/quiz/questions/[roomId].ts
  • src/pages/api/socket.ts
  • src/pages/quiz/battle/[battleId].tsx
  • src/pages/quiz/battle/[roomId].tsx
  • src/pages/quiz/lobby/[roomId].tsx
  • src/services/quizServer.ts
  • src/styles/lobby.module.css
  • tsconfig.json
💤 Files with no reviewable changes (2)
  • src/pages/api/quiz/battle/getQuestions.ts
  • src/pages/quiz/battle/[battleId].tsx

Comment on lines +57 to 79
useEffect(() => {
if (!socket) return;

const handleMatchFound = (roomId: string) => {
router.push(`/quiz/battle/${roomId}`);
};

socket.on('match_found', handleMatchFound);

return () => {
socket.off('match_found', handleMatchFound);
};
}, [socket, router]);

const handleFindMatch = () => {
if (!socket || !isConnected || !user) {
console.error('Socket not connected or user not logged in.');
return;
}

setIsMatchmaking(true);
socket.emit('join_random_queue', user.uid);

socket.on('match_found', (roomId: string) => {
router.push(`/quiz/battle/${roomId}`);
});
socket.emit('join_queue', user.uid);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a dequeue path for abandoned matchmaking attempts.

Once join_queue is emitted, this component never removes the socket from matchmaking unless the socket fully disconnects. Because the socket is held in app-level context, navigating away from this screen can leave a live queue entry behind; a later match_found can then redirect the wrong user or strand the opponent in an empty room. Emit a leave_queue on unmount/cancel and reset isMatchmaking on disconnect/timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/quiz/StartScreen.tsx` around lines 57 - 79, After emitting
'join_queue' in handleFindMatch, ensure we clean up abandoned matchmaking by
emitting 'leave_queue' and resetting isMatchmaking: update the useEffect that
listens to socket to (1) on component unmount return handler also emit
'leave_queue' with user.uid and setIsMatchmaking(false), (2) add a cancel method
(e.g., a new handleCancelMatch function) that emits 'leave_queue' and sets
isMatchmaking(false), and (3) listen for socket disconnect/timeout events in the
same useEffect to setIsMatchmaking(false) and emit 'leave_queue' when
appropriate; reference socket, handleFindMatch, setIsMatchmaking, user.uid,
'join_queue' and 'leave_queue' to locate and modify the code.

Comment on lines +13 to +35
useEffect(() => {
let socketInstance: Socket | null = null;

// Initialize socket server by calling the API route once
const initSocket = async () => {
try {
await fetch('/api/socket');
socketInstance = io({ path: '/api/socket', addTrailingSlash: false });

socketInstance.on('connect', () => setIsConnected(true));
socketInstance.on('disconnect', () => setIsConnected(false));

setSocket(socketInstance);
} catch (err) {
console.error("Socket initialization failed:", err);
}
};

initSocket();

return () => {
if (socketInstance) socketInstance.disconnect();
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "SocketContext" --extension tsx

Repository: OpenLake/canonforces

Length of output: 93


🏁 Script executed:

cat -n src/context/SocketContext.tsx

Repository: OpenLake/canonforces

Length of output: 1843


Guard async socket init against effect teardown.

The fetch('/api/socket') call resolves after cleanup can run. If the component unmounts during the await, cleanup sees socketInstance as null and cannot disconnect. The async continuation will then create an orphan socket with active listeners that never get cleaned up. Add a cancellation guard using an AbortController and cancelled flag to prevent socket creation and state updates after cleanup.

Suggested fix
     useEffect(() => {
+        let cancelled = false;
         let socketInstance: Socket | null = null;
+        const controller = new AbortController();
 
         // Initialize socket server by calling the API route once
         const initSocket = async () => {
             try {
-                await fetch('/api/socket');
+                const response = await fetch('/api/socket', { signal: controller.signal });
+                if (!response.ok || cancelled) return;
                 socketInstance = io({ path: '/api/socket', addTrailingSlash: false });
+                if (cancelled) {
+                    socketInstance.disconnect();
+                    return;
+                }
                 
                 socketInstance.on('connect', () => setIsConnected(true));
                 socketInstance.on('disconnect', () => setIsConnected(false));
                 
                 setSocket(socketInstance);
             } catch (err) {
-                console.error("Socket initialization failed:", err);
+                if (!cancelled) {
+                    console.error("Socket initialization failed:", err);
+                }
             }
         };
 
         initSocket();
 
         return () => {
+            cancelled = true;
+            controller.abort();
             if (socketInstance) socketInstance.disconnect();
         };
     }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/SocketContext.tsx` around lines 13 - 35, The async initSocket
inside the useEffect may create an orphan socket after the effect is torn down;
modify initSocket to use an AbortController (or a local cancelled boolean) and
check it after the await fetch('/api/socket') before constructing io(),
attaching listeners, or calling setSocket/setIsConnected; also call
controller.abort() (or set cancelled = true) in the effect cleanup and ensure
you disconnect any created socketInstance there so no listeners remain after
unmount.

Comment on lines +28 to +43
try {
const opponent = await redis.spop<string>(QUEUE_KEY);
if (opponent && opponent !== socket.id) {
console.log("Match found between", socket.id, "and", opponent);
const roomId = nanoid(10);
const questions = await getBankedQuestions('DSA', 'medium', 5);
await redis.set(`${ROOM_PREFIX}${roomId}`, JSON.stringify(questions), { ex: 3600 });
io.to(socket.id).emit('match_found', roomId);
io.to(opponent).emit('match_found', roomId);
} else {
console.log("No opponent found, adding to queue:", socket.id);
await redis.sadd(QUEUE_KEY, socket.id);
}
} catch (error) {
console.error("Matchmaking error:", error);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Rollback the queue pop when room setup fails.

After spop succeeds, any failure in getBankedQuestions or redis.set just logs and returns. The popped opponent is already gone from the queue, the caller is not requeued, and neither client gets an error event, so both users can hang in matchmaking indefinitely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/socket.ts` around lines 28 - 43, The current matchmaking flow
removes an opponent from the queue with redis.spop but if getBankedQuestions or
redis.set fails the popped opponent is lost; update the logic so after a
successful spop(opponent) you wrap the room setup (nanoid, getBankedQuestions,
redis.set, emits) in its own try/catch and if that inner block throws you re-add
the popped opponent back into the queue with redis.sadd(QUEUE_KEY, opponent) and
emit an error event (e.g., 'match_error') to both socket.id and opponent so
clients aren’t left hanging; reference redis.spop, redis.sadd,
getBankedQuestions, redis.set, ROOM_PREFIX, nanoid, socket.id and opponent when
making the change.

Comment on lines +50 to +52
socket.on('submit_answer', (roomId: string, isCorrect: boolean) => {
socket.to(roomId).emit('opponent_update', { isCorrect });
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't trust isCorrect from the client.

Any client can emit submit_answer(roomId, true) repeatedly and manufacture score updates because the server never validates the answer or enforces one submission per question. The battle result needs to be server-authoritative: accept the selected option/question index, validate against the room's question set, then broadcast the score delta.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/socket.ts` around lines 50 - 52, The socket handler for the
'submit_answer' event is trusting the client's isCorrect flag; instead, accept
the submitted questionId/index and selectedOption from the client, look up the
room's server-side question set (room state or Rooms map) inside the
'submit_answer' handler, validate the selected option against the canonical
answer, compute the score delta, enforce one submission per player per question
(track submissions in the room state or a per-player answeredQuestions set), and
only then emit 'opponent_update' with the server-computed isCorrect/scoreDelta;
also handle and emit errors for invalid question ids or duplicate submissions.

Comment on lines +57 to +66
socket.on('join_private_room',async (roomId:string)=>{
socket.join(roomId);
socket.to(roomId).emit('player_joined',socket.id);
console.log("player joined private room",roomId);
});
socket.on('start_private_battle',async (roomId:string)=>{
const questions=await getBankedQuestions('DSA','medium',5);
await redis.set(`${ROOM_PREFIX}${roomId}`,JSON.stringify(questions),{ex:3600});
io.to(roomId).emit('battle_starting',roomId);
console.log(`Battle Starting in room: ${roomId}`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce the 1v1 room contract on the server.

join_private_room accepts unlimited sockets, and start_private_battle can be triggered by any connection that knows the roomId. That lets a guest or third party overfill a room or force-start a battle early. Track host/player count server-side, reject joins after two players, and only allow the host to start once the room is full.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/socket.ts` around lines 57 - 66, The server currently allows
unlimited joins and any client to trigger start_private_battle; fix by
maintaining server-side per-room metadata (host id and player count/state) in
Redis (or in-memory) alongside ROOM_PREFIX data: when handling
join_private_room, if no metadata exists set host = socket.id and count = 1 and
save with TTL, otherwise fetch metadata and reject the join (emit an error event
and do not call socket.join) if count >= 2, and if accepted increment count and
save (emit player_joined to room); when handling start_private_battle, fetch the
room metadata and only proceed if metadata.host === socket.id and metadata.count
=== 2 (otherwise emit a permission or not_ready error), then atomically set the
question bank into `${ROOM_PREFIX}${roomId}` and emit battle_starting — ensure
all updates use the same room key naming and respect TTL and emit clear error
events for rejected joins/unauthorized starts.

Comment on lines +28 to +57
useEffect(() => {
if (!roomId || !socket || !isConnected) return;

console.log("Joining battle room:", roomId);
// Join the socket room so you receive opponent updates
socket.emit('join_battle', roomId as string);

// Fetch the questions from the server
fetch(`/api/quiz/questions/${roomId}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
})
.then(data => {
console.log("Fetched questions:", data);
if (data.questions) setQuestions(data.questions);
else console.error("No questions in response data:", data);
})
.catch(err => console.error('Failed to fetch battle questions:', err));

// Listen for opponent answering a question
socket.on('opponent_update', ({ isCorrect }: { isCorrect: boolean }) => {
if (isCorrect) setOpponentScore(prev => prev + 1);
setOpponentProgress(prev => prev + 1);
});

return () => {
socket.off('opponent_update');
};
}, [roomId, socket]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add missing isConnected dependency and abort controller for fetch cleanup.

  1. The linter correctly identifies that isConnected is checked on line 29 but missing from the dependency array. If connection state changes, this effect won't re-run.

  2. The fetch lacks an abort controller, which can cause state updates on unmounted components.

🔧 Proposed fix
     useEffect(() => {
         if (!roomId || !socket || !isConnected) return;
 
+        const abortController = new AbortController();
+
         console.log("Joining battle room:", roomId);
         socket.emit('join_battle', roomId as string);
 
-        fetch(`/api/quiz/questions/${roomId}`)
+        fetch(`/api/quiz/questions/${roomId}`, { signal: abortController.signal })
             .then(res => {
                 if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
                 return res.json();
             })
             .then(data => {
                 console.log("Fetched questions:", data);
                 if (data.questions) setQuestions(data.questions);
                 else console.error("No questions in response data:", data);
             })
-            .catch(err => console.error('Failed to fetch battle questions:', err));
+            .catch(err => {
+                if (err.name !== 'AbortError') {
+                    console.error('Failed to fetch battle questions:', err);
+                }
+            });
 
         socket.on('opponent_update', ({ isCorrect }: { isCorrect: boolean }) => {
             if (isCorrect) setOpponentScore(prev => prev + 1);
             setOpponentProgress(prev => prev + 1);
         });
 
         return () => {
+            abortController.abort();
             socket.off('opponent_update');
         };
-    }, [roomId, socket]);
+    }, [roomId, socket, isConnected]);
🧰 Tools
🪛 GitHub Check: lint

[warning] 57-57:
React Hook useEffect has a missing dependency: 'isConnected'. Either include it or remove the dependency array

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/quiz/battle/`[roomId].tsx around lines 28 - 57, The effect in
useEffect that joins the room and fetches questions uses roomId, socket and
checks isConnected but the dependency array only lists [roomId, socket], and the
fetch has no AbortController, risking stale state updates; update the dependency
array to include isConnected, create an AbortController before fetch and pass
its signal to fetch, check signal/abort in the promise chain (or ignore
setQuestions when aborted), and in the effect cleanup call controller.abort() in
addition to socket.off('opponent_update') to ensure the fetch is cancelled and
listeners removed; keep the existing socket.on('opponent_update', ...) handler
and state setters (setQuestions, setOpponentScore, setOpponentProgress)
untouched but guard them from running after abort/unmount.

Comment on lines +72 to +79
setTimeout(() => {
if (index + 1 < questions.length) {
setIndex(prev => prev + 1);
setSelectedAnswer(null);
} else {
setIsFinished(true);
}
}, 1000);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing cleanup for setTimeout can cause state updates on unmounted component.

If the user navigates away during the 1-second delay, setIndex, setSelectedAnswer, or setIsFinished will attempt to update state on an unmounted component. Store the timeout ID and clear it on unmount.

🔧 Proposed fix using a ref

Add a ref to track the timeout:

+    const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+    useEffect(() => {
+        return () => {
+            if (timeoutRef.current) clearTimeout(timeoutRef.current);
+        };
+    }, []);

Then in handleAnswer:

-        setTimeout(() => {
+        timeoutRef.current = setTimeout(() => {
             if (index + 1 < questions.length) {
                 setIndex(prev => prev + 1);
                 setSelectedAnswer(null);
             } else {
                 setIsFinished(true);
             }
         }, 1000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/quiz/battle/`[roomId].tsx around lines 72 - 79, The setTimeout
started after answering (the block that calls setIndex, setSelectedAnswer,
setIsFinished) can run after the component unmounts; store its ID (e.g., in a
ref like timeoutRef) when you call setTimeout and
clearTimeout(timeoutRef.current) in a useEffect cleanup to avoid state updates
on an unmounted component. Update the handler that creates the timeout (the
function that currently calls setTimeout and updates
setIndex/setSelectedAnswer/setIsFinished) to save the ID into the ref and ensure
you clear it on unmount (and before creating a new timeout) so no stale timer
fires.

Comment on lines +1 to +2
import { db } from "../lib/firebase";
import { collection, query, where, getDocs, writeBatch, doc } from "firebase/firestore";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether the shared Firebase helper is a web-SDK client and whether an admin helper still exists.
fd 'firebase\.(ts|tsx|js|jsx)$' src --exec sed -n '1,200p' {}
rg -n --type=ts --type=tsx 'firebase/firestore|firebase-admin|adminDb' src

Repository: OpenLake/canonforces

Length of output: 6225


🏁 Script executed:

# Read quizServer.ts to see the implementation
cat -n src/services/quizServer.ts

Repository: OpenLake/canonforces

Length of output: 2634


🏁 Script executed:

# Search for firebase-admin imports anywhere in the codebase
rg 'firebase-admin|initializeApp.*admin|getDatabase.*admin' --type=ts --type=js

Repository: OpenLake/canonforces

Length of output: 260


🏁 Script executed:

# Check where quizServer.ts is imported from
rg 'from.*quizServer|import.*quizServer' --type=ts --type=tsx --type=js

Repository: OpenLake/canonforces

Length of output: 91


🏁 Script executed:

# Check if there's an admin SDK helper file that might exist
fd -e ts -e js | grep -i admin | head -20

Repository: OpenLake/canonforces

Length of output: 89


🏁 Script executed:

# Read the firebase_admin.ts file
cat -n src/lib/firebase_admin.ts

Repository: OpenLake/canonforces

Length of output: 855


🏁 Script executed:

# Check src/pages/api/quiz/save.ts to see how it uses the admin SDK
cat -n src/pages/api/quiz/save.ts | head -50

Repository: OpenLake/canonforces

Length of output: 2159


Use the Admin SDK for quiz bank operations in this server-side module.

Lines 16–17 write to quiz_bank using the web-SDK client, and lines 37–45 read from it the same way. Since this module is server-only, import and use adminDb from src/lib/firebase_admin.ts instead—it's already configured and in use by src/pages/api/quiz/save.ts for the same operations. Using the web-SDK client here requires quiz_bank to be writable by unauthenticated users or fails in production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/quizServer.ts` around lines 1 - 2, This server module currently
imports the client web SDK (db, collection, query, where, getDocs, writeBatch,
doc) and performs reads/writes against "quiz_bank"; change it to use the Admin
SDK instance instead: import adminDb (from src/lib/firebase_admin.ts) and
replace web-SDK calls with Admin SDK equivalents — use
adminDb.collection('quiz_bank').where(...).get() for reads (instead of
query/getDocs) and use const batch = adminDb.batch(), adminDb.doc(...) (or
adminDb.collection('quiz_bank').doc(...)) and batch.commit() for writes (instead
of writeBatch/doc from the web client). Update all usages in functions that
read/write quiz_bank to reference adminDb and its
.collection/.doc/.where/.get/.batch API.

Comment on lines +194 to +205
.countdown-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
animation: fadeIn 0.5s ease;
}

@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Rename this keyframe to satisfy the lint rule.

fadeIn violates the configured keyframes-name-pattern, so this stylesheet will keep failing stylelint.

Suggested fix
 .countdown-container {
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: 1rem;
-  animation: fadeIn 0.5s ease;
+  animation: fade-in 0.5s ease;
 }
 
-@keyframes fadeIn {
+@keyframes fade-in {
   from { opacity: 0; transform: translateY(10px); }
   to { opacity: 1; transform: translateY(0); }
 }
🧰 Tools
🪛 Stylelint (17.5.0)

[error] 202-202: Expected keyframe name "fadeIn" to be kebab-case (keyframes-name-pattern)

(keyframes-name-pattern)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/styles/lobby.module.css` around lines 194 - 205, The keyframe name
"fadeIn" violates the project's keyframes-name-pattern; rename the keyframe
(e.g., "countdownFadeIn" or "lobbyFadeIn") and update the reference in the
.countdown-container's animation property so the `@keyframes` block and the
animation: fadeIn 0.5s ease; line use the new name (ensure both the `@keyframes`
identifier and the animation usage in .countdown-container are changed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant