()` (line 166), add:
+
+```typescript
+ private completedBingoCards: CompletedBingoCard[] = []
+```
+
+- [ ] **Step 3: Change `tapBingoSquare` from toggle to tap-only**
+
+In `tapBingoSquare` (line 192), change:
+
+```typescript
+ square.tapped = !square.tapped
+```
+
+to:
+
+```typescript
+ 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:
+
+```typescript
+ 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):
+
+```typescript
+ 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):
+
+```typescript
+ 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:
+
+```typescript
+ 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:
+
+```typescript
+ 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):
+
+```typescript
+ 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:
+
+```typescript
+ completedBingoCards: this.completedBingoCards,
+```
+
+- [ ] **Step 10: Update `getGameStateForDisplay` to include `completedBingoCards`**
+
+In `getGameStateForDisplay` (line 454), after `bingoAnnouncements` (line 467), add:
+
+```typescript
+ 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**
+
+```bash
+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**
+
+```typescript
+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**
+
+```bash
+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:
+
+```typescript
+ 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:
+
+```typescript
+ 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:
+
+```typescript
+ // 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**
+
+```bash
+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`**
+
+```tsx
+import { Outlet } from "@tanstack/react-router"
+
+interface RoomLayoutProps {
+ roomCode: string
+ connectionStatus: "disconnected" | "connecting" | "connected"
+}
+
+export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
+ return (
+
+
+ I❤️ESC
+
+ {roomCode}
+
+
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+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 ``.
+
+- [ ] **Step 1: Create `bottom-nav.tsx`**
+
+```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 (
+
+ )
+}
+
+function BingoIcon({ active }: { active: boolean }) {
+ return (
+
+ )
+}
+
+function TrophyIcon({ active }: { active: boolean }) {
+ return (
+
+ )
+}
+
+function WrenchIcon({ active }: { active: boolean }) {
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+```
+
+- [ ] **Step 2: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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:
+
+```tsx
+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 (
+
+
+
+ Bingo
+ {card.hasBingo && (
+
+ BINGO!
+
+ )}
+
+
+
+
+ {card.squares.map((square) => (
+
+ ))}
+
+ {card.hasBingo && onRequestNewCard && (
+
+ )}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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:
+
+```tsx
+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 (
+
+
+ {lobbyMode ? `Players (${entries.length})` : "Leaderboard"}
+
+
+
+ {entries.map((entry, i) => (
+
+
+
+ {i + 1}
+
+ {entry.displayName}
+
+ {!lobbyMode && (
+
+ P:{resultsEntered ? entry.predictionPoints : "?"}
+ J:{entry.juryPoints}
+ B:{entry.bingoPoints}
+ Q:{entry.quizPoints}
+ {entry.totalPoints}
+
+ )}
+
+ ))}
+
+ {!lobbyMode && (
+
+
How scoring works
+
+ - P = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place
+ - J = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus
+ - B = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line
+ - Q = Quiz points — 5 easy, 10 medium, 15 hard
+
+
+ )}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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`**
+
+```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 (
+
+
send({ type: "submit_prediction", ...prediction })}
+ />
+
+ )
+ }
+
+ if (currentAct === "live-event") {
+ return (
+
+ {gameState.currentJuryRound ? (
+
send({ type: "submit_jury_vote", rating })}
+ />
+ ) : (
+
+ Waiting for host to open voting...
+
+ )}
+
+ )
+ }
+
+ if (currentAct === "scoring") {
+ return (
+
+ {gameState.myPrediction && (
+
{}}
+ />
+ )}
+ {gameState.currentQuizQuestion && (
+ send({ type: "buzz" })}
+ />
+ )}
+
+ )
+ }
+
+ if (currentAct === "ended") {
+ return (
+
+
The party has ended. Thanks for playing!
+ {gameState.myPrediction && (
+
{}}
+ />
+ )}
+
+ )
+ }
+
+ return null
+}
+```
+
+- [ ] **Step 2: Create `bingo-tab.tsx`**
+
+```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 (
+
+
No bingo card yet. Cards are dealt when the Live Event begins.
+
+ )
+ }
+
+ return (
+
+ send({ type: "tap_bingo_square", tropeId })}
+ readonly={!isLiveEvent}
+ onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
+ />
+
+ )
+}
+```
+
+- [ ] **Step 3: Create `board-tab.tsx`**
+
+```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 (
+
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Create `bingo-claims.tsx`**
+
+```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 (
+
+
+ Bingo Claims ({completedCards.length})
+
+
+ {completedCards.map((claim, i) => (
+
+
+ {claim.displayName}
+
+ {new Date(claim.completedAt).toLocaleTimeString()}
+
+
+
+ {claim.card.squares.map((square) => (
+
+ {square.label}
+
+ ))}
+
+
+ ))}
+
+
+ )
+}
+```
+
+- [ ] **Step 5: Create `host-tab.tsx`**
+
+```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> = {
+ lobby: "Start Pre-Show",
+ "pre-show": "Start Live Event",
+ "live-event": "Start Scoring",
+}
+
+const prevActLabels: Partial> = {
+ "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 (
+
+ {/* Act Controls */}
+
+
+ Room Controls
+
+
+ {currentAct !== "ended" && (
+
+ {currentAct !== "lobby" && (
+
+ )}
+ {nextActLabels[currentAct] && (
+
+ )}
+
+ )}
+ {currentAct === "ended" && (
+
+ )}
+
+
+
+ {/* Display View Link */}
+
+
+ Display View
+
+
+
+ Project this on a TV for everyone to see.
+
+
+ {displayUrl}
+
+
+
+
+
+ {/* Jury Host (live-event) */}
+ {currentAct === "live-event" && (
+
send({ type: "open_jury_vote", countryCode })}
+ onCloseVote={() => send({ type: "close_jury_vote" })}
+ />
+ )}
+
+ {/* Quiz Host (scoring) */}
+ {currentAct === "scoring" && (
+ 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") && (
+ send({ type: "submit_actual_results", ...results })}
+ />
+ )}
+
+ {/* Bingo Claims */}
+ {gameState.completedBingoCards.length > 0 && (
+
+ )}
+
+ {/* End Party (destructive) */}
+ {currentAct !== "ended" && (
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 6: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 7: Commit**
+
+```bash
+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:
+
+```typescript
+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((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:
+
+```typescript
+ 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**
+
+```bash
+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 ``. Child routes render tab content.
+
+- [ ] **Step 1: Replace `play.$roomCode.tsx` with layout route**
+
+Replace the entire file:
+
+```tsx
+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 (
+
+
+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
+
+
+ )
+ }
+
+ if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
+ return (
+
+
Join Room {roomCode}
+ setManualName(e.target.value)}
+ maxLength={20}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && manualName.trim()) {
+ joinSentRef.current = true
+ send({ type: "join_room", displayName: manualName.trim() })
+ }
+ }}
+ />
+
+
+ )
+ }
+
+ if (!mySessionId) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+```
+
+- [ ] **Step 2: Create `play.$roomCode.index.tsx` (redirect)**
+
+```tsx
+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`**
+
+```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
+}
+```
+
+- [ ] **Step 4: Create `play.$roomCode.bingo.tsx`**
+
+```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
+}
+```
+
+- [ ] **Step 5: Create `play.$roomCode.board.tsx`**
+
+```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
+}
+```
+
+- [ ] **Step 6: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 7: Commit**
+
+```bash
+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:
+
+```tsx
+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 (
+
+
+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+```
+
+- [ ] **Step 2: Create `host.$roomCode.index.tsx` (redirect)**
+
+```tsx
+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`**
+
+```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
+}
+```
+
+- [ ] **Step 4: Create `host.$roomCode.bingo.tsx`**
+
+```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
+}
+```
+
+- [ ] **Step 5: Create `host.$roomCode.board.tsx`**
+
+```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
+}
+```
+
+- [ ] **Step 6: Create `host.$roomCode.host.tsx`**
+
+```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
+}
+```
+
+- [ ] **Step 7: Verify client builds**
+
+Run: `bun run --filter @celebrate-esc/client build`
+
+- [ ] **Step 8: Commit**
+
+```bash
+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