57 KiB
Act 1 Games — Predictions + Dishes
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: Implement the two Act 1 games — Prediction Voting and Dish of the Nation — including server game logic, WS message handling, client UI for all three views (host, player, display), and in-memory + DB persistence.
Architecture: Follows the existing room system pattern. Server keeps game state in-memory (extending InternalRoom), persists to PostgreSQL via service classes. All mutations flow through WebSocket messages. Client receives state slices via new message types and renders game UI. DB schema tables already exist from Plan 1 migrations (predictions, dishes, dish_guesses).
Tech Stack: Same as Plan 1 — Bun, Hono + @hono/node-ws, Drizzle + PostgreSQL, React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router, Zustand, Zod, Biome, Vitest
Plan Series
This is Plan 2 of 5:
- Foundation + Room System (done)
- Act 1 Games (Predictions + Dishes) ← this plan
- Act 2 Games (Jury Voting + Bingo)
- Act 3 Games (Quiz + Final Leaderboard)
- Polish + Deployment
Design Decisions
- ESC 2026 lineup data — create
packages/server/data/esc-2026.jsonwith the participating countries. Client receives the lineup as part of game state so it can render country selectors. - In-memory game state — extend
InternalRoominroom-manager.tswith predictions, dishes, and dish guesses. Keep the same pattern: in-memory is the source of truth, DB persistence is fire-and-forget. - Game state broadcast — when predictions or dishes change, broadcast the relevant state slice to the room. Players only see their own prediction (not others'). Everyone sees the dish list.
- Predictions lock — predictions are available from lobby through Act 1. They lock when the host advances to Act 2 (the existing
advance_acthandler just needs to broadcastpredictions_locked). - Dish reveal — host triggers reveal, which flips
revealed: trueon all dishes and broadcasts results with correct answers and who guessed right. - Country type — use country codes (e.g.,
"SE","DE") as identifiers. The lineup JSON maps codes to display names.
File Structure
packages/
├── shared/src/
│ ├── constants.ts # (modify) add ESC_YEAR
│ ├── game-types.ts # (create) prediction, dish, lineup types
│ ├── ws-messages.ts # (modify) add game messages to unions
│ └── index.ts # (modify) re-export game-types
├── server/
│ ├── data/
│ │ └── esc-2026.json # (create) country lineup
│ └── src/
│ ├── games/
│ │ ├── game-manager.ts # (create) in-memory game state per room
│ │ └── game-service.ts # (create) DB persistence for games
│ ├── ws/
│ │ └── handler.ts # (modify) add game message cases
│ └── rooms/
│ └── room-manager.ts # (modify) expose game manager per room
├── client/src/
│ ├── stores/
│ │ └── room-store.ts # (modify) add game state + actions
│ ├── hooks/
│ │ └── use-websocket.ts # (modify) handle game messages
│ ├── components/
│ │ ├── predictions-form.tsx # (create) prediction submission UI
│ │ ├── dish-list.tsx # (create) dish list + guess UI
│ │ ├── dish-host.tsx # (create) host: add dishes + reveal
│ │ └── dish-results.tsx # (create) revealed dish results
│ └── routes/
│ ├── host.$roomCode.tsx # (modify) add game UI in Play tab, dish controls in Host tab
│ ├── play.$roomCode.tsx # (modify) add game UI for act1
│ └── display.$roomCode.tsx # (modify) show dishes/predictions status on display
Chunk 1: Data + Shared Types
Task 1: ESC 2026 lineup data file
Files:
-
Create:
packages/server/data/esc-2026.json -
Step 1: Create the lineup JSON
{
"year": 2026,
"countries": [
{ "code": "AL", "name": "Albania" },
{ "code": "AM", "name": "Armenia" },
{ "code": "AU", "name": "Australia" },
{ "code": "AT", "name": "Austria" },
{ "code": "AZ", "name": "Azerbaijan" },
{ "code": "BE", "name": "Belgium" },
{ "code": "HR", "name": "Croatia" },
{ "code": "CY", "name": "Cyprus" },
{ "code": "CZ", "name": "Czechia" },
{ "code": "DK", "name": "Denmark" },
{ "code": "EE", "name": "Estonia" },
{ "code": "FI", "name": "Finland" },
{ "code": "FR", "name": "France" },
{ "code": "DE", "name": "Germany" },
{ "code": "GE", "name": "Georgia" },
{ "code": "GR", "name": "Greece" },
{ "code": "IS", "name": "Iceland" },
{ "code": "IE", "name": "Ireland" },
{ "code": "IL", "name": "Israel" },
{ "code": "IT", "name": "Italy" },
{ "code": "LV", "name": "Latvia" },
{ "code": "LT", "name": "Lithuania" },
{ "code": "LU", "name": "Luxembourg" },
{ "code": "MT", "name": "Malta" },
{ "code": "MD", "name": "Moldova" },
{ "code": "ME", "name": "Montenegro" },
{ "code": "NL", "name": "Netherlands" },
{ "code": "MK", "name": "North Macedonia" },
{ "code": "NO", "name": "Norway" },
{ "code": "PL", "name": "Poland" },
{ "code": "PT", "name": "Portugal" },
{ "code": "RO", "name": "Romania" },
{ "code": "SM", "name": "San Marino" },
{ "code": "RS", "name": "Serbia" },
{ "code": "SI", "name": "Slovenia" },
{ "code": "ES", "name": "Spain" },
{ "code": "SE", "name": "Sweden" },
{ "code": "CH", "name": "Switzerland" },
{ "code": "UA", "name": "Ukraine" },
{ "code": "GB", "name": "United Kingdom" }
]
}
- Step 2: Commit
git add packages/server/data/esc-2026.json
git commit -m "add ESC 2026 country lineup data"
Task 2: Shared game types
Files:
-
Create:
packages/shared/src/game-types.ts -
Modify:
packages/shared/src/index.ts -
Step 1: Create game types
Create packages/shared/src/game-types.ts:
import { z } from "zod"
// ─── Country Lineup ─────────────────────────────────────────────────
export const countrySchema = z.object({
code: z.string(),
name: z.string(),
})
export type Country = z.infer<typeof countrySchema>
export const lineupSchema = z.object({
year: z.number(),
countries: z.array(countrySchema),
})
export type Lineup = z.infer<typeof lineupSchema>
// ─── Predictions ────────────────────────────────────────────────────
export const predictionSchema = z.object({
playerId: z.string().uuid(),
predictedWinner: z.string(),
top3: z.array(z.string()).length(3),
nulPointsPick: z.string(),
})
export type Prediction = z.infer<typeof predictionSchema>
// ─── Dishes ─────────────────────────────────────────────────────────
export const dishSchema = z.object({
id: z.string().uuid(),
name: z.string(),
correctCountry: z.string(),
revealed: z.boolean(),
})
export type Dish = z.infer<typeof dishSchema>
export const dishGuessSchema = z.object({
dishId: z.string().uuid(),
playerId: z.string().uuid(),
guessedCountry: z.string(),
})
export type DishGuess = z.infer<typeof dishGuessSchema>
// ─── Game State (sent to clients) ───────────────────────────────────
/** State slice sent to each player — they only see their own prediction */
export const gameStateSchema = z.object({
lineup: lineupSchema,
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
dishes: z.array(dishSchema),
myDishGuesses: z.array(dishGuessSchema),
dishResults: z
.array(
z.object({
dish: dishSchema,
guesses: z.array(
z.object({
playerId: z.string().uuid(),
displayName: z.string(),
guessedCountry: z.string(),
correct: z.boolean(),
}),
),
}),
)
.nullable(),
})
export type GameState = z.infer<typeof gameStateSchema>
- Step 2: Add re-export to index.ts
Add to packages/shared/src/index.ts:
export * from "./game-types"
- Step 3: Verify types compile
Run: cd packages/shared && bun run tsc --noEmit
Expected: no errors
- Step 4: Commit
git add packages/shared/src/game-types.ts packages/shared/src/index.ts
git commit -m "add shared game types for predictions, dishes"
Task 3: WebSocket message types for games
Files:
-
Modify:
packages/shared/src/ws-messages.ts -
Step 1: Add client → server game messages
Add after the endRoomMessage definition (before the clientMessage union) in packages/shared/src/ws-messages.ts:
// ─── Client → Server (Act 1 games) ─────────────────────────────────
export const submitPredictionMessage = z.object({
type: z.literal("submit_prediction"),
predictedWinner: z.string(),
top3: z.array(z.string()).length(3),
nulPointsPick: z.string(),
})
export const addDishMessage = z.object({
type: z.literal("add_dish"),
name: z.string().min(1).max(100),
correctCountry: z.string(),
})
export const submitDishGuessMessage = z.object({
type: z.literal("submit_dish_guess"),
dishId: z.string().uuid(),
guessedCountry: z.string(),
})
export const revealDishesMessage = z.object({
type: z.literal("reveal_dishes"),
})
- Step 2: Update the clientMessage union
Replace the existing clientMessage union to include the new message types:
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
endRoomMessage,
submitPredictionMessage,
addDishMessage,
submitDishGuessMessage,
revealDishesMessage,
])
- Step 3: Add server → client game messages
Add import { gameStateSchema, dishSchema } from "./game-types" at the top of ws-messages.ts, alongside the existing imports.
Then add after the errorMessage definition (before the serverMessage union):
// ─── Server → Client (Act 1 games) ─────────────────────────────────
export const gameStateMessage = z.object({
type: z.literal("game_state"),
gameState: gameStateSchema,
})
export const predictionsLockedMessage = z.object({
type: z.literal("predictions_locked"),
})
export const dishAddedMessage = z.object({
type: z.literal("dish_added"),
dish: dishSchema,
})
export const dishGuessRecordedMessage = z.object({
type: z.literal("dish_guess_recorded"),
dishId: z.string().uuid(),
guessedCountry: z.string(),
})
export const dishesRevealedMessage = z.object({
type: z.literal("dishes_revealed"),
results: z.array(
z.object({
dish: dishSchema,
guesses: z.array(
z.object({
playerId: z.string().uuid(),
displayName: z.string(),
guessedCountry: z.string(),
correct: z.boolean(),
}),
),
}),
),
})
- Step 4: Update the serverMessage union
Replace the existing serverMessage union to include the new message types:
export const serverMessage = z.discriminatedUnion("type", [
roomStateMessage,
playerJoinedMessage,
playerDisconnectedMessage,
playerReconnectedMessage,
actChangedMessage,
roomEndedMessage,
errorMessage,
gameStateMessage,
predictionsLockedMessage,
dishAddedMessage,
dishGuessRecordedMessage,
dishesRevealedMessage,
])
- Step 5: Verify types compile
Run: cd packages/shared && bun run tsc --noEmit
Expected: no errors
- Step 6: Commit
git add packages/shared/src/ws-messages.ts
git commit -m "add WS message types for predictions, dishes"
Chunk 2: Server Game Logic
Task 4: Game manager (in-memory state)
Files:
-
Create:
packages/server/src/games/game-manager.ts -
Step 1: Create the game manager
Create packages/server/src/games/game-manager.ts:
import { randomUUID } from "node:crypto"
import type { Prediction, Dish, DishGuess, GameState, Lineup } from "@celebrate-esc/shared"
import lineupData from "../../data/esc-2026.json"
const lineup: Lineup = lineupData as Lineup
const countryCodes = new Set(lineup.countries.map((c) => c.code))
interface InternalDish {
id: string
name: string
correctCountry: string
revealed: boolean
}
interface InternalDishGuess {
dishId: string
playerId: string
guessedCountry: string
}
export class GameManager {
private predictions = new Map<string, Prediction>() // playerId → prediction
private dishes: InternalDish[] = []
private dishGuesses: InternalDishGuess[] = []
private locked = false
getLineup(): Lineup {
return lineup
}
isValidCountry(code: string): boolean {
return countryCodes.has(code)
}
// ─── Predictions ────────────────────────────────────────────────
arePredictionsLocked(): boolean {
return this.locked
}
lockPredictions(): void {
this.locked = true
}
submitPrediction(
playerId: string,
predictedWinner: string,
top3: string[],
nulPointsPick: string,
): { success: true } | { error: string } {
if (this.locked) return { error: "Predictions are locked" }
// Validate all countries exist
const allPicks = [predictedWinner, ...top3, nulPointsPick]
for (const code of allPicks) {
if (!this.isValidCountry(code)) return { error: `Invalid country: ${code}` }
}
// Winner must not be in top3
if (top3.includes(predictedWinner)) {
return { error: "Winner cannot also be in top 3" }
}
// No duplicates in top3
if (new Set(top3).size !== 3) {
return { error: "Top 3 must be unique countries" }
}
this.predictions.set(playerId, { playerId, predictedWinner, top3, nulPointsPick })
return { success: true }
}
getPrediction(playerId: string): Prediction | null {
return this.predictions.get(playerId) ?? null
}
getAllPredictions(): Map<string, Prediction> {
return this.predictions
}
// ─── Dishes ─────────────────────────────────────────────────────
addDish(name: string, correctCountry: string): { dish: InternalDish } | { error: string } {
if (!this.isValidCountry(correctCountry)) {
return { error: `Invalid country: ${correctCountry}` }
}
const dish: InternalDish = {
id: randomUUID(),
name,
correctCountry,
revealed: false,
}
this.dishes.push(dish)
return { dish }
}
getDishes(): InternalDish[] {
return this.dishes
}
submitDishGuess(
playerId: string,
dishId: string,
guessedCountry: string,
): { success: true } | { error: string } {
if (!this.isValidCountry(guessedCountry)) {
return { error: `Invalid country: ${guessedCountry}` }
}
const dish = this.dishes.find((d) => d.id === dishId)
if (!dish) return { error: "Dish not found" }
if (dish.revealed) return { error: "Dish already revealed" }
// Replace existing guess for same player+dish
this.dishGuesses = this.dishGuesses.filter(
(g) => !(g.playerId === playerId && g.dishId === dishId),
)
this.dishGuesses.push({ dishId, playerId, guessedCountry })
return { success: true }
}
getDishGuesses(playerId: string): DishGuess[] {
return this.dishGuesses
.filter((g) => g.playerId === playerId)
.map((g) => ({ dishId: g.dishId, playerId: g.playerId, guessedCountry: g.guessedCountry }))
}
revealDishes(): InternalDish[] {
for (const dish of this.dishes) {
dish.revealed = true
}
return this.dishes
}
getDishResults(
playerLookup: Map<string, string>,
): { dish: Dish; guesses: { playerId: string; displayName: string; guessedCountry: string; correct: boolean }[] }[] {
return this.dishes.map((dish) => ({
dish: { id: dish.id, name: dish.name, correctCountry: dish.correctCountry, revealed: dish.revealed },
guesses: this.dishGuesses
.filter((g) => g.dishId === dish.id)
.map((g) => ({
playerId: g.playerId,
displayName: playerLookup.get(g.playerId) ?? "Unknown",
guessedCountry: g.guessedCountry,
correct: g.guessedCountry === dish.correctCountry,
})),
}))
}
areAllDishesRevealed(): boolean {
return this.dishes.length > 0 && this.dishes.every((d) => d.revealed)
}
// ─── State for client ───────────────────────────────────────────
getGameStateForPlayer(playerId: string, playerLookup: Map<string, string>): GameState {
return {
lineup,
myPrediction: this.getPrediction(playerId),
predictionsLocked: this.locked,
dishes: this.dishes.map((d) => ({
id: d.id,
name: d.name,
correctCountry: d.revealed ? d.correctCountry : "",
revealed: d.revealed,
})),
myDishGuesses: this.getDishGuesses(playerId),
dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null,
}
}
}
- Step 2: Verify it compiles
Run: cd packages/server && bun run build
Expected: no errors
- Step 3: Commit
git add packages/server/src/games/game-manager.ts
git commit -m "add game manager for predictions, dishes in-memory state"
Task 5: Wire game manager into room manager
Files:
-
Modify:
packages/server/src/rooms/room-manager.ts -
Step 1: Add GameManager to InternalRoom
Add import at top of packages/server/src/rooms/room-manager.ts:
import { GameManager } from "../games/game-manager"
Add to InternalRoom interface:
interface InternalRoom {
// ...existing fields...
gameManager: GameManager
}
- Step 2: Initialize GameManager in createRoom
In the createRoom method, add gameManager: new GameManager() to the room object:
const room: InternalRoom = {
id: randomUUID(),
code,
currentAct: "lobby",
hostSessionId: sessionId,
players: new Map([[sessionId, host]]),
createdAt: now,
expiresAt: new Date(now.getTime() + ROOM_EXPIRY_HOURS * 60 * 60 * 1000),
gameManager: new GameManager(),
}
- Step 3: Add getGameManager method
Add a method to RoomManager:
getGameManager(code: string): GameManager | null {
const room = this.rooms.get(code)
return room?.gameManager ?? null
}
- Step 4: Add getPlayerLookup method
Add a helper to get a playerId → displayName map:
getPlayerLookup(code: string): Map<string, string> {
const room = this.rooms.get(code)
if (!room) return new Map()
const lookup = new Map<string, string>()
for (const player of room.players.values()) {
lookup.set(player.id, player.displayName)
}
return lookup
}
- Step 5: Add getPlayerIdBySession method
Add a helper to resolve sessionId → playerId:
getPlayerIdBySession(code: string, sessionId: string): string | null {
const room = this.rooms.get(code)
if (!room) return null
return room.players.get(sessionId)?.id ?? null
}
- Step 6: Verify it compiles
Run: cd packages/server && bun run build
Expected: no errors
- Step 7: Commit
git add packages/server/src/rooms/room-manager.ts
git commit -m "wire game manager into room manager"
Task 6: Game service (DB persistence)
Files:
-
Create:
packages/server/src/games/game-service.ts -
Step 1: Create the game service
Create packages/server/src/games/game-service.ts:
Note: DB persistence is fire-and-forget — the in-memory GameManager is the source of truth. Persist calls are added to the WS handler in Task 7 but failures are logged, not propagated to clients. The predictions table has no unique constraint on (player_id, room_id), so we use delete-then-insert for upserts. Same for dish_guesses on (player_id, dish_id).
import { eq, and } from "drizzle-orm"
import type { Database } from "../db/client"
import { predictions, dishes, dishGuesses } from "../db/schema"
export class GameService {
constructor(private db: Database) {}
async persistPrediction(data: {
playerId: string
roomId: string
predictedWinner: string
top3: string[]
nulPointsPick: string
}) {
// Delete existing prediction for this player+room, then insert
await this.db
.delete(predictions)
.where(and(eq(predictions.playerId, data.playerId), eq(predictions.roomId, data.roomId)))
await this.db.insert(predictions).values({
playerId: data.playerId,
roomId: data.roomId,
predictedWinner: data.predictedWinner,
top3: data.top3,
nulPointsPick: data.nulPointsPick,
})
}
async persistDish(data: {
id: string
roomId: string
name: string
correctCountry: string
}) {
await this.db.insert(dishes).values({
id: data.id,
roomId: data.roomId,
name: data.name,
correctCountry: data.correctCountry,
})
}
async persistDishGuess(data: {
playerId: string
dishId: string
guessedCountry: string
}) {
// Delete existing guess for this player+dish, then insert
await this.db
.delete(dishGuesses)
.where(and(eq(dishGuesses.playerId, data.playerId), eq(dishGuesses.dishId, data.dishId)))
await this.db.insert(dishGuesses).values({
playerId: data.playerId,
dishId: data.dishId,
guessedCountry: data.guessedCountry,
})
}
async markDishesRevealed(roomId: string) {
await this.db
.update(dishes)
.set({ revealed: true })
.where(eq(dishes.roomId, roomId))
}
}
- Step 2: Verify it compiles
Run: cd packages/server && bun run build
Expected: no errors
- Step 3: Commit
git add packages/server/src/games/game-service.ts
git commit -m "add game service for DB persistence of predictions, dishes"
Task 7: WebSocket handler — game message routing
Files:
-
Modify:
packages/server/src/ws/handler.ts -
Step 1: Add game state helper function
Add a helper function in handler.ts (before registerWebSocketRoutes) that sends game state to a specific player:
function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
const gm = roomManager.getGameManager(roomCode)
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
if (!gm || !playerId) return
const playerLookup = roomManager.getPlayerLookup(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, playerLookup)
sendTo(ws, { type: "game_state", gameState })
}
- Step 2: Send game state on join and reconnect
In the onOpen handler, after sending room_state on successful reconnect (the sendTo(ws, { type: "room_state", ... }) call), add:
sendGameState(ws, roomCode, sessionId)
In the join_room case, after sendTo(ws, { type: "room_state", ... }), add:
sendGameState(ws, roomCode, result.sessionId)
In the reconnect case, after sendTo(ws, { type: "room_state", ... }), add:
sendGameState(ws, roomCode, msg.sessionId)
- Step 3: Lock predictions on act advance to act2
In the advance_act case, after the broadcast of act_changed, add:
if (result.newAct === "act2") {
const gm = roomManager.getGameManager(roomCode)
if (gm) {
gm.lockPredictions()
broadcast(roomCode, { type: "predictions_locked" })
}
}
- Step 4: Add submit_prediction handler
Add a new case in the message switch:
case "submit_prediction": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitPrediction(playerId, msg.predictedWinner, msg.top3, msg.nulPointsPick)
if ("error" in result) {
sendError(ws, result.error)
return
}
// Send updated game state back to this player only
sendGameState(ws, roomCode, sessionId)
break
}
- Step 5: Add add_dish handler (host only)
case "add_dish": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can add dishes")
return
}
const room = roomManager.getRoom(roomCode)
if (room && room.currentAct !== "lobby" && room.currentAct !== "act1") {
sendError(ws, "Dishes can only be added during lobby or Act 1")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.addDish(msg.name, msg.correctCountry)
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "dish_added",
dish: {
id: result.dish.id,
name: result.dish.name,
correctCountry: "",
revealed: false,
},
})
break
}
- Step 6: Add submit_dish_guess handler
case "submit_dish_guess": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitDishGuess(playerId, msg.dishId, msg.guessedCountry)
if ("error" in result) {
sendError(ws, result.error)
return
}
sendTo(ws, {
type: "dish_guess_recorded",
dishId: msg.dishId,
guessedCountry: msg.guessedCountry,
})
break
}
- Step 7: Add reveal_dishes handler (host only)
case "reveal_dishes": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can reveal dishes")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
gm.revealDishes()
const playerLookup = roomManager.getPlayerLookup(roomCode)
const results = gm.getDishResults(playerLookup)
broadcast(roomCode, {
type: "dishes_revealed",
results,
})
break
}
- Step 8: Send display-safe game state to passive viewers
The display view has no sessionId/playerId, so it cannot receive a player-specific game_state. Add a getGameStateForDisplay() method to GameManager and send it on passive connections.
In packages/server/src/games/game-manager.ts, add:
getGameStateForDisplay(playerLookup: Map<string, string>): GameState {
return {
lineup,
myPrediction: null,
predictionsLocked: this.locked,
dishes: this.dishes.map((d) => ({
id: d.id,
name: d.name,
correctCountry: d.revealed ? d.correctCountry : "",
revealed: d.revealed,
})),
myDishGuesses: [],
dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null,
}
}
Then in handler.ts, add a helper:
function sendDisplayGameState(ws: WSContext, roomCode: string) {
const gm = roomManager.getGameManager(roomCode)
if (!gm) return
const playerLookup = roomManager.getPlayerLookup(roomCode)
const gameState = gm.getGameStateForDisplay(playerLookup)
sendTo(ws, { type: "game_state", gameState })
}
In the onOpen handler's else branch (passive viewer), after sending room_state, add:
sendDisplayGameState(ws, roomCode)
This ensures the display starts with a valid gameState (non-null), so subsequent dish_added and dishes_revealed broadcasts update correctly through the Zustand store.
- Step 9: Verify server compiles
Run: cd packages/server && bun run build
Expected: no errors
- Step 10: Commit
git add packages/server/src/ws/handler.ts
git commit -m "add WS handlers for predictions, dishes game messages"
Task 8: Game manager unit tests
Files:
-
Create:
packages/server/tests/game-manager.test.ts -
Step 1: Write tests
Create packages/server/tests/game-manager.test.ts:
import { describe, it, expect, beforeEach } from "vitest"
import { GameManager } from "../src/games/game-manager"
describe("GameManager", () => {
let gm: GameManager
beforeEach(() => {
gm = new GameManager()
})
describe("lineup", () => {
it("returns the ESC 2026 lineup", () => {
const lineup = gm.getLineup()
expect(lineup.year).toBe(2026)
expect(lineup.countries.length).toBeGreaterThan(20)
expect(lineup.countries[0]).toHaveProperty("code")
expect(lineup.countries[0]).toHaveProperty("name")
})
it("validates country codes", () => {
expect(gm.isValidCountry("DE")).toBe(true)
expect(gm.isValidCountry("XX")).toBe(false)
})
})
describe("predictions", () => {
it("accepts a valid prediction", () => {
const result = gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB")
expect(result).toEqual({ success: true })
expect(gm.getPrediction("p1")).toEqual({
playerId: "p1",
predictedWinner: "SE",
top3: ["DE", "IT", "FR"],
nulPointsPick: "GB",
})
})
it("rejects prediction with invalid country", () => {
const result = gm.submitPrediction("p1", "XX", ["DE", "IT", "FR"], "GB")
expect(result).toEqual({ error: "Invalid country: XX" })
})
it("rejects winner in top 3", () => {
const result = gm.submitPrediction("p1", "SE", ["SE", "IT", "FR"], "GB")
expect(result).toEqual({ error: "Winner cannot also be in top 3" })
})
it("rejects duplicate top 3", () => {
const result = gm.submitPrediction("p1", "SE", ["DE", "DE", "FR"], "GB")
expect(result).toEqual({ error: "Top 3 must be unique countries" })
})
it("allows overwriting a prediction", () => {
gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB")
gm.submitPrediction("p1", "NO", ["DE", "IT", "FR"], "GB")
expect(gm.getPrediction("p1")?.predictedWinner).toBe("NO")
})
it("rejects prediction when locked", () => {
gm.lockPredictions()
const result = gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB")
expect(result).toEqual({ error: "Predictions are locked" })
})
})
describe("dishes", () => {
it("adds a dish", () => {
const result = gm.addDish("Köttbullar", "SE")
expect("dish" in result).toBe(true)
if ("dish" in result) {
expect(result.dish.name).toBe("Köttbullar")
expect(result.dish.correctCountry).toBe("SE")
expect(result.dish.revealed).toBe(false)
}
})
it("rejects dish with invalid country", () => {
const result = gm.addDish("Mystery", "XX")
expect(result).toEqual({ error: "Invalid country: XX" })
})
it("accepts a dish guess", () => {
const addResult = gm.addDish("Köttbullar", "SE")
if (!("dish" in addResult)) throw new Error("unexpected")
const result = gm.submitDishGuess("p1", addResult.dish.id, "SE")
expect(result).toEqual({ success: true })
})
it("rejects guess for nonexistent dish", () => {
const result = gm.submitDishGuess("p1", "fake-id", "SE")
expect(result).toEqual({ error: "Dish not found" })
})
it("rejects guess after reveal", () => {
const addResult = gm.addDish("Köttbullar", "SE")
if (!("dish" in addResult)) throw new Error("unexpected")
gm.revealDishes()
const result = gm.submitDishGuess("p1", addResult.dish.id, "SE")
expect(result).toEqual({ error: "Dish already revealed" })
})
it("replaces an existing guess for the same dish", () => {
const addResult = gm.addDish("Köttbullar", "SE")
if (!("dish" in addResult)) throw new Error("unexpected")
gm.submitDishGuess("p1", addResult.dish.id, "DE")
gm.submitDishGuess("p1", addResult.dish.id, "SE")
const guesses = gm.getDishGuesses("p1")
expect(guesses).toHaveLength(1)
expect(guesses[0]?.guessedCountry).toBe("SE")
})
it("reveals dishes and produces results", () => {
const addResult = gm.addDish("Köttbullar", "SE")
if (!("dish" in addResult)) throw new Error("unexpected")
gm.submitDishGuess("p1", addResult.dish.id, "SE")
gm.submitDishGuess("p2", addResult.dish.id, "DE")
gm.revealDishes()
const lookup = new Map([
["p1", "Alice"],
["p2", "Bob"],
])
const results = gm.getDishResults(lookup)
expect(results).toHaveLength(1)
expect(results[0]?.guesses).toHaveLength(2)
const aliceGuess = results[0]?.guesses.find((g) => g.playerId === "p1")
expect(aliceGuess?.correct).toBe(true)
const bobGuess = results[0]?.guesses.find((g) => g.playerId === "p2")
expect(bobGuess?.correct).toBe(false)
})
})
describe("getGameStateForPlayer", () => {
it("hides correct country for unrevealed dishes", () => {
gm.addDish("Köttbullar", "SE")
const lookup = new Map<string, string>()
const state = gm.getGameStateForPlayer("p1", lookup)
expect(state.dishes[0]?.correctCountry).toBe("")
})
it("shows correct country after reveal", () => {
gm.addDish("Köttbullar", "SE")
gm.revealDishes()
const lookup = new Map<string, string>()
const state = gm.getGameStateForPlayer("p1", lookup)
expect(state.dishes[0]?.correctCountry).toBe("SE")
})
it("includes only the requesting player's prediction", () => {
gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB")
gm.submitPrediction("p2", "NO", ["DE", "IT", "FR"], "GB")
const lookup = new Map<string, string>()
const state = gm.getGameStateForPlayer("p1", lookup)
expect(state.myPrediction?.predictedWinner).toBe("SE")
})
})
})
- Step 2: Run tests
Run: cd packages/server && bun run test
Expected: all tests pass
- Step 3: Commit
git add packages/server/tests/game-manager.test.ts
git commit -m "add game manager unit tests"
Chunk 3: Client State + Hooks
Task 9: Update Zustand store with game state
Files:
-
Modify:
packages/client/src/stores/room-store.ts -
Step 1: Add game state to the store
Import types at top:
import type { GameState, Dish, DishGuess } from "@celebrate-esc/shared"
Add to the store interface:
gameState: GameState | null
setGameState: (gameState: GameState) => void
addDish: (dish: Dish) => void
recordDishGuess: (dishId: string, guessedCountry: string) => void
lockPredictions: () => void
setDishResults: (results: GameState["dishResults"]) => void
Add implementations:
gameState: null,
setGameState: (gameState) => set({ gameState }),
addDish: (dish) =>
set((state) => {
if (!state.gameState) return state
return {
gameState: {
...state.gameState,
dishes: [...state.gameState.dishes, dish],
},
}
}),
recordDishGuess: (dishId, guessedCountry) =>
set((state) => {
if (!state.gameState) return state
const existing = state.gameState.myDishGuesses.filter((g) => g.dishId !== dishId)
return {
gameState: {
...state.gameState,
myDishGuesses: [...existing, { dishId, playerId: "", guessedCountry }],
},
}
}),
lockPredictions: () =>
set((state) => {
if (!state.gameState) return state
return {
gameState: { ...state.gameState, predictionsLocked: true },
}
}),
setDishResults: (results) =>
set((state) => {
if (!state.gameState) return state
return {
gameState: {
...state.gameState,
dishResults: results,
dishes: state.gameState.dishes.map((d) => ({ ...d, revealed: true })),
},
}
}),
Also update reset to clear game state:
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }),
- Step 2: Verify it compiles
Run: cd packages/client && bun run build
Expected: no errors (or only type-check warnings from unused imports, which is fine at this stage)
- Step 3: Commit
git add packages/client/src/stores/room-store.ts
git commit -m "add game state to zustand store"
Task 10: Handle game messages in WebSocket hook
Files:
-
Modify:
packages/client/src/hooks/use-websocket.ts -
Step 1: Add game state handlers to the store destructure
Update the destructured values from useRoomStore():
const {
setRoom,
setMySessionId,
setConnectionStatus,
updatePlayerConnected,
addPlayer,
setAct,
reset,
setGameState,
addDish,
recordDishGuess,
lockPredictions,
setDishResults,
} = useRoomStore()
- Step 2: Add message handlers in the switch
Add cases in the ws.onmessage switch statement:
case "game_state":
setGameState(msg.gameState)
break
case "predictions_locked":
lockPredictions()
break
case "dish_added":
addDish(msg.dish)
break
case "dish_guess_recorded":
recordDishGuess(msg.dishId, msg.guessedCountry)
break
case "dishes_revealed":
setDishResults(msg.results)
break
- Step 3: Update the useEffect dependency array
Add the new store actions to the dependency array:
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, addDish, recordDishGuess, lockPredictions, setDishResults])
- Step 4: Verify it compiles
Run: cd packages/client && bun run build
Expected: no errors
- Step 5: Commit
git add packages/client/src/hooks/use-websocket.ts
git commit -m "handle game WS messages in client hook"
Chunk 4: Client UI Components
Task 11: Prediction form component
Files:
-
Create:
packages/client/src/components/predictions-form.tsx -
Step 1: Create the predictions form
Create packages/client/src/components/predictions-form.tsx:
import { useState } from "react"
import type { Country, Prediction } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface PredictionsFormProps {
countries: Country[]
existingPrediction: Prediction | null
locked: boolean
onSubmit: (prediction: { predictedWinner: string; top3: string[]; nulPointsPick: string }) => void
}
export function PredictionsForm({ countries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
const [winner, setWinner] = useState(existingPrediction?.predictedWinner ?? "")
const [top3, setTop3] = useState<string[]>(existingPrediction?.top3 ?? [])
const [nulPoints, setNulPoints] = useState(existingPrediction?.nulPointsPick ?? "")
if (locked) {
if (!existingPrediction) {
return (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
Predictions are locked. You didn't submit one in time.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions (locked)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>
<span className="font-medium">Winner:</span>{" "}
{countries.find((c) => c.code === existingPrediction.predictedWinner)?.name}
</p>
<p>
<span className="font-medium">Top 3:</span>{" "}
{existingPrediction.top3.map((code) => countries.find((c) => c.code === code)?.name).join(", ")}
</p>
<p>
<span className="font-medium">Nul Points:</span>{" "}
{countries.find((c) => c.code === existingPrediction.nulPointsPick)?.name}
</p>
</CardContent>
</Card>
)
}
function toggleTop3(code: string) {
setTop3((prev) => {
if (prev.includes(code)) return prev.filter((c) => c !== code)
if (prev.length >= 3) return prev
return [...prev, code]
})
}
const canSubmit = winner && top3.length === 3 && nulPoints && !top3.includes(winner)
return (
<Card>
<CardHeader>
<CardTitle>Predictions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-medium">Winner</label>
<select
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={winner}
onChange={(e) => setWinner(e.target.value)}
>
<option value="">Select a country...</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium">Top 3 (select 3, excl. winner)</label>
<div className="flex flex-wrap gap-1">
{countries
.filter((c) => c.code !== winner)
.map((c) => (
<button
type="button"
key={c.code}
onClick={() => toggleTop3(c.code)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
top3.includes(c.code)
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
} ${top3.length >= 3 && !top3.includes(c.code) ? "opacity-40" : ""}`}
>
{c.name}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">Nul Points (last place)</label>
<select
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={nulPoints}
onChange={(e) => setNulPoints(e.target.value)}
>
<option value="">Select a country...</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<Button onClick={() => onSubmit({ predictedWinner: winner, top3, nulPointsPick: nulPoints })} disabled={!canSubmit}>
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
</Button>
</CardContent>
</Card>
)
}
- Step 2: Commit
git add packages/client/src/components/predictions-form.tsx
git commit -m "add predictions form component"
Task 12: Dish components
Files:
-
Create:
packages/client/src/components/dish-list.tsx -
Create:
packages/client/src/components/dish-host.tsx -
Create:
packages/client/src/components/dish-results.tsx -
Step 1: Create dish list (player view)
Create packages/client/src/components/dish-list.tsx:
import { useState } from "react"
import type { Country, Dish, DishGuess } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface DishListProps {
dishes: Dish[]
myGuesses: DishGuess[]
countries: Country[]
onGuess: (dishId: string, guessedCountry: string) => void
}
export function DishList({ dishes, myGuesses, countries, onGuess }: DishListProps) {
if (dishes.length === 0) {
return (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
No dishes yet — the host will add them.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Dish of the Nation</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{dishes.map((dish) => (
<DishItem
key={dish.id}
dish={dish}
myGuess={myGuesses.find((g) => g.dishId === dish.id)}
countries={countries}
onGuess={onGuess}
/>
))}
</CardContent>
</Card>
)
}
function DishItem({
dish,
myGuess,
countries,
onGuess,
}: {
dish: Dish
myGuess: DishGuess | undefined
countries: Country[]
onGuess: (dishId: string, guessedCountry: string) => void
}) {
const [selected, setSelected] = useState(myGuess?.guessedCountry ?? "")
if (dish.revealed) {
return (
<div className="rounded-md border p-3">
<p className="font-medium">{dish.name}</p>
<p className="text-sm text-muted-foreground">
Answer: {countries.find((c) => c.code === dish.correctCountry)?.name}
</p>
{myGuess && (
<p className={`text-sm ${myGuess.guessedCountry === dish.correctCountry ? "text-green-600" : "text-red-500"}`}>
Your guess: {countries.find((c) => c.code === myGuess.guessedCountry)?.name}
{myGuess.guessedCountry === dish.correctCountry ? " ✓" : " ✗"}
</p>
)}
</div>
)
}
return (
<div className="rounded-md border p-3">
<p className="mb-2 font-medium">{dish.name}</p>
<div className="flex gap-2">
<select
className="flex-1 rounded-md border bg-background px-2 py-1 text-sm"
value={selected}
onChange={(e) => {
setSelected(e.target.value)
if (e.target.value) onGuess(dish.id, e.target.value)
}}
>
<option value="">Guess a country...</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
{myGuess && (
<p className="mt-1 text-xs text-muted-foreground">
Guessed: {countries.find((c) => c.code === myGuess.guessedCountry)?.name}
</p>
)}
</div>
)
}
- Step 2: Create dish host controls
Create packages/client/src/components/dish-host.tsx:
import { useState } from "react"
import type { Country, Dish } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface DishHostProps {
dishes: Dish[]
countries: Country[]
onAddDish: (name: string, correctCountry: string) => void
onReveal: () => void
}
export function DishHost({ dishes, countries, onAddDish, onReveal }: DishHostProps) {
const [name, setName] = useState("")
const [country, setCountry] = useState("")
const allRevealed = dishes.length > 0 && dishes.every((d) => d.revealed)
return (
<Card>
<CardHeader>
<CardTitle>Dish of the Nation</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{!allRevealed && (
<div className="flex flex-col gap-2">
<Input placeholder="Dish name" value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
<select
className="rounded-md border bg-background px-3 py-2 text-sm"
value={country}
onChange={(e) => setCountry(e.target.value)}
>
<option value="">Correct country...</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
<Button
variant="secondary"
onClick={() => {
if (name.trim() && country) {
onAddDish(name.trim(), country)
setName("")
setCountry("")
}
}}
disabled={!name.trim() || !country}
>
Add Dish
</Button>
</div>
)}
{dishes.length > 0 && (
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{dishes.length} dish(es) added:</p>
<ul className="text-sm text-muted-foreground">
{dishes.map((d) => (
<li key={d.id}>
{d.name} → {countries.find((c) => c.code === d.correctCountry)?.name ?? d.correctCountry}
{d.revealed && " (revealed)"}
</li>
))}
</ul>
</div>
)}
{dishes.length > 0 && !allRevealed && (
<Button onClick={onReveal}>Reveal All Dishes</Button>
)}
</CardContent>
</Card>
)
}
Note: The host's dish list shows the correct country (they set it), unlike the player view which hides it.
- Step 3: Create dish results component
Create packages/client/src/components/dish-results.tsx:
import type { GameState, Country } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface DishResultsProps {
results: NonNullable<GameState["dishResults"]>
countries: Country[]
}
export function DishResults({ results, countries }: DishResultsProps) {
return (
<Card>
<CardHeader>
<CardTitle>Dish Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{results.map((r) => (
<div key={r.dish.id} className="rounded-md border p-3">
<p className="font-medium">{r.dish.name}</p>
<p className="mb-2 text-sm text-muted-foreground">
Answer: {countries.find((c) => c.code === r.dish.correctCountry)?.name}
</p>
{r.guesses.length === 0 ? (
<p className="text-sm text-muted-foreground">No guesses</p>
) : (
<ul className="text-sm">
{r.guesses.map((g) => (
<li key={g.playerId} className={g.correct ? "text-green-600" : "text-red-500"}>
{g.displayName}: {countries.find((c) => c.code === g.guessedCountry)?.name}
{g.correct ? " ✓" : " ✗"}
</li>
))}
</ul>
)}
</div>
))}
</CardContent>
</Card>
)
}
- Step 4: Commit
git add packages/client/src/components/dish-list.tsx packages/client/src/components/dish-host.tsx packages/client/src/components/dish-results.tsx
git commit -m "add dish UI components (player list, host controls, results)"
Chunk 5: Route Integration
Task 13: Update player view with game UI
Files:
-
Modify:
packages/client/src/routes/play.$roomCode.tsx -
Step 1: Add game UI to player view
Import game components and store:
import { PredictionsForm } from "@/components/predictions-form"
import { DishList } from "@/components/dish-list"
import { DishResults } from "@/components/dish-results"
Update the store destructure:
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
Replace the {/* Game UI will be added in later plans */} comment and the act-specific conditional renders (lines 88-98) with:
{room.currentAct === "lobby" && !gameState && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
</div>
)}
{gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && (
<div className="flex flex-col gap-4">
<PredictionsForm
countries={gameState.lineup.countries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
<DishList
dishes={gameState.dishes}
myGuesses={gameState.myDishGuesses}
countries={gameState.lineup.countries}
onGuess={(dishId, guessedCountry) =>
send({ type: "submit_dish_guess", dishId, guessedCountry })
}
/>
</div>
)}
{gameState?.dishResults && (
<DishResults results={gameState.dishResults} countries={gameState.lineup.countries} />
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
</div>
)}
- Step 2: Verify it compiles
Run: cd packages/client && bun run build
Expected: no errors
- Step 3: Commit
git add packages/client/src/routes/play.\$roomCode.tsx
git commit -m "add game UI to player view"
Task 14: Update host view with game UI
Files:
-
Modify:
packages/client/src/routes/host.$roomCode.tsx -
Step 1: Add game UI to host view
Import game components:
import { PredictionsForm } from "@/components/predictions-form"
import { DishList } from "@/components/dish-list"
import { DishHost } from "@/components/dish-host"
import { DishResults } from "@/components/dish-results"
Update the store destructure:
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
Replace the TabsContent value="play" contents (currently just PlayerList + comment) with:
<TabsContent value="play" className="p-4">
{gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && (
<div className="flex flex-col gap-4">
<PredictionsForm
countries={gameState.lineup.countries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
<DishList
dishes={gameState.dishes}
myGuesses={gameState.myDishGuesses}
countries={gameState.lineup.countries}
onGuess={(dishId, guessedCountry) =>
send({ type: "submit_dish_guess", dishId, guessedCountry })
}
/>
</div>
)}
{gameState?.dishResults && (
<DishResults results={gameState.dishResults} countries={gameState.lineup.countries} />
)}
<PlayerList players={room.players} mySessionId={mySessionId} />
</TabsContent>
In the TabsContent value="host", add dish host controls inside the flex flex-col gap-4 div, after the Room Controls card:
{gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && (
<DishHost
dishes={gameState.dishes}
countries={gameState.lineup.countries}
onAddDish={(name, correctCountry) =>
send({ type: "add_dish", name, correctCountry })
}
onReveal={() => send({ type: "reveal_dishes" })}
/>
)}
- Step 2: Verify it compiles
Run: cd packages/client && bun run build
Expected: no errors
- Step 3: Commit
git add packages/client/src/routes/host.\$roomCode.tsx
git commit -m "add game UI to host view"
Task 15: Update display view
Files:
-
Modify:
packages/client/src/routes/display.$roomCode.tsx -
Step 1: Add dish results to display view
Import components:
import { DishResults } from "@/components/dish-results"
Update the store destructure:
const { room, connectionStatus, gameState } = useRoomStore()
Add after the lobby display section, before the closing </div>:
{gameState?.dishResults && (
<div className="mx-auto max-w-2xl p-8">
<DishResults results={gameState.dishResults} countries={gameState.lineup.countries} />
</div>
)}
{room.currentAct === "act1" && gameState && !gameState.dishResults && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Act 1 — Predictions & Dishes</p>
<p className="text-lg text-muted-foreground">
{gameState.dishes.length} dish(es) added
</p>
</div>
)}
- Step 2: Verify it compiles
Run: cd packages/client && bun run build
Expected: no errors
- Step 3: Commit
git add packages/client/src/routes/display.\$roomCode.tsx
git commit -m "add game status to display view"
Task 16: Fix host dish visibility
Files:
- Modify:
packages/server/src/games/game-manager.ts
The host needs to see correct countries for dishes they added (to verify), but the getGameStateForPlayer method hides them. Add a separate method or a flag.
- Step 1: Add isHost parameter to getGameStateForPlayer
Update the method signature and logic:
getGameStateForPlayer(playerId: string, playerLookup: Map<string, string>, isHost: boolean): GameState {
return {
lineup,
myPrediction: this.getPrediction(playerId),
predictionsLocked: this.locked,
dishes: this.dishes.map((d) => ({
id: d.id,
name: d.name,
correctCountry: d.revealed || isHost ? d.correctCountry : "",
revealed: d.revealed,
})),
myDishGuesses: this.getDishGuesses(playerId),
dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null,
}
}
- Step 2: Update the sendGameState helper in handler.ts
Update sendGameState in packages/server/src/ws/handler.ts:
function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
const gm = roomManager.getGameManager(roomCode)
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
if (!gm || !playerId) return
const playerLookup = roomManager.getPlayerLookup(roomCode)
const isHost = roomManager.isHost(roomCode, sessionId)
const gameState = gm.getGameStateForPlayer(playerId, playerLookup, isHost)
sendTo(ws, { type: "game_state", gameState })
}
- Step 3: Update the test for getGameStateForPlayer
In packages/server/tests/game-manager.test.ts, update the calls to pass the isHost parameter:
// Change all calls like:
gm.getGameStateForPlayer("p1", lookup)
// To:
gm.getGameStateForPlayer("p1", lookup, false)
Add a test for host visibility:
it("shows correct country to host for unrevealed dishes", () => {
gm.addDish("Köttbullar", "SE")
const lookup = new Map<string, string>()
const state = gm.getGameStateForPlayer("p1", lookup, true)
expect(state.dishes[0]?.correctCountry).toBe("SE")
})
- Step 4: Run tests
Run: cd packages/server && bun run test
Expected: all tests pass
- Step 5: Commit
git add packages/server/src/games/game-manager.ts packages/server/src/ws/handler.ts packages/server/tests/game-manager.test.ts
git commit -m "fix host dish visibility, show correct countries to host"
Task 17: End-to-end verification
- Step 1: Start the dev server
Run: bun run dev
Expected: both client and server start without errors
- Step 2: Manual smoke test
- Open
http://localhost:5173/→ create a room as host - Open a second tab → join the room as a player
- Verify both see the predictions form and empty dish list
- Submit a prediction as the player → form updates to show the prediction
- On the host tab → add a dish via the Host tab
- Verify the dish appears on the player's phone
- Submit a dish guess as the player
- On host tab → click "Reveal All Dishes"
- Verify results show on both host and player views
- Advance to Act 2 → verify predictions lock
- Step 3: Run all tests
Run: bun run test
Expected: all tests pass
- Step 4: Commit any fixes needed
If any issues found during smoke test, fix and commit.
- Step 5: Deploy
Run: bash deploy.sh
Expected: successful deployment
- Step 6: Commit deploy script changes if any
If the deploy script needed updates, commit.