The host marks the answering team "Got it" or "Wrong" (G / W). Wrong passes the turn to the next team in the buzz order; Got it closes the buzzers until "Next question" (B). Undo reverses either mark. In a direct game a correct answer adds 1 to the team's score in the game's first criterion, as an ordinary admin score row, so it flows through the scoring engine (multiplier, placement) and the existing range/step trigger. The point is written before the mark, so a refused point never leaves a "correct" with nothing behind it. Judged games only get the mark: the judges' scores stand. Needs supabase/migrations/0004_buzz_result.sql applied before deploying. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.9 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} |
/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.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_visibilityand strips scores whenhidden; returnsfrozen_snapshotwhenfrozen. - 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. - 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 listensgame:{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;
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.tsholds the rules as pure functions; server actions apply them using the server's clock. Screens count down locally between pings fromendsAt, offset byupdatedAtminus their own clock. - Sound effects are synthesised in
src/lib/sound.tswith the Web Audio API. Which sound a change triggers is decided by pure functions insrc/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.tsholds the rules as pure functions (buzzerViewbuilds the same{ open, buzzes }for the team page, the control room and the scoreboard);src/lib/buzzer.server.tsdoes 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
markBuzzserver action.checkMarkdecides whether the mark is allowed and whether a point moves (+1 for a new "correct", −1 when one is undone);bumpScoregives 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 thevalidate_scoretrigger 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.
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/