247 lines
6.9 KiB
Markdown
247 lines
6.9 KiB
Markdown
# 🌐 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
|
||
|
||
```sql
|
||
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
|
||
|
||
```yaml
|
||
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
|
||
|
||
```caddy
|
||
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.
|
||
> No `internal_only` — players need public access from mobile devices.
|
||
|
||
---
|
||
|
||
## Deploy
|
||
|
||
```bash
|
||
# 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`:
|
||
```js
|
||
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`):
|
||
```python
|
||
self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds
|
||
```
|
||
|
||
### Question time limit
|
||
Edit in `main.py` (two places must match):
|
||
```python
|
||
TIME_LIMIT = 20 # server-side validation
|
||
"time_limit": 20, # sent to client for countdown display
|
||
```
|
||
|
||
---
|
||
|
||
## Known Issues / Notes
|
||
|
||
- **Network on rebuild** — `docker compose up --build` recreates the container and drops it from `web_web-net`. Fix: define the network in `docker-compose.yml` (already done), or manually reconnect with `docker network connect web_web-net ni-quiz-ni-quiz-1`.
|
||
- **No gzip on WS proxies** — Caddy's `encode gzip` breaks 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 `GameState` object. 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 | |