commit b88f47b72d72b0c7107376105637b69832b635c8 Author: Carlos Lugo Date: Tue May 12 03:10:52 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b14151 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.log +__pycache__/ +node_modules/ +venv/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57258b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f9dcd5 --- /dev/null +++ b/README.md @@ -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 | \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4805338 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ff22f94 --- /dev/null +++ b/main.py @@ -0,0 +1,397 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse, JSONResponse +import json, time, random, sqlite3, os, asyncio +from datetime import datetime + +DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db") + +# ── DATABASE ────────────────────────────────────────────────────────────────── +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + ended_at TEXT, + total_players INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS 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 + ); + """) + # Safe migration: add dept column if it doesn't exist yet + try: + conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''") + except Exception: + pass # column already exists + +init_db() + +def save_session(players: list) -> int: + now = datetime.utcnow().isoformat() + with get_db() as conn: + cur = conn.execute( + "INSERT INTO sessions (started_at, ended_at, total_players) VALUES (?,?,?)", + (now, now, len(players)) + ) + session_id = cur.lastrowid + ranked = sorted(players, key=lambda p: p["score"], reverse=True) + for rank, p in enumerate(ranked, 1): + conn.execute( + "INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)", + (session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now) + ) + return session_id + +# ── APP ─────────────────────────────────────────────────────────────────────── +app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# ── QUESTIONS ───────────────────────────────────────────────────────────────── +QUESTIONS = [ + { + "q": "ΒΏCuΓ‘l es el enfoque principal del equipo de Network Intelligence?", + "options": [ + "Vender licencias de software a otras empresas", + "Construir herramientas propias que empoderan a los equipos operacionales", + "Administrar el presupuesto de TI de LCPR", + "Instalar equipos fΓ­sicos en planta externa" + ], + "correct": 1, + "fun_fact": "Β‘Exacto! Construimos para quienes operan la red dΓ­a a dΓ­a β€” no solo dashboards bonitos." + }, + { + "q": "ΒΏQuΓ© problema resolviΓ³ el equipo al reemplazar Power BI con frontend propio?", + "options": [ + "La pantalla se veΓ­a mΓ‘s bonita", + "Eliminamos la dependencia de licencias costosas y ganamos control total", + "Fue mΓ‘s fΓ‘cil de instalar en los servidores", + "Los colores del dashboard coincidΓ­an con los de Liberty" + ], + "correct": 1, + "fun_fact": "$0 en licencias de visualizaciΓ³n. El cΓ³digo es nuestro y lo extendemos como queremos." + }, + { + "q": "ΒΏQuΓ© es Pathfinder?", + "options": [ + "Una aplicaciΓ³n para rastrear camiones de campo", + "Un sistema de GPS para la red de fibra", + "Una plataforma web que centraliza herramientas operacionales de red en un solo lugar", + "Un reporte mensual de disponibilidad de red" + ], + "correct": 2, + "fun_fact": "Pathfinder es el 'panel de control' propio de LCPR β€” construido exactamente para nuestros flujos de trabajo." + }, + { + "q": "ΒΏQuΓ© logrΓ³ el proyecto de Firmware Upgrade Automation?", + "options": [ + "Actualizar manualmente 10 modems por semana", + "Contratar mΓ‘s tΓ©cnicos para visitas a domicilio", + "Automatizar la actualizaciΓ³n masiva de modems sin intervenciΓ³n humana", + "Comprar equipos nuevos para reemplazar los modems viejos" + ], + "correct": 2, + "fun_fact": "400+ modems actualizados en batches automΓ‘ticos. Antes era trabajo manual uno por uno." + }, + { + "q": "ΒΏHacia dΓ³nde se dirige el equipo en su visiΓ³n AIOps?", + "options": [ + "Depender mΓ‘s de herramientas comerciales como ServiceNow", + "Detectar y predecir fallas antes de que el cliente las reporte", + "Reducir el equipo de ingenierΓ­a a la mitad", + "Migrar toda la red a la nube pΓΊblica" + ], + "correct": 1, + "fun_fact": "La meta: que el tΓ©cnico salga a campo con el diagnΓ³stico ya hecho β€” no a diagnosticar." + } +] + +# ── GAME STATE ──────────────────────────────────────────────────────────────── +class GameState: + def __init__(self): + self.reset() + + def reset(self): + self.phase = "lobby" + self.players: dict = {} + self.current_q = -1 + self.q_start_time = 0 + self.answers_this_round = [] + self.question_order = list(range(len(QUESTIONS))) + random.shuffle(self.question_order) + +game = GameState() + +# ── CONNECTION MANAGER ──────────────────────────────────────────────────────── +class ConnectionManager: + def __init__(self): + self.players: dict = {} + self.hosts: list = [] + self._reset_task: asyncio.Task = None + + async def connect_player(self, ws_id: str, ws: WebSocket): + await ws.accept() + self.players[ws_id] = ws + + async def connect_host(self, ws: WebSocket): + await ws.accept() + self.hosts.append(ws) + # Cancel pending auto-reset if host reconnected in time + if self._reset_task and not self._reset_task.done(): + self._reset_task.cancel() + self._reset_task = None + + def disconnect(self, ws_id: str): + self.players.pop(ws_id, None) + game.players.pop(ws_id, None) + + def disconnect_host(self, ws: WebSocket): + if ws in self.hosts: + self.hosts.remove(ws) + # If no hosts remain and game is not in lobby, schedule auto-reset + if not self.hosts and game.phase != "lobby": + self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) + + async def _auto_reset(self, delay: int): + """Wait `delay` seconds, then reset the game if no host has reconnected.""" + await asyncio.sleep(delay) + if not self.hosts: + game.reset() + await self.broadcast_players({"type": "phase", "phase": "lobby"}) + + async def send_to(self, ws_id: str, data: dict): + ws = self.players.get(ws_id) + if ws: + try: + await ws.send_json(data) + except Exception: + pass + + async def broadcast_players(self, data: dict): + dead = [] + for ws_id, ws in list(self.players.items()): + try: + await ws.send_json(data) + except Exception: + dead.append(ws_id) + for d in dead: + self.disconnect(d) + + async def broadcast_hosts(self, data: dict): + dead = [] + for ws in list(self.hosts): + try: + await ws.send_json(data) + except Exception: + dead.append(ws) + for d in dead: + self.disconnect_host(d) + + async def broadcast_all(self, data: dict): + await self.broadcast_players(data) + await self.broadcast_hosts(data) + +mgr = ConnectionManager() + +def get_leaderboard(): + ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True) + return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked] + +# ── ROUTES ──────────────────────────────────────────────────────────────────── +@app.get("/", response_class=HTMLResponse) +async def player_page(request: Request): + return templates.TemplateResponse("player.html", {"request": request}) + +@app.get("/host", response_class=HTMLResponse) +async def host_page(request: Request): + return templates.TemplateResponse("host.html", {"request": request}) + +@app.get("/scores", response_class=HTMLResponse) +async def scores_page(request: Request): + return templates.TemplateResponse("scores.html", {"request": request}) + +@app.delete("/api/scores/clear") +async def clear_scores(): + with get_db() as conn: + conn.execute("DELETE FROM scores") + conn.execute("DELETE FROM sessions") + return {"ok": True} + +@app.get("/api/scores") +async def api_scores(): + with get_db() as conn: + sessions = conn.execute( + "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 50" + ).fetchall() + result = [] + for s in sessions: + players = conn.execute( + "SELECT player, score, rank, dept FROM scores WHERE session_id=? ORDER BY rank", + (s["id"],) + ).fetchall() + result.append({ + "id": s["id"], + "started_at": s["started_at"], + "ended_at": s["ended_at"], + "total_players": s["total_players"], + "players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players] + }) + return JSONResponse(result) + +# ── PLAYER WEBSOCKET ────────────────────────────────────────────────────────── +@app.websocket("/ws/player/{ws_id}") +async def player_ws(ws: WebSocket, ws_id: str): + await mgr.connect_player(ws_id, ws) + try: + await mgr.send_to(ws_id, {"type": "phase", "phase": game.phase}) + async for raw in ws.iter_text(): + msg = json.loads(raw) + + if msg["type"] == "join": + name = msg["name"].strip()[:40] + dept = msg.get("dept", "").strip()[:40] + display = msg.get("display", name).strip()[:40] + if not name: + continue + game.players[ws_id] = {"name": name, "score": 0, "answered": False, "dept": dept, "display": display} + await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept}) + await mgr.broadcast_hosts({ + "type": "lobby_update", + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) + }) + + elif msg["type"] == "answer": + if game.phase != "question": + continue + player = game.players.get(ws_id) + if not player or player["answered"]: + continue + elapsed = time.time() - game.q_start_time + TIME_LIMIT = 20 + if elapsed > TIME_LIMIT: + continue + + player["answered"] = True + chosen = msg["choice"] + q_idx = game.question_order[game.current_q] + correct = QUESTIONS[q_idx]["correct"] + is_correct = chosen == correct + + points = 0 + if is_correct: + speed_bonus = max(0, (TIME_LIMIT - elapsed) / TIME_LIMIT) + points = int(200 + 800 * speed_bonus) + player["score"] += points + + game.answers_this_round.append({ + "name": player["name"], "correct": is_correct, "elapsed": round(elapsed, 2) + }) + + await mgr.send_to(ws_id, { + "type": "answer_result", + "correct": is_correct, + "points": points, + "total": player["score"], + "correct_idx": correct + }) + + answered = sum(1 for p in game.players.values() if p["answered"]) + await mgr.broadcast_hosts({ + "type": "answer_update", + "answered": answered, + "total": len(game.players), + "details": game.answers_this_round + }) + + except WebSocketDisconnect: + mgr.disconnect(ws_id) + await mgr.broadcast_hosts({ + "type": "lobby_update", + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) + }) + +# ── HOST WEBSOCKET ──────────────────────────────────────────────────────────── +@app.websocket("/ws/host") +async def host_ws(ws: WebSocket): + await mgr.connect_host(ws) + try: + await ws.send_json({ + "type": "lobby_update", + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) + }) + + async for raw in ws.iter_text(): + msg = json.loads(raw) + + if msg["type"] == "start_game": + existing_players = dict(game.players) # preserve joined players + game.reset() + game.players = existing_players # restore them + # Reset scores and state for a fresh game + for p in game.players.values(): + p["score"] = 0 + p["answered"] = False + await mgr.broadcast_all({"type": "phase", "phase": "lobby"}) + + elif msg["type"] == "next_question": + game.current_q += 1 + if game.current_q >= len(QUESTIONS): + lb = get_leaderboard() + save_session(list(game.players.values())) # ← persist to SQLite + game.phase = "podium" + await mgr.broadcast_all({"type": "podium", "leaderboard": lb}) + continue + + game.phase = "question" + game.q_start_time = time.time() + game.answers_this_round = [] + for p in game.players.values(): + p["answered"] = False + + q_idx = game.question_order[game.current_q] + q = QUESTIONS[q_idx] + await mgr.broadcast_all({ + "type": "question", + "number": game.current_q + 1, + "total": len(QUESTIONS), + "q": q["q"], + "options": q["options"], + "time_limit": 20, + "correct_idx_hint": -1 # not revealed until show_results + }) + + elif msg["type"] == "show_results": + game.phase = "results" + q_idx = game.question_order[game.current_q] + q = QUESTIONS[q_idx] + await mgr.broadcast_all({ + "type": "results", + "correct_idx": q["correct"], + "fun_fact": q["fun_fact"], + "leaderboard": get_leaderboard() + }) + + elif msg["type"] == "reset": + game.reset() + await mgr.broadcast_all({"type": "phase", "phase": "lobby"}) + + except WebSocketDisconnect: + mgr.disconnect_host(ws) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c6f777 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +jinja2==3.1.4 +websockets==12.0 diff --git a/templates/host.html b/templates/host.html new file mode 100644 index 0000000..02421e0 --- /dev/null +++ b/templates/host.html @@ -0,0 +1,442 @@ + + + + + +NI Quiz Β· Host + + + + + +
+ + +
+ +
Network Intelligence Β· LCPR β€” Host Panel
+
+
+
SCAN TO PLAY
+
+
+
+
+
PLAYERS CONNECTED
+
0
+
participants ready
+
+
+
+
+ + + +
+
+ + +
+
+ Q 1 / 5 +
15
+ +
+
+
+
+
+
+
+
+
ANSWERED
+
0
+
of 0 players
+
+
+
+
LIVE RANKING
+
+
+
+
+
+ + +
+
+
+
QUESTION 1
+
+
βœ“ CORRECT ANSWER
+
+
+
+
+ + +
+
+
+
+
πŸ† TOP 10
+
+
+
+
+
+ + +
+

πŸ† GAME OVER!

+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/templates/player.html b/templates/player.html new file mode 100644 index 0000000..b10710f --- /dev/null +++ b/templates/player.html @@ -0,0 +1,763 @@ + + + + + +NI Quiz Β· Arcade + + + + + + + + +
+
🌐 NI QUIZ
+
NETWORK INTELLIGENCE Β· LCPR
+ + +
+ + +
+ +
Network Intelligence Β· LCPR
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
πŸ›Έ
+
+
+
WAITING FOR HOST...
+ +
+ + +
+ +
+
+ 000000 + 15 + Q1/5 +
+
+
+
+
+
+
+
+
+
+ + +
+
βœ“ CORRECT ANSWER
+
+
+
WAITING FOR HOST...
+
+ + +
+

πŸ† GAME OVER

+

Network Intelligence Β· LCPR

+
+ +
+ + + + \ No newline at end of file diff --git a/templates/scores.html b/templates/scores.html new file mode 100644 index 0000000..71ca60d --- /dev/null +++ b/templates/scores.html @@ -0,0 +1,325 @@ + + + + + +NI Quiz Β· Historial de Scores + + + +
+ +
+

🌐 NI Quiz · Historial

+

Network Intelligence Β· LCPR β€” Scores guardados por sesiΓ³n

+ +
+ +
+
Cargando historial...
+
πŸ“­

AΓΊn no hay sesiones guardadas.
Los scores se guardan al terminar el juego.

+ + +
+ + + + \ No newline at end of file