6.9 KiB
🌐 NI Quiz
Multiplayer quiz game — self-hosted, no app install required. Built for the Network Intelligence team's show-and-tell event at LCPR. Players join from their mobile browser, answer questions via a Space Invaders arcade interface, and scores are tracked across sessions.
Live at: https://ni-quiz.carloselugo.com
Stack
| Layer | Tech |
|---|---|
| Backend | Python · FastAPI · WebSockets |
| Storage | SQLite (Docker volume) |
| Frontend | Vanilla HTML/CSS/JS · Canvas 2D |
| Container | Docker Compose |
| Reverse proxy | Caddy (shared web_web-net) |
| Host | labmini-01 · Dell Optiplex · Ubuntu |
Architecture
Player (mobile browser)
↓ HTTPS / WSS
Caddy (ni-quiz.carloselugo.com)
↓ reverse_proxy :8000
ni-quiz container (FastAPI)
↓
SQLite (/data/quiz.db) — Docker volume quiz-data
All game state lives in memory on the server. SQLite persists session scores across restarts.
File Structure
/opt/ni-quiz/
├── main.py # FastAPI app — game logic, WebSockets, SQLite
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── templates/
│ ├── player.html # Mobile arcade game (Space Invaders)
│ ├── host.html # Projector/host control panel
│ └── scores.html # Score history — global, by dept, by session
└── static/ # Static assets (empty — all inline)
Features
Player (/)
- Language selector — English / Español
- Registration — first name, last name, department
- Space Invaders arcade — 4 lanes, one per answer; ship at bottom fires at the correct invader
- 8-bit sound effects via Web Audio API
- Particle explosions on hit
- Score flash after each answer — points earned + running total
- Podium screen — top 10 with medals at game end
- Exit button — leaves session cleanly from lobby or podium
Host Panel (/host) — projector view
- Password-protected — blocks access before WebSocket connects
- QR code auto-generated with the player join URL
- Live lobby — player count + name chips update in real time
- Live leaderboard during questions — who answered, time taken, correct/wrong
- Full top 10 leaderboard after each question with department
- Game controls — Start, Next Question, Show Results, Reset
- Clear Scores — wipes all SQLite history with confirmation
- Auto-reset — if host disconnects mid-game, server resets automatically after 5 minutes if no host reconnects
Score History (/scores)
- Top 5 cards — all-time best scores across all sessions
- Global tab — full player ranking by best historical score, with department and session count
- By department tab — each department ranked internally, departments sorted by their top player
- Sessions tab — collapsible session list with full rankings and department per player
Scoring System
points = 200 + 800 × (time_remaining / time_limit)
| Condition | Points |
|---|---|
| Correct, fastest possible | 1000 |
| Correct, just before timeout | 200 |
| Wrong answer | 0 |
| No answer / timeout | 0 |
Time limit: 20 seconds per question.
SQLite Schema
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_players INTEGER DEFAULT 0
);
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
Scores are saved when the host advances past the last question. The dept column is added automatically via migration on startup if it doesn't exist (safe for existing DBs).
API Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/ |
Player game page |
GET |
/host |
Host control panel (password protected) |
GET |
/scores |
Score history page |
GET |
/api/scores |
JSON — all sessions and scores |
DELETE |
/api/scores/clear |
Wipe all sessions and scores |
WS |
/ws/player/{id} |
Player WebSocket |
WS |
/ws/host |
Host WebSocket |
Docker Compose
services:
ni-quiz:
build: .
volumes:
- quiz-data:/data
environment:
- DB_PATH=/data/quiz.db
restart: unless-stopped
networks:
- web-net
volumes:
quiz-data:
networks:
web-net:
external: true
name: web_web-net
Caddyfile Block
ni-quiz.carloselugo.com {
import security_headers
reverse_proxy ni-quiz:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
log {
output file /var/log/caddy/ni-quiz.log
format json
}
}
No
encode gzip— gzip breaks WebSocket upgrades. Nointernal_only— players need public access from mobile devices.
Deploy
# First deploy
cd /opt/ni-quiz
docker compose up -d --build
docker network connect web_web-net ni-quiz-ni-quiz-1
# Subsequent deploys (network defined in docker-compose.yml)
docker compose down
docker compose up -d --build
# Restart only (no rebuild — e.g. after editing main.py)
docker compose restart ni-quiz
# Backup DB
docker compose cp ni-quiz:/data/quiz.db ./quiz-backup-$(date +%Y%m%d).db
Configuration
Host password
Edit in templates/host.html:
const HOST_PASSWORD = 'ni2026'; // ← change this
The password is checked client-side and stored in sessionStorage — valid for the browser tab session only.
Auto-reset timeout
Edit in main.py (ConnectionManager.disconnect_host):
self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds
Question time limit
Edit in main.py (two places must match):
TIME_LIMIT = 20 # server-side validation
"time_limit": 20, # sent to client for countdown display
Known Issues / Notes
- Network on rebuild —
docker compose up --buildrecreates the container and drops it fromweb_web-net. Fix: define the network indocker-compose.yml(already done), or manually reconnect withdocker network connect web_web-net ni-quiz-ni-quiz-1. - No gzip on WS proxies — Caddy's
encode gzipbreaks WebSocket upgrades. Always omit for WS-heavy services. - Host password is client-side — suitable for access control at an internal event. For stricter security, move auth to the backend with
fastapi.security. - Single game instance — all game state is a single global
GameStateobject. Concurrent games are not supported.
URLs
| URL | |
|---|---|
| Players | https://ni-quiz.carloselugo.com |
| Host / Projector | https://ni-quiz.carloselugo.com/host |
| Score History | https://ni-quiz.carloselugo.com/scores |