Skip to content

embedded-bed/quiz-battle

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Quiz Battle

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.

Stack

  • Frontend: React + Vite SPA, served by nginx. Polls /api/state for the current winner.
  • Backend: plain FastAPI HTTP service. Holds a single winner flag; first press wins; reset clears it.
  • Deployment: docker compose, single host, one open port (4333)

Project layout

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

Media

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.

Setup

First-time setup on a fresh clone:

  1. Drop your media files into media/ (filenames in the table above). The app runs without them, just with silent fallbacks.
  2. Phone buzzers (optional). Create a Cloudflare tunnel pointing at http://frontend:80, save the token to secrets/cloudflared.env as TUNNEL_TOKEN=..., and set <your-tunnel-host> in frontend/nginx.conf (the server_name line in the tunnel server block) to whatever hostname Cloudflare gave you.
  3. LAN-only? Skip step 2 and remove (or comment out) the cloudflared service in docker-compose.yml. The buzzer pages stay reachable from within the LAN at http://<host>:4333/buzz/1 if you also comment out the ^/buzz block in the LAN nginx server.

Run

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 recipes

Open http://<host>:4333 from any machine on the LAN. Press Start, the intro video plays, and the app navigates to the battle screen.

How the buzzers work

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.

API

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/reset

Testing without hardware

scripts/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 1

Smoke 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.

Security

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 Host header.
  • The backend has no rate limiting; a malicious client can spam /api/press until the operator resets.

If you need any of that to be different, this isn't the right starting point.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors