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

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

1944 lines
61 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Menu Rework — App-Style Navigation Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace ad-hoc tabs with an iOS-style bottom navigation bar driven by TanStack Router nested routes, and add bingo card completion/redraw flow.
**Architecture:** Convert flat route files into layout routes with nested children. Shared `RoomLayout` component renders sticky header + `BottomNav` + `<Outlet />`. Player gets 3 tabs (Game, Bingo, Leaderboard), host gets 4 (+ Host). Bingo completion stores cards server-side and allows redraw.
**Tech Stack:** TanStack Router (file-based nested routes), React 19, Zustand, Tailwind v4, shadcn/ui, Hono WebSocket server, Zod schemas.
**Spec:** `docs/superpowers/specs/2026-03-13-menu-rework-design.md`
---
## File Structure
### New Files
- `packages/shared/src/game-types.ts` — add `completedBingoCardSchema`
- `packages/shared/src/ws-messages.ts` — add `requestNewBingoCardMessage`
- `packages/client/src/components/room-layout.tsx` — sticky header + bottom nav + outlet wrapper
- `packages/client/src/components/bottom-nav.tsx` — fixed bottom tab bar
- `packages/client/src/components/bingo-claims.tsx` — host verification of completed bingo cards
- `packages/client/src/components/game-tab.tsx` — shared game content by act
- `packages/client/src/components/bingo-tab.tsx` — shared bingo tab content
- `packages/client/src/components/board-tab.tsx` — shared leaderboard tab content
- `packages/client/src/components/host-tab.tsx` — host-only controls tab content
- `packages/client/src/routes/play.$roomCode.game.tsx` — player game child route
- `packages/client/src/routes/play.$roomCode.bingo.tsx` — player bingo child route
- `packages/client/src/routes/play.$roomCode.board.tsx` — player leaderboard child route
- `packages/client/src/routes/play.$roomCode.index.tsx` — redirect to game
- `packages/client/src/routes/host.$roomCode.game.tsx` — host game child route
- `packages/client/src/routes/host.$roomCode.bingo.tsx` — host bingo child route
- `packages/client/src/routes/host.$roomCode.board.tsx` — host leaderboard child route
- `packages/client/src/routes/host.$roomCode.host.tsx` — host controls child route
- `packages/client/src/routes/host.$roomCode.index.tsx` — redirect to game
- `packages/server/src/games/__tests__/game-manager-bingo.test.ts` — bingo tests
### Modified Files
- `packages/shared/src/game-types.ts` — add `completedBingoCardSchema`, extend `gameStateSchema`
- `packages/shared/src/ws-messages.ts` — add `requestNewBingoCardMessage` to `clientMessage` union
- `packages/server/src/games/game-manager.ts` — tap-only bingo, completion on redraw, scoring across cards, `requestNewBingoCard()`, per-player card generation
- `packages/server/src/ws/handler.ts` — add `request_new_bingo_card` case, update `tap_bingo_square` for completion, generate bingo card on join
- `packages/client/src/routes/play.$roomCode.tsx` — convert to layout route (header + nav + outlet)
- `packages/client/src/routes/host.$roomCode.tsx` — convert to layout route (header + nav + outlet)
- `packages/client/src/hooks/use-websocket.ts` — expose `send` via Zustand store instead of return value
- `packages/client/src/stores/room-store.ts` — add `send` function to store
- `packages/client/src/components/bingo-card.tsx` — add `readonly` prop
- `packages/client/src/components/leaderboard.tsx` — lobby mode (names only), absorb player list
### Unchanged Files
- `packages/client/src/routes/__root.tsx`
- `packages/client/src/routes/index.tsx`
- `packages/client/src/routes/display.$roomCode.tsx`
---
## Chunk 1: Server-Side Changes
### Task 1: Shared Types — completedBingoCard Schema + WS Message
**Files:**
- Modify: `packages/shared/src/game-types.ts`
- Modify: `packages/shared/src/ws-messages.ts`
- [ ] **Step 1: Add `completedBingoCardSchema` to `game-types.ts`**
After the `bingoCardSchema` block (line 88), add:
```typescript
export const completedBingoCardSchema = z.object({
playerId: z.string(),
displayName: z.string(),
card: bingoCardSchema,
completedAt: z.string(),
})
export type CompletedBingoCard = z.infer<typeof completedBingoCardSchema>
```
- [ ] **Step 2: Add `completedBingoCards` to `gameStateSchema`**
In `gameStateSchema` (line 122), after the `bingoAnnouncements` field (line 136), add:
```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<string>()` (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 (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
<span className="text-lg font-bold">IESC</span>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
<span
className={`h-2 w-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500"
: "bg-red-500"
}`}
title={connectionStatus}
/>
</div>
</header>
<main className="flex-1 pb-20">
<Outlet />
</main>
</div>
)
}
```
- [ ] **Step 2: Commit**
```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 `<Link>`.
- [ ] **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 (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<circle cx="12" cy="12" r="10" />
<polygon points="10,8 16,12 10,16" />
</svg>
)
}
function BingoIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
)
}
function TrophyIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
)
}
function WrenchIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
)
}
interface TabConfig {
to: string
label: string
icon: (props: { active: boolean }) => React.ReactNode
}
export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
const matchRoute = useMatchRoute()
const tabs: TabConfig[] = [
{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
]
if (isHost) {
tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex">
{tabs.map((tab) => {
const active = !!matchRoute({ to: tab.to, params: { roomCode } })
return (
<Link
key={tab.to}
to={tab.to}
params={{ roomCode }}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
active
? "text-primary"
: "text-muted-foreground"
}`}
>
<tab.icon active={active} />
<span>{tab.label}</span>
</Link>
)
})}
</div>
</nav>
)
}
```
- [ ] **Step 2: Verify client builds**
Run: `bun run --filter @celebrate-esc/client build`
- [ ] **Step 3: Commit**
```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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>Bingo</CardTitle>
{card.hasBingo && (
<Badge variant="default" className="bg-green-600">
BINGO!
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-4 gap-1.5">
{card.squares.map((square) => (
<button
key={square.tropeId}
type="button"
onClick={() => !readonly && onTap(square.tropeId)}
disabled={readonly}
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: readonly
? "border-muted text-muted-foreground"
: "border-muted hover:bg-muted/50"
} ${readonly ? "cursor-default" : "cursor-pointer"}`}
>
{square.label}
</button>
))}
</div>
{card.hasBingo && onRequestNewCard && (
<Button onClick={onRequestNewCard} variant="outline" className="w-full">
Draw New Card
</Button>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Verify client builds**
Run: `bun run --filter @celebrate-esc/client build`
- [ ] **Step 3: Commit**
```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 (
<Card>
<CardHeader>
<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{entries.map((entry, i) => (
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
<div className="flex items-center gap-2">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="text-sm font-medium">{entry.displayName}</span>
</div>
{!lobbyMode && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span title="Quiz points">Q:{entry.quizPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
)}
</div>
))}
</div>
{!lobbyMode && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium">How scoring works</p>
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
<li><strong>Q</strong> = Quiz points 5 easy, 10 medium, 15 hard</li>
</ul>
</div>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Verify client builds**
Run: `bun run --filter @celebrate-esc/client build`
- [ ] **Step 3: Commit**
```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 (
<div className="flex flex-col gap-4 p-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) => send({ type: "submit_prediction", ...prediction })}
/>
</div>
)
}
if (currentAct === "live-event") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</div>
)
}
if (currentAct === "scoring") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
{gameState.currentQuizQuestion && (
<QuizBuzzer
question={gameState.currentQuizQuestion}
buzzStatus={gameState.myQuizBuzzStatus}
onBuzz={() => send({ type: "buzz" })}
/>
)}
</div>
)
}
if (currentAct === "ended") {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
</div>
)
}
return null
}
```
- [ ] **Step 2: Create `bingo-tab.tsx`**
```tsx
import type { ClientMessage, GameState } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
import { BingoCard } from "@/components/bingo-card"
interface BingoTabProps {
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function BingoTab({ currentAct, gameState, send }: BingoTabProps) {
const isLiveEvent = currentAct === "live-event"
if (!gameState.myBingoCard) {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-muted-foreground">No bingo card yet. Cards are dealt when the Live Event begins.</p>
</div>
)
}
return (
<div className="flex flex-col gap-4 p-4">
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
readonly={!isLiveEvent}
onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
/>
</div>
)
}
```
- [ ] **Step 3: Create `board-tab.tsx`**
```tsx
import type { GameState } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
import { Leaderboard } from "@/components/leaderboard"
interface BoardTabProps {
currentAct: Act
gameState: GameState
}
export function BoardTab({ currentAct, gameState }: BoardTabProps) {
return (
<div className="flex flex-col gap-4 p-4">
<Leaderboard
entries={gameState.leaderboard}
resultsEntered={!!gameState.actualResults}
lobbyMode={currentAct === "lobby"}
/>
</div>
)
}
```
- [ ] **Step 4: Create `bingo-claims.tsx`**
```tsx
import type { CompletedBingoCard } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface BingoClaimsProps {
completedCards: CompletedBingoCard[]
}
export function BingoClaims({ completedCards }: BingoClaimsProps) {
if (completedCards.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Bingo Claims ({completedCards.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{completedCards.map((claim, i) => (
<div key={`${claim.playerId}-${i}`} className="flex flex-col gap-1.5 border-b pb-3 last:border-0 last:pb-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{claim.displayName}</span>
<span className="text-xs text-muted-foreground">
{new Date(claim.completedAt).toLocaleTimeString()}
</span>
</div>
<div className="grid grid-cols-4 gap-0.5">
{claim.card.squares.map((square) => (
<div
key={square.tropeId}
className={`rounded px-1 py-0.5 text-center text-xs ${
square.tapped
? "bg-primary/20 text-primary"
: "bg-muted/50 text-muted-foreground"
}`}
>
{square.label}
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
)
}
```
- [ ] **Step 5: Create `host-tab.tsx`**
```tsx
import { useState } from "react"
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { JuryHost } from "@/components/jury-host"
import { QuizHost } from "@/components/quiz-host"
import { ActualResultsForm } from "@/components/actual-results-form"
import { BingoClaims } from "@/components/bingo-claims"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const nextActLabels: Partial<Record<Act, string>> = {
lobby: "Start Pre-Show",
"pre-show": "Start Live Event",
"live-event": "Start Scoring",
}
const prevActLabels: Partial<Record<Act, string>> = {
"pre-show": "Back to Lobby",
"live-event": "Back to Pre-Show",
scoring: "Back to Live Event",
ended: "Back to Scoring",
}
interface HostTabProps {
roomCode: string
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function HostTab({ roomCode, currentAct, gameState, send }: HostTabProps) {
const [copied, setCopied] = useState(false)
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
const displayUrl = `${window.location.origin}${base}/display/${roomCode}`
function copyDisplayUrl() {
navigator.clipboard.writeText(displayUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col gap-4 p-4">
{/* Act Controls */}
<Card>
<CardHeader>
<CardTitle>Room Controls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{currentAct !== "ended" && (
<div className="flex gap-2">
{currentAct !== "lobby" && (
<Button
variant="outline"
onClick={() => send({ type: "revert_act" })}
className="flex-1"
>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
{nextActLabels[currentAct] && (
<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
{nextActLabels[currentAct]}
</Button>
)}
</div>
)}
{currentAct === "ended" && (
<Button variant="outline" onClick={() => send({ type: "revert_act" })}>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
</CardContent>
</Card>
{/* Display View Link */}
<Card>
<CardHeader>
<CardTitle>Display View</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Project this on a TV for everyone to see.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs">{displayUrl}</code>
<Button variant="outline" size="sm" onClick={copyDisplayUrl}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</CardContent>
</Card>
{/* Jury Host (live-event) */}
{currentAct === "live-event" && (
<JuryHost
entries={gameState.lineup.entries}
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
onCloseVote={() => send({ type: "close_jury_vote" })}
/>
)}
{/* Quiz Host (scoring) */}
{currentAct === "scoring" && (
<QuizHost
question={gameState.currentQuizQuestion}
onStartQuestion={() => send({ type: "start_quiz_question" })}
onJudge={(correct) => send({ type: "judge_quiz_answer", correct })}
onSkip={() => send({ type: "skip_quiz_question" })}
/>
)}
{/* Actual Results Form (scoring/ended) */}
{(currentAct === "scoring" || currentAct === "ended") && (
<ActualResultsForm
entries={gameState.lineup.entries}
existingResults={gameState.actualResults}
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
/>
)}
{/* Bingo Claims */}
{gameState.completedBingoCards.length > 0 && (
<BingoClaims completedCards={gameState.completedBingoCards} />
)}
{/* End Party (destructive) */}
{currentAct !== "ended" && (
<Button
variant="destructive"
onClick={() => send({ type: "end_room" })}
className="w-full"
>
End Party
</Button>
)}
</div>
)
}
```
- [ ] **Step 6: Verify client builds**
Run: `bun run --filter @celebrate-esc/client build`
- [ ] **Step 7: Commit**
```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<RoomStore>((set) => ({
room: null,
mySessionId: null,
connectionStatus: "disconnected",
gameState: null,
send: noop,
setRoom: (room) => set({ room }),
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
setConnectionStatus: (status) => set({ connectionStatus: status }),
updatePlayerConnected: (playerId, connected) =>
set((state) => {
if (!state.room) return state
return {
room: {
...state.room,
players: state.room.players.map((p) => (p.id === playerId ? { ...p, connected } : p)),
},
}
}),
addPlayer: (player) =>
set((state) => {
if (!state.room) return state
if (state.room.players.some((p) => p.id === player.id)) return state
return {
room: {
...state.room,
players: [...state.room.players, player],
},
}
}),
setAct: (act) =>
set((state) => {
if (!state.room) return state
return { room: { ...state.room, currentAct: act } }
}),
setGameState: (gameState) => set({ gameState }),
lockPredictions: () =>
set((state) => {
if (!state.gameState) return state
return {
gameState: { ...state.gameState, predictionsLocked: true },
}
}),
setSend: (send) => set({ send }),
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null, send: noop }),
}))
```
- [ ] **Step 2: Update `useWebSocket` to store `send` in Zustand**
In `packages/client/src/hooks/use-websocket.ts`, add `setSend` to the destructured store and call `setSend(send)` after creating the `send` callback:
After `const send = useCallback(...)`, add:
```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 `<Outlet />`. 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 (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
</p>
</div>
)
}
if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
<h2 className="text-xl font-bold">Join Room {roomCode}</h2>
<Input
placeholder="Your name"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
maxLength={20}
onKeyDown={(e) => {
if (e.key === "Enter" && manualName.trim()) {
joinSentRef.current = true
send({ type: "join_room", displayName: manualName.trim() })
}
}}
/>
<Button
onClick={() => {
if (manualName.trim()) {
joinSentRef.current = true
send({ type: "join_room", displayName: manualName.trim() })
}
}}
disabled={!manualName.trim()}
>
Join
</Button>
</div>
)
}
if (!mySessionId) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Joining room...</p>
</div>
)
}
return (
<>
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
</>
)
}
```
- [ ] **Step 2: Create `play.$roomCode.index.tsx` (redirect)**
```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 <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
```
- [ ] **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 <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
```
- [ ] **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 <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
```
- [ ] **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 (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
</p>
</div>
)
}
return (
<>
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
<BottomNav basePath="/host/$roomCode" roomCode={roomCode} isHost={true} />
</>
)
}
```
- [ ] **Step 2: Create `host.$roomCode.index.tsx` (redirect)**
```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 <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
```
- [ ] **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 <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
}
```
- [ ] **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 <BoardTab currentAct={room.currentAct} gameState={gameState} />
}
```
- [ ] **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 <HostTab roomCode={roomCode} currentAct={room.currentAct} gameState={gameState} send={send} />
}
```
- [ ] **Step 7: Verify client builds**
Run: `bun run --filter @celebrate-esc/client build`
- [ ] **Step 8: Commit**
```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