A real-time 1v1 competitive exam-prep app for CAT aspirants. Players are matched by Elo rating and race through the same set of timed CAT questions — Quant, DILR, and VARC — with live scores, Elo adjustments, and a full post-match review.
Available as a React Native app (iOS + Android) and a web app from a single codebase.
┌──────────────────────────────────┐ ┌──────────────────────────────────────┐
│ React Native / Expo Web │ │ Node.js + Express + TypeScript │
│ │◄─REST─►│ │
│ iOS · Android · Browser │ │ /matchmaking (Socket.io namespace) │
│ │◄──WS──►│ /game (Socket.io namespace) │
│ .mobile.tsx / .desktop.tsx │ │ │
│ runtime platform switcher │ │ ┌──────────┐ ┌──────────────────┐ │
└──────────────────────────────────┘ │ │ Postgres │ │ Redis │ │
│ │ (Prisma) │ │ matchmaking q │ │
│ │ │ │ game state │ │
│ │ │ │ question pool │ │
│ └──────────┘ │ leaderboard │ │
│ └──────────────────┘ │
└──────────────────────────────────────┘
│
┌───────────┴──────────┐
│ Firebase Auth │
│ Sentry │
└──────────────────────┘
| Layer | Technology | Why |
|---|---|---|
| Mobile / Web | React Native (Expo SDK 54) | Single TypeScript codebase for iOS, Android, and web |
| Backend | Node.js + Express + TypeScript | Same language across the stack; first-class WebSocket support |
| Real-time | Socket.io | Rooms, namespaces, reconnection, and transport fallback built-in |
| Database | PostgreSQL 16 via Prisma ORM | Relational integrity with type-safe queries |
| Cache / Queue | Redis 7 | Matchmaking sorted set, ephemeral game state, question pool, leaderboard cache |
| Auth | Firebase Auth | Email/password + Google; handles token issuance, revocation, and Google OAuth |
| CI | GitHub Actions | Lint + type-check on every push and PR |
/
├── server/ # Node.js + Express backend
│ ├── src/
│ │ ├── config/ # env validation (Zod), Firebase Admin, Redis, CORS
│ │ ├── middleware/ # auth, admin, rate limiting, Zod request validation
│ │ ├── routes/ # health, auth, users, admin, questions, games, leaderboard
│ │ ├── services/ # matchmaking, gameSession, elo, leaderboard, questionPool,
│ │ │ # streak, botPlayer, userCache, analytics
│ │ └── lib/ # logger (pino), Sentry
│ └── prisma/
│ └── schema.prisma
├── mobile/ # React Native (Expo) + web
│ └── src/
│ ├── components/ # Button, Card, Text, Chip, Avatar, TierBadge,
│ │ # Skeleton, ShareLinkModal, MathText, TitaAnswerPad,
│ │ # web/DesktopFrame, web/LeftRail, web/PageContainer
│ ├── context/ # AuthContext, ThemeProvider, AppPreferencesContext
│ ├── hooks/ # useDesktopLayout, useCurrentProfile, useKeyboardShortcuts,
│ │ # useDocumentTitle
│ ├── navigation/ # RootNavigator, linking config, shared types
│ ├── screens/ # *.tsx (platform switcher), *.mobile.tsx, *.desktop.tsx
│ ├── services/ # api, socket, questions, leaderboard, share, analytics
│ └── theme/ # Studyhall design system: tokens, themes, ThemeProvider
└── packages/
└── types/ # Shared TypeScript interfaces (User, ApiResponse)
Every duel match needs 20 questions from the right difficulty window without scanning the full questions table.
Layer 1 — ID pool (qpool:{category}): One sorted set per category; each member is a question ID, score is its difficulty rating. Populated at worker startup and refreshed every 5 minutes. Selection uses ZRANGEBYSCORE to retrieve IDs within the Elo-derived difficulty window and samples randomly from that list.
Storing difficulty as the sorted-set score (rather than separate keys per difficulty level) means the filter degrades gracefully when distribution is skewed, and automatically improves as difficulty ratings converge over time.
Layer 2 — Content cache (qcontent:{id}): Full question content cached on first use, evicted after 24 hours. On a cold start or cache miss, a targeted findMany for just the selected IDs runs — no full table scan.
After warm-up, match creation makes zero findMany calls against the questions table.
The first deployed version set active_game:{userId} the moment match:found was emitted. If one device failed to connect before the countdown, both players were permanently locked — neither could re-queue until the Redis TTL expired.
The fix introduces a two-phase lock:
pending_match:{userId}is set immediately aftermatch:found(short TTL)active_game:{userId}is set only after countdown completes and both players are in the room- A 10-second pre-start timeout cancels the match and re-queues the waiting player if the other never joins
GET /api/games/activereturns only genuinelyACTIVEduels — pre-start reservations are invisible to the reconnect flow
State machine: FOUND → WAITING_FOR_PLAYERS → COUNTDOWN → ACTIVE → FINISHED / CANCELLED
Two classes of race condition required Redis NX locks:
- Double-countdown: Two sockets joining the room concurrently could each trigger
startCountdown. Agame:{gameId}:startingNX lock ensures only the first caller proceeds; the lock is checked again after the countdown delay (the in-memory object could be stale from a competing write). - Double-end: Timer expiry and both players finishing simultaneously could both call
endGame. Agame:{gameId}:endingNX lock serialises this path.
Result replay for network flicker: When a game ends, each player's game:finished payload is written to Redis with a 5-minute TTL before broadcasting. If a player's socket drops at exactly that moment and they reconnect afterward, game:join detects FINISHED status and replays their stored result immediately — they never get stuck on the question screen.
verifyIdToken(token, true) — Firebase's full revocation check — involves a live network call on every request. Under load this added ~120ms per API call.
The hot path uses local JWT verification (cryptographic only) plus an in-process Redis blocklist check (isFirebaseUidBlocked). Revocations written through the app — account deletion, admin token revoke — push the Firebase UID into the revoked_uids Redis set immediately. High-risk boundaries (account deletion re-auth, admin actions) use a separate requireFirebaseRevocationCheck middleware that calls verifyIdToken(token, true).
Known residual risk: If a user is disabled directly in the Firebase Console or by an external service, the app-local blocklist won't know. The 2026-05-12 security audit flagged this and recommended persisting blocked UIDs in Postgres as a durable fallback. That work is tracked in PRE_PHASE7_ISSUES.md.
All screens exist as three files:
HomeScreen.tsx ← 11-line switcher, imported by navigation
HomeScreen.mobile.tsx ← full native implementation
HomeScreen.desktop.tsx ← full web/desktop implementation
useDesktopLayout returns true when Platform.OS === 'web' and viewport width ≥ 1024px and the device has hover/pointer capability. The switcher composes the right variant at runtime; navigation imports are unchanged.
Desktop screens add a persistent left rail, right-rail panels, keyboard shortcuts (1–4 for answer selection, Enter to submit, Escape to quit), document.title sync, and beforeunload warnings during active duels — none of which are visible on native.
Every submitted answer increments timesServed and timesCorrect on the question row. Under match load this creates write contention on hot question rows.
questionServeBuffer.ts batches these increments in an in-memory map and flushes to Postgres every 30 seconds using updateMany. Counts never need to be exact — they feed difficulty calibration, not billing — so a crash-loss of one flush window is acceptable.
Match persistence (persistMatch) runs inside a single Prisma $transaction with a 3-retry loop and exponential backoff. On final failure, the failed payload is pushed to a match_persist_failed Redis list (dead-letter queue) so it can be replayed without losing the result. game:finished is broadcast before the transaction — players see their result immediately regardless of DB latency.
On disconnect during an active duel, the server starts a 15-second forfeit timer. If the player reconnects before it fires, the timer is cancelled and they resume the match. If it fires, endGame runs with the opponent as forced winner — forfeit counts as a full Elo win to discourage ragequitting.
opponent:disconnected and opponent:reconnected events give the still-connected player real-time visibility into the grace window.
Each duel runs from a per-player queue; head is the currently-served question. Skip rotates head to tail. Revisit (jumping to a skipped Q from the navigator) has two modes depending on what's left in the queue:
- Any fresh remaining: displaced current is placed right behind target — after answering, the player returns to the fresh Q they were on.
- All-skipped (cycle mode): queue is rotated so target becomes head; every other item keeps its original ring position. After answering, the player advances to target's natural successor in the cycle.
Cycle: queue = [3, 4, 8, 10, 2], all skipped, current = Q3
Jump to Q8: rotate → [8, 10, 2, 3, 4]
Answer Q8: shift → [10, 2, 3, 4] ← next is Q10, not Q4
Insertion patterns ([target, current, ...rest]) destroy the ring's relative order in a cycle, which is why this mode needs rotation specifically. Separately, skippedIds is tracked as explicit server state — never derived from seenIds − answered − current — because jumping to a skipped Q must not mark the displaced current as skipped.
| Table | Key Columns |
|---|---|
users |
id, firebase_uid, email, display_name, elo_rating (default 1200), peak_elo, rank_tier, games_played, wins, win_rate, current_streak, longest_streak, is_bot, is_guest |
questions |
id, category (QUANT/DILR/VARC), question_type (MCQ/TITA), difficulty (1–5), text, options (JSON), correct_answer, correct_answer_text, explanation, source, passage_id |
passages |
id, text, source, source_pdf, is_verified (for VARC reading comprehension) |
matches |
id (= gameId), player1_id, player2_id, winner_id, is_draw, scores, elo_changes, status (completed/forfeited), finished_at |
match_answers |
match_id, user_id, question_id, selected_answer, typed_answer (TITA), is_correct, time_taken_ms |
practice_answers |
user_id, question_id, selected_answer, typed_answer, is_correct, time_taken_ms |
Indexes worth noting: eloRating DESC on users (matchmaking), [rankTier, eloRating DESC] composite (tier leaderboards), [matchId, userId] on match_answers (per-player review queries).
- Graceful shutdown: The server tracks open sockets (
openSockets) and in-flight requests (busySockets). OnSIGTERM, new connections getConnection: closeheaders, in-flight requests are allowed to complete, and idle sockets are destroyed. - Question pool atomic rebuild: Pool refresh writes to a temp key, renames it over the live key, then deletes the old key — no window where the pool is empty. Removed or unverified question IDs are excluded on rebuild, not just on add.
- Redis adapter for Socket.io:
@socket.io/redis-adapterpub/sub allows horizontal scaling to multiple server processes with shared room membership. - Zod env validation at startup: Missing or malformed
DATABASE_URL,REDIS_URL, Firebase credentials, and production-required settings fail fast with a descriptive error before the process accepts any connections. - Socket payload validation: Socket.io game events run through
socketPayloadSchemas.ts(Zod) to bound field lengths and reject malformed submissions. REST practice answers use the same validation middleware.
Questions are sourced from extracted real CAT papers (QUANT, DILR, VARC) plus manually curated content:
- MCQ: standard multiple-choice with four options
- TITA (Type In The Answer): numeric entry with exact-match validation and a custom numpad UI on mobile
- VARC passages: the
Passagemodel stores reading comprehension text; passages are preloaded intoGameStateat match init (one DB read) and attached to questions before emitting — zero per-question DB calls during a live duel
Math rendering uses a lightweight MathText component that handles $...$ inline spans, fractions, roots, and common CAT extraction artifacts — no LaTeX renderer dependency.
1. User taps "Find Match"
2. ZADD matchmaking_queue <elo_rating> <user_id> (Redis sorted set)
3. Poll every 2s:
a. For each queued user, ZRANGEBYSCORE ±150 Elo
b. Pair the closest Elo match
c. Remove both, create game room
4. If no match after 30s → widen to ±300 Elo
5. If no match after 60s → emit queue:timeout
A bot player fills the queue if no human opponent is found within the expanded window, ensuring users can always play.
E = 1 / (1 + 10^((opponent_elo - player_elo) / 400)) ← expected score
R' = R + K × (actual - E) ← new rating
K = 32 (< 30 games played)
K = 16 (≥ 30 games played)
Floor = 100
Forfeit counts as score 1/0 (full win for opponent).
Rank tiers: Bronze (< 1000) → Silver (1000–1299) → Gold (1300–1599) → Platinum (1600–1899) → Diamond (1900+)
- 119 tests across 16 server test suites covering Elo calculation (symmetry, K-factor switching, Elo floor, all tier boundaries), question pool, socket payload validation, and auth/admin routes
- TypeScript strict mode across all packages —
npx tsc --noEmitis part of CI - ESLint + Prettier enforced in CI; pre-commit hooks block type errors
- 2026-05-12 security audit identified: 18 npm dependency vulnerabilities (1 critical —
protobufjs), Socket.io payload validation gaps (since patched), andverifyIdTokenrevocation tradeoffs (tracked inPRE_PHASE7_ISSUES.md). Dependency remediation is the primary pre-launch gate.
A custom token-based design system with:
- Three font families: Source Serif 4, Geist, JetBrains Mono with 18 typographic presets
- Warm-neutral palette (
mossaccent,coralsignal,inkscale) with full light/dark mode - Reduce Motion support across all animations
- Persisted theme preference (system / light / dark) via
expo-secure-store - Theme-aware scrollbar injection on web (Firefox + WebKit)
| Phase | Status | What was built |
|---|---|---|
| 1 | ✅ | Monorepo scaffold, Docker, Postgres + Redis, Firebase Auth, profile screen |
| 2 | ✅ | Question bank, admin API, AI question generation, solo practice mode |
| 3 | ✅ | Socket.io matchmaking, real-time duel, live score sync, match persistence |
| 4 | ✅ | Elo rating system, leaderboard (Global / Around Me / By Tier), match history |
| 5 | ✅ | Studyhall design system, animations, haptics, onboarding, deep linking, analytics, settings |
| 6 | ✅ | Web adaptation with desktop layouts, keyboard shortcuts, left-rail navigation |
| 7 | 🚧 | Launch: dependency audit remediation, production deploy (AWS + Cloudflare Pages), monitoring |
- Node.js v18+
- Docker Desktop
- Expo Go on your phone (for native development)
- Firebase project with Email/Password auth enabled
git clone <repo-url>
cd cat-duel
npm install # installs all workspaces: server, mobile, packages/typesdocker compose up -d
# PostgreSQL 16 on :5432 (db: catduel, user/pass: catduel)
# Redis 7 on :6379Create server/.env:
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://catduel:catduel@localhost:5432/catduel
REDIS_URL=redis://localhost:6379
REDIS_TLS=false
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CLIENT_EMAIL=your-client-email@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"cd server
npx prisma migrate dev
npx prisma db seed # seeds test users and questionsnpm run dev:server # from repo root
# → http://localhost:3000/api/healthCreate mobile/.env:
EXPO_PUBLIC_FIREBASE_API_KEY=...
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=...
EXPO_PUBLIC_FIREBASE_PROJECT_ID=...
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=...
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...
EXPO_PUBLIC_FIREBASE_APP_ID=...
EXPO_PUBLIC_API_URL=http://192.168.x.x:3000 # your machine's LAN IP
EXPO_PUBLIC_APP_URL=http://192.168.x.x:8081/--cd mobile && npx expo start
# Scan QR with Expo Go for native, or press W for webAll responses: { success: boolean; data?: T; error?: { code: string; message: string } }
Protected routes require Authorization: Bearer <firebase-id-token>.
