Real-time 1v1 AP exam battles. Two students face the same questions at the same time — faster and more accurate wins. Per-subject ELO, streaks, and a validated question bank.
Studiem is deployed and live at studiem.app. Accounts, practice mode, per-subject ELO, the leaderboard, friends, chat, and direct challenges are all working in production.
Matchmaking is the one exception — it is built and functional, but still a prototype. The queue pairs players by ELO bracket and runs a full battle end to end, so you can play a real match through it today. What it is not yet is production-hardened: it runs on a single instance with in-memory state, and pairing behaviour under low queue volume or concurrent joins is still being tuned. If you are evaluating this repo and matchmaking behaves unexpectedly, that is known and actively being worked on — everything around it is stable.
| Area | State |
|---|---|
| Auth (email/password + Google OAuth) | ✅ Live |
| Practice mode & card mastery | ✅ Live |
| Per-subject ELO & leaderboard | ✅ Live |
| Friends, presence, chat, challenges | ✅ Live |
| Question bank (~1,000 AP Chemistry cards) | ✅ Live |
| 1v1 matchmaking | 🚧 Prototype — works, not yet hardened |
Studying alone is boring and low-retention. Quizlet's multiplayer is asynchronous, Kahoot needs a teacher to host, and ChatGPT has no matchmaking or ranking. Studiem is self-serve, real-time, and ranked.
- 1v1 battles — matchmaking pairs you with an opponent in your ELO bracket, then both players get the identical question set (prototype — see Status)
- Per-subject ELO — your AP Chemistry rating is independent of your AP Biology rating
- Practice mode — solo drills with per-card mastery tracking, retry-missed, and endless queues
- Social layer — friends, presence, in-battle chat, and direct challenges
- Validated content — ~1,000 AP Chemistry cards across Units 1–9, each answered blind by a validator model before it can be served
┌──────────────────────────────────────────────────────────┐
│ Browser — Next.js 16 / React 19 (Vercel) │
│ app/ components/ lib/socket.ts middleware.ts │
└─────────┬───────────────────────────┬────────────────────┘
│ Socket.io (WS / polling) │ Supabase JS (REST + auth)
▼ ▼
┌─────────────────────┐ ┌────────────────────────────────┐
│ Battle Server │ │ Supabase (Postgres + Auth) │
│ Node 18+ · Express │◄─│ profiles elo_ratings │
│ Socket.io (Railway)│ │ battles source_cards │
│ │─►│ question_variants friendships │
│ index.js elo.js │ │ user_card_stats leaderboard │
│ questions.js │ │ Email/password + Google OAuth │
└─────────────────────┘ └────────────────────────────────┘
Two independent services. They never call each other over HTTP — all real-time traffic is Socket.io, and both talk to Supabase directly with different keys (anon key in the browser, service role key server-side only).
Full detail lives in ARCHITECTURE.md.
| Decision | Why |
|---|---|
| Railway (not Vercel) for the server | Socket.io needs a persistent process; serverless cannot hold connections |
| Server-side question selection | Clients can never pre-load answers; the reviewed gate is enforced server-side |
| In-memory battle state | Zero-latency game events. Trades away horizontal scaling — see below |
| Service role key server-only | The browser has no write access to ELO or battle records |
| Async per-player battle flow | Each player answers at their own pace; no lockstep waiting between questions |
Known scaling limit: battles, queue, and presence are process-local maps, so the battle server runs as a single instance. Horizontal scaling requires a Redis adapter for Socket.io first.
| Layer | Choice |
|---|---|
| Frontend | Next.js 16 (App Router), React 19, TypeScript (strict), Tailwind CSS 4 |
| Realtime | Socket.io 4 |
| Backend | Node.js 18+, Express 5 |
| Database & Auth | Supabase (Postgres, RLS, Supabase Auth) |
| Hosting | Vercel (web) · Railway (server) |
| Content pipeline | Writer/validator LLM agents over a typed card schema |
- Node.js 18 or newer
- A Supabase project (free tier is fine)
git clone https://github.com/seanluofficial/studiem.git
cd studiem
npm install --prefix server
npm install --prefix webRun every file in supabase/migrations/ in filename order in the Supabase SQL editor:
001_initial.sql → 002_questions.sql → 003_seed_apchem.sql → … → 013_user_card_stats.sql
Migrations are not auto-applied. Then import the question bank:
node scripts/import.jsCopy the examples and fill in your Supabase credentials:
cp server/.env.example server/.env
cp web/.env.example web/.env.localserver/.env
| Variable | Description |
|---|---|
SUPABASE_URL |
Your project URL |
SUPABASE_SERVICE_ROLE_KEY |
Service role key — server only, never expose to the browser |
PORT |
Defaults to 4000 locally; Railway sets this in production |
web/.env.local
| Variable | Description |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Same project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Anon/public key |
NEXT_PUBLIC_SOCKET_URL |
Battle server URL — http://localhost:4000 in dev |
# terminal 1 — battle server
npm run dev --prefix server
# terminal 2 — web app
npm run dev --prefix webOpen http://localhost:3000.
web/ Next.js frontend
app/ Routes: lobby, battle, practice, leaderboard, profile, auth
components/ BattleRoom, PracticeMode, FriendsPanel, ChatBox, …
lib/socket.ts Socket.io client singleton
lib/supabase/ Browser + server Supabase helpers
middleware.ts Auth guard on every route
server/
index.js Socket.io server — matchmaking, game loop, presence
elo.js Per-subject ELO calculation and persistence
questions.js Question loader (Supabase, JSON fallback for local dev)
streak.js stats.js Streaks and per-card mastery
supabase/migrations/ Ordered SQL migrations
scripts/ Content generation, validation, and bulk import
content/ Source question JSON + CED data per subject
ARCHITECTURE.md System design and data flow
PRD.md Product spec
CARD_SCHEMA.md Question card schema
DEVELOPMENT_ROADMAP.md Prioritized work queue
TECH_DEBT.md Known debt, tracked by ID
Run these before committing — all must pass:
npm run typecheck --prefix web # TypeScript, strict
node --check server/index.js # Server syntax
node --check server/elo.js
node --check server/questions.jsCI runs the same checks on every push and pull request.
- TypeScript stays strict — no
any, no@ts-ignore - All socket payloads are untrusted: validate ELO, subject, display name, and client timings server-side before use
- Never authorize off a client-supplied
roomId— verify the socket is actually in the battle - New migrations go in
supabase/migrations/NNN_description.sql, incrementing from the last file
The server is the only authority. Every value arriving from a socket event is validated — ELO is clamped and NaN/Infinity rejected, subjects are checked against a known set, display names are trimmed and length-capped, and client-reported timings are bounds-checked so a client cannot win every tiebreak by reporting 1ms.
Found a vulnerability? See SECURITY.md. Please do not open a public issue.
Proprietary — all rights reserved. See LICENSE. The source is public for reference and review; it is not licensed for reuse, redistribution, or commercial operation.