Files
esc/docs/superpowers/plans/2026-03-12-act2-jury-bingo.md

63 KiB
Raw Permalink Blame History

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"