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-wsfor runtime-agnostic WebSocket (Node 22 on Uberspace)
Plan Series
This is Plan 1 of 5:
- Foundation + Room System ← this plan
- Act 1 Games (Predictions + Dishes)
- Act 2 Games (Jury Voting + Bingo)
- Act 3 Games (Quiz + Final Leaderboard)
- 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
.envfor 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
- Open
http://localhost:5173in a browser - Enter a display name, click "Create Room"
- Verify you're redirected to
/host/:roomCode - Copy the room code
- Open
http://localhost:5173/display/:roomCodein another tab (simulating TV) - Verify the display shows the room code and the host in the player list
- Open
http://localhost:5173in an incognito window - Enter the room code + a display name, click "Join Room"
- Verify the player appears in both the host and display views
- On the host view, click "Start Act 1"
- Verify all views update to show "Act 1"
- Refresh the player's browser tab
- 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.