🌐 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. No internal_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 rebuilddocker 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
S
Description
No description provided
Readme
2.8 MiB
Languages
HTML 81.4%
Python 18.3%
Dockerfile 0.3%