Initial commit

This commit is contained in:
2026-05-12 03:10:52 +00:00
commit b88f47b72d
9 changed files with 2210 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
# 🌐 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 |