# 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` + ``. 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: ```typescript export const completedBingoCardSchema = z.object({ playerId: z.string(), displayName: z.string(), card: bingoCardSchema, completedAt: z.string(), }) export type CompletedBingoCard = z.infer ``` - [ ] **Step 2: Add `completedBingoCards` to `gameStateSchema`** In `gameStateSchema` (line 122), after the `bingoAnnouncements` field (line 136), add: ```typescript completedBingoCards: z.array(completedBingoCardSchema), ``` - [ ] **Step 3: Add `requestNewBingoCardMessage` to `ws-messages.ts`** After `tapBingoSquareMessage` (line 55), add: ```typescript 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`: ```typescript 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** ```bash 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`: ```typescript import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared" ``` - [ ] **Step 2: Add `completedBingoCards` storage** After `private announcedBingo = new Set()` (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 (

Joining room...

) } 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