63 KiB
Act 2 Games (Jury Voting + Bingo) Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add two live-event games — Jury Voting (host opens/closes per-country ratings, consensus scoring) and ESC Bingo (4×4 trope cards, tap-to-mark, server-validated bingo lines) — both active during the live-event act.
Architecture: Extend the existing GameManager with jury and bingo state. Add new WS message types for both games. Player phones show tabs for Jury and Bingo during live-event. The host controls jury voting rounds. Bingo runs passively. Both feed into a shared leaderboard (future scoring task).
Tech Stack: Zod schemas (shared), in-memory game state (GameManager), Hono WebSocket handler, React + shadcn/ui + Zustand (client), Vitest (tests)
File Structure
New Files
| File | Responsibility |
|---|---|
packages/server/data/bingo-tropes.json |
Array of 30+ tropes with id + label |
packages/client/src/components/jury-voting.tsx |
Player jury rating UI (1-12 slider/buttons) |
packages/client/src/components/jury-host.tsx |
Host controls: open/close voting per country |
packages/client/src/components/jury-display.tsx |
Display: current voting country, results reveal |
packages/client/src/components/bingo-card.tsx |
Player bingo card UI (4×4 grid, tap squares) |
packages/client/src/components/bingo-display.tsx |
Display: bingo announcements |
Modified Files
| File | Changes |
|---|---|
packages/shared/src/game-types.ts |
Add jury round, jury vote, bingo card, bingo square, scoring schemas; extend gameStateSchema |
packages/shared/src/ws-messages.ts |
Add jury + bingo client→server and server→client messages |
packages/server/src/games/game-manager.ts |
Add jury round management, bingo card generation, bingo validation, scoring |
packages/server/src/ws/handler.ts |
Add message handlers for all new jury + bingo messages |
packages/server/src/db/schema.ts |
Add juryRounds, juryVotes, bingoCards tables |
packages/server/src/games/game-service.ts |
Add jury + bingo persistence methods |
packages/client/src/stores/room-store.ts |
Add jury + bingo state slices |
packages/client/src/hooks/use-websocket.ts |
Handle new server message types |
packages/client/src/routes/play.$roomCode.tsx |
Show jury/bingo tabs during live-event |
packages/client/src/routes/host.$roomCode.tsx |
Add jury host controls in Host tab |
packages/client/src/routes/display.$roomCode.tsx |
Show jury results + bingo announcements |
packages/server/tests/game-manager.test.ts |
Add jury + bingo tests |
Chunk 1: Shared Types + Data Files
Task 1: Verify Scoring Config
Files:
-
Existing:
packages/server/data/scoring.json -
Step 1: Verify scoring.json exists and contains jury/bingo keys
The file already exists. Verify it contains jury_max_per_round, bingo_per_square, and bingo_full_bonus. No changes needed.
Run: cat packages/server/data/scoring.json
Task 2: Bingo Tropes Data File
Files:
-
Create:
packages/server/data/bingo-tropes.json -
Step 1: Create bingo-tropes.json with 30+ tropes
Create a JSON array of objects, each with id (string slug) and label (short display text). These are classic Eurovision tropes/clichés that viewers spot during the broadcast. Need at least 30 for sufficient card variety (each 4×4 card uses 16).
[
{ "id": "key-change", "label": "Key change" },
{ "id": "pyrotechnics", "label": "Pyrotechnics on stage" },
{ "id": "wind-machine", "label": "Wind machine" },
{ "id": "costume-change", "label": "Costume change mid-song" },
{ "id": "barefoot", "label": "Performer is barefoot" },
{ "id": "rain-on-stage", "label": "Rain/water effect on stage" },
{ "id": "flag-waving", "label": "Flag waving in audience" },
{ "id": "crowd-singalong", "label": "Crowd sings along" },
{ "id": "heart-hands", "label": "Heart-shaped hand gesture" },
{ "id": "political-message", "label": "Political/peace message" },
{ "id": "prop-on-stage", "label": "Unusual prop on stage" },
{ "id": "dancer-lift", "label": "Dancer does a lift" },
{ "id": "whistle-note", "label": "Whistle note / extreme high note" },
{ "id": "spoken-word", "label": "Spoken word section" },
{ "id": "guitar-solo", "label": "Guitar solo" },
{ "id": "ethnic-instrument", "label": "Traditional/ethnic instrument" },
{ "id": "glitter-outfit", "label": "Glitter/sequin outfit" },
{ "id": "backup-choir", "label": "Dramatic backup choir" },
{ "id": "confetti", "label": "Confetti drop" },
{ "id": "led-floor", "label": "LED floor visuals" },
{ "id": "love-ballad", "label": "Slow love ballad" },
{ "id": "rap-section", "label": "Rap section in song" },
{ "id": "audience-clap", "label": "Audience clap-along" },
{ "id": "mirror-outfit", "label": "Mirror/reflective outfit" },
{ "id": "trampolines", "label": "Trampolines or acrobatics" },
{ "id": "drone-camera", "label": "Drone camera shot" },
{ "id": "language-switch", "label": "Song switches language mid-song" },
{ "id": "standing-ovation", "label": "Standing ovation" },
{ "id": "host-joke-flop", "label": "Host joke falls flat" },
{ "id": "technical-glitch", "label": "Technical glitch on screen" },
{ "id": "twelve-points", "label": "Commentator says \"douze points\"" },
{ "id": "cry-on-stage", "label": "Performer cries on stage" },
{ "id": "country-shoutout", "label": "Shoutout to their country" },
{ "id": "dramatic-pause", "label": "Dramatic pause before last note" },
{ "id": "staging-surprise", "label": "Staging surprise/reveal" }
]
- Step 2: Commit
git add packages/server/data/bingo-tropes.json
git commit -m "add bingo tropes data file (35 tropes)"
Task 3: Shared Types — Jury + Bingo Schemas
Files:
-
Modify:
packages/shared/src/game-types.ts -
Step 1: Write the updated game-types.ts
Add these schemas after the existing Predictions section, before the Game State section. The game state schema must be extended to include jury and bingo state.
Add after line 38 (export type Prediction = z.infer<typeof predictionSchema>):
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundSchema = z.object({
id: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
status: z.enum(["open", "closed"]),
})
export type JuryRound = z.infer<typeof juryRoundSchema>
export const juryResultSchema = z.object({
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export type JuryResult = z.infer<typeof juryResultSchema>
// ─── Bingo ──────────────────────────────────────────────────────────
export const bingoSquareSchema = z.object({
tropeId: z.string(),
label: z.string(),
tapped: z.boolean(),
})
export type BingoSquare = z.infer<typeof bingoSquareSchema>
export const bingoCardSchema = z.object({
squares: z.array(bingoSquareSchema).length(16),
hasBingo: z.boolean(),
})
export type BingoCard = z.infer<typeof bingoCardSchema>
// ─── Scoring ────────────────────────────────────────────────────────
export const leaderboardEntrySchema = z.object({
playerId: z.string(),
displayName: z.string(),
juryPoints: z.number(),
bingoPoints: z.number(),
totalPoints: z.number(),
})
export type LeaderboardEntry = z.infer<typeof leaderboardEntrySchema>
Then replace the existing gameStateSchema (lines 42-48) with:
export const gameStateSchema = z.object({
lineup: lineupSchema,
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
predictionSubmitted: z.record(z.string(), z.boolean()),
// Jury
currentJuryRound: juryRoundSchema.nullable(),
juryResults: z.array(juryResultSchema),
myJuryVote: z.number().nullable(), // 1-12 or null if not voted this round
// Bingo
myBingoCard: bingoCardSchema.nullable(),
bingoAnnouncements: z.array(z.object({
playerId: z.string(),
displayName: z.string(),
})),
// Leaderboard
leaderboard: z.array(leaderboardEntrySchema),
})
- Step 2: Verify shared package builds
Run: cd packages/shared && bun run build
Expected: No errors (or if no build step, just verify types: bunx tsc --noEmit)
Actually, check how the shared package is built:
Run: cat packages/shared/package.json
If there's no build step, just check types compile:
Run: cd packages/shared && bunx tsc --noEmit
- Step 3: Commit
git add packages/shared/src/game-types.ts
git commit -m "add jury, bingo, leaderboard schemas to shared game types"
Task 4: Shared Types — WS Messages
Files:
-
Modify:
packages/shared/src/ws-messages.ts -
Step 1: Add jury + bingo client messages
Add after submitPredictionMessage (line 32) and before clientMessage (line 34):
export const openJuryVoteMessage = z.object({
type: z.literal("open_jury_vote"),
countryCode: z.string(),
})
export const closeJuryVoteMessage = z.object({
type: z.literal("close_jury_vote"),
})
export const submitJuryVoteMessage = z.object({
type: z.literal("submit_jury_vote"),
rating: z.number().int().min(1).max(12),
})
export const tapBingoSquareMessage = z.object({
type: z.literal("tap_bingo_square"),
tropeId: z.string(),
})
Update clientMessage discriminated union to include the new messages:
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,
closeJuryVoteMessage,
submitJuryVoteMessage,
tapBingoSquareMessage,
])
- Step 2: Add jury + bingo server messages
Add after predictionsLockedMessage (line 88) and before serverMessage (line 90):
export const juryVoteOpenedMessage = z.object({
type: z.literal("jury_vote_opened"),
roundId: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
})
export const juryVoteClosedMessage = z.object({
type: z.literal("jury_vote_closed"),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export const bingoAnnouncedMessage = z.object({
type: z.literal("bingo_announced"),
playerId: z.string(),
displayName: z.string(),
})
Update serverMessage discriminated union:
export const serverMessage = z.discriminatedUnion("type", [
roomStateMessage,
playerJoinedMessage,
playerDisconnectedMessage,
playerReconnectedMessage,
actChangedMessage,
roomEndedMessage,
errorMessage,
gameStateMessage,
predictionsLockedMessage,
juryVoteOpenedMessage,
juryVoteClosedMessage,
bingoAnnouncedMessage,
])
- Step 3: Verify types compile
Run: cd packages/shared && bunx tsc --noEmit
Expected: No errors
- Step 4: Commit
git add packages/shared/src/ws-messages.ts
git commit -m "add jury voting, bingo WS message types"
Chunk 2: Server — GameManager Jury + Bingo Logic
Task 5: GameManager — Jury Voting Logic
Files:
-
Modify:
packages/server/src/games/game-manager.ts -
Modify:
packages/server/tests/game-manager.test.ts -
Step 1: Write failing tests for jury voting
Add a new describe block in packages/server/tests/game-manager.test.ts:
describe("jury voting", () => {
it("opens a jury round", () => {
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(result).toEqual({ success: true })
const round = gm.getCurrentJuryRound()
expect(round).not.toBeNull()
expect(round!.countryCode).toBe("SE")
expect(round!.status).toBe("open")
})
it("rejects opening when a round is already open", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
expect(result).toEqual({ error: "A jury round is already open" })
})
it("accepts a valid jury vote", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ success: true })
})
it("rejects jury vote when no round is open", () => {
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ error: "No jury round is open" })
})
it("rejects jury vote outside 1-12 range", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 0)
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
})
it("allows overwriting a jury vote in the same round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p1", 10)
const result = gm.closeJuryRound()
expect("averageRating" in result && result.averageRating).toBe(10)
})
it("closes a jury round and computes average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p2", 10)
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 9,
totalVotes: 2,
})
})
it("rejects close when no round is open", () => {
const result = gm.closeJuryRound()
expect(result).toEqual({ error: "No jury round is open" })
})
it("accumulates results across rounds", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10)
gm.closeJuryRound()
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 6)
gm.closeJuryRound()
expect(gm.getJuryResults()).toHaveLength(2)
})
it("computes jury scores based on closeness to average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8) // avg will be 8, diff=0, score=5
gm.submitJuryVote("p2", 10) // diff=2, score=3
gm.submitJuryVote("p3", 3) // diff=5, score=0
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(5) // exact match
expect(gm.getJuryScore("p2")).toBe(3) // 2 away
expect(gm.getJuryScore("p3")).toBe(0) // 5 away, clamped to 0
})
it("handles closing a round with zero votes", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 0,
totalVotes: 0,
})
})
it("returns the player's current vote for a round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(gm.getPlayerJuryVote("p1")).toBeNull()
gm.submitJuryVote("p1", 7)
expect(gm.getPlayerJuryVote("p1")).toBe(7)
})
})
- Step 2: Run tests to verify they fail
Run: cd packages/server && bun test
Expected: FAIL — methods don't exist yet
- Step 3: Implement jury voting in GameManager
Add imports at top of packages/server/src/games/game-manager.ts:
import type { Prediction, GameState, Lineup, JuryRound, JuryResult } from "@celebrate-esc/shared"
import scoringConfig from "../../data/scoring.json"
Add after the predictions section (after hasPrediction method, around line 61):
// ─── Jury Voting ────────────────────────────────────────────────
private currentJuryRound: {
id: string
countryCode: string
countryName: string
countryFlag: string
votes: Map<string, number> // playerId → rating
} | null = null
private juryResults: JuryResult[] = []
private juryScores = new Map<string, number>() // playerId → cumulative jury points
openJuryRound(
countryCode: string,
countryName: string,
countryFlag: string,
): { success: true } | { error: string } {
if (this.currentJuryRound) return { error: "A jury round is already open" }
this.currentJuryRound = {
id: crypto.randomUUID(),
countryCode,
countryName,
countryFlag,
votes: new Map(),
}
return { success: true }
}
submitJuryVote(playerId: string, rating: number): { success: true } | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
if (rating < 1 || rating > 12) return { error: "Rating must be between 1 and 12" }
this.currentJuryRound.votes.set(playerId, rating)
return { success: true }
}
getPlayerJuryVote(playerId: string): number | null {
if (!this.currentJuryRound) return null
return this.currentJuryRound.votes.get(playerId) ?? null
}
closeJuryRound(): JuryResult | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
const round = this.currentJuryRound
const votes = Array.from(round.votes.values())
const averageRating = votes.length > 0
? Math.round((votes.reduce((a, b) => a + b, 0) / votes.length) * 10) / 10
: 0
// Compute per-player scores: jury_max_per_round - round(|rating - average|), min 0
const maxPts = scoringConfig.jury_max_per_round
for (const [playerId, rating] of round.votes) {
const diff = Math.abs(rating - averageRating)
const pts = Math.max(0, maxPts - Math.round(diff))
this.juryScores.set(playerId, (this.juryScores.get(playerId) ?? 0) + pts)
}
const result: JuryResult = {
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
averageRating,
totalVotes: votes.length,
}
this.juryResults.push(result)
this.currentJuryRound = null
return result
}
getCurrentJuryRound(): JuryRound | null {
if (!this.currentJuryRound) return null
return {
id: this.currentJuryRound.id,
countryCode: this.currentJuryRound.countryCode,
countryName: this.currentJuryRound.countryName,
countryFlag: this.currentJuryRound.countryFlag,
status: "open",
}
}
getJuryResults(): JuryResult[] {
return this.juryResults
}
getJuryScore(playerId: string): number {
return this.juryScores.get(playerId) ?? 0
}
- Step 4: Run tests to verify they pass
Run: cd packages/server && bun test
Expected: All jury tests PASS
- Step 5: Commit
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts
git commit -m "add jury voting logic to GameManager with tests"
Task 6: GameManager — Bingo Logic
Files:
-
Modify:
packages/server/src/games/game-manager.ts -
Modify:
packages/server/tests/game-manager.test.ts -
Step 1: Write failing tests for bingo
Add to packages/server/tests/game-manager.test.ts:
describe("bingo", () => {
it("generates a bingo card with 16 unique squares", () => {
gm.generateBingoCards(["p1", "p2"])
const card = gm.getBingoCard("p1")
expect(card).not.toBeNull()
expect(card!.squares).toHaveLength(16)
expect(card!.hasBingo).toBe(false)
// All squares should be unique tropes
const ids = card!.squares.map((s) => s.tropeId)
expect(new Set(ids).size).toBe(16)
})
it("generates different cards for different players", () => {
gm.generateBingoCards(["p1", "p2"])
const card1 = gm.getBingoCard("p1")!
const card2 = gm.getBingoCard("p2")!
const ids1 = card1.squares.map((s) => s.tropeId).sort()
const ids2 = card2.squares.map((s) => s.tropeId).sort()
// Cards should differ (extremely unlikely to be identical with 35 tropes choose 16)
expect(ids1).not.toEqual(ids2)
})
it("taps a bingo square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toMatchObject({ success: true, hasBingo: false })
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
})
it("rejects tap on unknown trope", () => {
gm.generateBingoCards(["p1"])
const result = gm.tapBingoSquare("p1", "nonexistent")
expect(result).toEqual({ error: "Trope not on your card" })
})
it("rejects tap when no card exists", () => {
const result = gm.tapBingoSquare("p1", "key-change")
expect(result).toEqual({ error: "No bingo card found" })
})
it("allows untapping a square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
gm.tapBingoSquare("p1", tropeId) // tap
gm.tapBingoSquare("p1", tropeId) // untap
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(false)
})
it("detects bingo on a completed row", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Tap first row (indices 0-3)
let result: { success: true; hasBingo: boolean } | { error: string } = { success: true, hasBingo: false }
for (let i = 0; i < 4; i++) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
})
it("detects bingo on a completed column", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Tap first column (indices 0, 4, 8, 12)
let result: { success: true; hasBingo: boolean } | { error: string } = { success: true, hasBingo: false }
for (const i of [0, 4, 8, 12]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("detects bingo on a diagonal", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Tap main diagonal (indices 0, 5, 10, 15)
let result: { success: true; hasBingo: boolean } | { error: string } = { success: true, hasBingo: false }
for (const i of [0, 5, 10, 15]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("revokes bingo when a completing square is untapped", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Complete first row
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
// Untap one square in the row
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
expect(gm.getBingoCard("p1")!.hasBingo).toBe(false)
})
it("does not duplicate bingo announcements on re-bingo", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Complete first row → bingo
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
// Untap and re-tap → re-bingo
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false) // deduplicated
expect(gm.getBingoAnnouncements()).toHaveLength(1)
})
it("computes bingo score", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
// Tap 5 squares including a full row (row 0)
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.tapBingoSquare("p1", card.squares[4]!.tropeId) // one extra
// Score: 5 tapped * 2 + 10 bingo bonus = 20
expect(gm.getBingoScore("p1")).toBe(20)
})
})
- Step 2: Run tests to verify they fail
Run: cd packages/server && bun test
Expected: FAIL — bingo methods don't exist
- Step 3: Implement bingo in GameManager
Add import at top:
import tropesData from "../../data/bingo-tropes.json"
Add type for tropes:
const tropes: { id: string; label: string }[] = tropesData
Add after the jury section:
// ─── Bingo ──────────────────────────────────────────────────────
private bingoCards = new Map<string, {
squares: { tropeId: string; label: string; tapped: boolean }[]
hasBingo: boolean
}>()
private bingoAnnouncements: { playerId: string; displayName: string }[] = []
private announcedBingo = new Set<string>() // playerIds who already got announced
generateBingoCards(playerIds: string[]): void {
for (const playerId of playerIds) {
// Shuffle tropes and pick 16
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, 16)
this.bingoCards.set(playerId, {
squares: selected.map((t) => ({
tropeId: t.id,
label: t.label,
tapped: false,
})),
hasBingo: false,
})
}
}
getBingoCard(playerId: string): { squares: { tropeId: string; label: string; tapped: boolean }[]; hasBingo: boolean } | null {
return this.bingoCards.get(playerId) ?? null
}
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean } | { error: string } {
const card = this.bingoCards.get(playerId)
if (!card) return { error: "No bingo card found" }
const square = card.squares.find((s) => s.tropeId === tropeId)
if (!square) return { error: "Trope not on your card" }
square.tapped = !square.tapped
// Check for bingo
card.hasBingo = this.checkBingo(card.squares)
return { success: true, hasBingo: card.hasBingo }
}
private checkBingo(squares: { tapped: boolean }[]): boolean {
// 4×4 grid: index = row*4 + col
// Check rows
for (let row = 0; row < 4; row++) {
if (squares[row * 4]!.tapped && squares[row * 4 + 1]!.tapped && squares[row * 4 + 2]!.tapped && squares[row * 4 + 3]!.tapped) return true
}
// Check columns
for (let col = 0; col < 4; col++) {
if (squares[col]!.tapped && squares[col + 4]!.tapped && squares[col + 8]!.tapped && squares[col + 12]!.tapped) return true
}
// Check diagonals
if (squares[0]!.tapped && squares[5]!.tapped && squares[10]!.tapped && squares[15]!.tapped) return true
if (squares[3]!.tapped && squares[6]!.tapped && squares[9]!.tapped && squares[12]!.tapped) return true
return false
}
addBingoAnnouncement(playerId: string, displayName: string): boolean {
if (this.announcedBingo.has(playerId)) return false
this.announcedBingo.add(playerId)
this.bingoAnnouncements.push({ playerId, displayName })
return true
}
getBingoAnnouncements(): { playerId: string; displayName: string }[] {
return this.bingoAnnouncements
}
getBingoScore(playerId: string): number {
const card = this.bingoCards.get(playerId)
if (!card) return 0
const tappedCount = card.squares.filter((s) => s.tapped).length
let score = tappedCount * scoringConfig.bingo_per_square
if (card.hasBingo) score += scoringConfig.bingo_full_bonus
return score
}
- Step 4: Run tests to verify they pass
Run: cd packages/server && bun test
Expected: All bingo tests PASS
- Step 5: Commit
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts packages/server/data/bingo-tropes.json
git commit -m "add bingo logic to GameManager with tests"
Task 7: GameManager — Update Game State Methods + Leaderboard
Files:
-
Modify:
packages/server/src/games/game-manager.ts -
Modify:
packages/server/tests/game-manager.test.ts -
Step 1: Write failing tests for updated game state
Add to packages/server/tests/game-manager.test.ts:
describe("getGameStateForPlayer (with jury + bingo)", () => {
it("includes jury round state", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.currentJuryRound).not.toBeNull()
expect(state.currentJuryRound!.countryCode).toBe("SE")
expect(state.myJuryVote).toBe(8)
})
it("includes bingo card", () => {
gm.generateBingoCards(["p1"])
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.myBingoCard).not.toBeNull()
expect(state.myBingoCard!.squares).toHaveLength(16)
})
it("includes leaderboard with jury + bingo scores", () => {
gm.generateBingoCards(["p1"])
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.closeJuryRound()
const card = gm.getBingoCard("p1")!
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
expect(state.leaderboard).toHaveLength(1)
expect(state.leaderboard[0]!.juryPoints).toBe(5) // exact match = max points
expect(state.leaderboard[0]!.bingoPoints).toBe(2) // 1 tapped * 2
expect(state.leaderboard[0]!.totalPoints).toBe(7)
})
})
- Step 2: Run tests to verify they fail
Run: cd packages/server && bun test
Expected: FAIL — updated signatures don't match
- Step 3: Update getGameStateForPlayer and getGameStateForDisplay
Update the getGameStateForPlayer signature to also accept a display name map (needed for leaderboard):
getGameStateForPlayer(
playerId: string,
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: this.getPrediction(playerId),
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: this.getPlayerJuryVote(playerId),
myBingoCard: this.getBingoCard(playerId) ?? null,
bingoAnnouncements: this.bingoAnnouncements,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
getGameStateForDisplay(
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: null,
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: null,
myBingoCard: null,
bingoAnnouncements: this.bingoAnnouncements,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
private buildLeaderboard(
playerIds: string[],
displayNames: Record<string, string>,
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; totalPoints: number }[] {
return playerIds
.map((id) => {
const juryPoints = this.getJuryScore(id)
const bingoPoints = this.getBingoScore(id)
return {
playerId: id,
displayName: displayNames[id] ?? "Unknown",
juryPoints,
bingoPoints,
totalPoints: juryPoints + bingoPoints,
}
})
.sort((a, b) => b.totalPoints - a.totalPoints)
}
- Step 4: Run tests to verify they pass
Run: cd packages/server && bun test
Expected: All tests PASS
- Step 5: Commit
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts
git commit -m "extend GameManager game state with jury, bingo, leaderboard"
Task 8: Room Manager — Add Display Name Lookup
Files:
- Modify:
packages/server/src/rooms/room-manager.ts
The leaderboard needs display names, so add a helper to RoomManager.
- Step 1: Add getPlayerDisplayNames method
Add after getAllPlayerIds (line 155):
getPlayerDisplayNames(code: string): Record<string, string> {
const room = this.rooms.get(code)
if (!room) return {}
const result: Record<string, string> = {}
for (const player of room.players.values()) {
result[player.id] = player.displayName
}
return result
}
- Step 2: Commit
git add packages/server/src/rooms/room-manager.ts
git commit -m "add getPlayerDisplayNames to RoomManager"
Task 9: WebSocket Handler — Jury + Bingo Messages
Files:
-
Modify:
packages/server/src/ws/handler.ts -
Step 1: Update sendGameState and sendDisplayGameState to pass display names
Update sendGameState:
function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
const gm = roomManager.getGameManager(roomCode)
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
if (!gm || !playerId) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
Update sendDisplayGameState:
function sendDisplayGameState(ws: WSContext, roomCode: string) {
const gm = roomManager.getGameManager(roomCode)
if (!gm) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
- Step 2: Add open_jury_vote handler
Add in the switch (msg.type) block, after the submit_prediction case:
case "open_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can open jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
// Look up country details from lineup
const entry = gm.getLineup().entries.find((e) => e.country.code === msg.countryCode)
if (!entry) {
sendError(ws, "Invalid country code")
return
}
const result = gm.openJuryRound(entry.country.code, entry.country.name, entry.country.flag)
if ("error" in result) {
sendError(ws, result.error)
return
}
const round = gm.getCurrentJuryRound()!
broadcast(roomCode, {
type: "jury_vote_opened",
roundId: round.id,
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
})
// Update game state for all (includes currentJuryRound)
broadcastGameStateToAll(roomCode)
break
}
- Step 3: Add close_jury_vote handler
case "close_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can close jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.closeJuryRound()
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "jury_vote_closed",
countryCode: result.countryCode,
countryName: result.countryName,
countryFlag: result.countryFlag,
averageRating: result.averageRating,
totalVotes: result.totalVotes,
})
// Update game state for everyone (leaderboard changed)
broadcastGameStateToAll(roomCode)
break
}
- Step 4: Add submit_jury_vote handler
case "submit_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitJuryVote(playerId, msg.rating)
if ("error" in result) {
sendError(ws, result.error)
return
}
// Send updated game state to just this player (to confirm their vote)
sendGameState(ws, roomCode, sessionId)
break
}
- Step 5: Add tap_bingo_square handler
case "tap_bingo_square": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Bingo is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.tapBingoSquare(playerId, msg.tropeId)
if ("error" in result) {
sendError(ws, result.error)
return
}
// Send updated game state to this player
sendGameState(ws, roomCode, sessionId)
// If player achieved bingo, try to announce (addBingoAnnouncement deduplicates)
if (result.hasBingo) {
const room = roomManager.getRoom(roomCode)
const player = room?.players.find((p) => p.sessionId === sessionId)
if (player) {
const isNew = gm.addBingoAnnouncement(playerId, player.displayName)
if (isNew) {
broadcast(roomCode, {
type: "bingo_announced",
playerId,
displayName: player.displayName,
})
broadcastGameStateToAll(roomCode)
}
}
}
break
}
- Step 6: Update advance_act handler to generate bingo cards on live-event start
In the existing advance_act case, after the predictions lock block (after line 221), add:
// Generate bingo cards when entering live-event
if (result.newAct === "live-event") {
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
gm.generateBingoCards(allPlayerIds)
// Send updated game state to everyone (includes bingo cards)
broadcastGameStateToAll(roomCode)
}
Note: The broadcastGameStateToAll(roomCode) should go after both the prediction lock AND bingo card generation, so restructure the live-event block:
if (result.newAct === "live-event") {
const gm = roomManager.getGameManager(roomCode)
if (gm) {
gm.lockPredictions()
broadcast(roomCode, { type: "predictions_locked" })
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
gm.generateBingoCards(allPlayerIds)
broadcastGameStateToAll(roomCode)
}
}
- Step 7: Run all server tests
Run: cd packages/server && bun test
Expected: All tests PASS
- Step 8: Commit
git add packages/server/src/ws/handler.ts
git commit -m "add jury voting, bingo WS message handlers"
Chunk 3: Client — UI Components + Route Integration
Task 10: Client Store — Add Jury + Bingo State
Files:
-
Modify:
packages/client/src/stores/room-store.ts -
Step 1: Update store to handle new game state shape
The store already has setGameState which replaces the whole game state. Since the new GameState schema includes jury/bingo/leaderboard fields, no new actions are needed — setGameState already handles it.
However, add convenience actions for the new broadcast messages:
import type { RoomState, Player, GameState, JuryResult } from "@celebrate-esc/shared"
No additional store changes needed — the gameState object already contains all jury/bingo/leaderboard data. The setGameState action replaces it wholesale on each game_state message.
Verify this is correct — read the file, confirm setGameState is sufficient. If the GameState type already includes the new fields from Task 3, the store works as-is.
- Step 2: Commit (skip if no changes needed)
Task 11: Client WebSocket — Handle New Server Messages
Files:
-
Modify:
packages/client/src/hooks/use-websocket.ts -
Step 1: Add handlers for new server message types
In the switch (msg.type) block, add cases:
The server broadcasts broadcastGameStateToAll after opening jury votes, closing jury votes, and bingo announcements (all handled in Task 9). So the client only needs no-op cases for the supplementary broadcast messages to avoid unhandled message warnings:
case "jury_vote_opened":
case "jury_vote_closed":
case "bingo_announced":
// State updates arrive via game_state; these are supplementary signals
break
- Step 2: Commit
git add packages/client/src/hooks/use-websocket.ts
git commit -m "handle new jury, bingo WS message types in client"
Task 12: Jury Voting Component — Player View
Files:
-
Create:
packages/client/src/components/jury-voting.tsx -
Step 1: Create the jury voting component
This shows on the player's phone during live-event when a jury round is open.
import { useState } from "react"
import type { JuryRound } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryVotingProps {
round: JuryRound
myVote: number | null
onVote: (rating: number) => void
}
export function JuryVoting({ round, myVote, onVote }: JuryVotingProps) {
const [selectedRating, setSelectedRating] = useState<number | null>(myVote)
return (
<Card>
<CardHeader>
<CardTitle className="text-center">
<span className="text-2xl">{round.countryFlag}</span>{" "}
{round.countryName}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-center text-sm text-muted-foreground">
Rate this performance (1-12)
</p>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i + 1).map((rating) => (
<Button
key={rating}
variant={selectedRating === rating ? "default" : "outline"}
size="lg"
onClick={() => setSelectedRating(rating)}
className="text-lg font-bold"
>
{rating}
</Button>
))}
</div>
<Button
onClick={() => {
if (selectedRating) onVote(selectedRating)
}}
disabled={!selectedRating}
className="w-full"
>
{myVote ? "Update Vote" : "Submit Vote"}
</Button>
{myVote && (
<p className="text-center text-sm text-muted-foreground">
Your vote: {myVote}
</p>
)}
</CardContent>
</Card>
)
}
- Step 2: Commit
git add packages/client/src/components/jury-voting.tsx
git commit -m "add jury voting player component"
Task 13: Jury Host Controls Component
Files:
-
Create:
packages/client/src/components/jury-host.tsx -
Step 1: Create jury host controls
The host selects a country from the lineup to open voting, then closes it when ready.
import { useState } from "react"
import type { Entry, JuryRound, JuryResult } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryHostProps {
entries: Entry[]
currentRound: JuryRound | null
results: JuryResult[]
onOpenVote: (countryCode: string) => void
onCloseVote: () => void
}
export function JuryHost({ entries, currentRound, results, onOpenVote, onCloseVote }: JuryHostProps) {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const votedCountries = new Set(results.map((r) => r.countryCode))
if (currentRound) {
return (
<Card>
<CardHeader>
<CardTitle>
Voting: {currentRound.countryFlag} {currentRound.countryName}
</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={onCloseVote} variant="destructive" className="w-full">
Close Voting
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Jury Voting</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{results.length > 0 && (
<div className="mb-2">
<p className="mb-1 text-sm font-medium text-muted-foreground">
Completed ({results.length})
</p>
{results.map((r) => (
<div key={r.countryCode} className="flex items-center justify-between text-sm">
<span>{r.countryFlag} {r.countryName}</span>
<span className="text-muted-foreground">avg {r.averageRating} ({r.totalVotes} votes)</span>
</div>
))}
</div>
)}
<div className="flex flex-col gap-1">
{entries.map((entry) => {
const voted = votedCountries.has(entry.country.code)
const isSelected = selectedCountry === entry.country.code
return (
<button
key={entry.country.code}
type="button"
disabled={voted}
onClick={() => setSelectedCountry(isSelected ? null : entry.country.code)}
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
voted
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isSelected
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{entry.country.flag} {entry.artist} — {entry.song}
</button>
)
})}
</div>
{selectedCountry && (
<Button
onClick={() => {
onOpenVote(selectedCountry)
setSelectedCountry(null)
}}
className="w-full"
>
Open Voting
</Button>
)}
</CardContent>
</Card>
)
}
- Step 2: Commit
git add packages/client/src/components/jury-host.tsx
git commit -m "add jury host controls component"
Task 14: Jury Display Component
Files:
-
Create:
packages/client/src/components/jury-display.tsx -
Step 1: Create jury display component
Shows on the TV during live-event: current voting country or last result.
import type { JuryRound, JuryResult } from "@celebrate-esc/shared"
interface JuryDisplayProps {
currentRound: JuryRound | null
results: JuryResult[]
}
export function JuryDisplay({ currentRound, results }: JuryDisplayProps) {
if (currentRound) {
return (
<div className="flex flex-col items-center gap-4">
<p className="text-lg text-muted-foreground">Now voting</p>
<div className="text-center">
<span className="text-6xl">{currentRound.countryFlag}</span>
<p className="mt-2 text-3xl font-bold">{currentRound.countryName}</p>
</div>
<p className="text-muted-foreground">Rate 1-12 on your phone</p>
</div>
)
}
if (results.length > 0) {
const lastResult = results[results.length - 1]!
const topRated = [...results].sort((a, b) => b.averageRating - a.averageRating)[0]!
return (
<div className="flex flex-col items-center gap-6">
{/* Latest result with dramatic "12 points" moment */}
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">Latest result</p>
<span className="text-4xl">{lastResult.countryFlag}</span>
<p className="mt-2 text-2xl font-bold">{lastResult.countryName}</p>
<p className="text-4xl font-bold text-primary">{lastResult.averageRating}</p>
<p className="text-sm text-muted-foreground">{lastResult.totalVotes} votes</p>
</div>
{/* "12 points go to..." — show the current top-rated country */}
{results.length > 1 && (
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-6 text-center">
<p className="text-lg font-medium text-muted-foreground">
And 12 points go to...
</p>
<p className="mt-2 text-3xl font-bold">
{topRated.countryFlag} {topRated.countryName}
</p>
<p className="text-2xl font-bold text-primary">
{topRated.averageRating} avg
</p>
</div>
)}
{/* All results ranked */}
<div className="w-full max-w-md">
<p className="mb-2 text-sm font-medium text-muted-foreground">Rankings</p>
{[...results]
.sort((a, b) => b.averageRating - a.averageRating)
.map((r, i) => (
<div key={r.countryCode} className="flex items-center justify-between border-b py-1 text-sm">
<span>
<span className="mr-2 font-bold text-muted-foreground">{i + 1}</span>
{r.countryFlag} {r.countryName}
</span>
<span className="font-medium">{r.averageRating}</span>
</div>
))}
</div>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Live Event</p>
<p className="text-muted-foreground">Waiting for host to open voting...</p>
</div>
)
}
- Step 2: Commit
git add packages/client/src/components/jury-display.tsx
git commit -m "add jury display component"
Task 15: Bingo Card Component
Files:
-
Create:
packages/client/src/components/bingo-card.tsx -
Step 1: Create the bingo card component
4×4 grid shown on the player's phone. Squares are tappable.
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface BingoCardProps {
card: BingoCardType
onTap: (tropeId: string) => void
}
export function BingoCard({ card, onTap }: BingoCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>Bingo</CardTitle>
{card.hasBingo && (
<Badge variant="default" className="bg-green-600">
BINGO!
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-1.5">
{card.squares.map((square) => (
<button
key={square.tropeId}
type="button"
onClick={() => onTap(square.tropeId)}
className={`flex aspect-square items-center justify-center rounded-md border p-1 text-center text-xs leading-tight transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: "border-muted hover:bg-muted/50"
}`}
>
{square.label}
</button>
))}
</div>
</CardContent>
</Card>
)
}
- Step 2: Commit
git add packages/client/src/components/bingo-card.tsx
git commit -m "add bingo card player component"
Task 16: Bingo Display Component
Files:
-
Create:
packages/client/src/components/bingo-display.tsx -
Step 1: Create bingo display component
Shows bingo announcements on the TV.
interface BingoDisplayProps {
announcements: { playerId: string; displayName: string }[]
}
export function BingoDisplay({ announcements }: BingoDisplayProps) {
if (announcements.length === 0) return null
return (
<div className="flex flex-col items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">Bingo Winners</p>
{announcements.map((a, i) => (
<p key={`${a.playerId}-${i}`} className="text-lg font-bold text-green-600">
{a.displayName} got BINGO!
</p>
))}
</div>
)
}
- Step 2: Commit
git add packages/client/src/components/bingo-display.tsx
git commit -m "add bingo display component"
Task 17: Leaderboard Component
Files:
-
Create:
packages/client/src/components/leaderboard.tsx -
Step 1: Create leaderboard component
Shown on the display view and optionally on player/host views.
import type { LeaderboardEntry } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface LeaderboardProps {
entries: LeaderboardEntry[]
}
export function Leaderboard({ entries }: LeaderboardProps) {
if (entries.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Leaderboard</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{entries.map((entry, i) => (
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
<div className="flex items-center gap-2">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="text-sm font-medium">{entry.displayName}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}
- Step 2: Commit
git add packages/client/src/components/leaderboard.tsx
git commit -m "add leaderboard component"
Task 18: Player Route — Integrate Jury + Bingo Tabs
Files:
-
Modify:
packages/client/src/routes/play.$roomCode.tsx -
Step 1: Update imports
Add at top:
import { JuryVoting } from "@/components/jury-voting"
import { BingoCard } from "@/components/bingo-card"
import { Leaderboard } from "@/components/leaderboard"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
- Step 2: Update the live-event section
Replace the section that currently shows predictions for all non-ended acts. The player view should show:
- lobby / pre-show: Predictions form (as now)
- live-event: Tabs for Jury, Bingo, and Predictions (locked, read-only)
- scoring / ended: Leaderboard
Replace the main content area (lines 86-109) with:
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
{gameState && room.currentAct === "live-event" && (
<Tabs defaultValue="jury" className="flex-1">
<TabsList className="w-full">
<TabsTrigger value="jury" className="flex-1">Jury</TabsTrigger>
<TabsTrigger value="bingo" className="flex-1">Bingo</TabsTrigger>
</TabsList>
<TabsContent value="jury" className="mt-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</TabsContent>
<TabsContent value="bingo" className="mt-4">
{gameState.myBingoCard ? (
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
No bingo card yet
</div>
)}
</TabsContent>
</Tabs>
)}
{gameState && (room.currentAct === "scoring") && (
<Leaderboard entries={gameState.leaderboard} />
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
{gameState && <Leaderboard entries={gameState.leaderboard} />}
</div>
)}
- Step 3: Verify client builds
Run: cd packages/client && bun run build
Expected: No errors
- Step 4: Commit
git add packages/client/src/routes/play.\$roomCode.tsx
git commit -m "integrate jury voting, bingo tabs in player route"
Task 19: Host Route — Integrate Jury Controls
Files:
-
Modify:
packages/client/src/routes/host.$roomCode.tsx -
Step 1: Update imports
Add:
import { JuryHost } from "@/components/jury-host"
import { JuryVoting } from "@/components/jury-voting"
import { BingoCard } from "@/components/bingo-card"
import { Leaderboard } from "@/components/leaderboard"
- Step 2: Add jury controls to Host tab
In the Host tab's <CardContent>, after the room controls card, add jury controls when in live-event:
{room.currentAct === "live-event" && gameState && (
<JuryHost
entries={gameState.lineup.entries}
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
onCloseVote={() => send({ type: "close_jury_vote" })}
/>
)}
And add leaderboard on host tab during scoring/ended:
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
- Step 3: Update Play tab to match player route
The Play tab should show the same jury/bingo UI as the player route during live-event. Replace the Play tab content with the same pattern as Task 18:
<TabsContent value="play" className="p-4">
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
{gameState && room.currentAct === "live-event" && (
<Tabs defaultValue="jury">
<TabsList className="w-full">
<TabsTrigger value="jury" className="flex-1">Jury</TabsTrigger>
<TabsTrigger value="bingo" className="flex-1">Bingo</TabsTrigger>
</TabsList>
<TabsContent value="jury" className="mt-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</TabsContent>
<TabsContent value="bingo" className="mt-4">
{gameState.myBingoCard ? (
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
No bingo card yet
</div>
)}
</TabsContent>
</Tabs>
)}
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</TabsContent>
- Step 4: Verify client builds
Run: cd packages/client && bun run build
Expected: No errors
- Step 5: Commit
git add packages/client/src/routes/host.\$roomCode.tsx
git commit -m "integrate jury controls, bingo, leaderboard in host route"
Task 20: Display Route — Integrate Jury + Bingo + Leaderboard
Files:
-
Modify:
packages/client/src/routes/display.$roomCode.tsx -
Step 1: Update imports
Add:
import { JuryDisplay } from "@/components/jury-display"
import { BingoDisplay } from "@/components/bingo-display"
import { Leaderboard } from "@/components/leaderboard"
- Step 2: Update display sections
Replace the live-event / scoring / ended sections (the generic ACT_LABELS[room.currentAct] block on lines 43-47 and the ended block on lines 49-53) with:
{room.currentAct === "pre-show" && gameState && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Pre-Show — Predictions</p>
<p className="text-lg text-muted-foreground">
{Object.values(gameState.predictionSubmitted).filter(Boolean).length} / {Object.keys(gameState.predictionSubmitted).length} predictions submitted
</p>
{gameState.leaderboard.some((e) => e.totalPoints > 0) && (
<Leaderboard entries={gameState.leaderboard} />
)}
</div>
)}
{room.currentAct === "live-event" && gameState && (
<div className="flex flex-col items-center gap-8">
<JuryDisplay
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
/>
<BingoDisplay announcements={gameState.bingoAnnouncements} />
<Leaderboard entries={gameState.leaderboard} />
</div>
)}
{room.currentAct === "scoring" && gameState && (
<div className="flex flex-col items-center gap-8">
<p className="text-2xl text-muted-foreground">Scoring</p>
<Leaderboard entries={gameState.leaderboard} />
</div>
)}
{room.currentAct === "ended" && gameState && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">The party has ended. Thanks for playing!</p>
<Leaderboard entries={gameState.leaderboard} />
</div>
)}
Note: This replaces the existing pre-show and ended blocks from the current display route as well.
- Step 3: Verify client builds
Run: cd packages/client && bun run build
Expected: No errors
- Step 4: Commit
git add packages/client/src/routes/display.\$roomCode.tsx
git commit -m "integrate jury display, bingo announcements, leaderboard in display route"
Task 21: DB Schema — Add Jury + Bingo Tables
Files:
-
Modify:
packages/server/src/db/schema.ts -
Step 1: Add jury and bingo tables
Add after the predictions table:
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
export const juryRounds = pgTable("jury_rounds", {
id: uuid("id").primaryKey().defaultRandom(),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
countryCode: varchar("country_code").notNull(),
status: juryRoundStatusEnum("status").notNull().default("open"),
openedAt: timestamp("opened_at").notNull().defaultNow(),
})
export const juryVotes = pgTable("jury_votes", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
juryRoundId: uuid("jury_round_id")
.notNull()
.references(() => juryRounds.id),
rating: integer("rating").notNull(),
})
export const bingoCards = pgTable("bingo_cards", {
id: uuid("id").primaryKey().defaultRandom(),
playerId: uuid("player_id")
.notNull()
.references(() => players.id),
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
squares: jsonb("squares").notNull(), // Array of { tropeId, label, tapped }
})
Note: Add integer and jsonb to the import from drizzle-orm/pg-core.
- Step 2: Push schema to DB
Run: cd packages/server && bunx drizzle-kit push
If there are issues with existing data, drop and recreate:
ssh serve "dropdb esc && createdb esc"
cd packages/server && bunx drizzle-kit push
- Step 3: Commit
git add packages/server/src/db/schema.ts
git commit -m "add jury rounds, jury votes, bingo cards DB tables"
Task 22: Full Integration Test
Files:
-
No new files — run existing tests and verify everything builds.
-
Step 1: Run all server tests
Run: cd packages/server && bun test
Expected: All tests PASS
- Step 2: Build client
Run: cd packages/client && bun run build
Expected: Clean build with no errors
- Step 3: Verify types across packages
Run: cd packages/shared && bunx tsc --noEmit
Expected: No type errors
- Step 4: Final commit if any fixups needed
git add -A
git commit -m "fix integration issues from Act 2 games implementation"