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
5.7 KiB
5.7 KiB
HTTP and WebSocket endpoints
All pages are server-rendered HTML except where noted. <slug> is the URL-safe tournament id created from its name (e.g. labor-day-2s).
Public
| Method | Path | Returns |
|---|---|---|
| GET | / |
List of tournaments. |
| GET | /healthz |
{ ok, tournaments, uptime } JSON. Used by the Docker healthcheck. |
| GET | /t/<slug> |
The QR landing page. Registration form while phase is checkin; otherwise the live board / results (client-rendered from state JSON). |
| POST | /t/<slug>/register |
Form fields name, captain, phone, players, playerNames (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
| GET | /t/<slug>/team/<code> |
Personalized board for the team with that 4-character code. ?new=1 shows the post-registration confirmation instead. |
| GET | /t/<slug>/display |
TV display mode of the board. |
| GET | /t/<slug>/state.json |
The public state document (below). |
| WS | /ws/<slug> |
Sends the public state document immediately on connect and again on every change. Clients send nothing. Server pings every 30 s. |
| GET | /static/* |
CSS, JS, icons. |
Public state document
{
"slug": "labor-day-2s", "name": "Labor Day 2s", "date": "2026-09-07", "notes": "", "phase": "live", // checkin | closed | live | final
"banner": null, "rules": { "teamSize": 2, "pointsTo": 21, "winBy": 2, "cap": 25, "bestOf": 1, "tiebreaks": ["headToHead","setRatio","pointRatio","pointDiff","seed"] },
"stages": ["pool","bracket"], "courtCount": 2,
"teams": [{ "id": "t1", "name": "Net Gains", "players": 2, "seed": 1, "status": "registered", "poolId": "A" }],
"withdrawn": [],
"pools": [{ "id": "A",
"standings": [{ "teamId": "t1", "name": "Net Gains", "w": 2, "l": 1, "setsW": 2, "setsL": 1, "pf": 59, "pa": 45, "pointDiff": 14, "decidedBy": "pointRatio", "status": "registered" }],
"matches": [ /* match objects */ ] }],
"bracket": [ /* match objects with stage bracket | losers | final */ ],
"courts": [{ "court": 1, "status": "open", "match": { /* match object */ } }, { "court": 2, "status": "paused", "match": null }],
"upNext": [{ /* match object */ , "court": 2, "etaMin": 12 }],
"avgMatchMin": 14, "champion": null,
"recentAlerts": [{ "kind": "on_deck", "text": "Net Gains vs Block Party: you're ON DECK for Court 2 (~12 min).", "at": 1757260000000, "teams": ["t1","t2"], "match": "m7" }],
"generatedAt": 1757260000000
}
A match object:
{ "id": "m7", "stage": "pool", "round": 2, "slot": null, "label": null,
"status": "live", // pending | on_deck | live | final | forfeit | void | bye
"court": 1,
"a": { "id": "t1", "name": "Net Gains" }, "b": { "id": "t2", "name": "Block Party" }, // null while TBD
"winner": null, "sets": [], "live": [14, 11], // running score while live, else null
"feeds": "m12", "feedsSide": "A", "conditional": false }
Phone numbers, captain names, and team codes never appear in the public document.
Organizer
All organizer routes require the session cookie set by /admin/login (or Cloudflare Access when TRUST_CF_ACCESS=true). Unauthenticated requests are redirected to the login page.
| Method | Path | Body (form or JSON) | Effect |
|---|---|---|---|
| GET | /admin/login |
Login form. | |
| POST | /admin/login |
password, next |
Sets cookie, redirects to next. |
| GET | /admin/logout |
Clears cookie. | |
| GET | /admin |
Desk home: tournament list and creation form. | |
| POST | /admin/new |
name, date, courtCount, teamSize, pointsTo, winBy, cap (0 = none), bestOf, stages (pool,bracket / pool / bracket), notes |
Creates a tournament. |
| GET | /admin/t/<slug> |
Tournament desk. | |
| GET | /admin/t/<slug>/qr |
Printable QR page. qr.png returns the PNG. |
|
| POST | /admin/t/<slug>/phase |
phase = checkin / closed / live / final |
Change phase. live requires generated play and starts court assignment. |
| POST | /admin/t/<slug>/banner |
banner |
Set (or clear with empty) the persistent banner. |
| POST | /admin/t/<slug>/broadcast |
text |
One-time alert to all team pages. |
| POST | /admin/t/<slug>/generate |
what=pools, poolSize — or what=bracket, perPool, wildcards, type (single/double), thirdPlace |
Generate pools or bracket. |
| POST | /admin/t/<slug>/regenerate |
Clear all generated play (only before any score). | |
| POST | /admin/t/<slug>/score |
match, sets ("21-18, 19-21, 15-9") |
Record or correct a result. Validated against the rules. |
| POST | /admin/t/<slug>/live |
match, a, b |
Update the running score of a live match (JSON, used by the score pad). |
| POST | /admin/t/<slug>/forfeit |
match, team |
Record a forfeit by team. |
| POST | /admin/t/<slug>/swap |
match, court |
Move a live match to another open court. |
| POST | /admin/t/<slug>/pushback |
match |
Push a pending match two places back in the queue. |
| POST | /admin/t/<slug>/court |
court, op = pause/resume, reason |
Pause or resume a court. |
| POST | /admin/t/<slug>/team |
op = add (name,captain,players) / edit (id,name,captain,players) / seed (id,seed) / remove (id) |
Roster management. |
| POST | /admin/t/<slug>/withdraw |
id, mode = forfeit/void |
Withdraw a team. |
| POST | /admin/t/<slug>/delete |
confirm=yes |
Delete the tournament and its data. |
Form posts respond with a 303 back to the desk carrying ?msg= or ?error=. Requests with Content-Type: application/json get { ok: true, msg } or { ok: false, error } with a 400 on failure. Every action is recorded in the events table with the organizer identity as actor.