A two-team buzzer-style quiz app for live events. The web UI runs on the local network at http://<host>:4333; teams buzz in from their phones (or any HTTP client) and the first press wins.
- Frontend: React + Vite SPA, served by nginx. Polls
/api/statefor the current winner. - Backend: plain FastAPI HTTP service. Holds a single
winnerflag; first press wins; reset clears it. - Deployment: docker compose, single host, one open port (4333)
quiz-battle/
├── backend/ FastAPI app
├── frontend/ Vite + React SPA + nginx
├── media/ images, video, sounds (gitignored — you provide)
├── scripts/buzzer.py stdlib CLI for testing the backend
├── secrets/ cloudflared.env (gitignored)
├── docker-compose.yml
├── justfile build / run / stop / log
├── CLAUDE.md context for working on the code
└── README.md
The media/ folder is gitignored — drop your own files in. The frontend reads exactly these filenames:
| File | Used by | Plays when |
|---|---|---|
team1.png |
Battle page | Left (red) team image |
team2.png |
Battle page | Right (blue) team image |
theme.mp3 |
Start page | Looping menu music while waiting on Start |
dancing-theme.gif |
Start page | Looping image shown above the Start button |
intro.mp4 |
Start page | After clicking Start, full-screen until done |
intro.mp3 |
Start page | Plays once alongside intro.mp4 after Start |
team1.mp3 |
Battle page | Buzzer sound when team 1 wins |
team2.mp3 |
Battle page | Buzzer sound when team 2 wins |
Filenames must match exactly. The folder is mounted into both containers as a live read-only volume, so cp my-buzzer.mp3 media/team1.mp3 and a hard refresh (Ctrl+Shift+R) is all it takes — no rebuild needed. The hard refresh matters because browsers aggressively cache audio; a normal refresh may serve the old bytes.
Every file is optional. If intro.mp4 is missing, the start page falls back to a "Skip to Battle" button; if a .mp3 is missing, that moment is silent (a warning is logged in the browser console). Use any browser-supported format under the listed name — .mp3, .wav, and .ogg all work. The intro audio and video both start on the Start click, which is the user gesture browsers require for autoplay.
First-time setup on a fresh clone:
- Drop your media files into
media/(filenames in the table above). The app runs without them, just with silent fallbacks. - Phone buzzers (optional). Create a Cloudflare tunnel pointing at
http://frontend:80, save the token tosecrets/cloudflared.envasTUNNEL_TOKEN=..., and set<your-tunnel-host>infrontend/nginx.conf(theserver_nameline in the tunnel server block) to whatever hostname Cloudflare gave you. - LAN-only? Skip step 2 and remove (or comment out) the
cloudflaredservice indocker-compose.yml. The buzzer pages stay reachable from within the LAN athttp://<host>:4333/buzz/1if you also comment out the^/buzzblock in the LAN nginx server.
Common tasks are wrapped in a justfile:
just build # docker compose build
just run # docker compose up -d, then tails the logs
just log # tail logs of an already-running stack
just stop # docker compose down
just # list available recipesOpen http://<host>:4333 from any machine on the LAN. Press Start, the intro video plays, and the app navigates to the battle screen.
Each team opens a single URL on their phone:
- Team 1 →
https://<your-tunnel-host>/buzz/1 - Team 2 →
https://<your-tunnel-host>/buzz/2
That gives them a full-screen, team-coloured tap area. The page polls /api/state, so it immediately shows whether their press won or another team got there first, and unlocks automatically when the operator resets.
The buzzer pages are reachable on both the LAN and the tunnel — teams on Wi-Fi at the venue can hit http://<host>:4333/buzz/1, and remote teams hit the public hostname. What's not exposed through the tunnel is the operator side: /, /battle, /media/*, and /api/reset all return 404 when the request comes in via the public hostname, so attendees can't reach the operator UI or clear the winner.
The same endpoints power the buzzer pages, the operator UI, and any external scripting you want to do.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/press |
Register a buzzer press. Body {team: 1|2} or ?team=. Returns {accepted, winner} — fire-and-forget is fine. |
| POST | /api/reset |
Clear the winner, accept presses again. (Operator-only; not exposed through the tunnel.) |
| POST | /api/score |
Adjust a team's score. Body {team: 1|2, delta: <int>}. Operator-only. |
| POST | /api/score/reset |
Zero both scores. Operator-only. |
| GET | /api/state |
{winner, scores: {"1": N, "2": M}}. Polled by the browser. |
| GET | /api/health |
Liveness probe. |
curl -X POST http://<host>:4333/api/press -H 'content-type: application/json' -d '{"team":1}'
curl -X POST 'http://<host>:4333/api/press?team=2' # query form
curl -X POST http://<host>:4333/api/resetscripts/buzzer.py is a stdlib-only CLI for exercising the backend without a phone or a browser. Run it against a running stack:
# Browser-side: poll /api/state and print every change.
# Add --auto-reset to also POST /api/reset after each new winner (operator behaviour).
python3 scripts/buzzer.py watch
python3 scripts/buzzer.py watch --auto-reset 2
# Buzzer-side: fire a press.
python3 scripts/buzzer.py press 1
python3 scripts/buzzer.py press 2
# Operator-side: clear the winner / inspect state.
python3 scripts/buzzer.py reset
python3 scripts/buzzer.py state
# End-to-end: simulate N rounds (two teams race, then reset).
python3 scripts/buzzer.py demo --rounds 5
# Point it at a remote host:
python3 scripts/buzzer.py --base http://192.168.1.42:4333 press 1Smoke test in two terminals: run watch --auto-reset 1 in one, fire press 1 / press 2 from the other and watch each WINNER and RESET appear. Use python3 scripts/buzzer.py --help (or <subcommand> --help) for full options.
This app is not hardened. It's designed to run for the duration of a single quiz event (typically ~30 minutes), on a trusted LAN with a small Cloudflare tunnel for the phone buzzer pages. Specifically:
- No authentication anywhere. Anyone on the LAN can hit the operator UI; anyone who knows the tunnel hostname can press the buzzers.
- The host-based split between LAN and tunnel (see Phone-as-buzzer above) is a convenience gate, not a security boundary — a knowledgeable attacker could spoof the
Hostheader. - The backend has no rate limiting; a malicious client can spam
/api/pressuntil the operator resets.
If you need any of that to be different, this isn't the right starting point.