Files
esc/docs/superpowers/plans/2026-03-13-menu-rework.md
Felix Förtsch b095ce0d69 fix plan review issues: WebSocket sharing, test signatures, host-tab leaderboard, bingo flow
- add Task 10: store WebSocket send in Zustand, child routes use store
- fix all tapBingoSquare test calls to 2-arg signature (no displayName)
- fix requestNewBingoCard test calls to include displayName
- remove duplicate Leaderboard from host-tab.tsx
- fix bingo completion tests: cards move on redraw, not on detection
- fix addBingoAnnouncement to track announcements via count comparison
- remove use-websocket.ts and room-store.ts from Unchanged Files
- renumber tasks 10-13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:56:23 +01:00

61 KiB

Menu Rework — App-Style Navigation 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: Replace ad-hoc tabs with an iOS-style bottom navigation bar driven by TanStack Router nested routes, and add bingo card completion/redraw flow.

Architecture: Convert flat route files into layout routes with nested children. Shared RoomLayout component renders sticky header + BottomNav + <Outlet />. Player gets 3 tabs (Game, Bingo, Leaderboard), host gets 4 (+ Host). Bingo completion stores cards server-side and allows redraw.

Tech Stack: TanStack Router (file-based nested routes), React 19, Zustand, Tailwind v4, shadcn/ui, Hono WebSocket server, Zod schemas.

Spec: docs/superpowers/specs/2026-03-13-menu-rework-design.md


File Structure

New Files

  • packages/shared/src/game-types.ts — add completedBingoCardSchema
  • packages/shared/src/ws-messages.ts — add requestNewBingoCardMessage
  • packages/client/src/components/room-layout.tsx — sticky header + bottom nav + outlet wrapper
  • packages/client/src/components/bottom-nav.tsx — fixed bottom tab bar
  • packages/client/src/components/bingo-claims.tsx — host verification of completed bingo cards
  • packages/client/src/components/game-tab.tsx — shared game content by act
  • packages/client/src/components/bingo-tab.tsx — shared bingo tab content
  • packages/client/src/components/board-tab.tsx — shared leaderboard tab content
  • packages/client/src/components/host-tab.tsx — host-only controls tab content
  • packages/client/src/routes/play.$roomCode.game.tsx — player game child route
  • packages/client/src/routes/play.$roomCode.bingo.tsx — player bingo child route
  • packages/client/src/routes/play.$roomCode.board.tsx — player leaderboard child route
  • packages/client/src/routes/play.$roomCode.index.tsx — redirect to game
  • packages/client/src/routes/host.$roomCode.game.tsx — host game child route
  • packages/client/src/routes/host.$roomCode.bingo.tsx — host bingo child route
  • packages/client/src/routes/host.$roomCode.board.tsx — host leaderboard child route
  • packages/client/src/routes/host.$roomCode.host.tsx — host controls child route
  • packages/client/src/routes/host.$roomCode.index.tsx — redirect to game
  • packages/server/src/games/__tests__/game-manager-bingo.test.ts — bingo tests

Modified Files

  • packages/shared/src/game-types.ts — add completedBingoCardSchema, extend gameStateSchema
  • packages/shared/src/ws-messages.ts — add requestNewBingoCardMessage to clientMessage union
  • packages/server/src/games/game-manager.ts — tap-only bingo, completion on redraw, scoring across cards, requestNewBingoCard(), per-player card generation
  • packages/server/src/ws/handler.ts — add request_new_bingo_card case, update tap_bingo_square for completion, generate bingo card on join
  • packages/client/src/routes/play.$roomCode.tsx — convert to layout route (header + nav + outlet)
  • packages/client/src/routes/host.$roomCode.tsx — convert to layout route (header + nav + outlet)
  • packages/client/src/hooks/use-websocket.ts — expose send via Zustand store instead of return value
  • packages/client/src/stores/room-store.ts — add send function to store
  • packages/client/src/components/bingo-card.tsx — add readonly prop
  • packages/client/src/components/leaderboard.tsx — lobby mode (names only), absorb player list

Unchanged Files

  • packages/client/src/routes/__root.tsx
  • packages/client/src/routes/index.tsx
  • packages/client/src/routes/display.$roomCode.tsx

Chunk 1: Server-Side Changes

Task 1: Shared Types — completedBingoCard Schema + WS Message

Files:

  • Modify: packages/shared/src/game-types.ts

  • Modify: packages/shared/src/ws-messages.ts

  • Step 1: Add completedBingoCardSchema to game-types.ts

After the bingoCardSchema block (line 88), add:

export const completedBingoCardSchema = z.object({
	playerId: z.string(),
	displayName: z.string(),
	card: bingoCardSchema,
	completedAt: z.string(),
})

export type CompletedBingoCard = z.infer<typeof completedBingoCardSchema>
  • Step 2: Add completedBingoCards to gameStateSchema

In gameStateSchema (line 122), after the bingoAnnouncements field (line 136), add:

	completedBingoCards: z.array(completedBingoCardSchema),
  • Step 3: Add requestNewBingoCardMessage to ws-messages.ts

After tapBingoSquareMessage (line 55), add:

export const requestNewBingoCardMessage = z.object({
	type: z.literal("request_new_bingo_card"),
})
  • Step 4: Add to clientMessage discriminated union

In the clientMessage array (line 82), add requestNewBingoCardMessage after tapBingoSquareMessage:

	tapBingoSquareMessage,
	requestNewBingoCardMessage,
  • Step 5: Verify build

Run: bun run --filter @celebrate-esc/shared build 2>&1 || echo "shared has no build script, checking types..."; cd packages/shared && bunx tsc --noEmit 2>&1 || true

Note: the shared package uses barrel exports (export *) so new types auto-export.

  • Step 6: Commit
git add packages/shared/src/game-types.ts packages/shared/src/ws-messages.ts
git commit -m "add completedBingoCard schema, request_new_bingo_card WS message"

Task 2: Server — Bingo Completion Logic

Files:

  • Modify: packages/server/src/games/game-manager.ts

Context: The bingo section starts at line 159. Key methods: tapBingoSquare (line 187), addBingoAnnouncement (line 209), getBingoScore (line 220), getGameStateForPlayer (line 432), getGameStateForDisplay (line 454), buildLeaderboard (line 475).

  • Step 1: Add CompletedBingoCard import

Update the import on line 1 to include CompletedBingoCard:

import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared"
  • Step 2: Add completedBingoCards storage

After private announcedBingo = new Set<string>() (line 166), add:

	private completedBingoCards: CompletedBingoCard[] = []
  • Step 3: Change tapBingoSquare from toggle to tap-only

In tapBingoSquare (line 192), change:

		square.tapped = !square.tapped

to:

		if (square.tapped) return { success: true, hasBingo: card.hasBingo }
		square.tapped = true
  • Step 4: Simplify tapBingoSquare — detect bingo but don't move card

The card moves to completedBingoCards only when the player requests a new card (in requestNewBingoCard). This avoids double-counting between the active card and the completed array. The full updated method:

	tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean; isNewBingo: 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" }
		if (square.tapped) return { success: true, hasBingo: card.hasBingo, isNewBingo: false }
		square.tapped = true
		const hadBingo = card.hasBingo
		card.hasBingo = this.checkBingo(card.squares)
		const isNewBingo = card.hasBingo && !hadBingo
		return { success: true, hasBingo: card.hasBingo, isNewBingo }
	}

Note: signature unchanged (no displayName needed here — it's passed in addBingoAnnouncement separately).

  • Step 5: Update addBingoAnnouncement to track playerId:cardIndex

Replace the addBingoAnnouncement method (lines 209-214):

	addBingoAnnouncement(playerId: string, displayName: string): boolean {
		// Count how many bingos this player already announced
		const count = this.bingoAnnouncements.filter((a) => a.playerId === playerId).length
		// Count how many bingo-detected cards this player has (completed + current if hasBingo)
		const completedCount = this.completedBingoCards.filter((c) => c.playerId === playerId).length
		const activeCard = this.bingoCards.get(playerId)
		const totalBingos = completedCount + (activeCard?.hasBingo ? 1 : 0)
		// Only announce if there are more bingos than announcements
		if (count >= totalBingos) return false
		this.bingoAnnouncements.push({ playerId, displayName })
		return true
	}
  • Step 6: Update getBingoScore to accumulate across all cards

Cards are in completedBingoCards only AFTER redraw, so there's no overlap with the active card. Replace the getBingoScore method (lines 220-227):

	getBingoScore(playerId: string): number {
		let totalTapped = 0
		let totalBonuses = 0
		// Count completed cards (moved here on redraw)
		const completed = this.completedBingoCards.filter((c) => c.playerId === playerId)
		for (const c of completed) {
			totalTapped += c.card.squares.filter((s) => s.tapped).length
			totalBonuses += scoringConfig.bingo_full_bonus
		}
		// Count active card (never overlaps with completed — card moves on redraw)
		const activeCard = this.bingoCards.get(playerId)
		if (activeCard) {
			totalTapped += activeCard.squares.filter((s) => s.tapped).length
			if (activeCard.hasBingo) totalBonuses += scoringConfig.bingo_full_bonus
		}
		return totalTapped * scoringConfig.bingo_per_square + totalBonuses
	}
  • Step 7: Add requestNewBingoCard method

This is where the completed card moves to completedBingoCards — NOT during tap detection. This avoids double-counting in getBingoScore.

After getBingoScore, add:

	requestNewBingoCard(playerId: string, displayName: string): { success: true } | { error: string } {
		const currentCard = this.bingoCards.get(playerId)
		if (!currentCard || !currentCard.hasBingo) {
			return { error: "No completed bingo card to replace" }
		}
		// Move current card to completedBingoCards
		this.completedBingoCards.push({
			playerId,
			displayName,
			card: { squares: currentCard.squares.map((s) => ({ ...s })), hasBingo: true },
			completedAt: new Date().toISOString(),
		})
		// Generate new card excluding tropes from the just-completed card
		const excludeIds = new Set(currentCard.squares.map((s) => s.tropeId))
		const available = tropes.filter((t) => !excludeIds.has(t.id))
		const pool = available.length >= 16 ? available : tropes
		const shuffled = [...pool].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,
		})
		return { success: true }
	}
  • Step 8: Add getCompletedBingoCards accessor

After requestNewBingoCard, add:

	getCompletedBingoCards(): CompletedBingoCard[] {
		return this.completedBingoCards
	}
  • Step 8b: Add generateBingoCardForPlayer method

The spec requires bingo cards to be viewable before live-event. Add a per-player card generation method (called on join):

	generateBingoCardForPlayer(playerId: string): void {
		if (this.bingoCards.has(playerId)) return
		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,
		})
	}

This is called from the WS handler when a player joins, so cards are available immediately. The existing generateBingoCards(allPlayerIds) bulk method stays for backward compatibility but can now skip players who already have cards (add a guard: if (this.bingoCards.has(playerId)) continue).

  • Step 9: Update getGameStateForPlayer to include completedBingoCards

In getGameStateForPlayer (line 432), after bingoAnnouncements (line 446), add:

			completedBingoCards: this.completedBingoCards,
  • Step 10: Update getGameStateForDisplay to include completedBingoCards

In getGameStateForDisplay (line 454), after bingoAnnouncements (line 467), add:

			completedBingoCards: this.completedBingoCards,
  • Step 11: Verify server tests still pass

Run: bun test --filter packages/server

Expected: all existing tests pass (the bingo-specific tests are new and will be written in Task 3).

  • Step 12: Commit
git add packages/server/src/games/game-manager.ts
git commit -m "add bingo completion logic: tap-only, card storage, scoring across cards, redraw"

Task 3: Server — Bingo Completion Tests

Files:

  • Create: packages/server/src/games/__tests__/game-manager-bingo.test.ts

  • Step 1: Write bingo tests

import { describe, it, expect, beforeEach } from "bun:test"
import { GameManager } from "../game-manager"

describe("Bingo", () => {
	let gm: GameManager

	beforeEach(() => {
		gm = new GameManager()
	})

	describe("generateBingoCards", () => {
		it("should create a 16-square card for each player", () => {
			gm.generateBingoCards(["p1", "p2"])
			const card1 = gm.getBingoCard("p1")
			const card2 = gm.getBingoCard("p2")
			expect(card1).not.toBeNull()
			expect(card1!.squares).toHaveLength(16)
			expect(card1!.hasBingo).toBe(false)
			expect(card2).not.toBeNull()
			expect(card2!.squares).toHaveLength(16)
		})

		it("should return null for unknown player", () => {
			expect(gm.getBingoCard("unknown")).toBeNull()
		})
	})

	describe("tapBingoSquare", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should mark a square as tapped", () => {
			const card = gm.getBingoCard("p1")!
			const tropeId = card.squares[0]!.tropeId
			const result = gm.tapBingoSquare("p1", tropeId)
			expect(result).toHaveProperty("success", true)
			expect(card.squares[0]!.tapped).toBe(true)
		})

		it("should be tap-only (not toggle)", () => {
			const card = gm.getBingoCard("p1")!
			const tropeId = card.squares[0]!.tropeId
			gm.tapBingoSquare("p1", tropeId)
			expect(card.squares[0]!.tapped).toBe(true)
			// Tap again — should stay tapped
			const result = gm.tapBingoSquare("p1", tropeId)
			expect(result).toHaveProperty("success", true)
			expect(card.squares[0]!.tapped).toBe(true)
		})

		it("should error for unknown player", () => {
			const result = gm.tapBingoSquare("unknown", "trope1")
			expect(result).toHaveProperty("error")
		})

		it("should error for trope not on card", () => {
			const result = gm.tapBingoSquare("p1", "nonexistent-trope")
			expect(result).toHaveProperty("error")
		})
	})

	describe("bingo detection", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should detect a completed row", () => {
			const card = gm.getBingoCard("p1")!
			// Tap first row (indices 0-3)
			for (let i = 0; i < 4; i++) {
				const result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
				if (i < 3) {
					expect((result as any).hasBingo).toBe(false)
				} else {
					expect((result as any).hasBingo).toBe(true)
					expect((result as any).isNewBingo).toBe(true)
				}
			}
		})

		it("should detect a completed column", () => {
			const card = gm.getBingoCard("p1")!
			// Tap first column (indices 0, 4, 8, 12)
			for (const i of [0, 4, 8, 12]) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			expect(card.hasBingo).toBe(true)
		})

		it("should detect a completed diagonal", () => {
			const card = gm.getBingoCard("p1")!
			// Tap main diagonal (indices 0, 5, 10, 15)
			for (const i of [0, 5, 10, 15]) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			expect(card.hasBingo).toBe(true)
		})

		it("should detect anti-diagonal", () => {
			const card = gm.getBingoCard("p1")!
			// Tap anti-diagonal (indices 3, 6, 9, 12)
			for (const i of [3, 6, 9, 12]) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			expect(card.hasBingo).toBe(true)
		})
	})

	describe("bingo completion flow", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should NOT move card to completedBingoCards on bingo detection (only on redraw)", () => {
			const card = gm.getBingoCard("p1")!
			// Complete first row — sets hasBingo but does NOT move card
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			expect(card.hasBingo).toBe(true)
			expect(gm.getCompletedBingoCards()).toHaveLength(0)
		})

		it("should move card to completedBingoCards on requestNewBingoCard", () => {
			const card = gm.getBingoCard("p1")!
			// Complete first row
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			// Redraw moves the completed card
			gm.requestNewBingoCard("p1", "Player 1")
			const completed = gm.getCompletedBingoCards()
			expect(completed).toHaveLength(1)
			expect(completed[0]!.playerId).toBe("p1")
			expect(completed[0]!.displayName).toBe("Player 1")
			expect(completed[0]!.card.hasBingo).toBe(true)
			expect(completed[0]!.completedAt).toBeTruthy()
		})
	})

	describe("requestNewBingoCard", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should error if card has no bingo", () => {
			const result = gm.requestNewBingoCard("p1", "Player 1")
			expect(result).toHaveProperty("error")
		})

		it("should generate a fresh card after bingo", () => {
			const card = gm.getBingoCard("p1")!
			const originalTropes = card.squares.map((s) => s.tropeId)
			// Complete first row
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			const result = gm.requestNewBingoCard("p1", "Player 1")
			expect(result).toHaveProperty("success", true)
			const newCard = gm.getBingoCard("p1")!
			expect(newCard.hasBingo).toBe(false)
			expect(newCard.squares.every((s) => !s.tapped)).toBe(true)
			expect(newCard.squares).toHaveLength(16)
		})
	})

	describe("getBingoScore — accumulation across cards", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should score tapped squares on active card", () => {
			const card = gm.getBingoCard("p1")!
			gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
			gm.tapBingoSquare("p1", card.squares[1]!.tropeId)
			// 2 tapped squares * 2 points = 4
			expect(gm.getBingoScore("p1")).toBe(4)
		})

		it("should include bingo bonus on completed card", () => {
			const card = gm.getBingoCard("p1")!
			// Complete first row (4 squares)
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			// 4 tapped * 2 = 8, plus 10 bonus = 18
			expect(gm.getBingoScore("p1")).toBe(18)
		})

		it("should accumulate scores across completed + new card", () => {
			const card = gm.getBingoCard("p1")!
			// Complete first row (4 squares) — triggers completion
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			// Request new card
			gm.requestNewBingoCard("p1", "Player 1")
			const newCard = gm.getBingoCard("p1")!
			// Tap 2 squares on new card
			gm.tapBingoSquare("p1", newCard.squares[0]!.tropeId)
			gm.tapBingoSquare("p1", newCard.squares[1]!.tropeId)
			// Old card: 4 tapped * 2 = 8 + 10 bonus = 18
			// New card: 2 tapped * 2 = 4
			// Total: 22
			expect(gm.getBingoScore("p1")).toBe(22)
		})

		it("should return 0 for unknown player", () => {
			expect(gm.getBingoScore("unknown")).toBe(0)
		})
	})

	describe("addBingoAnnouncement — multiple per player", () => {
		beforeEach(() => {
			gm.generateBingoCards(["p1"])
		})

		it("should announce first bingo", () => {
			const card = gm.getBingoCard("p1")!
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			const isNew = gm.addBingoAnnouncement("p1", "Player 1")
			expect(isNew).toBe(true)
		})

		it("should not re-announce same bingo", () => {
			const card = gm.getBingoCard("p1")!
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			gm.addBingoAnnouncement("p1", "Player 1")
			const isNew = gm.addBingoAnnouncement("p1", "Player 1")
			expect(isNew).toBe(false)
		})

		it("should announce second bingo after redraw", () => {
			const card = gm.getBingoCard("p1")!
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
			}
			gm.addBingoAnnouncement("p1", "Player 1")
			// Redraw
			gm.requestNewBingoCard("p1", "Player 1")
			const newCard = gm.getBingoCard("p1")!
			// Complete first row of new card
			for (let i = 0; i < 4; i++) {
				gm.tapBingoSquare("p1", newCard.squares[i]!.tropeId)
			}
			const isNew = gm.addBingoAnnouncement("p1", "Player 1")
			expect(isNew).toBe(true)
			expect(gm.getBingoAnnouncements()).toHaveLength(2)
		})
	})

	describe("game state includes completedBingoCards", () => {
		it("should include completedBingoCards in player game state", () => {
			gm.generateBingoCards(["p1"])
			const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
			expect(state.completedBingoCards).toEqual([])
		})

		it("should include completedBingoCards in display game state", () => {
			gm.generateBingoCards(["p1"])
			const state = gm.getGameStateForDisplay(["p1"], { p1: "Player 1" })
			expect(state.completedBingoCards).toEqual([])
		})
	})
})
  • Step 2: Run tests

Run: bun test --filter packages/server

Expected: all tests pass (existing 93 + new bingo tests).

  • Step 3: Commit
git add packages/server/src/games/__tests__/game-manager-bingo.test.ts
git commit -m "add bingo completion tests: tap-only, detection, scoring, redraw, announcements"

Task 4: Server — WS Handler Updates

Files:

  • Modify: packages/server/src/ws/handler.ts

Context: The tap_bingo_square case is at line 423. The handler needs to pass displayName to tapBingoSquare() and handle the new isNewBingo field. A new request_new_bingo_card case is needed.

  • Step 1: Update tap_bingo_square case

Replace the tap_bingo_square case (lines 423-460) with:

					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
						}
						sendGameState(ws, roomCode, sessionId)
						if (result.isNewBingo) {
							const room = roomManager.getRoom(roomCode)
							const player = room?.players.find((p) => p.sessionId === sessionId)
							const displayName = player?.displayName ?? "Unknown"
							const isNew = gm.addBingoAnnouncement(playerId, displayName)
							if (isNew) {
								broadcast(roomCode, {
									type: "bingo_announced",
									playerId,
									displayName,
								})
								broadcastGameStateToAll(roomCode)
							}
						}
						break
					}
  • Step 2: Add request_new_bingo_card case

After the tap_bingo_square case, add:

					case "request_new_bingo_card": {
						if (!sessionId) {
							sendError(ws, "Not joined")
							return
						}
						if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
							sendError(ws, "New bingo cards are 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 room = roomManager.getRoom(roomCode)
						const player = room?.players.find((p) => p.sessionId === sessionId)
						const displayName = player?.displayName ?? "Unknown"
						const result = gm.requestNewBingoCard(playerId, displayName)
						if ("error" in result) {
							sendError(ws, result.error)
							return
						}
						broadcastGameStateToAll(roomCode)
						break
					}
  • Step 3: Generate bingo card on player join

In the join_room case (around line 163), after sendGameState(ws, roomCode, result.sessionId) (line 177), add bingo card generation:

							// Generate bingo card for new player immediately
							const gm = roomManager.getGameManager(roomCode)
							if (gm) {
								const playerId = roomManager.getPlayerIdBySession(roomCode, result.sessionId)
								if (playerId) gm.generateBingoCardForPlayer(playerId)
							}

Also update the generateBingoCards call when entering live-event (line 230) to skip players who already have cards. The existing generateBingoCards method needs a guard added (done in Task 2, Step 8b).

  • Step 4: Verify all tests pass

Run: bun test --filter packages/server

  • Step 5: Commit
git add packages/server/src/ws/handler.ts
git commit -m "update tap_bingo_square for completion flow, add request_new_bingo_card handler, generate cards on join"

Chunk 2: Client-Side — Components

Task 5: Client — RoomLayout Component (Sticky Header)

Files:

  • Create: packages/client/src/components/room-layout.tsx

Context: Replaces RoomHeader. Shows "I❤️ESC" left, room code + connection dot right. Sticky, safe-area aware.

  • Step 1: Create room-layout.tsx
import { Outlet } from "@tanstack/react-router"

interface RoomLayoutProps {
	roomCode: string
	connectionStatus: "disconnected" | "connecting" | "connected"
}

export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
	return (
		<div className="flex min-h-screen flex-col">
			<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
				<span className="text-lg font-bold">I❤️ESC</span>
				<div className="flex items-center gap-2">
					<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
					<span
						className={`h-2 w-2 rounded-full ${
							connectionStatus === "connected"
								? "bg-green-500"
								: connectionStatus === "connecting"
									? "bg-yellow-500"
									: "bg-red-500"
						}`}
						title={connectionStatus}
					/>
				</div>
			</header>
			<main className="flex-1 pb-20">
				<Outlet />
			</main>
		</div>
	)
}
  • Step 2: Commit
git add packages/client/src/components/room-layout.tsx
git commit -m "add RoomLayout component: sticky header with safe-area support"

Task 6: Client — BottomNav Component

Files:

  • Create: packages/client/src/components/bottom-nav.tsx

Context: Fixed bottom tab bar. iOS-style SVG icons. Player: Game | Bingo | Leaderboard. Host: Game | Bingo | Board | Host. Each tab is a TanStack Router <Link>.

  • Step 1: Create bottom-nav.tsx
import { Link, useMatchRoute } from "@tanstack/react-router"

interface BottomNavProps {
	basePath: "/play/$roomCode" | "/host/$roomCode"
	roomCode: string
	isHost: boolean
}

function GameIcon({ active }: { active: boolean }) {
	return (
		<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
			<circle cx="12" cy="12" r="10" />
			<polygon points="10,8 16,12 10,16" />
		</svg>
	)
}

function BingoIcon({ active }: { active: boolean }) {
	return (
		<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
			<rect x="3" y="3" width="7" height="7" rx="1" />
			<rect x="14" y="3" width="7" height="7" rx="1" />
			<rect x="3" y="14" width="7" height="7" rx="1" />
			<rect x="14" y="14" width="7" height="7" rx="1" />
		</svg>
	)
}

function TrophyIcon({ active }: { active: boolean }) {
	return (
		<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
			<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
			<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
			<path d="M4 22h16" />
			<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
			<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
			<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
		</svg>
	)
}

function WrenchIcon({ active }: { active: boolean }) {
	return (
		<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
			<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
		</svg>
	)
}

interface TabConfig {
	to: string
	label: string
	icon: (props: { active: boolean }) => React.ReactNode
}

export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
	const matchRoute = useMatchRoute()

	const tabs: TabConfig[] = [
		{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
		{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
		{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
	]

	if (isHost) {
		tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
	}

	return (
		<nav
			className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
			style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
		>
			<div className="flex">
				{tabs.map((tab) => {
					const active = !!matchRoute({ to: tab.to, params: { roomCode } })
					return (
						<Link
							key={tab.to}
							to={tab.to}
							params={{ roomCode }}
							className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
								active
									? "text-primary"
									: "text-muted-foreground"
							}`}
						>
							<tab.icon active={active} />
							<span>{tab.label}</span>
						</Link>
					)
				})}
			</div>
		</nav>
	)
}
  • Step 2: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 3: Commit
git add packages/client/src/components/bottom-nav.tsx
git commit -m "add BottomNav component: fixed bottom tab bar with iOS-style SVG icons"

Task 7: Client — BingoCard Readonly Mode + Completion UI

Files:

  • Modify: packages/client/src/components/bingo-card.tsx

Context: Current component (43 lines) always allows tapping. Add readonly prop to disable interaction. Add completion UI showing "Draw New Card" button.

  • Step 1: Update BingoCard component

Replace the full file content:

import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"

interface BingoCardProps {
	card: BingoCardType
	onTap: (tropeId: string) => void
	readonly?: boolean
	onRequestNewCard?: () => void
}

export function BingoCard({ card, onTap, readonly, onRequestNewCard }: 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 className="flex flex-col gap-3">
				<div className="grid grid-cols-4 gap-1.5">
					{card.squares.map((square) => (
						<button
							key={square.tropeId}
							type="button"
							onClick={() => !readonly && onTap(square.tropeId)}
							disabled={readonly}
							className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
								square.tapped
									? "border-primary bg-primary/20 font-medium text-primary"
									: readonly
										? "border-muted text-muted-foreground"
										: "border-muted hover:bg-muted/50"
							} ${readonly ? "cursor-default" : "cursor-pointer"}`}
						>
							{square.label}
						</button>
					))}
				</div>
				{card.hasBingo && onRequestNewCard && (
					<Button onClick={onRequestNewCard} variant="outline" className="w-full">
						Draw New Card
					</Button>
				)}
			</CardContent>
		</Card>
	)
}
  • Step 2: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 3: Commit
git add packages/client/src/components/bingo-card.tsx
git commit -m "add readonly mode, draw-new-card button to BingoCard"

Task 8: Client — Leaderboard Absorbs Player List

Files:

  • Modify: packages/client/src/components/leaderboard.tsx

Context: Current component (49 lines) shows leaderboard table with P/J/B/Q columns. In lobby, should show simplified "who's here" view. Absorbs PlayerList role.

  • Step 1: Update Leaderboard component

Replace the full file content:

import type { LeaderboardEntry } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

interface LeaderboardProps {
	entries: LeaderboardEntry[]
	resultsEntered?: boolean
	lobbyMode?: boolean
}

export function Leaderboard({ entries, resultsEntered, lobbyMode }: LeaderboardProps) {
	if (entries.length === 0) return null

	return (
		<Card>
			<CardHeader>
				<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
			</CardHeader>
			<CardContent className="flex flex-col gap-4">
				<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>
							{!lobbyMode && (
								<div className="flex items-center gap-3 text-xs text-muted-foreground">
									<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
									<span title="Jury points">J:{entry.juryPoints}</span>
									<span title="Bingo points">B:{entry.bingoPoints}</span>
									<span title="Quiz points">Q:{entry.quizPoints}</span>
									<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
								</div>
							)}
						</div>
					))}
				</div>
				{!lobbyMode && (
					<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
						<p className="mb-1 font-medium">How scoring works</p>
						<ul className="flex flex-col gap-0.5">
							<li><strong>P</strong> = Prediction points  25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
							<li><strong>J</strong> = Jury points  rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
							<li><strong>B</strong> = Bingo points  2 pts per tapped trope + 10 bonus for a full bingo line</li>
							<li><strong>Q</strong> = Quiz points  5 easy, 10 medium, 15 hard</li>
						</ul>
					</div>
				)}
			</CardContent>
		</Card>
	)
}
  • Step 2: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 3: Commit
git add packages/client/src/components/leaderboard.tsx
git commit -m "add lobbyMode to Leaderboard, absorb PlayerList role"

Task 9: Client — Shared Tab Components

Files:

  • Create: packages/client/src/components/game-tab.tsx
  • Create: packages/client/src/components/bingo-tab.tsx
  • Create: packages/client/src/components/board-tab.tsx
  • Create: packages/client/src/components/host-tab.tsx
  • Create: packages/client/src/components/bingo-claims.tsx

Context: These components extract tab content from the current route files. They consume state from useRoomStore() and accept a send function prop. Shared between host and player where applicable.

  • Step 1: Create game-tab.tsx
import type { ClientMessage, GameState } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
import { PredictionsForm } from "@/components/predictions-form"
import { JuryVoting } from "@/components/jury-voting"
import { QuizBuzzer } from "@/components/quiz-buzzer"

interface GameTabProps {
	currentAct: Act
	gameState: GameState
	send: (message: ClientMessage) => void
}

export function GameTab({ currentAct, gameState, send }: GameTabProps) {
	if (currentAct === "lobby" || currentAct === "pre-show") {
		return (
			<div className="flex flex-col gap-4 p-4">
				<PredictionsForm
					entries={gameState.lineup.entries}
					existingPrediction={gameState.myPrediction}
					locked={gameState.predictionsLocked}
					onSubmit={(prediction) => send({ type: "submit_prediction", ...prediction })}
				/>
			</div>
		)
	}

	if (currentAct === "live-event") {
		return (
			<div className="flex flex-col gap-4 p-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>
				)}
			</div>
		)
	}

	if (currentAct === "scoring") {
		return (
			<div className="flex flex-col gap-4 p-4">
				{gameState.myPrediction && (
					<PredictionsForm
						entries={gameState.lineup.entries}
						existingPrediction={gameState.myPrediction}
						locked={true}
						actualResults={gameState.actualResults}
						onSubmit={() => {}}
					/>
				)}
				{gameState.currentQuizQuestion && (
					<QuizBuzzer
						question={gameState.currentQuizQuestion}
						buzzStatus={gameState.myQuizBuzzStatus}
						onBuzz={() => send({ type: "buzz" })}
					/>
				)}
			</div>
		)
	}

	if (currentAct === "ended") {
		return (
			<div className="flex flex-col items-center gap-4 p-4 py-8">
				<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
				{gameState.myPrediction && (
					<PredictionsForm
						entries={gameState.lineup.entries}
						existingPrediction={gameState.myPrediction}
						locked={true}
						actualResults={gameState.actualResults}
						onSubmit={() => {}}
					/>
				)}
			</div>
		)
	}

	return null
}
  • Step 2: Create bingo-tab.tsx
import type { ClientMessage, GameState } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
import { BingoCard } from "@/components/bingo-card"

interface BingoTabProps {
	currentAct: Act
	gameState: GameState
	send: (message: ClientMessage) => void
}

export function BingoTab({ currentAct, gameState, send }: BingoTabProps) {
	const isLiveEvent = currentAct === "live-event"

	if (!gameState.myBingoCard) {
		return (
			<div className="flex flex-col items-center gap-4 p-4 py-8">
				<p className="text-muted-foreground">No bingo card yet. Cards are dealt when the Live Event begins.</p>
			</div>
		)
	}

	return (
		<div className="flex flex-col gap-4 p-4">
			<BingoCard
				card={gameState.myBingoCard}
				onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
				readonly={!isLiveEvent}
				onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
			/>
		</div>
	)
}
  • Step 3: Create board-tab.tsx
import type { GameState } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
import { Leaderboard } from "@/components/leaderboard"

interface BoardTabProps {
	currentAct: Act
	gameState: GameState
}

export function BoardTab({ currentAct, gameState }: BoardTabProps) {
	return (
		<div className="flex flex-col gap-4 p-4">
			<Leaderboard
				entries={gameState.leaderboard}
				resultsEntered={!!gameState.actualResults}
				lobbyMode={currentAct === "lobby"}
			/>
		</div>
	)
}
  • Step 4: Create bingo-claims.tsx
import type { CompletedBingoCard } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

interface BingoClaimsProps {
	completedCards: CompletedBingoCard[]
}

export function BingoClaims({ completedCards }: BingoClaimsProps) {
	if (completedCards.length === 0) return null

	return (
		<Card>
			<CardHeader>
				<CardTitle>Bingo Claims ({completedCards.length})</CardTitle>
			</CardHeader>
			<CardContent className="flex flex-col gap-3">
				{completedCards.map((claim, i) => (
					<div key={`${claim.playerId}-${i}`} className="flex flex-col gap-1.5 border-b pb-3 last:border-0 last:pb-0">
						<div className="flex items-center justify-between">
							<span className="text-sm font-medium">{claim.displayName}</span>
							<span className="text-xs text-muted-foreground">
								{new Date(claim.completedAt).toLocaleTimeString()}
							</span>
						</div>
						<div className="grid grid-cols-4 gap-0.5">
							{claim.card.squares.map((square) => (
								<div
									key={square.tropeId}
									className={`rounded px-1 py-0.5 text-center text-xs ${
										square.tapped
											? "bg-primary/20 text-primary"
											: "bg-muted/50 text-muted-foreground"
									}`}
								>
									{square.label}
								</div>
							))}
						</div>
					</div>
				))}
			</CardContent>
		</Card>
	)
}
  • Step 5: Create host-tab.tsx
import { useState } from "react"
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { JuryHost } from "@/components/jury-host"
import { QuizHost } from "@/components/quiz-host"
import { ActualResultsForm } from "@/components/actual-results-form"
import { BingoClaims } from "@/components/bingo-claims"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

const nextActLabels: Partial<Record<Act, string>> = {
	lobby: "Start Pre-Show",
	"pre-show": "Start Live Event",
	"live-event": "Start Scoring",
}

const prevActLabels: Partial<Record<Act, string>> = {
	"pre-show": "Back to Lobby",
	"live-event": "Back to Pre-Show",
	scoring: "Back to Live Event",
	ended: "Back to Scoring",
}

interface HostTabProps {
	roomCode: string
	currentAct: Act
	gameState: GameState
	send: (message: ClientMessage) => void
}

export function HostTab({ roomCode, currentAct, gameState, send }: HostTabProps) {
	const [copied, setCopied] = useState(false)
	const base = import.meta.env.BASE_URL.replace(/\/$/, "")
	const displayUrl = `${window.location.origin}${base}/display/${roomCode}`

	function copyDisplayUrl() {
		navigator.clipboard.writeText(displayUrl).then(() => {
			setCopied(true)
			setTimeout(() => setCopied(false), 2000)
		})
	}

	return (
		<div className="flex flex-col gap-4 p-4">
			{/* Act Controls */}
			<Card>
				<CardHeader>
					<CardTitle>Room Controls</CardTitle>
				</CardHeader>
				<CardContent className="flex flex-col gap-3">
					{currentAct !== "ended" && (
						<div className="flex gap-2">
							{currentAct !== "lobby" && (
								<Button
									variant="outline"
									onClick={() => send({ type: "revert_act" })}
									className="flex-1"
								>
									{prevActLabels[currentAct] ?? "Back"}
								</Button>
							)}
							{nextActLabels[currentAct] && (
								<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
									{nextActLabels[currentAct]}
								</Button>
							)}
						</div>
					)}
					{currentAct === "ended" && (
						<Button variant="outline" onClick={() => send({ type: "revert_act" })}>
							{prevActLabels[currentAct] ?? "Back"}
						</Button>
					)}
				</CardContent>
			</Card>

			{/* Display View Link */}
			<Card>
				<CardHeader>
					<CardTitle>Display View</CardTitle>
				</CardHeader>
				<CardContent className="flex flex-col gap-2">
					<p className="text-sm text-muted-foreground">
						Project this on a TV for everyone to see.
					</p>
					<div className="flex items-center gap-2">
						<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs">{displayUrl}</code>
						<Button variant="outline" size="sm" onClick={copyDisplayUrl}>
							{copied ? "Copied!" : "Copy"}
						</Button>
					</div>
				</CardContent>
			</Card>

			{/* Jury Host (live-event) */}
			{currentAct === "live-event" && (
				<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" })}
				/>
			)}

			{/* Quiz Host (scoring) */}
			{currentAct === "scoring" && (
				<QuizHost
					question={gameState.currentQuizQuestion}
					onStartQuestion={() => send({ type: "start_quiz_question" })}
					onJudge={(correct) => send({ type: "judge_quiz_answer", correct })}
					onSkip={() => send({ type: "skip_quiz_question" })}
				/>
			)}

			{/* Actual Results Form (scoring/ended) */}
			{(currentAct === "scoring" || currentAct === "ended") && (
				<ActualResultsForm
					entries={gameState.lineup.entries}
					existingResults={gameState.actualResults}
					onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
				/>
			)}

			{/* Bingo Claims */}
			{gameState.completedBingoCards.length > 0 && (
				<BingoClaims completedCards={gameState.completedBingoCards} />
			)}

			{/* End Party (destructive) */}
			{currentAct !== "ended" && (
				<Button
					variant="destructive"
					onClick={() => send({ type: "end_room" })}
					className="w-full"
				>
					End Party
				</Button>
			)}
		</div>
	)
}
  • Step 6: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 7: Commit
git add packages/client/src/components/game-tab.tsx packages/client/src/components/bingo-tab.tsx packages/client/src/components/board-tab.tsx packages/client/src/components/host-tab.tsx packages/client/src/components/bingo-claims.tsx
git commit -m "add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims"

Task 10: Client — WebSocket Send Sharing via Zustand

Files:

  • Modify: packages/client/src/stores/room-store.ts
  • Modify: packages/client/src/hooks/use-websocket.ts

Context: CRITICAL: useWebSocket creates a new WebSocket connection on every call. If child routes call it, they create duplicate connections. When a child route unmounts (tab switch), its cleanup calls reset(), wiping the Zustand store. Solution: layout route is the ONLY component that calls useWebSocket. It stores send in Zustand. Child routes read send from the store.

  • Step 1: Add send to room store

In packages/client/src/stores/room-store.ts, add send to the interface and initial state:

import { create } from "zustand"
import type { RoomState, Player, GameState, ClientMessage } from "@celebrate-esc/shared"

interface RoomStore {
	room: RoomState | null
	mySessionId: string | null
	connectionStatus: "disconnected" | "connecting" | "connected"
	gameState: GameState | null
	send: (message: ClientMessage) => void

	setRoom: (room: RoomState) => void
	setMySessionId: (sessionId: string) => void
	setConnectionStatus: (status: "disconnected" | "connecting" | "connected") => void
	updatePlayerConnected: (playerId: string, connected: boolean) => void
	addPlayer: (player: Player) => void
	setAct: (act: RoomState["currentAct"]) => void
	setGameState: (gameState: GameState) => void
	lockPredictions: () => void
	setSend: (send: (message: ClientMessage) => void) => void
	reset: () => void
}

const noop = () => {}

export const useRoomStore = create<RoomStore>((set) => ({
	room: null,
	mySessionId: null,
	connectionStatus: "disconnected",
	gameState: null,
	send: noop,

	setRoom: (room) => set({ room }),
	setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
	setConnectionStatus: (status) => set({ connectionStatus: status }),

	updatePlayerConnected: (playerId, connected) =>
		set((state) => {
			if (!state.room) return state
			return {
				room: {
					...state.room,
					players: state.room.players.map((p) => (p.id === playerId ? { ...p, connected } : p)),
				},
			}
		}),

	addPlayer: (player) =>
		set((state) => {
			if (!state.room) return state
			if (state.room.players.some((p) => p.id === player.id)) return state
			return {
				room: {
					...state.room,
					players: [...state.room.players, player],
				},
			}
		}),

	setAct: (act) =>
		set((state) => {
			if (!state.room) return state
			return { room: { ...state.room, currentAct: act } }
		}),

	setGameState: (gameState) => set({ gameState }),

	lockPredictions: () =>
		set((state) => {
			if (!state.gameState) return state
			return {
				gameState: { ...state.gameState, predictionsLocked: true },
			}
		}),

	setSend: (send) => set({ send }),

	reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null, send: noop }),
}))
  • Step 2: Update useWebSocket to store send in Zustand

In packages/client/src/hooks/use-websocket.ts, add setSend to the destructured store and call setSend(send) after creating the send callback:

After const send = useCallback(...), add:

	const { setSend, ...rest } = useRoomStore()

	useEffect(() => {
		setSend(send)
	}, [send, setSend])

The hook still returns { send } for backward compatibility with the layout route's join flow, but child routes should use useRoomStore().send instead.

  • Step 3: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 4: Commit
git add packages/client/src/stores/room-store.ts packages/client/src/hooks/use-websocket.ts
git commit -m "share WebSocket send via Zustand store, prevent duplicate connections"

Chunk 3: Client-Side — Route Restructure

Task 11: Client — Play Route Restructure

Files:

  • Modify: packages/client/src/routes/play.$roomCode.tsx — convert to layout route
  • Create: packages/client/src/routes/play.$roomCode.index.tsx — redirect
  • Create: packages/client/src/routes/play.$roomCode.game.tsx — game child
  • Create: packages/client/src/routes/play.$roomCode.bingo.tsx — bingo child
  • Create: packages/client/src/routes/play.$roomCode.board.tsx — leaderboard child

Context: The current play.$roomCode.tsx (176 lines) contains everything: WebSocket setup, join flow, game content. Convert it to a layout route that handles WS + join flow and renders RoomLayout with <Outlet />. Child routes render tab content.

  • Step 1: Replace play.$roomCode.tsx with layout route

Replace the entire file:

import { useEffect, useRef, useState } from "react"
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { RoomLayout } from "@/components/room-layout"
import { BottomNav } from "@/components/bottom-nav"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export const Route = createFileRoute("/play/$roomCode")({
	component: PlayLayout,
})

function PlayLayout() {
	const { roomCode } = Route.useParams()
	const { send } = useWebSocket(roomCode)
	const { room, mySessionId, connectionStatus } = useRoomStore()
	const joinSentRef = useRef(false)
	const [manualName, setManualName] = useState("")

	useEffect(() => {
		if (connectionStatus !== "connected" || mySessionId || joinSentRef.current) return

		const displayName = sessionStorage.getItem("esc-party-join-name")
		if (displayName) {
			joinSentRef.current = true
			sessionStorage.removeItem("esc-party-join-name")
			send({ type: "join_room", displayName })
		}
	}, [connectionStatus, mySessionId, send])

	if (!room) {
		return (
			<div className="flex min-h-screen items-center justify-center">
				<p className="text-muted-foreground">
					{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
				</p>
			</div>
		)
	}

	if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
		return (
			<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
				<h2 className="text-xl font-bold">Join Room {roomCode}</h2>
				<Input
					placeholder="Your name"
					value={manualName}
					onChange={(e) => setManualName(e.target.value)}
					maxLength={20}
					onKeyDown={(e) => {
						if (e.key === "Enter" && manualName.trim()) {
							joinSentRef.current = true
							send({ type: "join_room", displayName: manualName.trim() })
						}
					}}
				/>
				<Button
					onClick={() => {
						if (manualName.trim()) {
							joinSentRef.current = true
							send({ type: "join_room", displayName: manualName.trim() })
						}
					}}
					disabled={!manualName.trim()}
				>
					Join
				</Button>
			</div>
		)
	}

	if (!mySessionId) {
		return (
			<div className="flex min-h-screen items-center justify-center">
				<p className="text-muted-foreground">Joining room...</p>
			</div>
		)
	}

	return (
		<>
			<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
			<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
		</>
	)
}
  • Step 2: Create play.$roomCode.index.tsx (redirect)
import { createFileRoute, redirect } from "@tanstack/react-router"

export const Route = createFileRoute("/play/$roomCode/")({
	beforeLoad: ({ params }) => {
		throw redirect({ to: "/play/$roomCode/game", params })
	},
})
  • Step 3: Create play.$roomCode.game.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { GameTab } from "@/components/game-tab"

export const Route = createFileRoute("/play/$roomCode/game")({
	component: PlayGame,
})

function PlayGame() {
	const { room, gameState, send } = useRoomStore()

	if (!room || !gameState) return null

	return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
  • Step 4: Create play.$roomCode.bingo.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BingoTab } from "@/components/bingo-tab"

export const Route = createFileRoute("/play/$roomCode/bingo")({
	component: PlayBingo,
})

function PlayBingo() {
	const { room, gameState, send } = useRoomStore()

	if (!room || !gameState) return null

	return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
  • Step 5: Create play.$roomCode.board.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BoardTab } from "@/components/board-tab"

export const Route = createFileRoute("/play/$roomCode/board")({
	component: PlayBoard,
})

function PlayBoard() {
	const { room, gameState } = useRoomStore()

	if (!room || !gameState) return null

	return <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
  • Step 6: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 7: Commit
git add packages/client/src/routes/play.\$roomCode.tsx packages/client/src/routes/play.\$roomCode.index.tsx packages/client/src/routes/play.\$roomCode.game.tsx packages/client/src/routes/play.\$roomCode.bingo.tsx packages/client/src/routes/play.\$roomCode.board.tsx
git commit -m "restructure play routes: layout with nested game, bingo, board children"

Task 12: Client — Host Route Restructure

Files:

  • Modify: packages/client/src/routes/host.$roomCode.tsx — convert to layout route
  • Create: packages/client/src/routes/host.$roomCode.index.tsx — redirect
  • Create: packages/client/src/routes/host.$roomCode.game.tsx — game child
  • Create: packages/client/src/routes/host.$roomCode.bingo.tsx — bingo child
  • Create: packages/client/src/routes/host.$roomCode.board.tsx — leaderboard child
  • Create: packages/client/src/routes/host.$roomCode.host.tsx — host controls child

Context: The host is a player with an extra "Host" tab. Game, Bingo, and Board tabs are identical to player tabs. The WebSocket connection is established in the layout route.

  • Step 1: Replace host.$roomCode.tsx with layout route

Replace the entire file:

import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { RoomLayout } from "@/components/room-layout"
import { BottomNav } from "@/components/bottom-nav"

export const Route = createFileRoute("/host/$roomCode")({
	component: HostLayout,
})

function HostLayout() {
	const { roomCode } = Route.useParams()
	useWebSocket(roomCode)
	const { room, connectionStatus } = useRoomStore()

	if (!room) {
		return (
			<div className="flex min-h-screen items-center justify-center">
				<p className="text-muted-foreground">
					{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
				</p>
			</div>
		)
	}

	return (
		<>
			<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
			<BottomNav basePath="/host/$roomCode" roomCode={roomCode} isHost={true} />
		</>
	)
}
  • Step 2: Create host.$roomCode.index.tsx (redirect)
import { createFileRoute, redirect } from "@tanstack/react-router"

export const Route = createFileRoute("/host/$roomCode/")({
	beforeLoad: ({ params }) => {
		throw redirect({ to: "/host/$roomCode/game", params })
	},
})
  • Step 3: Create host.$roomCode.game.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { GameTab } from "@/components/game-tab"

export const Route = createFileRoute("/host/$roomCode/game")({
	component: HostGame,
})

function HostGame() {
	const { room, gameState, send } = useRoomStore()

	if (!room || !gameState) return null

	return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
  • Step 4: Create host.$roomCode.bingo.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BingoTab } from "@/components/bingo-tab"

export const Route = createFileRoute("/host/$roomCode/bingo")({
	component: HostBingo,
})

function HostBingo() {
	const { room, gameState, send } = useRoomStore()

	if (!room || !gameState) return null

	return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
  • Step 5: Create host.$roomCode.board.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { BoardTab } from "@/components/board-tab"

export const Route = createFileRoute("/host/$roomCode/board")({
	component: HostBoard,
})

function HostBoard() {
	const { room, gameState } = useRoomStore()

	if (!room || !gameState) return null

	return <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
  • Step 6: Create host.$roomCode.host.tsx
import { createFileRoute } from "@tanstack/react-router"
import { useRoomStore } from "@/stores/room-store"
import { HostTab } from "@/components/host-tab"

export const Route = createFileRoute("/host/$roomCode/host")({
	component: HostControls,
})

function HostControls() {
	const { roomCode } = Route.useParams()
	const { room, gameState, send } = useRoomStore()

	if (!room || !gameState) return null

	return <HostTab roomCode={roomCode} currentAct={room.currentAct} gameState={gameState} send={send} />
}
  • Step 7: Verify client builds

Run: bun run --filter @celebrate-esc/client build

  • Step 8: Commit
git add packages/client/src/routes/host.\$roomCode.tsx packages/client/src/routes/host.\$roomCode.index.tsx packages/client/src/routes/host.\$roomCode.game.tsx packages/client/src/routes/host.\$roomCode.bingo.tsx packages/client/src/routes/host.\$roomCode.board.tsx packages/client/src/routes/host.\$roomCode.host.tsx
git commit -m "restructure host routes: layout with nested game, bingo, board, host children"

Task 13: End-to-End Verification

Files: None (verification only)

  • Step 1: Run all server tests

Run: bun test --filter packages/server

Expected: all tests pass (93 existing + new bingo tests).

  • Step 2: Run full client build

Run: bun run --filter @celebrate-esc/client build

Expected: clean build, no errors.

  • Step 3: Verify route tree generation

Check that packages/client/src/routeTree.gen.ts was regenerated by TanStack Router plugin with the new nested routes. It should include entries for all new child routes.

Run: grep -c "Route" packages/client/src/routeTree.gen.ts

Expected: significantly more route entries than before.

  • Step 4: Start dev server and verify manually

Run: bun run --filter @celebrate-esc/client dev (in one terminal) Run: bun run --filter @celebrate-esc/server dev (in another terminal)

Verify:

  1. Navigate to / — landing page unchanged
  2. Create a room — redirected to /host/XXXX/game
  3. Bottom nav visible with 4 tabs (Game, Bingo, Board, Host)
  4. Sticky header shows "I❤️ESC" + room code + green dot
  5. Tap each tab — content switches, URL updates
  6. Browser back/forward works between tabs
  7. Open /play/XXXX in another tab — redirected to /play/XXXX/game
  8. Bottom nav has 3 tabs (Game, Bingo, Leaderboard)
  9. Bingo tab shows "No bingo card yet" in lobby
  10. Display view (/display/XXXX) unchanged — no bottom nav