Files
game-scoreboard/docs/ARCHITECTURE.md
T
joshuaandClaude Opus 5.5 186a112b95 Phase 11: mark buzzer answers, +1 for a correct one
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>
2026-10-05 09:55:50 +02:00

128 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_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.
- 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
```ts
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.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.
## 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/
```