A self-hosted, Kahoot-style live quiz for real classrooms. A teacher hosts on the projector, students join from their phones with a PIN, answer timed questions, and watch the leaderboard move between rounds.
Built to run a whole class on the cheapest VPS a school can rent: one container, one file, no database server, no Redis.
Every number below is measured, not estimated — see Benchmarks.
| First load for a student | ~110 KB over the wire (app + font, Brotli) |
| Container image | 116 MB |
| Memory, 80 players mid-game | ~80 MB |
| Answer round-trip, 150 simulated players | p95 3 ms |
| Services to run | 1 — the container |
| Runtime dependencies | 4 (jose, react, react-dom, zod) |
Everything else is Bun's standard library: HTTP, WebSockets, SQLite, password hashing, image processing and the bundler are all built in.
For the teacher
- Quiz editor with two question kinds: multiple choice (2–6 answers) and true/false
- Optional media per question: an image (uploaded or linked) or an embedded YouTube clip
- Per-quiz defaults — time limit, points, and a backdrop theme with live preview
- Import questions from CSV
- Host view for the projector: PIN, live answer count, reveal, leaderboard
- Per-session reports, exportable as CSV
- Share the final podium: one press copies a ready-made image plus a text recap and publishes the scoreboard at a public link
For students
- Join with a PIN — no account, no install, no app store
- Nickname is optional; skip it and get a random one
- Pick an animal avatar
- Speed-weighted scoring, rank-change arrows (▲3 / ▼1), emoji reactions, podium
- Look the scoreboard up again later from the link the teacher shared
For whoever runs it
- One SQLite file holds accounts, quizzes, results and live game state — back it up by copying it
- A restart mid-game does not lose the session
- Migrations apply automatically on boot, idempotently
- Structured JSON logs, and a health check that actually queries the database
- Bun 1.4 or newer (the image pipeline uses
Bun.Image) - For deployment: Docker, and roughly 1 GB of RAM
git clone https://github.com/andynur/yahoot.git
cd yahoot
bun install
cp .env.example .env
# Set JWT_SECRET to something random:
# openssl rand -hex 32
bun run db:migrate
bun run db:seed # demo teacher: demo@example.com / demo1234
bun devOpen http://localhost:3001. Sign in, press Host, and join from your device
at http://<your-lan-ip>:3001 with the PIN on screen.
The image carries the backend plus a pre-built frontend, so it needs nothing but a volume:
docker build -t yahoot .
docker run -d --name yahoot \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-v yahoot-data:/data \
-v yahoot-uploads:/app/apps/server/uploads \
-p 3020:3020 \
yahootBoth volumes are required — without them a redeploy wipes every quiz and picture. See DEPLOY.md for Easypanel and other hosts.
| Variable | Default | Notes |
|---|---|---|
JWT_SECRET |
— | Required. Generate with openssl rand -hex 32; never reuse the development value. |
DATABASE_PATH |
./data/yahoot.db |
Relative paths resolve from the repo root. In Docker, point it at a volume. |
PORT / SERVER_PORT |
3020 |
PORT is what most hosts inject and wins if set. |
WEB_PORT |
3001 |
Development front-end server only. |
WEB_ORIGIN |
http://localhost:3001 |
CORS fallback. LAN origins are reflected automatically. |
NODE_ENV |
development |
production tightens CORS. |
AUTO_MIGRATE |
1 |
Set 0 to skip migrations on container boot. |
One build-time variable, read by bun run build:web:
| Variable | Default | Notes |
|---|---|---|
PUBLIC_URL |
— | Your public origin, e.g. https://quiz.school.sch.id. Rewrites the social-preview image to an absolute URL. Optional: the tags are root-relative otherwise, which nearly every scraper resolves correctly. |
bun dev # server + web, both watching
bun test # unit tests
bunx tsc --noEmit # type check
bun run db:migrate # apply pending migrations
bun run db:seed # demo teacher and quiz
bun run build:web # production bundle into apps/web/dist
bun run check:web # fail if that bundle is stale
bun run loadtest # simulate a full class (CLIENTS=150 bun run loadtest)
bun run audit # dependency vulnerability scanapps/server one Bun.serve: REST + WebSocket + game engine + /uploads
db/ SQLite connection, migrations, seed
game/ pure state machine + the engine that drives it
state/ live game state (the live_* tables)
http/ routes, auth, uploads, image re-encoding, static serving
apps/web React: teacher dashboard, host screen, player screen
packages/shared the wire protocol (Zod schemas) + pure scoring
A few rules the code holds to, because the game breaks in subtle ways otherwise:
- The server owns time and score. The client renders a cosmetic countdown from a server-supplied deadline; late answers are rejected server-side.
- Live state lives in SQLite, never in process memory — a restart mid-question still closes it on time.
- Closing a question is claimed atomically. A whole class answering in one burst would otherwise score the room once per answer.
- Every WebSocket message is defined once, as a Zod schema in
packages/shared/protocol.ts, imported by both sides. - Zod never reaches the browser. The client imports runtime values from
@shared/wireand types withimport type; the build fails if a value import drags Zod back in.
When a game ends the host can publish its scoreboard at an unguessable URL
(/r/<token>) and copy a podium image drawn on a canvas — no screenshot library,
and composed for a chat window rather than a projector. The picture and a short
text recap go on the clipboard together: paste once into the message box for the
image, again into the caption field for the recap and the link.
Publishing is opt-in and reversible. A result page carries every student's nickname and score, so nothing is reachable until the teacher who ran the game asks for a link, and "Stop sharing" kills it immediately. The page itself never exposes player ids — during a game an id doubles as a rejoin credential.
Video is YouTube-only by design: a self-hosted clip would stream from the same box that is running the game, to every phone in the room at once. Uploaded images are re-encoded on arrival (capped at 1600 px, WebP) so a 4 MB photo costs a class ~250 KB instead of ~4 MB each.
bun run loadtest drives a real game over real WebSockets — it connects N
players, answers every question, and verifies each one was scored exactly once.
$ CLIENTS=150 bun run loadtest
answer round-trip (ms) n=600 p50=1 p95=3 max=27
broadcast fan-out (ms) p50=3 p95=9
accepted=600 rejected=0 errors=0 dropped=0
scoredOncePerQuestion=OK
CLEAN — no dropped messages
Measured on a laptop against the container; a 1 GB VPS is the intended target.
Single replica only — Bun's WebSocket pub/sub is per-process, so two instances cannot see each other's players. One process comfortably handles a class; a whole school hosting simultaneous games would need a different fan-out design.
Issues and pull requests are welcome.
Write Markdown, technical documentation, code comments, commit messages, and pull request text in English. Indonesian belongs only in intentional UI localization or tests of localized behavior.
bun install
bun test && bunx tsc --noEmit
bunx prettier --write .If you change anything under apps/web/src, rebuild and commit the bundle —
bun run build:web — because the container ships it. bun run check:web fails
if you forget.
MIT © Andy Nur
Made with ❤️ for HSI Boarding School.
