Live scoreboards
What the spectator sees
A scoreboard shows two things:
- Standings of the match (overall or per division), updated on every approved score.
- "On the range": per scoring tablet, the competitor currently shooting — stage, name, division, phase (make ready / running / scoring / approved), number of shots, last shot time or final time, and the provisional hits as soon as the RO enters them.
For a shootoff the board shows the double elimination bracket instead of standings: a tree from left to right with one column per round, every bout at the midpoint of the two bouts that feed it and connector lines between them (amber once decided), the winners bracket on top, the losers bracket below and the finals on the right (with a last chance bracket and lower final for three-life formats, and heat counts on best-of bouts); winner green, loser red, the champion on top and the placements below. The positions come from layoutBracket in packages/scoring, shared by the board, the portal and the tablet. The portal pushes the bracket to the Durable Object after every decision (bracket message in the live protocol).
Where it runs
| Form | How |
|---|---|
| Tablet in dashboard mode | same app, DashboardScreen; without a network it falls back to the locally calculated standings from the scores already synchronised to that tablet |
| TV / kiosk / browser | GET /scoreboard/:matchId?division=OPEN — framework-free HTML page with WebSocket; works on the existing Raspberry Pi kiosks of agisko-dashboards |
| Custom integration | WebSocket /live/matches/:id?role=board, protocol in packages/shared/src/live.ts |
Without internet: the local hub
All live traffic normally goes through the portal: the scoring tablets push approved scores to the portal, which recomputes the standings and fans them out to the boards. On a range without internet, one tablet takes that role for the Wi-Fi it is on (menu Local hub → This tablet is the hub); it shows the address the other tablets enter under Connect to a hub. From then on:
- scoring tablets send every approved score to the hub (and keep it in their own outbox for the portal);
- the hub stores the scores, queues them for the portal and forwards them the moment any internet is available; every other tablet pulls them back, so all tablets compute the same standings locally;
- the "On the range" panel travels the same way: scorers post the current competitor to the hub, dashboards poll it every two seconds;
- the dashboard shows the shooting order of the squad on the current stage bottom right: On the line (next up), On deck and everybody who still has to shoot. The order follows the squad rotation (see scoring).
The hub serves only scores and the live panel; every tablet downloads the match from the portal while it still has internet. Protocol: apps/tablet/src/hub/server.ts (plain HTTP on port 8787, JSON).
How it works
- One Durable Object (
MatchLive) per match as the hub, using the WebSocket Hibernation API (no cost when nothing happens). - After every accepted
sync/pushthe worker calculates the standings (buildAllResults, per division + overall) and POSTs them to the DO. The DO stores the snapshot and sends it to all boards; a new board immediately receives the latest state. - Scoring tablets connect as
role=scorer(Bearer token required) and send ephemeralcurrentevents while scoring. The DO keeps the latest state per tablet and removes it when the tablet loses its connection. These events never touch D1. - Final scores never come in via WebSocket: always via the sync API, so that offline and live follow the same path.
- Boards for matches with visibility
club/privatemust be logged in (cookie or Bearer) and have the permission; public matches are open.
Messages (summary)
Client → server: subscribe { role, divisions? }, current { shooter } (scorer), current_clear { deviceId }, ping.
Server → client: hello, results { division, rows[], updatedAt }, current { shooters[] }, error.