Quivya — Quiz Platform
"A QuizzUp successor built from scratch — real-time PvP, per-category Elo, Gaussian question pooling, daily challenges, offline mode, and a full admin platform. Solo to prod."
1. Why This Exists
QuizzUp was the quiz app that got everything right: deep category specialization, per-topic competitive ranking, a progression loop tight enough to pull you back after a wrong answer. Then it shut down. Nothing that came after it matched it.
Quivya is a personal attempt to rebuild what made it work — and fix what it got wrong. Built solo, from a blank repo to a deployed cross-platform product: web live at quivya.io, iOS and Android builds configured via EAS. The scope grew well past "quiz app" — real-time multiplayer, skill-based matchmaking, offline mode, a full content moderation platform, and an AI-assisted question difficulty scoring pipeline.
2. Architecture & Stack
Monorepo (pnpm workspaces + Turborepo) with four apps: mobile (React Native + Expo Router), backend (Hono on Node.js, Railway), admin (React, Vercel), and landing (Vercel). Shared packages/db owns the Prisma schema and migrations — one source of truth for the data model across the whole repo.
Database is PostgreSQL via Supabase. Real-time multiplayer runs over Socket.io on the Railway backend. Auth is JWT-based with Google + Apple Sign-In via a unified OAuth callback — both providers deprecated custom-scheme redirects, so a single HTTPS relay handles both regardless of platform.
Three environments: local dev, beta (api-beta.quivya.io, for EAS internal builds), and production (api.quivya.io). CORS is split: /api/admin runs its own policy, isolated from the general origin list so an admin config change can't accidentally open the mobile API.
3. CI/CD Pipeline
GitHub Actions runs on every PR into dev or main: spins up a real Postgres 16 service container, runs migrate:deploy against an ephemeral DB as a migration smoke test (catches broken migrations before merge, not just schema drift), then typecheck and build across the monorepo. PRs can't merge with broken migrations or type errors.
Deploys are push-triggered: Railway picks up main for the backend, Vercel for admin and landing. Production migrations are run manually — never automated, never via db push.
4. Per-Category Elo & Matchmaking
Ranking is per sub-category — your score in History is completely independent from Science. Ratings live in UserSubcategoryElo, range from 100 to 3000, start at 1000, and update synchronously on every completed game via a single DB round-trip alongside XP and streak writes.
The non-obvious part is how Elo works without a real opponent — because most games are solo:
// Solo mode: opponent = difficulty of the quiz itself
const avgDifficulty = computeAvgDifficulty(questions, answers);
const actualScore = correctCount / totalCount;
const expected = expectedScore(playerRating, avgDifficulty);
const delta = Math.round(K_SOLO * (actualScore - expected));
const newRating = clamp(playerRating + delta, ELO_MIN, ELO_MAX);
// Real PvP: opponent = actual other player's rating (K_RANKED = 32)
// Bot fallback: opponent = impliedBotRating(botAccuracy) (K_SOLO = 12)
In solo mode, the "opponent" is the average difficulty of the questions served — so the system can move your rating even without another player. K=12 for solo and bot matches, K=32 for real PvP. Bot fallback triggers after 10 seconds unmatched: a bot with randomized accuracy (0.45–0.75) gets an implied rating via round(1000 + (botAccuracy - 0.6) * 2000), so bot matches still affect real Elo sensibly rather than being free wins.
5. Gaussian Question Pooling
Question selection uses Gaussian-weighted sampling without replacement (Efraimidis-Spirakis). Each candidate gets a weight based on proximity to the player's current Elo — questions near the target difficulty are far more likely to be picked, but the distribution isn't rigid. This produces sessions that feel calibrated without being predictable.
// Gaussian-weighted sampling without replacement (Efraimidis-Spirakis)
// Each question gets a weight based on proximity to the player's Elo
const weight = Math.exp(-0.5 * ((difficulty - targetMean) / sigma) ** 2);
const key = Math.random() ** (1 / weight); // higher weight → higher key → wins
// Endless mode: target mean climbs with correct-answer streak
const endlessTarget = clamp(700 + streak * 40, 700, 2000);
// New players: 8-game ramp, start easier, converge to real Elo by game 8
const rampedTarget = lerp(rating - 300, rating, Math.min(gamesPlayed / 8, 1)); New players get an 8-game ramp — target difficulty starts 300 points below real rating and converges linearly, so first sessions don't punish new accounts with expert-level questions. Endless mode ignores Elo entirely: difficulty climbs with in-session correct-answer streak, starting easy and escalating until the player breaks.
Question difficulty is scored once by Claude Haiku, then z-score normalized per sub-category — so difficulties are comparable across topics despite very different raw AI scoring distributions. Harder questions pay more XP: clamp(difficulty / 1000, 0.6, 1.8) multiplied against the base reward.
6. Daily Challenge & Streaks
Every day, every player worldwide gets the same sub-category, same questions, same order — picked by a deterministic PRNG seeded from the UTC date (mulberry32). No per-player calibration; fairness takes priority. Results feed a global daily leaderboard, with a winner tally cron at 00:10 UTC.
Streaks track UTC calendar days. Milestone tiers at 3/7/14/30/60/100/365 days. A push notification cron at 19:00 UTC fires if you haven't played yet that day — enough friction to build habit, not enough to feel punishing.
7. Offline Mode
Sub-categories can be downloaded for offline play. Offline sessions use a client-generated UUID as their primary key — the same UUID on batch upload makes syncs idempotent. A session uploaded twice can't be double-counted. The backend applies the same Elo and XP logic as online sessions when syncing.
8. The Admin Platform
Separate Vercel deployment, excluded from search indexing, running its own CORS policy. Covers the full moderation and content surface:
- Tinder-swipe moderation for reported questions and profiles (avatar + bio).
- AI difficulty scoring pipeline — batch-score via Claude Haiku, z-score normalize, review and flush per batch.
- AI-assisted title generation for cosmetic Elo-gated profile titles.
- Community content review — user-submitted questions and theme suggestions queue for approval, plus a free-text feedback inbox.
- Multi-batch JSON importer with bracket-depth parser, CSV/JSON export by language.
- Category management — create sub-categories, toggle draft/active, manage FR/EN translations.
9. What's Next
Main outstanding items are architectural: replacing the in-memory quiz session store with Redis (env var already wired, code not), adding a backend test suite beyond migration/type checks, and solving Socket.io horizontal scaling for multi-instance PvP. On the product side: friend-vs-friend direct challenges and a proper global cross-category leaderboard.
"The best quiz app died. Someone had to rebuild it — properly this time."