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>
This commit is contained in:
2026-10-05 09:55:50 +02:00
co-authored by Claude Opus 5.5
parent bbcf84bbc3
commit 186a112b95
19 changed files with 562 additions and 133 deletions
+14 -3
View File
@@ -33,7 +33,7 @@ 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`.
- 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.
- 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")
@@ -73,8 +73,12 @@ type ScoreboardPayload = {
} | 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
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
};
@@ -100,6 +104,13 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
- 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
```
+8
View File
@@ -73,5 +73,13 @@ human a short "how to test this manually" checklist. Don't start the next phase
**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.
## Phase 11 — Buzzer answers
- Apply `supabase/migrations/0004_buzz_result.sql`, then `npm run gen:types`.
- `checkMark` / `bumpScore` / `answeringTeamId` in `src/lib/buzzer.ts` (tested); `markBuzz` server action.
- Control room: "Got it" (`G`), "Wrong" (`W`), "Undo", "Next question" (`B`).
- Phone and scoreboard follow whose turn it is; "correct" and "wrong" sounds.
**Done when:** team A buzzes then team B; "Wrong" hands the turn to B; "Got it" gives B one point
in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question" resets the phones.
## Stretch (only if asked)
Participants within teams · co-host admins · CSV export of results.
+3 -2
View File
@@ -1,6 +1,6 @@
# Data model
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`).
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`).
| Table | Purpose | Key columns |
|---|---|---|
@@ -11,7 +11,7 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
| `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` |
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at`, `result` (correct/wrong/NULL) |
## 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.
@@ -19,4 +19,5 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
- 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.
- Buzzer answers (`0004_buzz_result.sql`): `buzzes.result` is the host's mark. The team answering is the earliest buzz with `result IS NULL`, unless some buzz is `correct` (question decided). The point for a correct answer is not stored on the buzz: it is +1 on the admin's `scores` row for the game's first criterion.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+27 -14
View File
@@ -65,9 +65,10 @@ 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).
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first; "Clear buzzers".
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first;
"Got it" / "Wrong" for the team answering, "Undo" on a marked team, and "Next question".
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
`B` clear buzzers, `?` help.
`G` got it, `W` wrong, `B` next question (clears the buzzers), `?` help.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
@@ -111,24 +112,36 @@ 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.
- Buzzer: a two-note ding when a team starts answering, a rising arpeggio for "got it" and two
falling notes for "wrong".
- 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).
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
- 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.
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz and
queue up behind the team answering.
- The team answering is the earliest buzz the host hasn't marked. The host marks it:
- **Got it** (`G`) — the question is decided and the buzzers close. In a direct game the team
gets +1 in the game's first criterion (so the point follows the game's multiplier and
placement rules like any other score). In a judged game nothing is added: the judges score.
- **Wrong** (`W`) — that team is out for this question and the turn passes to the next team
in the buzz order. With nobody waiting, the buzzers stay open for the other teams.
- **Undo** on a marked team removes the mark, and takes the point back if it was "got it".
- **Next question** (`B`) clears everything and opens the buzzers again.
- If the point can't be added (the score is at the criterion's maximum, or +1 doesn't fit its
step), the answer stays unmarked and the host sees why.
- The phone's screen turns the team's colour while it is that team's turn ("You buzzed first!
Answer now." / "Your turn! Answer now.") and when it got it. Otherwise it shows the team's
place and who is answering, "Not this time" after a wrong answer, or who got it.
- The control room lists the order with the gap behind the first buzz ("+0.42s") and each mark.
- The scoreboard shows "{Team} buzzed!" in the team's colour with the waiting teams after it,
"{Team} to answer" when the turn passes, and "{Team} got it!". It is not a score, so it shows
whether scores are visible, frozen or hidden (the point itself follows the usual visibility rules).
- The phone asks the browser to keep the screen on while the page is open.
## 5. Non-functional
@@ -141,5 +154,5 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox; s
- Individual contestants within teams (stretch: `participants` per team).
- Multiple admins / co-hosts (stretch).
- 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).
- Buzzers for individual contestants, a configurable points value per correct answer, and
penalties for a wrong answer (use the bonus/penalty panel).
+8 -3
View File
@@ -91,11 +91,16 @@ Selected block filled in team colour. Tap the selected block again to clear.
└─────────────────────────┘
```
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
on release. After buzzing it shows the team's place ("1st", "2nd"), then ✓ or ✗ once the host
marks the answer. While it's the team's turn to answer, and when it got it, 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.
On the scoreboard the buzzer is a banner in the answering team's colour above the standings:
"Lions buzzed!" with the waiting teams in small text, "Eagles to answer" when the turn passes,
"Eagles got it!" at the end. It pops in on each change; reduced motion shows it without the pop.
In the control room the answering team's row is highlighted (Floodlight) and carries the two
buttons "Got it +1" and "Wrong"; marked rows show ✓ / ✗ and an "Undo".
## Copy
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
+91 -23
View File
@@ -5,7 +5,8 @@ import { z } from "zod";
import { requireAdmin } from "@/lib/auth";
import type { Json } from "@/lib/database.types";
import { loadEventData } from "@/lib/queries/scoring";
import { bumpScore, checkMark, type BuzzResult } from "@/lib/buzzer";
import { loadBuzzes, loadEventData } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { buildFrozenSnapshot } from "@/lib/scoreboard";
import { toScoreboardInput } from "@/lib/scoreboard.server";
@@ -130,28 +131,8 @@ export async function saveAdminScore(
const parsed = scoreSchema.safeParse({ value });
if (!parsed.success) return fail("That score isn't a valid number.");
if (parsed.data.value === null) {
const { error } = await supabase
.from("scores")
.delete()
.eq("game_id", gameId)
.eq("team_id", teamId)
.eq("criterion_id", criterionId)
.is("judge_id", null);
if (error) return scoreError(error.message);
} else {
const { error } = await supabase.from("scores").upsert(
{
game_id: gameId,
team_id: teamId,
criterion_id: criterionId,
judge_id: null,
value: parsed.data.value,
},
{ onConflict: "team_id,criterion_id,judge_id" },
);
if (error) return scoreError(error.message);
}
const { error } = await writeAdminScore(supabase, gameId, teamId, criterionId, parsed.data.value);
if (error) return scoreError(error.message);
// No revalidatePath: the control room refreshes itself, so the "saved" tick isn't
// held up by a full page re-render.
@@ -159,6 +140,93 @@ export async function saveAdminScore(
return ok;
}
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
/** Upserts the admin's row for one cell, or deletes it when `value` is null. */
function writeAdminScore(
supabase: Supabase,
gameId: string,
teamId: string,
criterionId: string,
value: number | null,
) {
return value === null
? supabase
.from("scores")
.delete()
.eq("game_id", gameId)
.eq("team_id", teamId)
.eq("criterion_id", criterionId)
.is("judge_id", null)
: supabase.from("scores").upsert(
{ game_id: gameId, team_id: teamId, criterion_id: criterionId, judge_id: null, value },
{ onConflict: "team_id,criterion_id,judge_id" },
);
}
// ---------- buzzer answers ----------
const buzzResultSchema = z.enum(["correct", "wrong"]).nullable();
/**
* Marks the answering team's buzz: "correct" ends the question, "wrong" passes the turn to the
* next team that buzzed, null undoes an earlier mark. In a direct game a correct answer also
* adds a point to the team's score in the game's first criterion (and undoing takes it back);
* in a judged game the judges' scores stand, so only the mark changes.
*/
export async function markBuzz(
eventId: string,
gameId: string,
teamId: string,
result: BuzzResult,
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, teamId) || !buzzResultSchema.safeParse(result).success) return BAD_ID;
const [game, buzzes, criteria] = await Promise.all([
// The game must be in this event; RLS alone would allow a game from another of your events.
supabase.from("games").select("scoring_mode").eq("id", gameId).eq("event_id", eventId).maybeSingle(),
loadBuzzes(supabase, gameId),
supabase.from("criteria").select("id, min_value").eq("game_id", gameId).order("sort_order").limit(1),
]);
const loadError = game.error ?? buzzes.error ?? criteria.error;
if (loadError) return dbFail("Marking the answer", loadError.message);
if (!game.data) return BAD_ID;
const check = checkMark(buzzes.data ?? [], teamId, result);
if (!check.ok) return fail(check.error);
if (!check.changed) return ok;
// The point first: if it can't be saved (say the score is at its maximum), the answer
// stays unmarked and the host sees why, instead of a "correct" with no point behind it.
const criterion = criteria.data?.[0];
if (check.pointDelta !== 0 && game.data.scoring_mode === "direct" && criterion) {
const current = await supabase
.from("scores")
.select("value")
.eq("team_id", teamId)
.eq("criterion_id", criterion.id)
.is("judge_id", null)
.maybeSingle();
if (current.error) return dbFail("Adding the point", current.error.message);
const next = bumpScore(current.data?.value ?? null, check.pointDelta, criterion.min_value);
const { error } = await writeAdminScore(supabase, gameId, teamId, criterion.id, next);
if (error) return scoreError(error.message);
}
const { error } = await supabase
.from("buzzes")
.update({ result })
.eq("game_id", gameId)
.eq("team_id", teamId);
if (error) return dbFail("Marking the answer", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId, gameId, buzzer: true });
return ok;
}
// ---------- bonus / penalty ----------
const adjustmentSchema = z.object({
+80 -19
View File
@@ -1,27 +1,34 @@
"use client";
import { clearBuzzes } from "@/app/admin/live-actions";
import { clearBuzzes, markBuzz } from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer";
import type { LiveGame } from "@/lib/live-view";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
/** Who buzzed for the current question, in order, and the button that clears them for the next. */
export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; gameId: string; buzzer: BuzzerView }) {
/**
* Who buzzed for the current question, in order. The host marks the answering team:
* "Got it" gives the point and ends the question, "Wrong" passes the turn down the order.
*/
export function BuzzerPanel({ eventId, game, buzzer }: { eventId: string; game: LiveGame; buzzer: BuzzerView }) {
const { pending, error, run } = useAction();
const { buzzes } = buzzer;
const { buzzes, answeringTeamId } = buzzer;
const decided = buzzes.some((entry) => entry.result === "correct");
// Only a direct game takes the point; in a judged game the judges' scores stand.
const pointCriterion = game.scoringMode === "direct" ? game.criteria[0] : undefined;
return (
<div className="rounded-md border border-ink/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-bold">Buzzers</h3>
<Button
variant="secondary"
variant={decided ? "primary" : "secondary"}
disabled={pending || buzzes.length === 0}
onClick={() => run(() => clearBuzzes(eventId, gameId))}
onClick={() => run(() => clearBuzzes(eventId, game.id))}
>
Clear buzzers (B)
Next question (B)
</Button>
</div>
@@ -30,20 +37,74 @@ export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; game
{buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."}
</p>
) : (
<ol role="status" className="mt-2 flex flex-col gap-1">
{buzzes.map((entry, index) => (
<li
key={entry.teamId}
className={`flex items-center gap-2 rounded-md px-2 ${index === 0 ? "min-h-11 bg-floodlight text-xl font-bold" : ""}`}
>
<span className="w-5 tabular-nums">{index + 1}</span>
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<span className="text-sm tabular-nums">{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}</span>
</li>
))}
<ol className="mt-2 flex flex-col gap-1">
{buzzes.map((entry, index) => {
const answering = entry.teamId === answeringTeamId;
return (
<li
key={entry.teamId}
aria-current={answering ? "true" : undefined}
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
answering ? "bg-floodlight text-xl font-bold" : entry.result === "wrong" ? "text-ink/70" : ""
}`}
>
<span className="w-5 tabular-nums">{index + 1}</span>
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className={`min-w-0 flex-1 truncate ${entry.result === "wrong" ? "line-through" : ""}`}>
{entry.name}
</span>
<span className="text-sm font-normal tabular-nums">
{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}
</span>
{answering && (
<>
<Button
disabled={pending}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "correct"))}
>
{pointCriterion ? "Got it +1 (G)" : "Got it (G)"}
</Button>
<Button
variant="danger"
disabled={pending}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "wrong"))}
>
Wrong (W)
</Button>
</>
)}
{entry.result && (
<>
<strong className={entry.result === "correct" ? "" : "text-alert-strong"}>
{entry.result === "correct" ? "✓ Got it" : "✗ Wrong"}
</strong>
<Button
variant="secondary"
size="sm"
disabled={pending}
aria-label={`Undo for ${entry.name}`}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, null))}
>
Undo
</Button>
</>
)}
</li>
);
})}
</ol>
)}
<p role="status" className="mt-2 text-sm text-ink/70">
{decided
? "Answered. Press “Next question” to open the buzzers again."
: buzzes.length > 0 && !answeringTeamId
? "Nobody is waiting to answer. The buzzers are still open."
: pointCriterion
? `“Got it” adds 1 to ${pointCriterion.label}. “Wrong” passes to the next team.`
: "Judged game: “Got it” adds no point — the judges score. “Wrong” passes to the next team."}
</p>
<ErrorText>{error}</ErrorText>
</div>
);
+18 -3
View File
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
import {
clearBuzzes,
markBuzz,
pauseTimer,
setCurrentGame,
setGameStatus,
@@ -35,7 +36,9 @@ const shortcuts = [
["P", "Previous game"],
["H", "Cycle what the room sees: visible → frozen → hidden"],
["T", "Start or pause the timer"],
["B", "Clear the buzzers for the next question"],
["G", "Buzzer: the answering team got it"],
["W", "Buzzer: wrong, pass to the next team"],
["B", "Buzzer: next question (clears the buzzers)"],
["?", "Show or hide this help"],
] as const;
@@ -79,6 +82,16 @@ export function ControlRoom({ view }: { view: LiveView }) {
if (buzzerGameId && !pending) run(() => clearBuzzes(event.id, buzzerGameId));
}, [buzzerGameId, event.id, pending, run]);
const answeringTeamId = currentGame?.buzzer?.answeringTeamId;
const markAnswer = useCallback(
(result: "correct" | "wrong") => {
if (currentGame && answeringTeamId && !pending) {
run(() => markBuzz(event.id, currentGame.id, answeringTeamId, result));
}
},
[answeringTeamId, currentGame, event.id, pending, run],
);
useEffect(() => {
const onKeyDown = (keyEvent: KeyboardEvent) => {
// Never steal keys from a field the host is typing in.
@@ -92,12 +105,14 @@ export function ControlRoom({ view }: { view: LiveView }) {
else if (key === "h") cycleVisibility();
else if (key === "t") toggleTimer();
else if (key === "b") clearBuzzers();
else if (key === "g") markAnswer("correct");
else if (key === "w") markAnswer("wrong");
else if (key === "?") setShowHelp((shown) => !shown);
else if (key === "escape") setShowHelp(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [clearBuzzers, cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]);
}, [clearBuzzers, cycleVisibility, goTo, markAnswer, nextGame?.id, previousGame?.id, toggleTimer]);
const teamsById = new Map(teams.map((team) => [team.id, team]));
@@ -221,7 +236,7 @@ export function ControlRoom({ view }: { view: LiveView }) {
{currentGame ? (
<>
{currentGame.buzzer && (
<BuzzerPanel eventId={event.id} gameId={currentGame.id} buzzer={currentGame.buzzer} />
<BuzzerPanel eventId={event.id} game={currentGame} buzzer={currentGame.buzzer} />
)}
{/* Keyed by game so half-saved edits never carry over to another game. */}
<ScoringGrid key={currentGame.id} eventId={event.id} game={currentGame} teams={teams} />
+28 -15
View File
@@ -39,7 +39,6 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
const { team, currentGame, buzzer } = payload;
const position = buzzer ? buzzer.buzzes.findIndex((entry) => entry.teamId === team.id) : -1;
const first = buzzer?.buzzes[0];
const canBuzz = Boolean(buzzer?.open) && position < 0 && !sending;
const press = async () => {
@@ -72,27 +71,41 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
if (linkOff) return <BuzzerMessage>{linkOff}</BuzzerMessage>;
const mine = position >= 0 ? buzzer?.buzzes[position] : undefined;
const answering = buzzer?.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
const winner = buzzer?.buzzes.find((entry) => entry.result === "correct");
const myTurn = answering?.teamId === team.id;
const status = !buzzer
? buzzerMessages.noGame
: position === 0
? "You buzzed first!"
: position > 0
? `You buzzed ${ordinal(position + 1)}. ${first?.name} was first.`
: sending
? "Buzzing…"
: !buzzer.open
? buzzerMessages.closed
: first
? `${first.name} buzzed first. You can still buzz in.`
: "Ready. Tap to buzz in.";
: mine?.result === "correct"
? "You got it!"
: winner
? `${winner.name} got it. Wait for the next question.`
: mine?.result === "wrong"
? "Not this time. Wait for the next question."
: myTurn
? position === 0
? "You buzzed first! Answer now."
: "Your turn! Answer now."
: mine
? `You buzzed ${ordinal(position + 1)}. ${answering?.name} is answering.`
: sending
? "Buzzing…"
: !buzzer.open
? buzzerMessages.closed
: answering
? `${answering.name} is answering. You can still buzz in.`
: "Ready. Tap to buzz in.";
const won = position === 0;
// The screen lights up while it's this team's moment: answering, or just got it.
const won = myTurn || mine?.result === "correct";
const textColor = readableTextColor(team.color);
return (
<main
className="mx-auto flex min-h-dvh max-w-xl flex-col items-center gap-4 p-4 pb-8 transition-colors"
// First in: the whole screen turns the team's colour, so the table sees it at a glance.
// The whole screen turns the team's colour, so the table sees at a glance it's their turn.
style={won ? { backgroundColor: team.color, color: textColor } : undefined}
>
<header className="w-full text-center">
@@ -122,7 +135,7 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
} ${won ? "border-current" : "border-ink"}`}
style={{ backgroundColor: team.color, color: textColor }}
>
{position >= 0 ? ordinal(position + 1) : "Buzz"}
{mine?.result === "correct" ? "✓" : mine?.result === "wrong" ? "✗" : mine ? ordinal(position + 1) : "Buzz"}
</button>
</div>
+26 -12
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { BuzzEntry } from "@/lib/buzzer";
import type { BuzzerView } from "@/lib/buzzer";
import { readableTextColor } from "@/lib/color";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import { useSound } from "@/lib/hooks/use-sound";
@@ -124,10 +124,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
{payload.timer && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
{payload.buzzer && payload.buzzer.buzzes.length > 0 && (
// Keyed by the first team so the pop replays when a new question has a new winner.
<BuzzBanner key={payload.buzzer.buzzes[0].teamId} buzzes={payload.buzzer.buzzes} />
)}
{payload.buzzer && <BuzzBanner buzzer={payload.buzzer} />}
{hidden && (
<p className="self-center rounded-full border-2 border-alert px-[2.5vw] py-[1vh] text-[clamp(1rem,3.2vh,2.5rem)] font-bold">
@@ -250,20 +247,37 @@ function TeamRow({
);
}
/** Who buzzed: the first team fills the banner in its colour, the rest follow in order. */
function BuzzBanner({ buzzes }: { buzzes: BuzzEntry[] }) {
const [first, ...rest] = buzzes;
/**
* The buzzer's headline: the team answering fills the banner in its colour, with the teams
* still waiting after it; once a team got it, the banner says so. Nothing while nobody is
* answering (no buzz yet, or everyone so far was wrong).
*/
function BuzzBanner({ buzzer }: { buzzer: BuzzerView }) {
const winner = buzzer.buzzes.find((entry) => entry.result === "correct");
const answering = buzzer.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
const team = winner ?? answering;
if (!team) return null;
const waiting = winner ? [] : buzzer.buzzes.filter((entry) => entry.result === null && entry !== answering);
// The very first buzz "buzzed"; a team that inherits the turn after a wrong answer is "to answer".
const headline = winner
? `${team.name} got it!`
: buzzer.buzzes[0] === answering
? `${team.name} buzzed!`
: `${team.name} to answer`;
return (
<div
// Keyed by the headline so the pop replays whenever the turn or the outcome changes.
key={headline}
role="status"
className="buzz-pop flex shrink-0 items-baseline justify-center gap-[2vw] rounded-[1vh] px-[3vw] py-[1.5vh] font-display leading-none font-bold"
style={{ backgroundColor: first.color, color: readableTextColor(first.color) }}
style={{ backgroundColor: team.color, color: readableTextColor(team.color) }}
>
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{first.name} buzzed!</span>
{rest.length > 0 && (
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{headline}</span>
{waiting.length > 0 && (
<span className="truncate text-[clamp(1rem,3.5vh,3rem)]">
then {rest.map((entry) => entry.name).join(", ")}
then {waiting.map((entry) => entry.name).join(", ")}
</span>
)}
</div>
+9
View File
@@ -82,6 +82,15 @@ export async function buzz(token: string): Promise<BuzzerLookup> {
if (!game?.buzzer_enabled) return { ok: false, status: 409, error: buzzerMessages.noGame };
if (!buzzerOpen(game)) return { ok: false, status: 409, error: buzzerMessages.closed };
// Once a team got it, the question is over until the host clears the buzzers.
const decided = await supabase
.from("buzzes")
.select("id", { count: "exact", head: true })
.eq("game_id", game.id)
.eq("result", "correct");
if (decided.error) throw new Error(`Could not check the buzzers: ${decided.error.message}`);
if (decided.count) return { ok: false, status: 409, error: buzzerMessages.answered };
// The database stamps the time and keeps the first row per team (see 0003_buzzer.sql).
const { error } = await supabase
.from("buzzes")
+113 -14
View File
@@ -1,13 +1,26 @@
import { describe, expect, it } from "vitest";
import { buzzerOpen, buzzerView, formatBuzzGap, ordinal } from "./buzzer";
import {
answeringTeamId,
bumpScore,
buzzerOpen,
buzzerView,
checkMark,
formatBuzzGap,
ordinal,
type BuzzResult,
} from "./buzzer";
const teams = [
{ id: "lions", name: "Lions", color: "#E63946" },
{ id: "eagles", name: "Eagles", color: "#3A86FF" },
{ id: "sharks", name: "Sharks", color: "#2EC4B6" },
];
const live = { buzzer_enabled: true, status: "live" };
const buzz = (team_id: string, buzzed_at: string) => ({ team_id, buzzed_at });
const buzz = (team_id: string, buzzed_at: string, result: BuzzResult = null) => ({ team_id, buzzed_at, result });
/** Buzzes in order, a tenth of a second apart. */
const order = (...entries: Array<[string, BuzzResult?]>) =>
entries.map(([teamId, result], index) => buzz(teamId, `2026-10-05T19:00:00.${index}00Z`, result ?? null));
describe("buzzerOpen", () => {
it("is open only for a buzzer game that isn't done", () => {
@@ -19,6 +32,27 @@ describe("buzzerOpen", () => {
});
});
describe("answeringTeamId", () => {
it("is the first buzz until the host marks it", () => {
expect(answeringTeamId([])).toBeNull();
expect(answeringTeamId(order(["lions"], ["eagles"]))).toBe("lions");
});
it("passes down the order on a wrong answer", () => {
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"], ["sharks"]))).toBe("eagles");
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "wrong"], ["sharks"]))).toBe("sharks");
});
it("is nobody when everyone so far was wrong, until the next buzz", () => {
expect(answeringTeamId(order(["lions", "wrong"]))).toBeNull();
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"]))).toBe("eagles");
});
it("is nobody once a team got it, even with teams still waiting", () => {
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "correct"], ["sharks"]))).toBeNull();
});
});
describe("buzzerView", () => {
it("is null when there is no game or it doesn't use buzzers", () => {
expect(buzzerView(null, teams, [])).toBeNull();
@@ -26,7 +60,7 @@ describe("buzzerView", () => {
});
it("is open and empty before anyone buzzes", () => {
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [] });
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [], answeringTeamId: null });
});
it("keeps the given order and measures each team against the first buzz", () => {
@@ -34,26 +68,91 @@ describe("buzzerView", () => {
buzz("eagles", "2026-10-05T19:00:00.100000+00:00"),
buzz("lions", "2026-10-05T19:00:00.520000+00:00"),
]);
expect(view?.buzzes).toEqual([
{ teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0 },
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420 },
]);
expect(view).toEqual({
open: true,
answeringTeamId: "eagles",
buzzes: [
{ teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0, result: null },
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420, result: null },
],
});
});
it("stays open after a wrong answer, with the next team answering", () => {
const view = buzzerView(live, teams, order(["lions", "wrong"], ["eagles"]));
expect(view?.open).toBe(true);
expect(view?.answeringTeamId).toBe("eagles");
expect(view?.buzzes.map((entry) => entry.result)).toEqual(["wrong", null]);
});
it("closes once a team got it", () => {
const view = buzzerView(live, teams, order(["lions", "correct"], ["eagles"]));
expect(view?.open).toBe(false);
expect(view?.answeringTeamId).toBeNull();
});
it("still shows who buzzed once the game is done, but closed", () => {
const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, [
buzz("lions", "2026-10-05T19:00:00.000Z"),
]);
const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, order(["lions"]));
expect(view?.open).toBe(false);
expect(view?.buzzes).toHaveLength(1);
});
it("skips a buzz from a team that has since been deleted", () => {
const view = buzzerView(live, teams, [
buzz("gone", "2026-10-05T19:00:00.000Z"),
buzz("lions", "2026-10-05T19:00:01.000Z"),
const view = buzzerView(live, teams, order(["gone"], ["lions"]));
expect(view?.buzzes).toEqual([
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 0, result: null },
]);
expect(view?.buzzes.map((entry) => entry.teamId)).toEqual(["lions"]);
expect(view?.answeringTeamId).toBe("lions");
});
});
describe("checkMark", () => {
const waiting = order(["lions"], ["eagles"]);
it("lets the host mark the answering team, and a correct answer earns a point", () => {
expect(checkMark(waiting, "lions", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
expect(checkMark(waiting, "lions", "wrong")).toEqual({ ok: true, changed: true, pointDelta: 0 });
});
it("refuses a team that hasn't buzzed or whose turn it isn't", () => {
expect(checkMark(waiting, "sharks", "correct").ok).toBe(false);
expect(checkMark(waiting, "eagles", "correct").ok).toBe(false);
// Decided already: nobody else can be marked.
expect(checkMark(order(["lions", "correct"], ["eagles"]), "eagles", "wrong").ok).toBe(false);
});
it("moves on to the next team after a wrong answer", () => {
const afterWrong = order(["lions", "wrong"], ["eagles"]);
expect(checkMark(afterWrong, "eagles", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
expect(checkMark(afterWrong, "lions", "correct").ok).toBe(false);
});
it("does nothing when the mark is already what was asked (a double click)", () => {
expect(checkMark(order(["lions", "correct"]), "lions", "correct")).toEqual({ ok: true, changed: false, pointDelta: 0 });
expect(checkMark(waiting, "lions", null)).toEqual({ ok: true, changed: false, pointDelta: 0 });
});
it("undoes a mark, taking the point back if it was correct", () => {
expect(checkMark(order(["lions", "correct"]), "lions", null)).toEqual({ ok: true, changed: true, pointDelta: -1 });
expect(checkMark(order(["lions", "wrong"], ["eagles"]), "lions", null)).toEqual({
ok: true,
changed: true,
pointDelta: 0,
});
});
});
describe("bumpScore", () => {
it("adds a point, starting from zero when there is no score yet", () => {
expect(bumpScore(null, 1, 0)).toBe(1);
expect(bumpScore(4, 1, 0)).toBe(5);
});
it("takes the point back, clearing the cell rather than going below the minimum", () => {
expect(bumpScore(5, -1, 0)).toBe(4);
expect(bumpScore(1, -1, 0)).toBe(0);
expect(bumpScore(1, -1, 1)).toBeNull();
expect(bumpScore(null, -1, 0)).toBeNull();
});
});
+65 -15
View File
@@ -5,21 +5,28 @@ export const buzzerMessages = {
linkOff: "This link has been turned off. Ask the host for a new one.",
noGame: "No buzzer for this game. Keep this page open.",
closed: "This game is finished, so the buzzers are closed.",
answered: "This question has been answered. Wait for the next one.",
} as const;
/** How the host marked a team's answer; null until they do. */
export type BuzzResult = "correct" | "wrong" | null;
export type BuzzEntry = {
teamId: string;
name: string;
color: string;
/** How long after the first buzz this team buzzed; 0 for the first. */
afterFirstMs: number;
result: BuzzResult;
};
export type BuzzerView = {
/** Teams can buzz right now. False once the game is marked done. */
/** Teams can buzz right now. False once someone got it, or the game is marked done. */
open: boolean;
/** In the order the teams buzzed. Empty until someone does, and again after a clear. */
buzzes: BuzzEntry[];
/** Whose turn it is to answer, or null: nobody is waiting, or the question is decided. */
answeringTeamId: string | null;
};
/** What a team's phone gets. Nothing here is a score, so it ignores scoreboard visibility. */
@@ -34,12 +41,22 @@ export type BuzzerPayload = {
};
type GameFacts = { buzzer_enabled: boolean; status: string };
type BuzzFacts = { team_id: string; result: BuzzResult };
/** Buzzers work only for the current game, only if it uses them, and only until it's done. */
export function buzzerOpen(game: GameFacts | null | undefined): boolean {
return Boolean(game?.buzzer_enabled) && game?.status !== "done";
}
/**
* The team whose turn it is: the earliest buzz the host hasn't marked. A wrong answer passes
* the turn down the order; a correct one ends the question. `buzzes` must be in buzz order.
*/
export function answeringTeamId(buzzes: BuzzFacts[]): string | null {
if (buzzes.some((buzz) => buzz.result === "correct")) return null;
return buzzes.find((buzz) => buzz.result === null)?.team_id ?? null;
}
/**
* The buzzer state for the current game, or null when it doesn't use buzzers.
* `buzzes` must already be in buzz order (the database's clock decides, see 0003_buzzer.sql).
@@ -47,29 +64,62 @@ export function buzzerOpen(game: GameFacts | null | undefined): boolean {
export function buzzerView(
game: GameFacts | null | undefined,
teams: Array<{ id: string; name: string; color: string }>,
buzzes: Array<{ team_id: string; buzzed_at: string }>,
buzzes: Array<BuzzFacts & { buzzed_at: string }>,
): BuzzerView | null {
if (!game?.buzzer_enabled) return null;
const teamsById = new Map(teams.map((team) => [team.id, team]));
const firstAt = buzzes.length ? Date.parse(buzzes[0].buzzed_at) : 0;
// A deleted team's buzzes are deleted with it; this only covers the moment in between.
const known = buzzes.filter((buzz) => teamsById.has(buzz.team_id));
const firstAt = known.length ? Date.parse(known[0].buzzed_at) : 0;
return {
open: buzzerOpen(game),
buzzes: buzzes.flatMap((buzz) => {
const team = teamsById.get(buzz.team_id);
if (!team) return [];
return [
{
teamId: team.id,
name: team.name,
color: team.color,
afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt),
},
];
open: buzzerOpen(game) && !known.some((buzz) => buzz.result === "correct"),
buzzes: known.map((buzz) => {
const team = teamsById.get(buzz.team_id)!;
return {
teamId: team.id,
name: team.name,
color: team.color,
afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt),
result: buzz.result,
};
}),
answeringTeamId: answeringTeamId(known),
};
}
export type MarkCheck =
| { ok: true; changed: boolean; pointDelta: -1 | 0 | 1 }
| { ok: false; error: string };
/**
* May the host mark this team's buzz as `result`, and does the team's score move?
* Marking correct or wrong is only for the team answering; `null` undoes an earlier mark.
*/
export function checkMark(buzzes: BuzzFacts[], teamId: string, result: BuzzResult): MarkCheck {
const buzz = buzzes.find((candidate) => candidate.team_id === teamId);
if (!buzz) return { ok: false, error: "That team hasn't buzzed for this question. Reload the page." };
if (buzz.result === result) return { ok: true, changed: false, pointDelta: 0 };
if (result !== null && answeringTeamId(buzzes) !== teamId) {
return { ok: false, error: "It isn't that team's turn to answer. Reload the page." };
}
const pointDelta = Number(result === "correct") - Number(buzz.result === "correct");
return { ok: true, changed: true, pointDelta: pointDelta as -1 | 0 | 1 };
}
/**
* A team's score in the buzzer criterion after gaining a point for a correct answer, or
* losing it again when the host undoes that. null = no score. Taking the point back never
* goes below the criterion's minimum: the cell is cleared instead.
*/
export function bumpScore(current: number | null, pointDelta: -1 | 1, minValue: number): number | null {
if (pointDelta === 1) return (current ?? 0) + 1;
if (current === null) return null;
return current - 1 < minValue ? null : current - 1;
}
/** "+0.42s" behind the first buzz, for the host's list. */
export function formatBuzzGap(afterFirstMs: number): string {
return `+${(afterFirstMs / 1000).toFixed(2)}s`;
+4
View File
@@ -63,18 +63,21 @@ export type Database = {
buzzed_at: string;
game_id: string;
id: string;
result: Database["public"]["Enums"]["buzz_result"] | null;
team_id: string;
};
Insert: {
buzzed_at?: string;
game_id: string;
id?: string;
result?: Database["public"]["Enums"]["buzz_result"] | null;
team_id: string;
};
Update: {
buzzed_at?: string;
game_id?: string;
id?: string;
result?: Database["public"]["Enums"]["buzz_result"] | null;
team_id?: string;
};
Relationships: [
@@ -430,6 +433,7 @@ export type Database = {
[_ in never]: never;
};
Enums: {
buzz_result: "correct" | "wrong";
criterion_input: "blocks" | "number";
game_status: "upcoming" | "live" | "done";
judge_source: "nominated" | "open_link";
+30 -6
View File
@@ -67,13 +67,37 @@ describe("soundsForUpdate", () => {
expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]);
});
it("dings for a new first buzz only", () => {
const buzzed = (...ids: string[]): Partial<ScoreboardPayload> => ({
buzzer: {
open: true,
buzzes: ids.map((teamId, index) => ({ teamId, name: teamId, color: "#000000", afterFirstMs: index * 100 })),
},
// Team ids in buzz order; "a:wrong" / "a:correct" is a buzz the host has marked.
const buzzed = (...marks: string[]): Partial<ScoreboardPayload> => {
const buzzes = marks.map((mark, index) => {
const [teamId, result = null] = mark.split(":") as [string, ("correct" | "wrong")?];
return { teamId, name: teamId, color: "#000000", afterFirstMs: index * 100, result };
});
const decided = buzzes.some((entry) => entry.result === "correct");
return {
buzzer: {
open: !decided,
buzzes,
answeringTeamId: decided ? null : (buzzes.find((entry) => entry.result === null)?.teamId ?? null),
},
};
};
it("plays the verdict when the host marks an answer", () => {
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:correct")), motion)).toEqual(["correct"]);
// Wrong with the next team already waiting: just the "wrong", no ding on top.
expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["wrong"]);
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:wrong")), motion)).toEqual(["wrong"]);
// Already marked: silent on the next poll.
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a:correct")), motion)).toEqual([]);
});
it("dings when a team buzzes in after everyone before it was wrong, or a mark is undone", () => {
expect(soundsForUpdate(payload(buzzed("a:wrong")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["buzzIn"]);
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
});
it("dings for a new first buzz only", () => {
expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
// A second team buzzing, or nothing changing, is silent.
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a", "b")), motion)).toEqual([]);
+10 -3
View File
@@ -33,9 +33,16 @@ export function soundsForUpdate(
}
}
// A new first buzz. Later buzzes are silent: the room only needs to hear who won the race.
const firstBuzz = after.buzzer?.buzzes[0]?.teamId;
if (firstBuzz && firstBuzz !== before.buzzer?.buzzes[0]?.teamId) sounds.push("buzzIn");
// Buzzers: the host's verdict if there is a new one, otherwise a ding for a new team
// answering. Teams that buzz in behind it are silent: the room only needs to hear who's up.
const marksBefore = new Map(before.buzzer?.buzzes.map((entry) => [entry.teamId, entry.result]));
const newlyMarked = (result: "correct" | "wrong") =>
after.buzzer?.buzzes.some((entry) => entry.result === result && marksBefore.get(entry.teamId) !== result) ?? false;
const answering = after.buzzer?.answeringTeamId;
if (newlyMarked("correct")) sounds.push("correct");
// The next team's turn starts with this same update; one sound, not two on top of each other.
else if (newlyMarked("wrong")) sounds.push("wrong");
else if (answering && answering !== before.buzzer?.answeringTeamId) sounds.push("buzzIn");
return sounds;
}
+2 -1
View File
@@ -185,7 +185,8 @@ describe("timer", () => {
describe("buzzer", () => {
const buzzer = {
open: true,
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0 }],
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0, result: null }],
answeringTeamId: "sharks",
};
it("is null when the current game has no buzzers", () => {
+14
View File
@@ -11,6 +11,8 @@ export type SoundName =
| "crash" // a team appears with no drum-roll (reduced motion)
| "game" // a new game starts
| "buzzIn" // a team is first on the buzzer
| "correct" // the answering team got it
| "wrong" // the answering team got it wrong
| "chime"; // confirmation when sound is switched on
/** Matches the scoreboard's reveal animation: the cover lifts 70% into 1800ms. */
@@ -142,6 +144,18 @@ export function createSoundPlayer(): SoundPlayer {
tone(start + 0.11, 1318.51, 0.45, 0.4, "square");
tone(start + 0.11, 2637.02, 0.3, 0.1, "sine");
},
correct: (start) => {
// A quick rising arpeggio that lands on a bright chord.
[659.25, 830.61, 987.77, 1318.51].forEach((frequency, index) => {
tone(start + index * 0.07, frequency, index === 3 ? 0.7 : 0.18, 0.3, "triangle");
tone(start + index * 0.07, frequency * 2, index === 3 ? 0.5 : 0.12, 0.08, "sine");
});
},
wrong: (start) => {
// Two short falling notes: "uh-oh". Shorter and higher than the time's-up buzzer.
tone(start, 311.13, 0.22, 0.4, "sawtooth");
tone(start + 0.24, 233.08, 0.45, 0.4, "sawtooth");
},
chime: (start) => {
tone(start, 783.99, 0.35, 0.3, "sine");
tone(start + 0.12, 1046.5, 0.5, 0.3, "sine");
+12
View File
@@ -0,0 +1,12 @@
-- Game Night Live — marking buzzer answers.
-- The host marks the answering team's buzz correct or wrong. Run after 0003_buzzer.sql.
--
-- result null : not judged yet. The first such buzz (in buzz order) is the team answering,
-- as long as nobody has been marked correct.
-- result wrong : that team is out for this question; the turn passes to the next buzz.
-- result correct : the question is decided; no more buzzes until the host clears.
create type buzz_result as enum ('correct', 'wrong');
alter table buzzes
add column result buzz_result;