Phase 10: team buzzers
Each team gets a buzzer link (/b/[token]); a game opts in with a
"Teams buzz in" checkbox. The database's clock orders the buzzes, so
the result doesn't depend on phone clocks. The control room lists the
order and clears it per question (B); the scoreboard shows who buzzed
first with a ding.
Buzzes follow the existing rules: token-gated route handlers with the
service-role client, RLS on the new table, and data-free realtime pings
on a new buzzer:{eventId} channel.
Needs supabase/migrations/0003_buzzer.sql applied before deploying.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+25
-4
@@ -17,6 +17,9 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|
||||
| `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()`.
|
||||
|
||||
@@ -30,15 +33,17 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa
|
||||
- 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.
|
||||
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled` and isn't done. 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 land here)
|
||||
- `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? })` which looks up the scoreboard token and sends to the right channels from the server.
|
||||
`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.
|
||||
@@ -66,6 +71,11 @@ type ScoreboardPayload = {
|
||||
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 the game is done
|
||||
buzzes: Array<{ teamId: string; name: string; color: string; afterFirstMs: number }>; // in buzz order
|
||||
} | null;
|
||||
updatedAt: string; // server clock; screens use it to correct their own
|
||||
};
|
||||
```
|
||||
@@ -80,11 +90,22 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
|
||||
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).
|
||||
|
||||
## Folder layout
|
||||
```
|
||||
src/
|
||||
app/ (routes above)
|
||||
components/ (ui/, scoreboard/, judge/, admin/)
|
||||
components/ (ui/, scoreboard/, judge/, buzzer/, admin/)
|
||||
lib/
|
||||
scoring/ (index.ts, *.test.ts)
|
||||
supabase/ (browser.ts, server.ts, admin.ts)
|
||||
|
||||
@@ -65,5 +65,13 @@ human a short "how to test this manually" checklist. Don't start the next phase
|
||||
- Scoreboard sound effects: reveal drum-roll + crash/fanfare, game-change chime, last-10-seconds ticks + buzzer; "Turn sound on" button.
|
||||
**Done when:** a timer started in the control room counts down on the scoreboard with ticks and a buzzer, and a reveal plays its drum-roll.
|
||||
|
||||
## Phase 10 — Team buzzers
|
||||
- Apply `supabase/migrations/0003_buzzer.sql`, then `npm run gen:types` and `npm run check:rls`.
|
||||
- Buzzer rules in `src/lib/buzzer.ts` (tested); `/b/[token]` phone page + `GET`/`POST` buzzer API.
|
||||
- "Teams buzz in" checkbox per game; buzzer links per team in the share panel.
|
||||
- Control room: buzz order + "Clear buzzers" (`B`). Scoreboard: "{Team} buzzed!" banner + ding.
|
||||
**Done when:** two phones on different teams buzz in a buzzer game; the control room and scoreboard
|
||||
show who was first within a second, and "Clear buzzers" resets both phones.
|
||||
|
||||
## Stretch (only if asked)
|
||||
Participants within teams · co-host admins · CSV export of results.
|
||||
|
||||
+5
-3
@@ -1,20 +1,22 @@
|
||||
# Data model
|
||||
|
||||
Full SQL in `supabase/migrations/0001_init.sql`.
|
||||
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`).
|
||||
|
||||
| Table | Purpose | Key columns |
|
||||
|---|---|---|
|
||||
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at` |
|
||||
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order` |
|
||||
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id` |
|
||||
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order`, `buzzer_token` |
|
||||
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id`, `buzzer_enabled` |
|
||||
| `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` |
|
||||
| `judges` | Judges per game | `name`, `token`, `revoked`, `source` (nominated/open_link) |
|
||||
| `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) |
|
||||
| `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` |
|
||||
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at` |
|
||||
|
||||
## Notes
|
||||
- `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts.
|
||||
- In **judged** games, an admin row (`judge_id IS NULL`) is an **override** for that cell.
|
||||
- Deleting a criterion/team/judge cascades its scores.
|
||||
- Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`).
|
||||
- Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones.
|
||||
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
|
||||
|
||||
+28
-3
@@ -10,6 +10,7 @@ live scoreboard, and judges score from their phones via links. Versatile enough
|
||||
| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
|
||||
| **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` |
|
||||
| **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) |
|
||||
| **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night |
|
||||
|
||||
## 3. Core concepts
|
||||
- **Event** — one game night. Has teams, games, one scoreboard link, visibility state.
|
||||
@@ -38,11 +39,13 @@ live scoreboard, and judges score from their phones via links. Versatile enough
|
||||
- Criteria list: add/remove/reorder; label, description, input type, min/max/step, weight.
|
||||
- Quick presets: "Karaoke (5 × 1–5)", "Single score (0–10)", "Points (number)", "Win/Lose (0–1)".
|
||||
- Admin notes + judge notes (multiline).
|
||||
- "Teams buzz in" checkbox: this game uses the team buzzers (see 4.7).
|
||||
- Judges (judged games only):
|
||||
- Nominate judges by name → each gets a personal link (copy button + QR code).
|
||||
- Toggle the open link on/off; anyone opening it types their name and becomes a judge.
|
||||
- Revoke a judge (link stops working, their scores excluded).
|
||||
- Share panel: scoreboard link + QR, all judge links per game.
|
||||
- Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team
|
||||
(shown once any game uses buzzers).
|
||||
|
||||
### 4.2 Admin — Control room (`/admin/events/[id]/live`)
|
||||
This is the screen used during the night. Must be fast and hard to mess up.
|
||||
@@ -62,7 +65,9 @@ This is the screen used during the night. Must be fast and hard to mess up.
|
||||
Recent adjustments list with undo.
|
||||
- Scoreboard visibility: **Visible / Frozen / Hidden** (see 4.3). Big obvious toggle.
|
||||
- "Open scoreboard" button (new tab) + "Reveal mode" button (see 4.3).
|
||||
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer, `?` help.
|
||||
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first; "Clear buzzers".
|
||||
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
|
||||
`B` clear buzzers, `?` help.
|
||||
|
||||
### 4.3 Scoreboard (`/s/[token]`)
|
||||
- Projector-first, 16:9, readable from the back of a hall.
|
||||
@@ -106,8 +111,26 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
|
||||
- Timer: a tick for each of the last 10 seconds (sharper for the last 3) and a buzzer at zero.
|
||||
- Browsers only allow sound after a click on the page, so the scoreboard has a "Turn sound on"
|
||||
button. The choice is remembered, but after a reload one click anywhere is needed again.
|
||||
- Buzzer: a two-note ding when a team is first to buzz.
|
||||
- Sounds are synthesised in the browser (no audio files). With reduced motion the drum-roll is skipped.
|
||||
|
||||
### 4.7 Buzzers (`/b/[token]`)
|
||||
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox; scoring is unchanged
|
||||
(the host or the judges still enter the points).
|
||||
- Every team has one buzzer link for the whole night. Someone on the team opens it on a phone:
|
||||
team name, the current game, and one big "Buzz" button.
|
||||
- The button works only while the current game uses buzzers and isn't done. Otherwise the page
|
||||
says there's no buzzer for this game, and wakes up by itself when one starts.
|
||||
- The server decides the order, using the database's clock — never the phones' clocks.
|
||||
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz, so
|
||||
the host knows who is next if the first answer is wrong.
|
||||
- The phone shows "You buzzed first!" (the screen turns the team's colour) or its place and who was first.
|
||||
- The control room lists the order with the gap behind the first buzz ("+0.42s"). "Clear buzzers"
|
||||
(or `B`) empties it for the next question.
|
||||
- The scoreboard shows "{Team} buzzed!" in the team's colour, with the following teams after it,
|
||||
and plays a ding. It is not a score, so it shows whether scores are visible, frozen or hidden.
|
||||
- The phone asks the browser to keep the screen on while the page is open.
|
||||
|
||||
## 5. Non-functional
|
||||
- Live latency target < 1s on normal Wi-Fi.
|
||||
- Works with ~10 teams, ~20 games, ~10 judges per game without lag.
|
||||
@@ -117,4 +140,6 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
|
||||
## 6. Out of scope (v1)
|
||||
- Individual contestants within teams (stretch: `participants` per team).
|
||||
- Multiple admins / co-hosts (stretch).
|
||||
- Per-game timers and buzzers for contestants (stretch). The event countdown timer is in 4.5.
|
||||
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
|
||||
- Buzzers for individual contestants, automatic points for buzzing first, and locking a team out
|
||||
after a wrong answer (the host just reads the next team from the order).
|
||||
|
||||
+22
@@ -75,6 +75,28 @@ Collapses to stacked panels on tablet.
|
||||
```
|
||||
Selected block filled in team colour. Tap the selected block again to clear.
|
||||
|
||||
### Buzzer (phone)
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ● Lions │
|
||||
│ Bible quiz │
|
||||
│ │
|
||||
│ ╭─────────╮ │
|
||||
│ │ │ │
|
||||
│ │ Buzz │ │
|
||||
│ │ │ │
|
||||
│ ╰─────────╯ │
|
||||
│ │
|
||||
│ Ready. Tap to buzz in. │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
One round button in the team's colour, as big as the screen allows; it reacts on touch-down, not
|
||||
on release. After buzzing it shows the team's place ("1st", "2nd"). First in: the whole screen
|
||||
turns the team's colour. Greyed out while there is no buzzer game.
|
||||
|
||||
On the scoreboard a buzz is a banner in the team's colour above the standings: "Lions buzzed!",
|
||||
then the later teams in small text. It pops in once; reduced motion shows it without the pop.
|
||||
|
||||
## Copy
|
||||
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
|
||||
Errors say what happened + what to do: "This link has been turned off. Ask the host for a new one."
|
||||
|
||||
Reference in New Issue
Block a user