Files
game-scoreboard/docs/ARCHITECTURE.md
T
joshuaandClaude Opus 5.5 bf5b6194f6 Add scoreboard countdown timer and sound effects
- Timer: one per event, set from the control room (duration, presets,
  start/pause/resume/reset, show/hide, T shortcut). Rules are pure
  functions in src/lib/timer.ts; the server's clock sets the end time and
  each screen counts down locally, corrected for its own clock.
  Needs supabase/migrations/0002_timer.sql.
- Sound effects on the scoreboard, synthesised with Web Audio (no files):
  drum-roll + crash per revealed team, fanfare for first place, a chime
  on game change, ticks for the last 10 seconds and a buzzer at zero.
  Triggers are pure functions comparing consecutive payloads.
- 'Turn sound on' button on the scoreboard, since browsers block audio
  until a click; the choice is remembered across reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:14:10 +02:00

5.2 KiB

Architecture

Stack

Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast) · Tailwind · zod · Vitest.

Routes

Route Who Notes
/login admin Supabase magic link
/admin admin Events list
/admin/events/[eventId] admin Setup: teams, games, criteria, judges, share links
/admin/events/[eventId]/live admin Control room
/s/[scoreboardToken] public Scoreboard
/j/[judgeToken] judge Personal judge page
/jo/[openToken] judge Open link → name form
GET /api/public/scoreboard/[token] public Sanitised scoreboard payload
GET /api/judge/[token] judge Game, criteria, teams, this judge's scores
PUT /api/judge/[token]/scores judge Upsert one score {teamId, criterionId, value}
POST /api/judge-open/[openToken] judge {name} → {judgeToken}

Admin mutations = server actions. src/proxy.ts (Next.js 16's name for middleware) protects /admin/*: must be logged in AND email in ADMIN_EMAILS. Every server action re-checks via requireAdmin().

Supabase clients (src/lib/supabase/)

  • browser.ts — anon key, used for Realtime subscribe + admin auth session.
  • server.ts — cookie-based, for admin server actions (RLS applies as the admin user).
  • admin.ts — service role, server-only (import 'server-only'). Used by public/judge route handlers after token validation.

Security model

  • RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access.
  • Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
  • Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
  • Scoreboard API checks scoreboard_visibility and strips scores when hidden; returns frozen_snapshot when frozen.
  • Light rate limiting on judge PUT (20 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game.

Realtime (Broadcast "ping then refetch")

Channels (names use tokens/ids; payload is always just { type: "refresh", scope }):

  • sb:{scoreboardToken} — scoreboard listens
  • game:{gameId} — all judges of that game listen (pinged for lock, spotlight, revoke and game edits — not for other judges' scores, which they never see)
  • admin:{eventId} — admin control room listens (judge submissions land here)

src/lib/realtime.ts exposes notify({ eventId, gameId? }) which looks up the scoreboard token and sends to the right channels from the server. Use Supabase's server-side broadcast (REST endpoint / httpSend — check current docs). Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches. Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.

Scoring engine (src/lib/scoring/)

Pure TS, no DB access. Input = plain data (teams, games, criteria, scores, adjustments). Output = per-game results + standings. See SCORING.md. Fully unit tested.

Scoreboard payload shape

type ScoreboardPayload = {
  eventName: string;
  visibility: 'visible' | 'frozen' | 'hidden';
  currentGame: { id: string; title: string; index: number; total: number } | null;
  reveal: { active: boolean; revealedCount: number } | null;
  teams: Array<{
    id: string; name: string; color: string;
    // omitted entirely when hidden:
    rank?: number; total?: number;
    // v1 sends totals only; no per-game points.
  }>;
  // null while the host has the timer hidden; sent regardless of score visibility
  timer: {
    status: 'idle' | 'running' | 'paused' | 'finished';
    remainingMs: number;      // as of updatedAt
    endsAt: string | null;    // set while running
    durationMs: number;
  } | null;
  updatedAt: string;          // server clock; screens use it to correct their own
};

During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing. When frozen, rank/total come from frozen_snapshot but currentGame stays live. A missing or invalid snapshot is treated as hidden, never as live.

Timer and sound

  • Timer state lives on events (see DATA_MODEL.md). src/lib/timer.ts holds the rules as pure functions; server actions apply them using the server's clock. Screens count down locally between pings from endsAt, offset by updatedAt minus their own clock.
  • Sound effects are synthesised in src/lib/sound.ts with the Web Audio API. Which sound a change triggers is decided by pure functions in src/lib/scoreboard-sounds.ts; the scoreboard compares each new payload with the previous one. No sound data travels over Realtime.

Folder layout

src/
  app/ (routes above)
  components/ (ui/, scoreboard/, judge/, admin/)
  lib/
    scoring/ (index.ts, *.test.ts)
    supabase/ (browser.ts, server.ts, admin.ts)
    realtime.ts
    tokens.ts (zod schemas)
    queries/ (data loaders shared by admin + API)
supabase/migrations/