Files
esc/docs/superpowers/plans/2026-03-11-foundation-room-system.md

81 KiB

ESC Party App — Foundation + Room System

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: Set up the monorepo, shared types, server with WebSocket, client with routing, and a fully working room system (create, join, reconnect, advance acts, end room).

Architecture: Bun workspace monorepo with three packages (shared, server, client). Server is the single source of truth — in-memory room state with PostgreSQL persistence. Client is a thin real-time UI driven by WebSocket state updates. Room creation via HTTP; all real-time interactions via WebSocket.

Tech Stack: Bun, Hono + @hono/node-ws, Drizzle + PostgreSQL, React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router, Zustand, Zod, Biome, Vitest

Deviations from standard stack (per spec):

  • No PGlite on client — server-authoritative, Zustand holds received room state
  • No Better Auth — simple UUID session tokens in sessionStorage
  • No pg-boss — no background jobs needed
  • @hono/node-server + @hono/node-ws for runtime-agnostic WebSocket (Node 22 on Uberspace)

Plan Series

This is Plan 1 of 5:

  1. Foundation + Room System ← this plan
  2. Act 1 Games (Predictions + Dishes)
  3. Act 2 Games (Jury Voting + Bingo)
  4. Act 3 Games (Quiz + Final Leaderboard)
  5. Polish + Deployment

File Structure

celebrate-esc/
├── .mise.toml
├── .gitignore
├── .env.example
├── biome.json
├── package.json                          # Bun workspace root
├── tsconfig.json                         # Base tsconfig
├── packages/
│   ├── shared/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── index.ts                  # Re-exports everything
│   │       ├── constants.ts              # MAX_PLAYERS, ROOM_CODE_LENGTH, etc.
│   │       ├── room-types.ts             # Room, Player, Act types
│   │       └── ws-messages.ts            # All WS message Zod schemas + types
│   ├── server/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── drizzle.config.ts
│   │   ├── src/
│   │   │   ├── index.ts                  # Entry point (serve + inject WS)
│   │   │   ├── app.ts                    # Hono app + node-ws setup
│   │   │   ├── env.ts                    # Zod env validation
│   │   │   ├── db/
│   │   │   │   ├── client.ts             # Drizzle singleton
│   │   │   │   └── schema.ts             # All Drizzle table definitions
│   │   │   ├── rooms/
│   │   │   │   ├── room-manager.ts       # In-memory state + WS broadcasting
│   │   │   │   └── room-service.ts       # DB persistence layer
│   │   │   └── ws/
│   │   │       └── handler.ts            # WS route + message routing
│   │   ├── data/
│   │   │   └── scoring.json              # Point values (placeholder for Plan 1)
│   │   └── tests/
│   │       ├── room-manager.test.ts
│   │       └── ws-handler.test.ts
│   └── client/
│       ├── package.json
│       ├── tsconfig.json
│       ├── vite.config.ts
│       ├── index.html
│       ├── components.json               # shadcn/ui config
│       └── src/
│           ├── main.tsx                   # React entry point
│           ├── app.css                    # Tailwind v4 imports
│           ├── lib/
│           │   └── utils.ts              # cn() utility
│           ├── stores/
│           │   └── room-store.ts         # Zustand: room state + WS connection
│           ├── hooks/
│           │   └── use-websocket.ts       # WebSocket connection hook
│           ├── routes/
│           │   ├── __root.tsx
│           │   ├── index.tsx              # Landing page
│           │   ├── display.$roomCode.tsx  # TV display view
│           │   ├── host.$roomCode.tsx     # Host phone view
│           │   └── play.$roomCode.tsx     # Player phone view
│           └── components/
│               ├── ui/                    # shadcn components (auto-generated)
│               ├── player-list.tsx
│               └── room-header.tsx

Chunk 1: Project Scaffolding

Task 1: Root configuration files

Files:

  • Create: .mise.toml

  • Create: .gitignore

  • Create: .env.example

  • Create: package.json

  • Create: tsconfig.json

  • Create: biome.json

  • Step 1: Create .mise.toml

Check current available versions first:

mise ls-remote bun | tail -5
mise ls-remote node | tail -5

Then create .mise.toml with exact pinned versions (adjust to actual latest):

[tools]
bun  = "1.1.38"
node = "22.3.0"

Run: mise install

  • Step 2: Create .gitignore
# mise
.mise.local.toml

# dependencies
node_modules/

# environment
.env
.env.local
.env.*.local

# build output
dist/

# OS
.DS_Store

# IDE
.vscode/
.idea/

  • Step 3: Create .env.example
DATABASE_URL=postgresql://localhost:5433/celebrate_esc
PORT=3001
  • Step 4: Create root package.json
{
	"name": "celebrate-esc",
	"private": true,
	"workspaces": [
		"packages/shared",
		"packages/server",
		"packages/client"
	],
	"scripts": {
		"dev": "bun --filter '*' dev",
		"dev:server": "bun --filter server dev",
		"dev:client": "bun --filter client dev",
		"build": "bun --filter '*' build",
		"test": "bun --filter '*' test",
		"lint": "biome check .",
		"format": "biome check --write ."
	}
}
  • Step 5: Create base tsconfig.json
{
	"compilerOptions": {
		"target": "ES2022",
		"module": "ESNext",
		"moduleResolution": "bundler",
		"strict": true,
		"esModuleInterop": true,
		"skipLibCheck": true,
		"forceConsistentCasingInFileNames": true,
		"resolveJsonModule": true,
		"isolatedModules": true,
		"declaration": true,
		"declarationMap": true,
		"sourceMap": true,
		"noUncheckedIndexedAccess": true,
		"noUnusedLocals": true,
		"noUnusedParameters": true
	}
}
  • Step 6: Create biome.json
{
	"$schema": "https://biomejs.dev/schemas/latest/schema.json",
	"organizeImports": {
		"enabled": true
	},
	"formatter": {
		"enabled": true,
		"indentStyle": "tab",
		"lineWidth": 120
	},
	"linter": {
		"enabled": true,
		"rules": {
			"recommended": true,
			"correctness": {
				"noUnusedImports": "error",
				"noUnusedVariables": "error"
			}
		}
	},
	"files": {
		"ignore": ["node_modules", "dist", "drizzle"]
	}
}
  • Step 7: Commit
git add .mise.toml .gitignore .env.example package.json tsconfig.json biome.json
git commit -m "scaffold monorepo root with tooling configs"

Task 2: Shared package scaffold

Files:

  • Create: packages/shared/package.json

  • Create: packages/shared/tsconfig.json

  • Create: packages/shared/src/index.ts

  • Step 1: Create packages/shared/package.json

{
	"name": "@celebrate-esc/shared",
	"version": "0.0.0",
	"private": true,
	"type": "module",
	"main": "src/index.ts",
	"types": "src/index.ts",
	"scripts": {
		"build": "tsc --noEmit",
		"typecheck": "tsc --noEmit"
	},
	"dependencies": {
		"zod": "latest"
	},
	"devDependencies": {
		"typescript": "latest"
	}
}
  • Step 2: Create packages/shared/tsconfig.json
{
	"extends": "../../tsconfig.json",
	"compilerOptions": {
		"outDir": "dist",
		"rootDir": "src"
	},
	"include": ["src"]
}
  • Step 3: Create packages/shared/src/index.ts
export * from "./constants"
export * from "./room-types"
export * from "./ws-messages"

Note: this file will fail typecheck until the source files are created in Chunk 2. That's expected.

  • Step 4: Commit
git add packages/shared/
git commit -m "scaffold shared package"

Task 3: Server package scaffold

Files:

  • Create: packages/server/package.json

  • Create: packages/server/tsconfig.json

  • Step 1: Create packages/server/package.json

{
	"name": "@celebrate-esc/server",
	"version": "0.0.0",
	"private": true,
	"type": "module",
	"scripts": {
		"dev": "bun --env-file=../../.env --watch src/index.ts",
		"build": "tsc --noEmit",
		"start": "node --experimental-strip-types src/index.ts",
		"test": "vitest run",
		"test:watch": "vitest",
		"typecheck": "tsc --noEmit"
	},
	"dependencies": {
		"@celebrate-esc/shared": "workspace:*",
		"@hono/node-server": "latest",
		"@hono/node-ws": "latest",
		"@hono/zod-validator": "latest",
		"drizzle-orm": "latest",
		"hono": "latest",
		"pg": "latest",
		"zod": "latest"
	},
	"devDependencies": {
		"@types/pg": "latest",
		"drizzle-kit": "latest",
		"typescript": "latest",
		"vitest": "latest"
	}
}
  • Step 2: Create packages/server/tsconfig.json
{
	"extends": "../../tsconfig.json",
	"compilerOptions": {
		"outDir": "dist",
		"rootDir": "src"
	},
	"include": ["src"]
}
  • Step 3: Commit
git add packages/server/
git commit -m "scaffold server package"

Task 4: Client package scaffold

Files:

  • Create: packages/client/package.json

  • Create: packages/client/tsconfig.json

  • Create: packages/client/vite.config.ts

  • Create: packages/client/index.html

  • Create: packages/client/src/main.tsx

  • Create: packages/client/src/app.css

  • Step 1: Create packages/client/package.json

{
	"name": "@celebrate-esc/client",
	"version": "0.0.0",
	"private": true,
	"type": "module",
	"scripts": {
		"dev": "vite",
		"build": "tsc -b && vite build",
		"preview": "vite preview",
		"typecheck": "tsc --noEmit"
	},
	"dependencies": {
		"@celebrate-esc/shared": "workspace:*",
		"@tanstack/react-router": "latest",
		"react": "latest",
		"react-dom": "latest",
		"zustand": "latest"
	},
	"devDependencies": {
		"@tailwindcss/vite": "latest",
		"@tanstack/router-plugin": "latest",
		"@types/react": "latest",
		"@types/react-dom": "latest",
		"@vitejs/plugin-react": "latest",
		"tailwindcss": "latest",
		"typescript": "latest",
		"vite": "latest"
	}
}
  • Step 2: Create packages/client/tsconfig.json
{
	"extends": "../../tsconfig.json",
	"compilerOptions": {
		"jsx": "react-jsx",
		"outDir": "dist",
		"rootDir": "src",
		"types": ["vite/client"]
	},
	"include": ["src"]
}
  • Step 3: Create packages/client/vite.config.ts
import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
	plugins: [TanStackRouterVite(), react(), tailwindcss()],
	server: {
		proxy: {
			"/api": {
				target: "http://localhost:3001",
				changeOrigin: true,
				rewrite: (path) => path.replace(/^\/api/, ""),
				ws: true,
			},
		},
	},
})

Note: the Vite proxy forwards /api to the server and strips the /api prefix via rewrite. The client connects to /api/ws/:roomCode which becomes /ws/:roomCode on the server. This mirrors production where Uberspace's --remove-prefix strips /celebrate-esc/api. The ws: true flag enables WebSocket proxying.

  • Step 4: Create packages/client/index.html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>ESC Party</title>
	</head>
	<body>
		<div id="root"></div>
		<script type="module" src="/src/main.tsx"></script>
	</body>
</html>
  • Step 5: Create packages/client/src/app.css
@import "tailwindcss";
  • Step 6: Create packages/client/src/main.tsx (placeholder)
import "./app.css"

function App() {
	return <div className="p-4">ESC Party  loading...</div>
}

const root = document.getElementById("root")!
import { createRoot } from "react-dom/client"
createRoot(root).render(<App />)

This is a temporary placeholder — will be replaced when TanStack Router is set up in Chunk 5.

  • Step 7: Install dependencies + verify
cd celebrate-esc  # project root
bun install

Expected: no errors, node_modules/ created, bun.lockb created.

Verify the client dev server starts:

cd packages/client && bun dev &
# Wait a few seconds, then check http://localhost:5173 shows "ESC Party — loading..."
# Kill the dev server
kill %1
  • Step 8: Commit
git add packages/client/ bun.lockb
git commit -m "scaffold client package with Vite, Tailwind v4, TanStack Router"

Chunk 2: Shared Types & Schemas

Task 5: Constants

Files:

  • Create: packages/shared/src/constants.ts

  • Step 1: Create constants file

export const MAX_PLAYERS = 10
export const ROOM_CODE_LENGTH = 4
export const ROOM_EXPIRY_HOURS = 12

/** Characters used for room codes — excludes I/O/0/1 to avoid confusion */
export const ROOM_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"

export const ACTS = ["lobby", "act1", "act2", "act3", "ended"] as const
export type Act = (typeof ACTS)[number]

/** Rating range for jury voting (Eurovision convention: 1-12) */
export const JURY_RATING_MIN = 1
export const JURY_RATING_MAX = 12

/** Bingo grid dimensions */
export const BINGO_GRID_SIZE = 4
export const BINGO_TOTAL_SQUARES = BINGO_GRID_SIZE * BINGO_GRID_SIZE
  • Step 2: Commit
git add packages/shared/src/constants.ts
git commit -m "add shared constants"

Task 6: Room and player types

Files:

  • Create: packages/shared/src/room-types.ts

  • Step 1: Create room types

import { z } from "zod"
import { ACTS, type Act } from "./constants"

export const playerSchema = z.object({
	id: z.string().uuid(),
	sessionId: z.string().uuid(),
	displayName: z.string().min(1).max(20),
	isHost: z.boolean(),
	connected: z.boolean(),
})

export type Player = z.infer<typeof playerSchema>

export const roomStateSchema = z.object({
	id: z.string().uuid(),
	code: z.string().length(4),
	currentAct: z.enum(ACTS),
	hostSessionId: z.string().uuid(),
	players: z.array(playerSchema),
	createdAt: z.string().datetime(),
	expiresAt: z.string().datetime(),
})

export type RoomState = z.infer<typeof roomStateSchema>

/** Client-side room state — includes the current player's session info */
export interface ClientRoomState {
	room: RoomState | null
	mySessionId: string | null
	myPlayerId: string | null
	isHost: boolean
	connectionStatus: "disconnected" | "connecting" | "connected"
}
  • Step 2: Commit
git add packages/shared/src/room-types.ts
git commit -m "add room and player Zod schemas"

Task 7: WebSocket message schemas

Files:

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

  • Step 1: Create WS message schemas

This file defines all message types for the room system. Game-specific messages will be added in later plans.

Design note: The spec lists roomCode in join_room and reconnect payloads. We omit it from the message schemas because the room code is already part of the WebSocket URL path (/ws/:roomCode). Including it in the message body would be redundant.

import { z } from "zod"
import { ACTS } from "./constants"
import { playerSchema, roomStateSchema } from "./room-types"

// ─── Client → Server ───────────────────────────────────────────────

export const joinRoomMessage = z.object({
	type: z.literal("join_room"),
	displayName: z.string().min(1).max(20),
})

export const reconnectMessage = z.object({
	type: z.literal("reconnect"),
	sessionId: z.string().uuid(),
})

export const advanceActMessage = z.object({
	type: z.literal("advance_act"),
})

export const endRoomMessage = z.object({
	type: z.literal("end_room"),
})

/** Union of all client → server messages (room system only — games add more) */
export const clientMessage = z.discriminatedUnion("type", [
	joinRoomMessage,
	reconnectMessage,
	advanceActMessage,
	endRoomMessage,
])

export type ClientMessage = z.infer<typeof clientMessage>

// ─── Server → Client ───────────────────────────────────────────────

export const roomStateMessage = z.object({
	type: z.literal("room_state"),
	room: roomStateSchema,
	sessionId: z.string().uuid().optional(),
})

export const playerJoinedMessage = z.object({
	type: z.literal("player_joined"),
	player: playerSchema,
})

export const playerDisconnectedMessage = z.object({
	type: z.literal("player_disconnected"),
	playerId: z.string().uuid(),
})

export const playerReconnectedMessage = z.object({
	type: z.literal("player_reconnected"),
	playerId: z.string().uuid(),
})

export const actChangedMessage = z.object({
	type: z.literal("act_changed"),
	newAct: z.enum(ACTS),
})

export const roomEndedMessage = z.object({
	type: z.literal("room_ended"),
})

export const errorMessage = z.object({
	type: z.literal("error"),
	message: z.string(),
})

/** Union of all server → client messages (room system only) */
export const serverMessage = z.discriminatedUnion("type", [
	roomStateMessage,
	playerJoinedMessage,
	playerDisconnectedMessage,
	playerReconnectedMessage,
	actChangedMessage,
	roomEndedMessage,
	errorMessage,
])

export type ServerMessage = z.infer<typeof serverMessage>
  • Step 2: Verify typecheck passes
cd packages/shared && bun run typecheck

Expected: no errors.

  • Step 3: Commit
git add packages/shared/src/ws-messages.ts
git commit -m "add WebSocket message Zod schemas for room system"

Chunk 3: Server Foundation

Task 8: Environment validation

Files:

  • Create: packages/server/src/env.ts

  • Step 1: Create env validation

import { z } from "zod"

const envSchema = z.object({
	DATABASE_URL: z.string().url(),
	PORT: z.coerce.number().default(3001),
})

export const env = envSchema.parse(process.env)
  • Step 2: Commit
git add packages/server/src/env.ts
git commit -m "add server env validation"

Task 9: Drizzle schema

Files:

  • Create: packages/server/src/db/schema.ts

  • Create: packages/server/src/db/client.ts

  • Create: packages/server/drizzle.config.ts

  • Step 1: Create Drizzle schema

Defines all tables from the spec. Game-specific tables are included now so migrations don't need to be regenerated in later plans.

import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"

export const actEnum = pgEnum("act", ["lobby", "act1", "act2", "act3", "ended"])
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
export const quizRoundStatusEnum = pgEnum("quiz_round_status", ["showing", "buzzing", "judging", "resolved"])

// ─── Room System ────────────────────────────────────────────────────

export const rooms = pgTable("rooms", {
	id: uuid("id").primaryKey().defaultRandom(),
	code: varchar("code", { length: 4 }).notNull().unique(),
	currentAct: actEnum("current_act").notNull().default("lobby"),
	hostSessionId: uuid("host_session_id").notNull(),
	actualWinner: varchar("actual_winner"),
	actualSecond: varchar("actual_second"),
	actualThird: varchar("actual_third"),
	actualLast: varchar("actual_last"),
	createdAt: timestamp("created_at").notNull().defaultNow(),
	expiresAt: timestamp("expires_at").notNull(),
})

export const players = pgTable("players", {
	id: uuid("id").primaryKey().defaultRandom(),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	sessionId: uuid("session_id").notNull().unique(),
	displayName: varchar("display_name", { length: 20 }).notNull(),
	isHost: boolean("is_host").notNull().default(false),
	connected: boolean("connected").notNull().default(false),
	joinedAt: timestamp("joined_at").notNull().defaultNow(),
})

// ─── Predictions (Plan 2) ──────────────────────────────────────────

export const predictions = pgTable("predictions", {
	id: uuid("id").primaryKey().defaultRandom(),
	playerId: uuid("player_id")
		.notNull()
		.references(() => players.id),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	predictedWinner: varchar("predicted_winner").notNull(),
	top3: jsonb("top_3").notNull().$type<string[]>(),
	nulPointsPick: varchar("nul_points_pick").notNull(),
})

// ─── Jury Voting (Plan 3) ──────────────────────────────────────────

export const juryRounds = pgTable("jury_rounds", {
	id: uuid("id").primaryKey().defaultRandom(),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	countryCode: varchar("country_code").notNull(),
	status: juryRoundStatusEnum("status").notNull().default("open"),
	openedAt: timestamp("opened_at").notNull().defaultNow(),
})

export const juryVotes = pgTable("jury_votes", {
	id: uuid("id").primaryKey().defaultRandom(),
	playerId: uuid("player_id")
		.notNull()
		.references(() => players.id),
	juryRoundId: uuid("jury_round_id")
		.notNull()
		.references(() => juryRounds.id),
	rating: integer("rating").notNull(),
})

// ─── Bingo (Plan 3) ────────────────────────────────────────────────

export const bingoCards = pgTable("bingo_cards", {
	id: uuid("id").primaryKey().defaultRandom(),
	playerId: uuid("player_id")
		.notNull()
		.references(() => players.id),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	squares: jsonb("squares").notNull().$type<{ tropeId: string; tapped: boolean }[]>(),
})

// ─── Dishes (Plan 2) ───────────────────────────────────────────────

export const dishes = pgTable("dishes", {
	id: uuid("id").primaryKey().defaultRandom(),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	name: varchar("name", { length: 100 }).notNull(),
	correctCountry: varchar("correct_country").notNull(),
	revealed: boolean("revealed").notNull().default(false),
})

export const dishGuesses = pgTable("dish_guesses", {
	id: uuid("id").primaryKey().defaultRandom(),
	playerId: uuid("player_id")
		.notNull()
		.references(() => players.id),
	dishId: uuid("dish_id")
		.notNull()
		.references(() => dishes.id),
	guessedCountry: varchar("guessed_country").notNull(),
})

// ─── Quiz (Plan 4) ─────────────────────────────────────────────────

export const quizRounds = pgTable("quiz_rounds", {
	id: uuid("id").primaryKey().defaultRandom(),
	roomId: uuid("room_id")
		.notNull()
		.references(() => rooms.id),
	questionId: varchar("question_id").notNull(),
	status: quizRoundStatusEnum("status").notNull().default("showing"),
})

export const quizAnswers = pgTable("quiz_answers", {
	id: uuid("id").primaryKey().defaultRandom(),
	playerId: uuid("player_id")
		.notNull()
		.references(() => players.id),
	quizRoundId: uuid("quiz_round_id")
		.notNull()
		.references(() => quizRounds.id),
	buzzedAt: timestamp("buzzed_at").notNull().defaultNow(),
	correct: boolean("correct"),
})
  • Step 2: Create DB client
import { drizzle } from "drizzle-orm/node-postgres"
import * as schema from "./schema"

export function createDb(databaseUrl: string) {
	return drizzle(databaseUrl, { schema })
}

export type Database = ReturnType<typeof createDb>
  • Step 3: Create Drizzle config
import { defineConfig } from "drizzle-kit"

export default defineConfig({
	schema: "./src/db/schema.ts",
	out: "./drizzle",
	dialect: "postgresql",
	dbCredentials: {
		url: process.env.DATABASE_URL!,
	},
})
  • Step 4: Commit
git add packages/server/src/db/ packages/server/drizzle.config.ts
git commit -m "add Drizzle schema with all tables, DB client"

Task 10: Hono app + entry point

Files:

  • Create: packages/server/src/app.ts

  • Create: packages/server/src/index.ts

  • Step 1: Create Hono app

import { Hono } from "hono"
import { cors } from "hono/cors"
import { createNodeWebSocket } from "@hono/node-ws"

const app = new Hono()

app.use("*", cors())

const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })

app.get("/health", (c) => c.json({ status: "ok" }))

export { app, injectWebSocket, upgradeWebSocket }
  • Step 2: Create entry point
import { serve } from "@hono/node-server"
import { app, injectWebSocket } from "./app"
import { env } from "./env"

const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
	console.log(`celebrate-esc server running on http://localhost:${info.port}`)
})

injectWebSocket(server)
  • Step 3: Create .env for local development

Create a root .env (not committed — gitignored):

cp .env.example .env
# Edit .env with your local PostgreSQL URL

The server's dev script (already set in Task 3) loads this file:

"dev": "bun --env-file=../../.env --watch src/index.ts"
  • Step 4: Verify server starts

Ensure PostgreSQL is running locally, create the database, then start the server:

createdb -h localhost -p 5433 celebrate_esc 2>/dev/null || true
cd packages/server && DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts &
curl http://localhost:3001/health
# Expected: {"status":"ok"}
kill %1
  • Step 5: Commit
git add packages/server/src/app.ts packages/server/src/index.ts packages/server/package.json
git commit -m "add Hono app with health endpoint, server entry point"

Task 11: Generate and apply initial migration

  • Step 1: Generate migration
cd packages/server
DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun drizzle-kit generate

Expected: migration file created in packages/server/drizzle/ directory.

  • Step 2: Apply migration
cd packages/server
DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun drizzle-kit migrate

Expected: tables created in the database.

  • Step 3: Verify tables exist
psql -h localhost -p 5433 celebrate_esc -c "\dt"

Expected: rooms, players, predictions, jury_rounds, jury_votes, bingo_cards, dishes, dish_guesses, quiz_rounds, quiz_answers tables listed.

  • Step 4: Create scoring.json placeholder
mkdir -p packages/server/data

Create packages/server/data/scoring.json:

{
	"prediction_winner": 25,
	"prediction_top3": 10,
	"prediction_nul_points": 15,
	"jury_max_per_round": 5,
	"bingo_per_square": 2,
	"bingo_full_bonus": 10,
	"quiz_easy": 5,
	"quiz_medium": 10,
	"quiz_hard": 15,
	"dish_correct": 5
}
  • Step 5: Commit
git add packages/server/drizzle/ packages/server/data/
git commit -m "generate initial Drizzle migration, add scoring config"

Chunk 4: Room System

Task 12: Room manager — write failing tests

Files:

  • Create: packages/server/tests/room-manager.test.ts

  • Step 1: Write room manager tests

import { describe, expect, it, beforeEach } from "vitest"
import { RoomManager } from "../src/rooms/room-manager"
import type { Act } from "@celebrate-esc/shared"

describe("RoomManager", () => {
	let manager: RoomManager

	beforeEach(() => {
		manager = new RoomManager()
	})

	describe("createRoom", () => {
		it("returns a 4-character room code and session ID", () => {
			const result = manager.createRoom("Host")
			expect(result.code).toMatch(/^[A-Z0-9]{4}$/)
			expect(result.sessionId).toBeDefined()
			expect(result.sessionId.length).toBe(36) // UUID
		})

		it("creates the host as a player in the room", () => {
			const { code, sessionId } = manager.createRoom("Host")
			const room = manager.getRoom(code)
			expect(room).toBeDefined()
			expect(room!.players).toHaveLength(1)
			expect(room!.players[0]!.displayName).toBe("Host")
			expect(room!.players[0]!.isHost).toBe(true)
			expect(room!.players[0]!.sessionId).toBe(sessionId)
		})

		it("starts in lobby state", () => {
			const { code } = manager.createRoom("Host")
			const room = manager.getRoom(code)
			expect(room!.currentAct).toBe("lobby")
		})

		it("generates unique room codes", () => {
			const codes = new Set<string>()
			for (let i = 0; i < 50; i++) {
				const { code } = manager.createRoom(`Host ${i}`)
				codes.add(code)
			}
			expect(codes.size).toBe(50)
		})
	})

	describe("joinRoom", () => {
		it("adds a player to an existing room", () => {
			const { code } = manager.createRoom("Host")
			const result = manager.joinRoom(code, "Player 1")

			expect("sessionId" in result).toBe(true)
			if ("sessionId" in result) {
				const room = manager.getRoom(code)
				expect(room!.players).toHaveLength(2)
				expect(room!.players[1]!.displayName).toBe("Player 1")
				expect(room!.players[1]!.isHost).toBe(false)
			}
		})

		it("rejects join if room not found", () => {
			const result = manager.joinRoom("ZZZZ", "Player")
			expect(result).toEqual({ error: "Room not found" })
		})

		it("rejects join if room has ended", () => {
			const { code } = manager.createRoom("Host")
			// Force room to ended state
			manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
			manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
			manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
			manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
			const result = manager.joinRoom(code, "Late Player")
			expect(result).toEqual({ error: "Room has ended" })
		})

		it("rejects join if display name is taken", () => {
			const { code } = manager.createRoom("Host")
			manager.joinRoom(code, "Player 1")
			const result = manager.joinRoom(code, "Player 1")
			expect(result).toEqual({ error: "Name already taken" })
		})

		it("rejects join if room is full (10 players)", () => {
			const { code } = manager.createRoom("Host")
			for (let i = 1; i <= 9; i++) {
				manager.joinRoom(code, `Player ${i}`)
			}
			const result = manager.joinRoom(code, "Player 10")
			expect(result).toEqual({ error: "Room is full" })
		})
	})

	describe("advanceAct", () => {
		it("advances through acts in order", () => {
			const { code } = manager.createRoom("Host")
			const room = manager.getRoom(code)!
			const hostSession = room.hostSessionId

			const expectedSequence: Act[] = ["act1", "act2", "act3", "ended"]
			for (const expected of expectedSequence) {
				const result = manager.advanceAct(code, hostSession)
				expect(result).toEqual({ newAct: expected })
				expect(manager.getRoom(code)!.currentAct).toBe(expected)
			}
		})

		it("cannot advance past ended", () => {
			const { code } = manager.createRoom("Host")
			const room = manager.getRoom(code)!
			// Advance to ended
			for (let i = 0; i < 4; i++) {
				manager.advanceAct(code, room.hostSessionId)
			}
			const result = manager.advanceAct(code, room.hostSessionId)
			expect(result).toEqual({ error: "Room has already ended" })
		})

		it("rejects advance from non-host", () => {
			const { code } = manager.createRoom("Host")
			const joinResult = manager.joinRoom(code, "Player")
			if ("sessionId" in joinResult) {
				const result = manager.advanceAct(code, joinResult.sessionId)
				expect(result).toEqual({ error: "Only the host can advance acts" })
			}
		})
	})

	describe("endRoom", () => {
		it("sets room to ended state", () => {
			const { code } = manager.createRoom("Host")
			const room = manager.getRoom(code)!
			const result = manager.endRoom(code, room.hostSessionId)
			expect(result).toEqual({ success: true })
			expect(manager.getRoom(code)!.currentAct).toBe("ended")
		})

		it("rejects end from non-host", () => {
			const { code } = manager.createRoom("Host")
			const joinResult = manager.joinRoom(code, "Player")
			if ("sessionId" in joinResult) {
				const result = manager.endRoom(code, joinResult.sessionId)
				expect(result).toEqual({ error: "Only the host can end the room" })
			}
		})
	})

	describe("getRoom", () => {
		it("returns null for non-existent room", () => {
			expect(manager.getRoom("ZZZZ")).toBeNull()
		})

		it("returns serialized room state", () => {
			const { code } = manager.createRoom("Host")
			const room = manager.getRoom(code)
			expect(room).toBeDefined()
			expect(room!.code).toBe(code)
			expect(room!.currentAct).toBe("lobby")
			expect(Array.isArray(room!.players)).toBe(true)
		})
	})

	describe("reconnect", () => {
		it("re-identifies an existing player by session ID", () => {
			const { code, sessionId } = manager.createRoom("Host")
			const result = manager.reconnectPlayer(code, sessionId)
			expect(result).toEqual({ success: true, playerId: expect.any(String) })
		})

		it("rejects reconnect with unknown session ID", () => {
			const { code } = manager.createRoom("Host")
			const result = manager.reconnectPlayer(code, "00000000-0000-0000-0000-000000000000")
			expect(result).toEqual({ error: "Session not found in this room" })
		})
	})
})
  • Step 2: Run tests to verify they fail
cd packages/server && bun test tests/room-manager.test.ts

Expected: FAIL — RoomManager module not found.

  • Step 3: Commit failing tests
git add packages/server/tests/room-manager.test.ts
git commit -m "add room manager tests (red)"

Task 13: Room manager — implement

Files:

  • Create: packages/server/src/rooms/room-manager.ts

  • Step 1: Implement RoomManager

import { randomUUID } from "node:crypto"
import { ACTS, MAX_PLAYERS, ROOM_CODE_CHARS, ROOM_CODE_LENGTH, ROOM_EXPIRY_HOURS } from "@celebrate-esc/shared"
import type { Act, RoomState, Player } from "@celebrate-esc/shared"

interface InternalPlayer {
	id: string
	sessionId: string
	displayName: string
	isHost: boolean
	connected: boolean
}

interface InternalRoom {
	id: string
	code: string
	currentAct: Act
	hostSessionId: string
	players: Map<string, InternalPlayer> // sessionId → player
	createdAt: Date
	expiresAt: Date
}

export class RoomManager {
	private rooms = new Map<string, InternalRoom>()

	createRoom(hostDisplayName: string): { code: string; sessionId: string } {
		const code = this.generateRoomCode()
		const sessionId = randomUUID()
		const now = new Date()

		const host: InternalPlayer = {
			id: randomUUID(),
			sessionId,
			displayName: hostDisplayName,
			isHost: true,
			connected: false,
		}

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

		this.rooms.set(code, room)
		return { code, sessionId }
	}

	joinRoom(code: string, displayName: string): { sessionId: string } | { error: string } {
		const room = this.rooms.get(code)
		if (!room) return { error: "Room not found" }
		if (room.currentAct === "ended") return { error: "Room has ended" }
		if (room.players.size >= MAX_PLAYERS) return { error: "Room is full" }

		for (const player of room.players.values()) {
			if (player.displayName.toLowerCase() === displayName.toLowerCase()) return { error: "Name already taken" }
		}

		const sessionId = randomUUID()
		room.players.set(sessionId, {
			id: randomUUID(),
			sessionId,
			displayName,
			isHost: false,
			connected: false,
		})

		return { sessionId }
	}

	advanceAct(code: string, sessionId: string): { newAct: Act } | { error: string } {
		const room = this.rooms.get(code)
		if (!room) return { error: "Room not found" }
		if (room.hostSessionId !== sessionId) return { error: "Only the host can advance acts" }
		if (room.currentAct === "ended") return { error: "Room has already ended" }

		const currentIndex = ACTS.indexOf(room.currentAct)
		const nextAct = ACTS[currentIndex + 1]!
		room.currentAct = nextAct
		return { newAct: nextAct }
	}

	endRoom(code: string, sessionId: string): { success: true } | { error: string } {
		const room = this.rooms.get(code)
		if (!room) return { error: "Room not found" }
		if (room.hostSessionId !== sessionId) return { error: "Only the host can end the room" }

		room.currentAct = "ended"
		return { success: true }
	}

	reconnectPlayer(code: string, sessionId: string): { success: true; playerId: string } | { error: string } {
		const room = this.rooms.get(code)
		if (!room) return { error: "Room not found" }

		const player = room.players.get(sessionId)
		if (!player) return { error: "Session not found in this room" }

		player.connected = true
		return { success: true, playerId: player.id }
	}

	setPlayerConnected(code: string, sessionId: string, connected: boolean): void {
		const room = this.rooms.get(code)
		if (!room) return

		const player = room.players.get(sessionId)
		if (player) {
			player.connected = connected
		}
	}

	getRoom(code: string): RoomState | null {
		const room = this.rooms.get(code)
		if (!room) return null

		return {
			id: room.id,
			code: room.code,
			currentAct: room.currentAct,
			hostSessionId: room.hostSessionId,
			players: Array.from(room.players.values()).map((p) => ({
				id: p.id,
				sessionId: p.sessionId,
				displayName: p.displayName,
				isHost: p.isHost,
				connected: p.connected,
			})),
			createdAt: room.createdAt.toISOString(),
			expiresAt: room.expiresAt.toISOString(),
		}
	}

	isHost(code: string, sessionId: string): boolean {
		const room = this.rooms.get(code)
		return room?.hostSessionId === sessionId
	}

	/** Clear all rooms — used in tests */
	reset(): void {
		this.rooms.clear()
	}

	private generateRoomCode(): string {
		let code: string
		do {
			code = Array.from(
				{ length: ROOM_CODE_LENGTH },
				() => ROOM_CODE_CHARS[Math.floor(Math.random() * ROOM_CODE_CHARS.length)]!,
			).join("")
		} while (this.rooms.has(code))
		return code
	}
}
  • Step 2: Run tests to verify they pass
cd packages/server && bun test tests/room-manager.test.ts

Expected: all tests PASS.

  • Step 3: Commit
git add packages/server/src/rooms/room-manager.ts
git commit -m "implement room manager, pass all room tests"

Task 14: Room service — DB persistence

Files:

  • Create: packages/server/src/rooms/room-service.ts

  • Step 1: Implement room service

This layer persists room and player state to PostgreSQL. It is called by the WebSocket handler after in-memory state changes.

import { eq } from "drizzle-orm"
import type { Database } from "../db/client"
import { players, rooms } from "../db/schema"

export class RoomService {
	constructor(private db: Database) {}

	async persistRoom(room: {
		id: string
		code: string
		currentAct: string
		hostSessionId: string
		expiresAt: Date
	}) {
		await this.db
			.insert(rooms)
			.values({
				id: room.id,
				code: room.code,
				currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
				hostSessionId: room.hostSessionId,
				expiresAt: room.expiresAt,
			})
			.onConflictDoUpdate({
				target: rooms.id,
				set: {
					currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
				},
			})
	}

	async persistPlayer(player: {
		id: string
		roomId: string
		sessionId: string
		displayName: string
		isHost: boolean
	}) {
		await this.db
			.insert(players)
			.values({
				id: player.id,
				roomId: player.roomId,
				sessionId: player.sessionId,
				displayName: player.displayName,
				isHost: player.isHost,
			})
			.onConflictDoUpdate({
				target: players.id,
				set: {
					connected: true,
				},
			})
	}

	async updateRoomAct(roomId: string, act: string) {
		await this.db
			.update(rooms)
			.set({ currentAct: act as "lobby" | "act1" | "act2" | "act3" | "ended" })
			.where(eq(rooms.id, roomId))
	}

	async updatePlayerConnected(sessionId: string, connected: boolean) {
		await this.db.update(players).set({ connected }).where(eq(players.sessionId, sessionId))
	}
}
  • Step 2: Commit
git add packages/server/src/rooms/room-service.ts
git commit -m "add room service for DB persistence"

Task 15: WebSocket handler + HTTP room creation

Files:

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

  • Modify: packages/server/src/app.ts

  • Modify: packages/server/src/index.ts

  • Step 1: Create WebSocket handler

import type { WSContext } from "hono/ws"
import { clientMessage } from "@celebrate-esc/shared"
import type { ServerMessage } from "@celebrate-esc/shared"
import { app, upgradeWebSocket } from "../app"
import { roomManager } from "../rooms/index"

// Track all WebSocket connections per room
// roomCode → Set of { ws, sessionId | null }
interface Connection {
	ws: WSContext
	sessionId: string | null
}
const roomConnections = new Map<string, Set<Connection>>()

function getConnections(roomCode: string): Set<Connection> {
	let conns = roomConnections.get(roomCode)
	if (!conns) {
		conns = new Set()
		roomConnections.set(roomCode, conns)
	}
	return conns
}

function broadcast(roomCode: string, message: ServerMessage) {
	const data = JSON.stringify(message)
	const conns = roomConnections.get(roomCode)
	if (!conns) return
	for (const conn of conns) {
		try {
			conn.ws.send(data)
		} catch {
			// Connection may be closed — will be cleaned up on onClose
		}
	}
}

function sendTo(ws: WSContext, message: ServerMessage) {
	ws.send(JSON.stringify(message))
}

function sendError(ws: WSContext, message: string) {
	sendTo(ws, { type: "error", message })
}

export function registerWebSocketRoutes() {
	app.get(
		"/ws/:roomCode",
		upgradeWebSocket((c) => {
			const roomCode = c.req.param("roomCode")
			let sessionId: string | null = c.req.query("sessionId") ?? null
			let connection: Connection | null = null

			return {
				onOpen(_event, ws) {
					const room = roomManager.getRoom(roomCode)
					if (!room) {
						sendError(ws, "Room not found")
						ws.close(4004, "Room not found")
						return
					}

					connection = { ws, sessionId }
					getConnections(roomCode).add(connection)

					// If sessionId provided, attempt reconnect
					if (sessionId) {
						const result = roomManager.reconnectPlayer(roomCode, sessionId)
						if ("error" in result) {
							sendError(ws, result.error)
							sessionId = null
							connection.sessionId = null
						} else {
							roomManager.setPlayerConnected(roomCode, sessionId, true)
							sendTo(ws, {
								type: "room_state",
								room: roomManager.getRoom(roomCode)!,
							})
							broadcast(roomCode, {
								type: "player_reconnected",
								playerId: result.playerId,
							})
						}
					} else {
						// Passive viewer (display) or player about to send join_room
						// Send current room state
						sendTo(ws, {
							type: "room_state",
							room: roomManager.getRoom(roomCode)!,
						})
					}
				},

				onMessage(event, ws) {
					let data: unknown
					try {
						data = JSON.parse(typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer))
					} catch {
						sendError(ws, "Invalid JSON")
						return
					}

					const parsed = clientMessage.safeParse(data)
					if (!parsed.success) {
						sendError(ws, `Invalid message: ${parsed.error.message}`)
						return
					}

					const msg = parsed.data

					switch (msg.type) {
						case "join_room": {
							if (sessionId) {
								sendError(ws, "Already joined")
								return
							}
							const result = roomManager.joinRoom(roomCode, msg.displayName)
							if ("error" in result) {
								sendError(ws, result.error)
								return
							}
							sessionId = result.sessionId
							if (connection) connection.sessionId = sessionId
							roomManager.setPlayerConnected(roomCode, sessionId, true)

							// Send room state with session ID to the new player
							sendTo(ws, {
								type: "room_state",
								room: roomManager.getRoom(roomCode)!,
								sessionId: result.sessionId,
							})

							// Broadcast player joined to everyone
							const room = roomManager.getRoom(roomCode)!
							const newPlayer = room.players.find((p) => p.sessionId === sessionId)!
							broadcast(roomCode, {
								type: "player_joined",
								player: newPlayer,
							})
							break
						}

						case "reconnect": {
							const result = roomManager.reconnectPlayer(roomCode, msg.sessionId)
							if ("error" in result) {
								sendError(ws, result.error)
								return
							}
							sessionId = msg.sessionId
							if (connection) connection.sessionId = sessionId
							roomManager.setPlayerConnected(roomCode, sessionId, true)
							sendTo(ws, {
								type: "room_state",
								room: roomManager.getRoom(roomCode)!,
							})
							broadcast(roomCode, {
								type: "player_reconnected",
								playerId: result.playerId,
							})
							break
						}

						case "advance_act": {
							if (!sessionId) {
								sendError(ws, "Not joined")
								return
							}
							const result = roomManager.advanceAct(roomCode, sessionId)
							if ("error" in result) {
								sendError(ws, result.error)
								return
							}
							broadcast(roomCode, {
								type: "act_changed",
								newAct: result.newAct,
							})
							break
						}

						case "end_room": {
							if (!sessionId) {
								sendError(ws, "Not joined")
								return
							}
							const result = roomManager.endRoom(roomCode, sessionId)
							if ("error" in result) {
								sendError(ws, result.error)
								return
							}
							broadcast(roomCode, { type: "room_ended" })
							break
						}
					}
				},

				onClose() {
					if (connection) {
						getConnections(roomCode).delete(connection)
					}
					if (sessionId) {
						roomManager.setPlayerConnected(roomCode, sessionId, false)
						const room = roomManager.getRoom(roomCode)
						if (room) {
							const player = room.players.find((p) => p.sessionId === sessionId)
							if (player) {
								broadcast(roomCode, {
									type: "player_disconnected",
									playerId: player.id,
								})
							}
						}
					}
				},
			}
		}),
	)
}
  • Step 2: Create roomManager singleton and update app.ts

Create packages/server/src/rooms/index.ts — a singleton module imported by both app.ts and handler.ts (avoids circular dependencies):

import { RoomManager } from "./room-manager"

export const roomManager = new RoomManager()

Replace packages/server/src/app.ts with:

import { Hono } from "hono"
import { cors } from "hono/cors"
import { createNodeWebSocket } from "@hono/node-ws"
import { zValidator } from "@hono/zod-validator"
import { z } from "zod"
import { roomManager } from "./rooms/index"

const app = new Hono()

app.use("*", cors())

const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })

app.get("/health", (c) => c.json({ status: "ok" }))

const createRoomSchema = z.object({
	displayName: z.string().min(1).max(20),
})

app.post("/rooms", zValidator("json", createRoomSchema), (c) => {
	const { displayName } = c.req.valid("json")
	const result = roomManager.createRoom(displayName)
	return c.json({ data: result })
})

export { app, injectWebSocket, upgradeWebSocket }

The handler.ts (Step 1) already imports roomManager from ../rooms/index.

  • Step 3: Update entry point to register WS routes

Modify packages/server/src/index.ts:

import { serve } from "@hono/node-server"
import { app, injectWebSocket } from "./app"
import { registerWebSocketRoutes } from "./ws/handler"
import { env } from "./env"

registerWebSocketRoutes()

const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
	console.log(`celebrate-esc server running on http://localhost:${info.port}`)
})

injectWebSocket(server)
  • Step 4: Verify server starts with WS support
cd packages/server
DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts &

# Test HTTP room creation (direct server access — no /api prefix)
curl -X POST http://localhost:3001/rooms \
  -H "Content-Type: application/json" \
  -d '{"displayName": "TestHost"}'
# Expected: {"data":{"code":"XXXX","sessionId":"uuid"}}

kill %1
  • Step 5: Commit
git add packages/server/src/ws/ packages/server/src/rooms/index.ts packages/server/src/app.ts packages/server/src/index.ts
git commit -m "add WebSocket handler, HTTP room creation, message routing"

Task 16: WebSocket integration test

Files:

  • Create: packages/server/tests/ws-handler.test.ts

  • Step 1: Write WebSocket integration test

This test starts the server and tests the WebSocket flow end-to-end using the native WebSocket client.

import { describe, expect, it, afterEach, beforeEach } from "vitest"
import { serve } from "@hono/node-server"
import type { Server } from "node:http"
import { app, injectWebSocket } from "../src/app"
import { registerWebSocketRoutes } from "../src/ws/handler"
import { roomManager } from "../src/rooms/index"

// Register WS routes once
registerWebSocketRoutes()

let server: Server

function waitForMessage(ws: WebSocket): Promise<unknown> {
	return new Promise((resolve) => {
		ws.addEventListener("message", (event) => {
			resolve(JSON.parse(event.data as string))
		}, { once: true })
	})
}

function waitForOpen(ws: WebSocket): Promise<void> {
	return new Promise((resolve) => {
		if (ws.readyState === WebSocket.OPEN) {
			resolve()
		} else {
			ws.addEventListener("open", () => resolve(), { once: true })
		}
	})
}

describe("WebSocket handler", () => {
	let port: number

	beforeEach(async () => {
		roomManager.reset()
		port = 3100 + Math.floor(Math.random() * 900)
		server = serve({ fetch: app.fetch, port })
		injectWebSocket(server)
	})

	afterEach(() => {
		server.close()
	})

	it("creates a room via HTTP and connects via WebSocket", async () => {
		// Create room via HTTP
		const res = await fetch(`http://localhost:${port}/rooms`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ displayName: "Host" }),
		})
		const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
		expect(data.code).toMatch(/^[A-Z0-9]{4}$/)

		// Connect as host via WebSocket
		const ws = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
		await waitForOpen(ws)

		const msg = await waitForMessage(ws) as { type: string; room: { code: string } }
		expect(msg.type).toBe("room_state")
		expect(msg.room.code).toBe(data.code)

		ws.close()
	})

	it("player joins room via WebSocket", async () => {
		// Create room
		const res = await fetch(`http://localhost:${port}/rooms`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ displayName: "Host" }),
		})
		const { data } = (await res.json()) as { data: { code: string; sessionId: string } }

		// Connect host
		const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
		await waitForOpen(hostWs)
		await waitForMessage(hostWs) // room_state

		// Connect player (no sessionId)
		const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`)
		await waitForOpen(playerWs)
		await waitForMessage(playerWs) // initial room_state

		// Player sends join_room
		playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" }))

		// Player receives room_state with sessionId
		const playerMsg = await waitForMessage(playerWs) as { type: string; sessionId?: string }
		expect(playerMsg.type).toBe("room_state")
		expect(playerMsg.sessionId).toBeDefined()

		// Host receives player_joined broadcast
		const hostMsg = await waitForMessage(hostWs) as { type: string; player: { displayName: string } }
		expect(hostMsg.type).toBe("player_joined")
		expect(hostMsg.player.displayName).toBe("Player 1")

		hostWs.close()
		playerWs.close()
	})
})
  • Step 2: Run integration tests
cd packages/server && bun test tests/ws-handler.test.ts

Expected: all tests PASS.

Note: if WebSocket tests are flaky due to timing, add small delays (await new Promise(r => setTimeout(r, 50))). But prefer the event-based waitForMessage approach.

  • Step 3: Run all tests
cd packages/server && bun test

Expected: all tests (room-manager + ws-handler) PASS.

  • Step 4: Commit
git add packages/server/tests/ws-handler.test.ts
git commit -m "add WebSocket integration tests"

Chunk 5: Client Foundation & Views

Task 17: Tailwind v4 + shadcn/ui setup

Files:

  • Modify: packages/client/package.json (add shadcn deps)

  • Create: packages/client/components.json

  • Create: packages/client/src/lib/utils.ts

  • Step 1: Add Vite React plugin dependency

Add to packages/client/package.json devDependencies:

"@vitejs/plugin-react": "latest"

Then run bun install from the workspace root.

  • Step 2: Initialize shadcn/ui
cd packages/client
bunx shadcn@latest init

When prompted:

  • Style: Default
  • Base color: Neutral
  • CSS variables: Yes
  • CSS file: src/app.css
  • Tailwind config: (none — Tailwind v4 uses CSS)
  • Components: src/components/ui
  • Utils: src/lib/utils

If the interactive init doesn't work well, create the files manually:

Create packages/client/components.json:

{
	"$schema": "https://ui.shadcn.com/schema.json",
	"style": "default",
	"rsc": false,
	"tsx": true,
	"tailwind": {
		"config": "",
		"css": "src/app.css",
		"baseColor": "neutral",
		"cssVariables": true
	},
	"aliases": {
		"components": "@/components",
		"utils": "@/lib/utils",
		"ui": "@/components/ui",
		"lib": "@/lib",
		"hooks": "@/hooks"
	},
	"iconLibrary": "lucide"
}

Create packages/client/src/lib/utils.ts:

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
	return twMerge(clsx(inputs))
}

Add path aliases to packages/client/tsconfig.json:

{
	"extends": "../../tsconfig.json",
	"compilerOptions": {
		"jsx": "react-jsx",
		"outDir": "dist",
		"rootDir": "src",
		"types": ["vite/client"],
		"baseUrl": ".",
		"paths": {
			"@/*": ["./src/*"]
		}
	},
	"include": ["src"]
}

Add path resolution to packages/client/vite.config.ts:

import path from "node:path"
import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
	plugins: [TanStackRouterVite(), react(), tailwindcss()],
	resolve: {
		alias: {
			"@": path.resolve(__dirname, "./src"),
		},
	},
	server: {
		proxy: {
			"/api": {
				target: "http://localhost:3001",
				changeOrigin: true,
				ws: true,
			},
		},
	},
})
  • Step 3: Add required dependencies
cd celebrate-esc  # project root
bun add --cwd packages/client clsx tailwind-merge lucide-react
  • Step 4: Install shadcn/ui components for Plan 1
cd packages/client
bunx shadcn@latest add button input card badge tabs
  • Step 5: Verify client builds
cd packages/client && bun run build

Expected: build succeeds, output in dist/.

  • Step 6: Commit
git add packages/client/
bun install  # Ensure lockfile is updated
git add bun.lockb
git commit -m "set up Tailwind v4, shadcn/ui, path aliases"

Task 18: TanStack Router + routes

Files:

  • Modify: packages/client/src/main.tsx

  • Create: packages/client/src/routes/__root.tsx

  • Create: packages/client/src/routes/index.tsx

  • Create: packages/client/src/routes/display.$roomCode.tsx

  • Create: packages/client/src/routes/host.$roomCode.tsx

  • Create: packages/client/src/routes/play.$roomCode.tsx

  • Step 1: Update main.tsx with router

import "./app.css"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"

const router = createRouter({ routeTree })

declare module "@tanstack/react-router" {
	interface Register {
		router: typeof router
	}
}

createRoot(document.getElementById("root")!).render(
	<StrictMode>
		<RouterProvider router={router} />
	</StrictMode>,
)
  • Step 2: Create root layout

packages/client/src/routes/__root.tsx:

import { createRootRoute, Outlet } from "@tanstack/react-router"

export const Route = createRootRoute({
	component: () => (
		<div className="min-h-screen bg-background text-foreground">
			<Outlet />
		</div>
	),
})
  • Step 3: Create landing page route (placeholder)

packages/client/src/routes/index.tsx:

import { createFileRoute } from "@tanstack/react-router"

export const Route = createFileRoute("/")({
	component: LandingPage,
})

function LandingPage() {
	return (
		<div className="flex min-h-screen items-center justify-center p-4">
			<h1 className="text-4xl font-bold">ESC Party</h1>
		</div>
	)
}
  • Step 4: Create display route (placeholder)

packages/client/src/routes/display.$roomCode.tsx:

import { createFileRoute } from "@tanstack/react-router"

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

function DisplayView() {
	const { roomCode } = Route.useParams()
	return (
		<div className="flex min-h-screen items-center justify-center p-4">
			<h1 className="text-4xl font-bold">Display  Room {roomCode}</h1>
		</div>
	)
}
  • Step 5: Create host route (placeholder)

packages/client/src/routes/host.$roomCode.tsx:

import { createFileRoute } from "@tanstack/react-router"

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

function HostView() {
	const { roomCode } = Route.useParams()
	return (
		<div className="p-4">
			<h1 className="text-2xl font-bold">Host  Room {roomCode}</h1>
		</div>
	)
}
  • Step 6: Create player route (placeholder)

packages/client/src/routes/play.$roomCode.tsx:

import { createFileRoute } from "@tanstack/react-router"

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

function PlayerView() {
	const { roomCode } = Route.useParams()
	return (
		<div className="p-4">
			<h1 className="text-2xl font-bold">Player  Room {roomCode}</h1>
		</div>
	)
}
  • Step 7: Verify routes work
cd packages/client && bun dev &
# Visit http://localhost:5173 — see "ESC Party"
# Visit http://localhost:5173/display/TEST — see "Display — Room TEST"
# Visit http://localhost:5173/host/TEST — see "Host — Room TEST"
# Visit http://localhost:5173/play/TEST — see "Player — Room TEST"
kill %1
  • Step 8: Commit
git add packages/client/src/
git commit -m "set up TanStack Router with landing, display, host, player routes"

Task 19: Zustand store + WebSocket hook

Files:

  • Create: packages/client/src/stores/room-store.ts

  • Create: packages/client/src/hooks/use-websocket.ts

  • Step 1: Create room store

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

interface RoomStore {
	room: RoomState | null
	mySessionId: string | null
	connectionStatus: "disconnected" | "connecting" | "connected"

	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
	reset: () => void
}

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

	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
			// Avoid duplicates
			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 } }
		}),

	reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected" }),
}))
  • Step 2: Create WebSocket hook
import { useEffect, useRef, useCallback } from "react"
import type { ClientMessage, ServerMessage } from "@celebrate-esc/shared"
import { useRoomStore } from "@/stores/room-store"

const SESSION_KEY = "esc-party-session"

function getStoredSession(): { roomCode: string; sessionId: string } | null {
	try {
		const raw = sessionStorage.getItem(SESSION_KEY)
		if (!raw) return null
		return JSON.parse(raw)
	} catch {
		return null
	}
}

function storeSession(roomCode: string, sessionId: string) {
	sessionStorage.setItem(SESSION_KEY, JSON.stringify({ roomCode, sessionId }))
}

export function useWebSocket(roomCode: string) {
	const wsRef = useRef<WebSocket | null>(null)
	const {
		setRoom,
		setMySessionId,
		setConnectionStatus,
		updatePlayerConnected,
		addPlayer,
		setAct,
		reset,
	} = useRoomStore()

	const send = useCallback((message: ClientMessage) => {
		if (wsRef.current?.readyState === WebSocket.OPEN) {
			wsRef.current.send(JSON.stringify(message))
		}
	}, [])

	useEffect(() => {
		const stored = getStoredSession()
		const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null

		const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
		const wsUrl = sessionId
			? `${protocol}//${window.location.host}/api/ws/${roomCode}?sessionId=${sessionId}`
			: `${protocol}//${window.location.host}/api/ws/${roomCode}`

		setConnectionStatus("connecting")
		const ws = new WebSocket(wsUrl)
		wsRef.current = ws

		ws.onopen = () => {
			setConnectionStatus("connected")
		}

		ws.onmessage = (event) => {
			const msg = JSON.parse(event.data) as ServerMessage

			switch (msg.type) {
				case "room_state": {
					setRoom(msg.room)
					if (msg.sessionId) {
						setMySessionId(msg.sessionId)
						storeSession(roomCode, msg.sessionId)
					} else if (sessionId) {
						// Reconnected with stored session
						setMySessionId(sessionId)
					}
					break
				}
				case "player_joined":
					addPlayer(msg.player)
					break
				case "player_disconnected":
					updatePlayerConnected(msg.playerId, false)
					break
				case "player_reconnected":
					updatePlayerConnected(msg.playerId, true)
					break
				case "act_changed":
					setAct(msg.newAct)
					break
				case "room_ended":
					setAct("ended")
					break
				case "error":
					console.error("Server error:", msg.message)
					break
			}
		}

		ws.onclose = () => {
			setConnectionStatus("disconnected")
		}

		return () => {
			ws.close()
			reset()
		}
	}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset])

	return { send }
}
  • Step 3: Commit
git add packages/client/src/stores/ packages/client/src/hooks/
git commit -m "add Zustand room store, WebSocket connection hook"

Note for Plan 5 (Polish): The current WebSocket hook does not auto-reconnect on connection drops. For a party app where phones may lose Wi-Fi momentarily, add reconnection with exponential backoff in Plan 5.


Task 20: Landing page (create + join room)

Files:

  • Modify: packages/client/src/routes/index.tsx

  • Step 1: Implement landing page

import { useState } from "react"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useRoomStore } from "@/stores/room-store"

export const Route = createFileRoute("/")({
	component: LandingPage,
})

function LandingPage() {
	return (
		<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-4">
			<h1 className="text-5xl font-bold tracking-tight">ESC Party</h1>
			<p className="text-muted-foreground text-lg">Eurovision Song Contest  Party Companion</p>
			<div className="flex flex-col gap-6 sm:flex-row">
				<CreateRoomCard />
				<JoinRoomCard />
			</div>
		</div>
	)
}

function CreateRoomCard() {
	const [displayName, setDisplayName] = useState("")
	const [loading, setLoading] = useState(false)
	const [error, setError] = useState("")
	const navigate = useNavigate()
	const setMySessionId = useRoomStore((s) => s.setMySessionId)

	async function handleCreate() {
		if (!displayName.trim()) return
		setLoading(true)
		setError("")

		try {
			const res = await fetch("/api/rooms", {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ displayName: displayName.trim() }),
			})
			const { data } = (await res.json()) as { data: { code: string; sessionId: string } }

			// Store session for reconnection
			sessionStorage.setItem("esc-party-session", JSON.stringify({
				roomCode: data.code,
				sessionId: data.sessionId,
			}))
			setMySessionId(data.sessionId)

			navigate({ to: "/host/$roomCode", params: { roomCode: data.code } })
		} catch {
			setError("Failed to create room")
		} finally {
			setLoading(false)
		}
	}

	return (
		<Card className="w-80">
			<CardHeader>
				<CardTitle>Host a Party</CardTitle>
			</CardHeader>
			<CardContent className="flex flex-col gap-3">
				<Input
					placeholder="Your name"
					value={displayName}
					onChange={(e) => setDisplayName(e.target.value)}
					maxLength={20}
					onKeyDown={(e) => e.key === "Enter" && handleCreate()}
				/>
				<Button onClick={handleCreate} disabled={!displayName.trim() || loading}>
					{loading ? "Creating..." : "Create Room"}
				</Button>
				{error && <p className="text-sm text-destructive">{error}</p>}
			</CardContent>
		</Card>
	)
}

function JoinRoomCard() {
	const [roomCode, setRoomCode] = useState("")
	const [displayName, setDisplayName] = useState("")
	const navigate = useNavigate()

	function handleJoin() {
		if (!roomCode.trim() || !displayName.trim()) return
		// Store display name temporarily — will be sent via WebSocket on connect
		sessionStorage.setItem("esc-party-join-name", displayName.trim())
		navigate({ to: "/play/$roomCode", params: { roomCode: roomCode.trim().toUpperCase() } })
	}

	return (
		<Card className="w-80">
			<CardHeader>
				<CardTitle>Join a Party</CardTitle>
			</CardHeader>
			<CardContent className="flex flex-col gap-3">
				<Input
					placeholder="Room code"
					value={roomCode}
					onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
					maxLength={4}
					className="text-center text-2xl tracking-widest"
				/>
				<Input
					placeholder="Your name"
					value={displayName}
					onChange={(e) => setDisplayName(e.target.value)}
					maxLength={20}
					onKeyDown={(e) => e.key === "Enter" && handleJoin()}
				/>
				<Button onClick={handleJoin} disabled={roomCode.length !== 4 || !displayName.trim()}>
					Join Room
				</Button>
			</CardContent>
		</Card>
	)
}
  • Step 2: Commit
git add packages/client/src/routes/index.tsx
git commit -m "implement landing page with create + join room forms"

Task 21: Shared components

Files:

  • Create: packages/client/src/components/player-list.tsx

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

  • Step 1: Create player list component

import { Badge } from "@/components/ui/badge"
import type { Player } from "@celebrate-esc/shared"

interface PlayerListProps {
	players: Player[]
	mySessionId: string | null
}

export function PlayerList({ players, mySessionId }: PlayerListProps) {
	return (
		<div className="flex flex-col gap-2">
			<h3 className="text-sm font-medium text-muted-foreground">
				Players ({players.length})
			</h3>
			<ul className="flex flex-col gap-1">
				{players.map((player) => (
					<li key={player.id} className="flex items-center gap-2">
						<span
							className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
						/>
						<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
							{player.displayName}
						</span>
						{player.isHost && (
							<Badge variant="secondary" className="text-xs">
								Host
							</Badge>
						)}
						{player.sessionId === mySessionId && (
							<span className="text-xs text-muted-foreground">(you)</span>
						)}
					</li>
				))}
			</ul>
		</div>
	)
}
  • Step 2: Create room header component
import { Badge } from "@/components/ui/badge"
import type { Act } from "@celebrate-esc/shared"

interface RoomHeaderProps {
	roomCode: string
	currentAct: Act
	connectionStatus: "disconnected" | "connecting" | "connected"
}

const actLabels: Record<Act, string> = {
	lobby: "Lobby",
	act1: "Act 1",
	act2: "Act 2",
	act3: "Act 3",
	ended: "Ended",
}

export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) {
	return (
		<div className="flex items-center justify-between border-b p-4">
			<div className="flex items-center gap-3">
				<span className="font-mono text-2xl font-bold tracking-widest">{roomCode}</span>
				<Badge variant="outline">{actLabels[currentAct]}</Badge>
			</div>
			<span
				className={`h-2 w-2 rounded-full ${
					connectionStatus === "connected"
						? "bg-green-500"
						: connectionStatus === "connecting"
							? "bg-yellow-500"
							: "bg-red-500"
				}`}
				title={connectionStatus}
			/>
		</div>
	)
}
  • Step 3: Commit
git add packages/client/src/components/player-list.tsx packages/client/src/components/room-header.tsx
git commit -m "add player list, room header components"

Task 22: Display view

Files:

  • Modify: packages/client/src/routes/display.$roomCode.tsx

  • Step 1: Implement display view

The display shows the room code prominently (for joining), player list, and current act. During lobby, it shows a large room code + QR code placeholder.

import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { RoomHeader } from "@/components/room-header"

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

function DisplayView() {
	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 (
		<div className="flex min-h-screen flex-col">
			<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
			<div className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
				{room.currentAct === "lobby" && <LobbyDisplay roomCode={roomCode} />}
				<PlayerList players={room.players} mySessionId={null} />
			</div>
		</div>
	)
}

function LobbyDisplay({ roomCode }: { roomCode: string }) {
	const joinUrl = `${window.location.origin}/play/${roomCode}`

	return (
		<div className="flex flex-col items-center gap-6">
			<h2 className="text-2xl text-muted-foreground">Join the party!</h2>
			<div className="rounded-lg border-4 border-dashed border-muted p-8">
				<span className="font-mono text-8xl font-bold tracking-[0.3em]">{roomCode}</span>
			</div>
			<p className="text-muted-foreground">
				Go to <span className="font-mono font-medium">{joinUrl}</span>
			</p>
			<p className="text-sm text-muted-foreground">or scan the QR code</p>
			{/* QR code will be added in Plan 5 (polish) */}
			<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-muted">
				<span className="text-sm text-muted-foreground">QR code</span>
			</div>
		</div>
	)
}
  • Step 2: Commit
git add packages/client/src/routes/display.\$roomCode.tsx
git commit -m "implement display view with lobby screen, player list"

Task 23: Host view

Files:

  • Modify: packages/client/src/routes/host.$roomCode.tsx

  • Step 1: Implement host view

The host view has two tabs: Play (same as player view — games added later) and Host (control panel). For Plan 1, the Play tab shows the player list and the Host tab shows advance act + end room buttons.

import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { RoomHeader } from "@/components/room-header"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import type { Act } from "@celebrate-esc/shared"

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

const nextActLabels: Partial<Record<Act, string>> = {
	lobby: "Start Act 1",
	act1: "Start Act 2",
	act2: "Start Act 3",
	act3: "End Party",
}

function HostView() {
	const { roomCode } = Route.useParams()
	const { send } = useWebSocket(roomCode)
	const { room, mySessionId, 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 (
		<div className="flex min-h-screen flex-col">
			<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
			<Tabs defaultValue="host" className="flex-1">
				<TabsList className="w-full rounded-none">
					<TabsTrigger value="play" className="flex-1">
						Play
					</TabsTrigger>
					<TabsTrigger value="host" className="flex-1">
						Host
					</TabsTrigger>
				</TabsList>
				<TabsContent value="play" className="p-4">
					<PlayerList players={room.players} mySessionId={mySessionId} />
					{/* Game UI will be added in later plans */}
				</TabsContent>
				<TabsContent value="host" className="p-4">
					<div className="flex flex-col gap-4">
						<Card>
							<CardHeader>
								<CardTitle>Room Controls</CardTitle>
							</CardHeader>
							<CardContent className="flex flex-col gap-3">
								{room.currentAct !== "ended" && (
									<Button
										onClick={() => send({ type: "advance_act" })}
										className="w-full"
									>
										{nextActLabels[room.currentAct] ?? "Next"}
									</Button>
								)}
								{room.currentAct !== "ended" && (
									<Button
										variant="destructive"
										onClick={() => send({ type: "end_room" })}
										className="w-full"
									>
										End Party
									</Button>
								)}
								{room.currentAct === "ended" && (
									<p className="text-center text-muted-foreground">
										The party has ended. Thanks for playing!
									</p>
								)}
							</CardContent>
						</Card>
						<PlayerList players={room.players} mySessionId={mySessionId} />
					</div>
				</TabsContent>
			</Tabs>
		</div>
	)
}
  • Step 2: Commit
git add packages/client/src/routes/host.\$roomCode.tsx
git commit -m "implement host view with play + host tabs, room controls"

Task 24: Player view

Files:

  • Modify: packages/client/src/routes/play.$roomCode.tsx

  • Step 1: Implement player view

The player connects to the WebSocket and sends join_room with the display name stored in sessionStorage from the landing page. Subsequent visits reconnect via session token.

import { useEffect, useRef, useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { useWebSocket } from "@/hooks/use-websocket"
import { useRoomStore } from "@/stores/room-store"
import { PlayerList } from "@/components/player-list"
import { RoomHeader } from "@/components/room-header"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

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

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

	// Auto-send join_room when connected for the first time (no existing session)
	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>
		)
	}

	// Fallback: if no stored display name and no session (e.g., direct URL access),
	// show a name input form
	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 (
		<div className="flex min-h-screen flex-col">
			<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
			<div className="flex-1 p-4">
				{room.currentAct === "lobby" && (
					<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>
				)}
				{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>
				)}
				{/* Game UI will be added in later plans */}
				<PlayerList players={room.players} mySessionId={mySessionId} />
			</div>
		</div>
	)
}
  • Step 2: Commit
git add packages/client/src/routes/play.\$roomCode.tsx
git commit -m "implement player view with auto-join, reconnection support"

Task 25: End-to-end manual smoke test

  • Step 1: Start the server
cd packages/server
DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts &
  • Step 2: Start the client
cd packages/client
bun dev &
  • Step 3: Test the full flow
  1. Open http://localhost:5173 in a browser
  2. Enter a display name, click "Create Room"
  3. Verify you're redirected to /host/:roomCode
  4. Copy the room code
  5. Open http://localhost:5173/display/:roomCode in another tab (simulating TV)
  6. Verify the display shows the room code and the host in the player list
  7. Open http://localhost:5173 in an incognito window
  8. Enter the room code + a display name, click "Join Room"
  9. Verify the player appears in both the host and display views
  10. On the host view, click "Start Act 1"
  11. Verify all views update to show "Act 1"
  12. Refresh the player's browser tab
  13. Verify the player reconnects and sees the current state
  • Step 4: Fix any issues found during smoke test

Address bugs, then commit fixes.

  • Step 5: Run all tests
cd celebrate-esc  # project root
bun test
  • Step 6: Run linter
biome check .

Fix any issues, then commit.

  • Step 7: Final commit
git add -A
git commit -m "fix issues from smoke test, pass lint"

Summary

After completing this plan, you have:

  • A working Bun workspace monorepo with shared, server, and client packages
  • Shared Zod schemas for room state and WebSocket messages
  • Hono server with WebSocket support and PostgreSQL persistence
  • Room system: create, join, reconnect, advance acts, end room
  • React client with TanStack Router, Zustand, and real-time WebSocket updates
  • Four views: landing, display, host (with tabs), player
  • Unit tests for room manager, integration tests for WebSocket handler

Next plan: Act 1 Games (Predictions + Dish of the Nation) — adds the first game logic on top of this foundation.