# 🌐 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 |