Tournament engine (pools, single/double elimination, standings with proper tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process Node server with SQLite via node:sqlite and a WebSocket live board, organizer desk, QR landing page that follows the tournament phase, Dockerfile and compose with cloudflared, and documentation for deploying and running a day. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
6.5 KiB
Architecture
Shape
One Node process. It serves HTML pages, a JSON state endpoint, a WebSocket that pushes that same JSON on every change, and it persists to a single SQLite file. There is no build step, no framework, and no native module: the only runtime dependencies are ws (WebSocket server) and qrcode (PNG generation). SQLite comes from Node's built-in node:sqlite (Node 22.13+).
phones / TV ──HTTP──▶ server/index.js ──▶ routes.js ──▶ Registry.mutate() ──▶ engine/*
◀──WS─── (ws fan-out) │ │
│ ├─▶ Store (SQLite JSON docs + event log)
│ └─▶ notify(slug) → publicState() → every socket
└─▶ views/*.js (HTML) + public/*.js (client render)
The engine is pure
engine/ knows nothing about HTTP or storage. It has three parts:
formats.js— generators. Snake-seeded pools, circle-method round robin, single elimination with standard seed order and byes, double elimination (winners, losers, grand final, conditional reset). Output is a flat list of match objects linked byfeeds/loserFeedsedges.tournament.js— state and rules. Registration validation, phases, generation, result recording with rally-scoring validation, propagation of winners and losers along the bracket edges, withdrawals (forfeit remaining games, or void all of a team's games), and standings with an ordered tiebreak list where head-to-head is evaluated as a mini-league among only the tied teams (so a three-way circle falls through to the next rule).courts.js— the court engine. It never stores a schedule. On every change it recomputes: which pending matches are eligible (both teams known, neither on a court, all feeder matches decided), in priority order (stage, round, push-backs, longest rest), and assigns the top ones to open courts. It then simulates the queue against expected court finish times to predict a court and ETA for each waiting match, marks the first N ason_deck, and emitsup_now/on_deck/broadcastalert events exactly once per match. ETAs use the rolling average of the last six match durations.
Because the schedule is derived, a withdrawal, a paused court, a corrected score, or a late team never leaves a stale plan behind; the next tick() simply produces the new truth.
Everything in the engine is unit-tested (npm test) and exercised end-to-end by simulate.js, which plays a 12-team day with random scores, a mid-pool withdrawal, and a court pause, then asserts invariants (no team on two courts, no duplicate alerts, withdrawn teams never reach the bracket, everything decided at the end).
Persistence: JSON documents, not tables
server/store.js keeps two JSON documents per tournament (Tournament.toJSON() and CourtEngine.toJSON()) in a tournaments table, rewritten on every mutation, plus an append-only events table with every engine event (registrations, results with actor, alerts, phase changes) as an audit trail. A tournament's state is a few hundred KB at most, a mutation happens a few times a minute, and SQLite in WAL mode handles that without noticing. The trade-off is that you cannot query across tournaments with SQL; for a season leaderboard later, a small read model can be built from the event log.
On startup Registry loads every document, rebuilds the in-memory objects, and reattaches the event logger. All reads are served from memory.
One state document for every public page
server/public-state.js builds a single JSON object: phase, banner, rules, teams (public fields only: never phone numbers or team codes), pools with standings and matches, bracket matches, court status with live scores, the up-next queue with ETAs, the champion, and the last twenty alerts. The public page, the team page, and the TV display are the same HTML shell and the same board.js; they differ only in a data-team id and a data-display flag. The initial state is embedded in the page, then a WebSocket at /ws/<slug> replaces it on every change, and a two-minute fetch of /t/<slug>/state.json covers a silently dropped socket.
Team pages compare the alert list against what they have already shown and raise an in-page toast, a vibration, and (if the visitor allowed it) a browser notification when their team is on deck or up. This is the alert channel in the MVP; SMS and Web Push are the next steps (see ROADMAP).
The organizer desk
/admin is server-rendered HTML with plain forms. Every action is a POST /admin/t/<slug>/<action> that runs inside Registry.mutate(), which applies the change, ticks the court engine if live, persists, and notifies subscribers. Form posts redirect back with a flash message; the score pad posts JSON via fetch for the running score (/live) and the final (/score). The desk also listens on the WebSocket and reloads when a court or phase changes, so two organizers can work at once.
Authentication is a signed cookie (HMAC-SHA256 of an expiry and identity) issued after ADMIN_PASSWORD is checked with a timing-safe compare, or, when TRUST_CF_ACCESS=true, Cloudflare Access's identity header.
Why not a framework, React, Postgres, Redis…
The whole system is a few dozen phones hitting one process for one afternoon. A single Node process with in-memory state and a file-backed SQLite store is simpler to reason about, simpler to deploy (one image, one volume), and has nothing that can be misconfigured. The client code is one file of template literals; the pages are small enough that a framework would be most of the code. If the app ever needs multiple instances, the Registry is the seam: replace its in-memory map and notify() with a shared store and pub/sub.
Security notes
- Public pages expose team names, player counts, and results; captain phone numbers and team codes are only shown in the desk and on the registration confirmation.
- A team code is a 4-character token (about 1M combinations) that only grants a read-only personalized view; nothing can be changed from a team page.
- Registration is open to anyone with the URL while the phase is
checkin, by design. Names are length-limited and HTML-escaped everywhere; bodies are capped at 64 KB. - Organizer actions require the cookie; the WebSocket only sends public state and ignores client messages.
- The container runs as the unprivileged
nodeuser and publishes no host ports in the tunnel configuration.