Files
route-rush/README.md
T

263 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Route Rush
Multiplayer network routing game built for team events. Players race to find the most efficient path through a 4×4 network grid before time runs out. Built with FastAPI, WebSockets, and vanilla JS — no client-side dependencies.
---
## Overview
Each round, all players receive the same grid with a start node and a destination. Links between nodes carry modifiers (normal, slow, boost, or down). Players trace a route by tapping nodes on their mobile device and submit before the timer expires. Points are awarded based on whether the route is valid, how fast it was submitted, and how efficient the path was.
Designed for 24 players. Runs as a Docker container on `labmini-01`, proxied through Caddy with automatic SSL.
---
## URLs
| Purpose | URL |
|---|---|
| Players (mobile) | `https://route-rush.carloselugo.com` |
| Host / Projector | `https://route-rush.carloselugo.com/host` |
| Score History | `https://route-rush.carloselugo.com/scores` |
---
## Stack
| Component | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, Uvicorn |
| Realtime | WebSockets (native FastAPI) |
| Persistence | SQLite via Docker volume |
| Frontend | Vanilla JS, HTML5 Canvas |
| Proxy | Caddy (automatic SSL, `web_web-net`) |
| Container | Docker Compose |
---
## File Structure
```
/opt/route-rush/
├── main.py # FastAPI app — game logic, grid generation, WebSockets, SQLite
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── static/ # Empty — all CSS/JS is inline in templates
└── templates/
├── player.html # Mobile game interface (HTML5 Canvas grid)
├── host.html # Host/projector control panel with QR code
└── scores.html # Score history viewer
```
---
## Docker Compose
```yaml
services:
route-rush:
build: .
container_name: route-rush
restart: unless-stopped
environment:
- DB_PATH=/data/rr.db
volumes:
- route_rush_data:/data
networks:
- web-net
volumes:
route_rush_data:
networks:
web-net:
external: true
name: web_web-net
```
> No ports exposed to host — Caddy reaches the container directly over `web_web-net`.
---
## Caddyfile Block
```caddy
route-rush.carloselugo.com {
import security_headers
reverse_proxy route-rush:8001 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
log {
output file /var/log/caddy/route-rush.log
format json
}
}
```
> No `encode gzip` — gzip breaks WebSocket upgrades.
> No `internal_only` — players need public access from their mobile devices.
After editing the Caddyfile, force-recreate Caddy (reload is not enough due to bind mount caching):
```bash
cd /opt/web
docker compose up -d --force-recreate caddy
```
---
## Deploy
```bash
# First deploy
cd /opt/route-rush
docker compose up -d --build
# After code changes
docker compose up -d --build
# View logs
docker logs route-rush --tail 50 -f
```
---
## Game Flow
```
Host opens /host → Players scan QR or open URL → Players enter name + dept
Host configures rounds (3/5/7) and time per round (30/45/60s)
Host clicks START GAME
[Each round]
Grid generated → broadcast to all players
Players trace route by tapping nodes → submit before timer expires
Timer expires or all players submit → Host clicks END ROUND
Results screen: all paths revealed on grid, leaderboard updated
After final round → Podium screen with confetti
Host clicks PLAY AGAIN → all players kicked back to join screen
```
---
## Scoring
| Condition | Points |
|---|---|
| Route reaches destination | 1000 base |
| Speed bonus (fastest = +500, slowest = +0) | 0 500 |
| Each extra hop over minimum (efficiency penalty) | 50 per hop |
| Route did not reach destination | 0 |
| Path through a down link | 0 |
| Time expired, no submission | 0 |
Formula: `points = 1000 + 500 × (time_remaining / time_limit) max(0, hop_cost 3) × 50`
---
## Grid Modifiers
| Modifier | Color | Effect |
|---|---|---|
| Normal | Cyan | Counts as 1 hop |
| Slow (2×) | Orange | Counts as 2 hops (efficiency penalty) |
| Boost (½×) | Green | Counts as 0.5 hops |
| Down (✕) | Red dashed | Cannot be traversed — route invalid if used |
**Rules:**
- The start node is always pre-selected. Players begin tracing from the first adjacent node.
- Backtracking is supported — tap the previous node to undo the last step.
- Loops are not allowed (a node can only appear once in the path).
- The start node always has at least one non-blocked neighbor (enforced at grid generation).
---
## API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | `/` | Player game page |
| GET | `/host` | Host control panel |
| 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 connection |
| WS | `/ws/host` | Host WebSocket connection |
---
## WebSocket Events
### Player → Server
| Event | Payload | Description |
|---|---|---|
| `join` | `{name, display, dept}` | Register player |
| `submit_path` | `{path: [nodeIds]}` | Submit route for scoring |
### Server → Player
| Event | Payload | Description |
|---|---|---|
| `joined` | `{name, display, dept}` | Confirmed join |
| `phase` | `{phase}` | Phase change (lobby/playing/results) |
| `round_start` | `{round, total_rounds, grid, time_limit}` | New round begins |
| `path_result` | `{points, total, reason, elapsed}` | Score for submitted path |
| `round_results` | `{grid, submissions, leaderboard, is_final}` | Round ended |
| `kicked` | — | Host reset — return to join screen |
| `error` | `{msg}` | Error (game full, invalid name) |
### Host → Server
| Event | Payload | Description |
|---|---|---|
| `start_round` | `{total_rounds?, time_limit?}` | Start next round |
| `end_round` | — | End current round and reveal results |
| `reset` | — | Kick all players and reset to lobby |
---
## SQLite Schema
```sql
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
rounds INTEGER DEFAULT 0
);
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
```
Scores persist in Docker volume `route_rush_data` and survive container rebuilds.
---
## Known Issues / Lessons Learned
- **`static/` directory must exist** — FastAPI's `StaticFiles` mount throws `RuntimeError` if the directory is missing, even if it's empty. Always create it: `mkdir -p /opt/route-rush/static`.
- **No gzip on WebSocket proxies** — Caddy `encode gzip` breaks WebSocket upgrades. Omit it for any service with WS connections.
- **Caddy stale bind mount** — after editing the Caddyfile, always use `--force-recreate caddy`, not just reload.
- **Player limit is 4** — enforced server-side. A 5th connection attempt returns an error. To raise the limit, change `len(game.players) >= 4` in `main.py` and update the lobby display in `host.html`.
- **PLAY AGAIN kicks all players** — by design. Host reset sends `kicked` to all connected players, clearing their session and returning them to the join screen. Players must re-enter their info for the next game.
---
## Scaling Notes
Current limit is 4 players. For larger groups (e.g. a full team event), the recommended approach is manual brackets: groups of 4 play simultaneously, winners advance to a final round. The host manages bracket progression manually between sessions.