┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ Next.js 16 (React 19) — Vercel │
│ │
│ app/page.tsx ← 891-line SPA shell │
│ components/ ← BattleRoom, FriendsPanel, ... │
│ lib/socket.ts ← Socket.io singleton │
│ lib/supabase/ ← Client + Server helpers │
│ middleware.ts ← Auth guard (every route) │
└──────────┬──────────────────────┬───────────────────────────┘
│ Socket.io (WS/poll) │ Supabase JS (REST + auth)
▼ ▼
┌──────────────────┐ ┌─────────────────────────────────────┐
│ Battle Server │ │ Supabase (Postgres + Auth) │
│ Node.js 22 │◄───│ │
│ Express 5 │ │ profiles elo_ratings │
│ Socket.io 4 │ │ battles source_cards │
│ Railway │ │ question_variants friendships │
│ │────► messages challenges │
│ index.js │ │ question_reports leaderboard(view)│
│ elo.js │ │ user_card_stats │
│ questions.js │ │ │
│ streak.js │ │ Auth: Supabase Auth │
│ supabase.js │ │ (email/password + Google OAuth) │
│ stats.js │ │ │
└──────────────────┘ └─────────────────────────────────────┘
Two separate services. The battle server is a persistent Node.js process on Railway. The web app is statically rendered + SSR on Vercel. They never call each other over HTTP — all real-time communication goes through Socket.io, and both read/write Supabase directly with different keys.
Supabase Auth manages sessions via cookies. @supabase/ssr handles cookie refresh in middleware.ts on every request.
web/middleware.ts runs on every non-static route. If no valid session: redirect to /login. Auth routes (/login, /signup, /auth/*) are excluded.
| Client | File | Key | Used for |
|---|---|---|---|
| Browser client | lib/supabase/client.ts |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Auth, profile reads, RLS-gated writes |
| Server client | lib/supabase/server.ts |
NEXT_PUBLIC_SUPABASE_ANON_KEY + session |
SSR data fetching (profile, leaderboard) |
| Battle server | server/*.js |
SUPABASE_SERVICE_ROLE_KEY |
ELO writes, battle records, streak updates — bypasses RLS |
/login → signInWithOAuth → Supabase → Google → /auth/callback
/auth/callback → exchangeCodeForSession → upsert profile row → redirect /
idle → queuing → countdown (3s) → battle → finished → complete
- Client emits
join_queuewith{ userId, displayName, elo, subject }. - Server validates all fields and appends to
queue[]. tryMatch()runs immediately and on a 5-second interval.- Match criteria: same subject, ELO within ±200 (±400 after 30s), timeout and emit
queue_timeoutafter 60s. createBattle(p1, p2)assigns a random roomId, joins both sockets to the room, emitsmatch_foundto each.
createBattle()
└─ setTimeout(startBattle, 3000) ← 3-second countdown in UI
└─ pickQuestions(subject, 1) ← currently 1 question for testing
└─ sendNextQuestion() for each player ← emits 'question' event
client: submit_answer → server: handleAnswer()
├─ validates: not done, not already answered, answerIndex (NOT CURRENTLY VALIDATED 0-3)
├─ scores the answer
├─ emits 'question_result' to answerer
├─ emits 'opponent_progress' to other player
└─ setTimeout(tryAdvanceQuestion, 1500) ← reveal window
tryAdvanceQuestion():
├─ if both ready → advance all players to next question
└─ if one waiting → emit 'waiting_for_opponent' to faster player
finishPlayer() → if both done → endBattle()
├─ determines winner: score comparison → time comparison → sids[0] fallback
├─ updateElo(state, winner) ← writes elo_ratings + battles
├─ updateStreak(userId) for each player ← writes profiles
└─ emit 'battle_complete' to room
- Player disconnects →
handleDisconnect()→endBattle(roomId, socketId)immediately. - No reconnect grace period (backlog #17 was marked done but is not implemented in code).
- Remaining player receives
opponent_disconnectedthenbattle_complete.
All presence state is in-memory on the server:
userSockets Map<userId, Set<socketId>> // all active sockets per user
socketToUser Map<socketId, userId> // reverse lookup
userActivity Map<userId, {subject, phase}> // current activity
userProfiles Map<userId, {displayName, elo}> // cache for challenge display
directChallenges Map<challengeId, {...}> // pending friend challenges with timers
On register_presence: server loads accepted friends from DB, emits presence_init with online friend IDs, notifies those friends of the new connection.
On disconnect: cleans all four maps, cancels pending direct challenges, emits friend_offline to friends.
Messaging is persisted to Supabase (messages table) and relayed in real-time via Socket.io. Conversations are pruned to last 50 messages after each send.
pickQuestions(subject, n)
└─ SELECT source_cards WHERE subject = ? AND reviewed = true
└─ shuffle IDs in JS, sample n*4
└─ SELECT question_variants WHERE source_card_id IN (sample)
└─ shuffle variants, return n
The two-step query avoids a single ORDER BY random() on a large join. The card shuffle happens in JS; the variant shuffle is also in JS.
When SUPABASE_URL is not set, loads all content/apchem/unit*.json files. Does not respect the subject parameter — always returns AP Chemistry questions regardless of what subject is requested.
{ id: string, stem: string, options: string[], correct_index: number, source_card_id: string, unit: string | null }id is a real UUID from question_variants when using DB, or a fake string in JSON fallback mode. source_card_id and unit are used server-side for stat recording; they are also sent to the client but not displayed.
// PracticeQuestion — built by web/app/practice/page.tsx
{ id: string, sourceCardId: string, stem: string, options: string[], correctIndex: number, correctExplanation: string | null, unit: string }Practice questions are fetched directly by the client from Supabase (browser → Supabase), weighted by per-user accuracy from user_card_stats. Stat recording calls upsert_card_stat RPC from the client.
| Table | Key Columns | Notes |
|---|---|---|
profiles |
id, display_name, current_streak, longest_streak, last_battle_date, battles_today, is_premium, premium_expires_at |
Columns added across migrations 001, 004, 006, 007 |
elo_ratings |
user_id, subject, rating |
UNIQUE(user_id, subject); starts at 1000 |
battles |
player1_id, player2_id, winner_id, subject, scores (JSONB) |
scores keyed by userId |
source_cards |
subject, unit, type, reviewed, content (JSONB), content_hash |
content_hash UNIQUE prevents duplicate cards |
question_variants |
source_card_id, rendered_stem, rendered_options, correct_index |
FK cascade from source_cards |
challenges |
challenger_id, opponent_id, questions_json, status |
async challenge flow |
question_reports |
question_variant_id, reporter_id, reason |
user-flagged bad questions |
user_card_stats |
user_id, source_card_id, subject, unit, correct_count, total_count, last_seen_at |
per-user per-card accuracy; UNIQUE(user_id, source_card_id); upsert_card_stat RPC writes atomically |
These exist in production code but have no migration file in supabase/migrations/:
| Table / Column | Used by |
|---|---|
friendships (requester_id, addressee_id, status) |
FriendsPanel, leaderboard friends tab, server presence |
messages (sender_id, receiver_id, content, read_at) |
ChatBox, server messaging |
profiles.invite_code |
FriendsPanel invite tab, /api/invite/[code] route |
If the database ever needs to be recreated from migrations, these must be added manually.
leaderboard — created in migration 005. Joins elo_ratings with profiles, returns rank, display_name, rating, user_id ordered by rating DESC partitioned by subject.
| Table | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
profiles |
public | own (id = auth.uid()) | own | — |
elo_ratings |
public | service role only | service role only | — |
battles |
public | service role only | — | — |
source_cards |
reviewed=true only | service role only | — | — |
question_variants |
joined reviewed cards | service role only | — | — |
question_reports |
own only | own (reporter_id = auth.uid()) | — | — |
challenges |
public | service role (via HTTP endpoint) | service role | — |
friendships |
? (not in migration file) | ? | ? | ? |
messages |
? (not in migration file) | ? | ? | ? |
app/page.tsx (lobby — battle phases only)
├─ NavBar (with onPracticeClick queue-leave guard)
├─ FriendsPanel
│ ├─ ChatBox
│ └─ FriendContextMenu
└─ BattleRoom
└─ RankBadge
app/practice/page.tsx (full practice drill — new route)
├─ NavBar
└─ PracticeMode
└─ AnswerButton
app/profile/page.tsx (SSR)
├─ NavBar
├─ Panel
├─ RankBadge
└─ AddFriendButton
app/leaderboard/page.tsx (SSR)
├─ NavBar
└─ RankBadge
app/challenge/[id]/page.tsx (CSR — fetches from battle server REST)
app/challenge/create/page.tsx
app/login/page.tsx
app/signup/page.tsx
app/auth/callback/route.ts
All game state (lobby → queuing → countdown → battle → result) and socket listeners live in a single Home component with ~25 useState hooks. Practice phases were extracted to /practice. There is no global state manager (no Zustand, no Context). TD-MA1 remains open for further decomposition.
/practice is a standalone 'use client' page with three phases: select → drill → summary. Phase data flows downward only — PracticeMode receives pre-loaded PracticeQuestion[] and calls onStop(results) when done. The parent page owns all Supabase queries and weighted sampling logic (lib/practice.ts). In-queue practice (lightweight) lives in page.tsx's queuing phase and does not record stats.
nixpacks.tomlinstallsserver/dependencies and runsnode server/index.js- Single process, single instance — in-memory state (battles, queue, presence) is not shared across instances
- Required env vars:
SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,PORT(set by Railway)
- Standard Next.js deployment —
web/directory - Required env vars:
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,NEXT_PUBLIC_SOCKET_URL middleware.tsruns on edge — auth guard on every request
The battle server cannot scale horizontally. In-memory battles, queue, and presence maps are process-local. Adding a second Railway instance would split socket connections and break matchmaking. A Redis adapter for Socket.io would be required before horizontal scaling is possible.
| Decision | Reason |
|---|---|
| Socket.io over WebSocket directly | Auto-reconnect, fallback to polling, room management built-in |
| Supabase over self-hosted Postgres | Managed auth (OAuth, sessions, JWTs), built-in RLS, no ops burden for MVP |
| Railway over Vercel for server | Vercel is serverless — Socket.io requires a persistent process |
| Server-side question selection | Prevents clients from pre-loading answers; reviewed gate enforced server-side |
| In-memory battle state | Zero latency for game events; acceptable for single-instance MVP |
| Service role key only on server | Client never gets write access to ELO or battle records |
| ELO K-factor = 32 | Standard for online games; produces ~16-point swings for evenly matched players |