Files
2026-06-04 01:22:38 +00:00

7.7 KiB
Raw Permalink Blame History

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 216 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

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

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):

cd /opt/web
docker compose up -d --force-recreate caddy

Deploy

# 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

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 16 — enforced server-side. A 17th connection attempt returns an error. To change the limit, update len(game.players) >= 16 in main.py and 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 16 players. For very large groups, the recommended approach is manual brackets: groups play simultaneously, winners advance to a final round. The host manages bracket progression manually between sessions.