Realtime multiplayer quiz game built with Next.js and Socket.IO.
- Install dependencies:
npm install- Configure environment variables (see below).
- If you are migrating an existing repo that still has
data/quizzes/*.jsonordata/quiz-governance/*.json, import them once into SQLite:
npm run migrate:quiz-storage- Start the app on the smoke-test port:
npm run dev -- --port 31001- Open
http://127.0.0.1:31001.
ADMIN_SESSION_SECRET:- required for
/api/admin/session(signed admin cookie sessions used by/add)
- required for
ADMIN_SESSION_TTL_SECONDS:- optional admin session lifetime (default
43200, 12h)
- optional admin session lifetime (default
ADMIN_VIEWER_TOKEN,ADMIN_EDITOR_TOKEN,ADMIN_ADMIN_TOKEN:- recommended machine-token credentials mapped to roles/capabilities
- accepted as
Authorization: Bearer <token>orx-admin-token
OPS_ADMIN_TOKEN:- machine-token fallback for
/api/debug/status(ops:read)
- machine-token fallback for
QUIZ_BUILDER_ADMIN_TOKEN:- machine-token fallback for
/api/quiz-builder(quiz:read+quiz:mutate)
- machine-token fallback for
QUIZ_DB_PATH:- optional SQLite file for canonical quiz storage (default
data/quizzes.sqlite) - runtime access uses
node:sqliteon Node 24+
- optional SQLite file for canonical quiz storage (default
DEBUG_ADMIN_TOKEN:- broad fallback machine token for dev/debug only
REDIS_URL:- Redis store/adapter URL for room state and multi-instance scaling
- optional only in local
developmentmode (memory fallback) - required when
NODE_ENVis notdevelopment(startup/runtime throws if missing)
NEXT_PUBLIC_SITE_URL:- canonical public site URL used by page metadata (default production URL:
https://macaquiz.com)
- canonical public site URL used by page metadata (default production URL:
ADMIN_MUTATION_RATE_LIMIT,ADMIN_MUTATION_RATE_WINDOW_MS:- mutation API rate-limit policy for
/api/quiz-builderwrites
- mutation API rate-limit policy for
PLAYER_SESSION_TTL_SECONDS:- player account session lifetime in seconds (default
2592000, 30 days)
- player account session lifetime in seconds (default
PLAYER_HISTORY_MAX_ENTRIES:- max persisted match-history entries per player identity
ROOM_JOIN_FAILURE_WINDOW_MS,ROOM_JOIN_LOCK_THRESHOLD,ROOM_JOIN_LOCK_BASE_MS,ROOM_JOIN_LOCK_MAX_MS:- brute-force protection policy for invalid
room:joinattempts
- brute-force protection policy for invalid
DEBUG_SOCKET=1:- enables socket debug logs
LOG_LEVEL:- structured log threshold (
debug,info,warn,error)
- structured log threshold (
OPS_METRICS_WINDOW_MS:- rolling window used by status metrics aggregation
ANALYTICS_ENABLED,ANALYTICS_STRICT_PRIVACY,ANALYTICS_WINDOW_MS,ANALYTICS_HASH_SALT:- product analytics controls (funnel/session/content aggregates)
- strict privacy mode hashes actor identifiers before aggregation
SLO_MIN_SAMPLE_SIZE,SLO_ROOM_JOIN_ERROR_RATE_MAX,SLO_ANSWER_ERROR_RATE_MAX,SLO_RECONNECT_RESTORE_RATE_MIN,SLO_TRANSITION_LAG_P95_MS_MAX,SLO_TICK_LAG_P95_MS_MAX,SLO_COMPLETION_RATE_MIN:- service-level objective thresholds published in
/api/debug/status
- service-level objective thresholds published in
DRAIN_DEFAULT_GRACE_MS:- default graceful drain window for websocket node rollout
ROOM_TTL_SECONDS,TOKEN_TTL_SECONDS,LOCK_RETRY_COUNT:- optional room/store tuning knobs
ROOM_TICK_INTERVAL_MS,ROOM_SCHEDULER_LEASE_MS:- optional realtime scheduler tuning for multi-instance deployments
ROOM_TICK_MIN_DELTA_MS,ROOM_TICK_COALESCE_MS,ROOM_TICK_OVERLOAD_SOCKET_COUNT,ROOM_TICK_OVERLOAD_MIN_INTERVAL_MS:- tick backpressure tuning (coalescing + stale drop strategy for large rooms)
PHASE_BROADCAST_DEDUPE_SECONDS:- short cross-node dedupe window for
game:phasebroadcasts
- short cross-node dedupe window for
PERF_BUDGET_JOIN_ACK_P95_MS,PERF_BUDGET_JOIN_ACK_P99_MS,PERF_BUDGET_ANSWER_ACK_P95_MS,PERF_BUDGET_ANSWER_ACK_P99_MS,PERF_BUDGET_PHASE_LAG_P95_MS,PERF_BUDGET_PHASE_LAG_P99_MS:- load/performance gate thresholds consumed by
npm run perf:gate
- load/performance gate thresholds consumed by
QUIZ_CACHE_CHECK_INTERVAL_MS,QUIZ_CACHE_REDIS_CHECK_INTERVAL_MS,QUIZ_CACHE_REDIS_KEY:- quiz metadata/content cache invalidation tuning
- Socket gateway:
src/pages/api/socket.ts - Room/game engine:
src/lib/rooms/* - Identity/profile services:
src/lib/identity/* - Quiz read APIs:
src/app/api/quizzes/* - Player/host routes:
src/app/page.tsx,src/app/create/page.tsx,src/app/join/page.tsx,src/app/room/[code]/page.tsx - Identity UI routes:
src/app/account/page.tsx,src/app/profile/page.tsx - Client socket layer:
src/lib/socket/* - Canonical quiz content:
- SQLite:
data/quizzes.sqlite - images:
public/images/quizzes/* - governance + version history: SQLite tables inside the same database
- SQLite:
- Legacy URL redirects kept only for compatibility:
src/app/room/new/page.tsxsrc/app/room/join/page.tsxsrc/app/room/[code]/play/page.tsx
- Legacy app-game runtime modules (old components/state model) were removed in Phase 0 to avoid accidental imports.
/api/quiz-builderand/addnow read/write canonical content only:data/quizzes.sqlitepublic/images/quizzes/*
- Legacy source trees are migration-only inputs:
src/app/lib/data/games/*public/images/games/*
- CI enforces a drift check from legacy inputs to canonical outputs via:
npm run check:content-drift
- Runtime and builder source of truth:
- quiz records live in
data/quizzes.sqlite - quiz images must live in
public/images/quizzes/<quizId>/*
- quiz records live in
- Existing canonical JSON migration:
- import current canonical quiz + governance JSON into SQLite:
npm run migrate:quiz-storage
- optionally remove the old JSON files after a successful import:
npm run migrate:quiz-storage -- --delete-json
- import current canonical quiz + governance JSON into SQLite:
- Canonical quiz schema:
QuizV2with{ id, title, version: 2, questions[] }questions[]uses{ id, prompt, options[], correctOptionId, image? }
- Legacy-to-canonical migration:
- dry-run:
npm run migrate:quizzes -- --dry-run - apply:
npm run migrate:quizzes - optional move semantics:
npm run migrate:quizzes -- --move - target is SQLite (
data/quizzes.sqlite), notdata/quizzes/*.json
- dry-run:
- Lifecycle states:
draftin_reviewpublishedarchived
- Runtime visibility:
/api/quizzes,/api/quizzes/[id], and gameplay quiz selection servepublishedquizzes only.
- Governance persistence:
- SQLite tables inside
data/quizzes.sqlite - one table for lifecycle records
- one table for immutable version snapshots
- SQLite tables inside
- Admin lifecycle actions:
- endpoint:
PATCH /api/quiz-builder - actions:
submit_review,request_changes,publish,archive,restore_draftrollback(promote previous immutable version)comment,flag,resolve_flag
- endpoint:
- Role gates:
editor/admin: review, publish, comments, flagsadmin: archive, restore draft, rollback, resolve flag
- Listing/filtering:
GET /api/quiz-builder?lifecycle=1GET /api/quiz-builder?lifecycle=1&state=draft|in_review|published|archived
- Start readiness:
- host can configure
minPlayersToStartin the lobby settings - game start is blocked until at least that many connected
playerroles are present
- host can configure
- Late join behavior:
- policies:
next_question: join now, answer from next questionspectator: join mid-game as spectatorblocked: deny mid-game joins
- policy is enforced server-side by room engine
- policies:
- Spectator role:
- spectators receive room/game snapshots and leaderboard updates
- spectators cannot submit answers or vote rematch
- Team mode:
- host can enable team mode and choose
teamCount(2-4) - host can assign players to teams in lobby (
team:assign) - scoring remains per-player and contributes to aggregated
teamStandings
- host can enable team mode and choose
- Room privacy and join governance:
roomPrivacy:public,private,invite_only- invite-only rooms require matching
inviteCodeinroom:join - optional
hostApprovalRequiredjoin workflow withroom:reviewJoinRequest
- End-of-match continuity:
- finished games support rematch voting (
game:rematchVote) - rematch auto-starts when all connected active players vote yes
- host can quick reset to lobby while preserving lobby settings (
game:quickReset)
- finished games support rematch voting (
- Host migration:
- if the current host disconnects past grace period, host role moves to the next connected player
- clients show handover messaging so both host and players understand who is now in control
- Identity/session model:
- guest users remain token-based and can play without account friction
- authenticated users can register/sign in via
/api/player/sessionand persistent cookie sessions - guest token identities can be upgraded/mapped to authenticated identities
- Profile and preferences:
/api/player/profilesupports read/update for display name, avatar, and language- UI entry points:
/accountand/profile
- Match history and baseline stats:
- room engine persists per-player completed-match summaries when games finish
/api/player/historyreturns recent matches and baseline progression stats
- Quiz metadata/content caching:
/api/quizzesand/api/quizzes/[id]use shared in-process quiz cache- cache refresh checks SQLite row revisions and reparses only changed quizzes
- optional Redis revision coordination is enabled automatically when
REDIS_URLis set
- Realtime scheduler hardening:
- ticker default interval is
250msand uses per-room distributed scheduler leases to reduce multi-node duplicate ticking - timed phase transitions are ticker-driven to keep phase progression deterministic
- phase broadcast dedupe window reduces duplicate
game:phaseemissions across nodes
- ticker default interval is
- Realtime hot-path optimization:
- room broadcast flushes are coalesced per room (
snapshot/players/phase) to avoid redundant reads/emits under bursty event traffic - reveal results are computed in one store read (
getRevealResults) and emitted per socket without per-socket room snapshot fetches - leaderboard emissions precompute rank/team lookup maps to avoid repeated
findscans per socket
- room broadcast flushes are coalesced per room (
- Backpressure controls:
- tick emissions are coalesced, stale ticks are dropped, and
volatileemits are used to avoid websocket queue buildup - large-room tick throttling is enforced when socket fan-out crosses
ROOM_TICK_OVERLOAD_SOCKET_COUNT /api/debug/statusnow includesmetrics.backpressurecounters (drop totals and reason breakdown)
- tick emissions are coalesced, stale ticks are dropped, and
- Load tooling and budget gates:
scripts/load-rooms.mjsnow supportsmany-rooms,fan-out, andreconnect-burstprofiles withp50/p95/p99metricsscripts/perf-gate.mjsenforces latency/skew budgets from a JSON load report- CI smoke job runs a reduced perf profile and fails on budget regressions
- Image delivery:
- active gameplay/preview images use Next image optimization path
- quiz image routes now publish explicit cache-control headers (
/images/quizzes/*)
- Validated profile envelopes (single node, local baseline):
- many-room:
6rooms x6players (36active players) - fan-out:
1room x16players - reconnect burst:
3rooms x8players with4concurrent reconnects per room
- many-room:
- Recommended production starting envelope per websocket node:
<= 16players per room for interactive games<= 30active rooms per node at6players/room average (180concurrent players)
- Scaling assumptions:
- Redis room store + Socket.IO Redis adapter enabled
- sticky websocket routing at the edge/load balancer
- tune room count/player cap upward only after
npm run perf:checkpasses against production-like hardware
- Structured logs:
- room lifecycle, phase transitions, reconnects, and key error paths emit JSON logs
- sensitive fields are redacted (
token,authorization,secret, etc.)
- Ops status endpoint:
/api/debug/statusexposes active rooms/players, reconnect rates, socket event error rates, and tick/transition lag- access is protected with RBAC
ops:readcredentials (session or machine token)
- Admin session endpoint:
/api/admin/sessionprovides authenticated admin cookie session for/add- roles:
viewer,editor,admin
- Documentation:
- runbook:
docs/operations/runbook.md - release checklist:
docs/operations/release-checklist.md
- runbook:
- CI quality gates:
.github/workflows/ci.ymlenforces lint, tests, build, smoke checks, and perf budget gate
- Extended status diagnostics:
/api/debug/statusnow includes:analytics(privacy-aware funnel/session/content aggregates)slo(objective-level health and thresholds)drain(graceful websocket drain state and active socket count)
- Node drain controls:
GET /api/debug/drain(read)PATCH /api/debug/drain(admin role required) for deploy-time graceful websocket draining
- Release safety scripts:
npm run slo:check(SLO gate)npm run canary:check(canary promote/rollback gate)
- Resilience scripts:
npm run chaos:run(disconnect storm, jitter, Redis degradation simulation hooks)npm run soak:rooms(long-running room stress loop with optional SLO fail-fast)
- Phase 7 docs:
docs/operations/slo-analytics.mddocs/operations/runbook.mddocs/operations/release-checklist.mddocs/operations/incident-template.md
Run with the app listening on http://127.0.0.1:31001:
node scripts/smoke-socket.mjs
node scripts/smoke-multiplayer.mjsOptional explicit endpoint:
SOCKET_URL=http://127.0.0.1:31001 node scripts/smoke-socket.mjs
SOCKET_URL=http://127.0.0.1:31001 node scripts/smoke-multiplayer.mjsRun with the app listening on http://127.0.0.1:31001:
npm run load:rooms
npm run perf:check
npm run bench:quiz-api
npm run slo:check
npm run canary:check
npm run chaos:run
npm run soak:roomsOptional tuning:
SOCKET_URL=http://127.0.0.1:31001 LOAD_PROFILE=many-rooms LOAD_ROOMS=8 LOAD_PLAYERS_PER_ROOM=8 npm run load:rooms
SOCKET_URL=http://127.0.0.1:31001 LOAD_PROFILE=fan-out LOAD_FANOUT_PLAYERS_PER_ROOM=20 npm run load:rooms
SOCKET_URL=http://127.0.0.1:31001 LOAD_PROFILE=reconnect-burst LOAD_RECONNECT_ROOMS=4 LOAD_RECONNECT_PLAYERS_PER_ROOM=10 npm run load:rooms
SOCKET_URL=http://127.0.0.1:31001 LOAD_PROFILE=all LOAD_REPORT_PATH=artifacts/perf/load-report.json npm run load:rooms
PERF_REPORT_PATH=artifacts/perf/load-report.json npm run perf:gate
SOCKET_URL=http://127.0.0.1:31001 QUIZ_BENCH_ITERS=50 npm run bench:quiz-api
OPS_ADMIN_TOKEN=<token> SLO_BASE_URL=http://127.0.0.1:31001 SLO_REQUIRE_REDIS_CONNECTED=0 npm run slo:check
OPS_ADMIN_TOKEN=<token> CANARY_BASE_URL=http://127.0.0.1:31001 CANARY_DURATION_MS=15000 CANARY_REQUIRE_REDIS_CONNECTED=0 npm run canary:check
OPS_ADMIN_TOKEN=<token> CHAOS_BASE_URL=http://127.0.0.1:31001 npm run chaos:run
OPS_ADMIN_TOKEN=<token> SOAK_BASE_URL=http://127.0.0.1:31001 SOAK_DURATION_MS=180000 npm run soak:roomsnpm run lint
npm run test
npm run check:content-drift
npm run build
node scripts/smoke-socket.mjs
node scripts/smoke-multiplayer.mjs
npm run load:rooms
npm run perf:check
npm run slo:check
npm run canary:check
npm run chaos:run