Files
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:08 +02:00

11 KiB
Raw Permalink Blame History

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, co-hosts
/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}
/b/[buzzerToken] team Team buzzer page
GET /api/buzzer/[token] team Team, current game, buzz order
POST /api/buzzer/[token]/buzz team Buzz in (no body) → the same payload

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 (which is also what uploads an admin's media file straight to storage).
  • 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 and the co-hosts the owner shared it with. Anon role has no policies → no direct table access.
  • Co-hosts are rows in event_cohosts, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in ADMIN_EMAILS, since that person couldn't log in.
  • 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.
  • Buzz checks: the token's team exists, its event's current game has buzzer_enabled, isn't done, and nobody has been marked correct for this question. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
  • Uploaded media lives in the private game-media bucket under {eventId}/…. Storage policies allow the event's admins only; anon has none. The scoreboard API hands out a signed URL (12 h) for the one item that is on screen, and nothing else. Links (YouTube, files) must be https://.
  • Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 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 and buzzes land here)
  • buzzer:{eventId} — team buzzer pages listen (pinged for buzzes, clears, game switches and edits to games/teams — not for scores)

src/lib/realtime.ts exposes notify({ eventId, gameId?, buzzer? }) which looks up the scoreboard token and sends to the right channels from the server. buzzer: true adds the buzzer channel; score saves leave it out so they don't cost an extra request. 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;
  // null unless the current game uses buzzers; sent regardless of score visibility
  buzzer: {
    open: boolean;            // false once a team got it, or the game is done
    buzzes: Array<{           // in buzz order
      teamId: string; name: string; color: string; afterFirstMs: number;
      result: 'correct' | 'wrong' | null;   // the host's mark
    }>;
    answeringTeamId: string | null;         // earliest unmarked buzz; null once someone got it
  } | null;
  // null with nothing on screen; sent regardless of score visibility (see "On screen")
  onScreen:
    | { kind: 'question'; id: string; shownAt: string; text: string;
        imageUrl: string | null; answer: string | null }   // answer only once revealed
    | { kind: 'sound'; id: string; shownAt: string; media: ScreenMedia; paused: boolean;
        answer: string | null }
    | { kind: 'video'; id: string; shownAt: string; media: ScreenMedia; paused: boolean }
    | null;
  updatedAt: string;          // server clock; screens use it to correct their own
};

ScreenMedia = { type: 'youtube'; videoId; startSeconds } or { type: 'file'; url }. 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.

Buzzers

  • A buzz is a row in buzzes, stamped by the database's clock (clock_timestamp()), so the order doesn't depend on phone clocks or on which server instance took the request. Network delay between a thumb and the database still counts — fair on shared Wi-Fi, not tournament-grade.
  • src/lib/buzzer.ts holds the rules as pure functions (buzzerView builds the same { open, buzzes } for the team page, the control room and the scoreboard); src/lib/buzzer.server.ts does the token-gated reads and the write.
  • Buzzes follow rule 4 like everything else: the ping carries no data and each screen refetches. The buzzing phone gets its result in the POST response, so it doesn't wait for the ping.
  • Clearing = deleting the current game's rows (admin server action).
  • Marking an answer is the markBuzz server action. checkMark decides whether the mark is allowed and whether a point moves (+1 for a new "correct", −1 when one is undone); bumpScore gives the new cell value. In a direct game the point is written to the admin's score row for the game's first criterion — an ordinary score, so the scoring engine turns it into leaderboard points like any other and the validate_score trigger still guards range and step. The score is written before the mark, so a refused point leaves the answer unmarked.
  • A buzz is refused once any buzz of the game is marked correct, until the host clears.

On screen (questions, sounds, videos)

  • A game's items are rows in game_items. What is on screen is state on events (screen_item_id, screen_shown_at, screen_answer_shown, screen_paused), like the timer. An item only counts while its game is the current game; switching game clears it.
  • src/lib/screen.ts holds the rules as pure functions: buildOnScreen (the sanitised payload part — no unrevealed answer, no sound/video name), parseYouTube, pickRandom, nextQuestion, and the upload checks. Server actions in live-actions.ts (show, random, answer, pause, clear) and item-actions.ts (setup CRUD).
  • Rule 4 still holds: the ping carries nothing; the scoreboard refetches and plays what the payload says. Each player is mounted with a key of item id + shownAt, so showing an item again restarts it, and it keeps the media URL it started with (a signed URL differs in every payload; following it would restart the file on every refetch).
  • Playback: files use <video> / <audio>; YouTube uses the youtube-nocookie.com embed, with mute and pause sent as postMessage player commands. The control room decides play/pause and restart only — there is no shared playback position, so a scoreboard opened mid-song starts the song from the beginning.
  • Uploads go from the admin's browser to storage (a server action can't carry 50 MB); the action then records the path, after checking it is inside that event's folder. Deleting an item, game or event deletes its files; duplicating copies them.

Folder layout

src/
  app/ (routes above)
  components/ (ui/, scoreboard/, judge/, buzzer/, 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/