From c905e9dc92b4a3aae681280404be5081cd974ad8 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:43:13 +0800 Subject: [PATCH 01/16] docs: add discovery notes and project constitution Document starter gaps, assumptions, and relevant files before implementation. Establish engineering principles and AI usage rules. Co-authored-by: Cursor --- .specify/memory/constitution.md | 57 +++++++++++++++++++++++++++++++++ docs/discovery.md | 39 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 .specify/memory/constitution.md create mode 100644 docs/discovery.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..29c3b43b --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,57 @@ +# Scribble Lab Constitution + +Engineering principles and AI-assisted development rules for the Scribble brownfield enhancement lab. + +## Engineering Principles + +1. **Extend, don't rewrite** — build on the starter's existing structure (Express routes, React pages, roomStore pattern). Do not replace working scaffolding. +2. **TypeScript first** — all new code is fully typed. Use Zod for backend validation. Avoid `any`. +3. **Immutability** — prefer pure functions and immutable updates in the room store. Clone rooms on read/write. +4. **Fail fast** — validate inputs at the API boundary. Return clear HTTP error messages. Never silently coerce invalid data. +5. **Minimal memory footprint** — rooms live in a Map; no database. Keep room objects lean; remove inactive rooms when appropriate. +6. **Polling-only sync** — all multi-player synchronization uses HTTP polling (~2s). No WebSockets, Socket.io, or push protocols. +7. **Deterministic game rules** — word selection, scoring, and role assignment must produce the same result given the same room state. No randomness that diverges across polls. +8. **Viewer-specific responses** — `GET /rooms/:code` returns different fields based on `participantId` query param (e.g. secret word visible only to drawer). + +## Out of Scope (Do Not Build) + +- WebSockets, databases, authentication, deployment/CI/Docker +- Multiple rounds, drawer rotation, timers, custom word packs +- Spectator mode, moderation, room passwords +- New state-management or routing libraries +- Unjustified top-level dependencies or unrelated refactors + +## AI Usage Rules + +1. **Spec before code** — every feature group gets updated spec, plan, and tasks artifacts before implementation begins. +2. **One scenario at a time** — complete specify → plan → tasks → implement → validate for each phase before moving to the next. +3. **Review all AI output** — read every generated diff. Verify it matches the spec and does not introduce out-of-scope dependencies. +4. **Granular commits** — each commit maps to a single logical change (one artifact set, one backend slice, one frontend slice). Commit messages describe the "why". +5. **No AI attribution in commits** — commit messages are professional and describe the change, not the tool used to produce it. +6. **Validate with two browser tabs** — every phase must be manually verified with two tabs simulating two players before committing. + +## Review Discipline + +Before each commit: + +- [ ] Code matches the current spec's acceptance criteria +- [ ] No forbidden technologies (WebSockets, DB, auth) in code or dependencies +- [ ] Backend and frontend build successfully (`npm run build`) +- [ ] Existing tests pass (`npm test`) +- [ ] No unrelated changes or drive-by refactors +- [ ] Types are consistent between backend model and frontend API types + +## Testing Expectations + +- Extend existing Vitest tests for new backend logic (roomStore, schemas) +- Extend frontend API tests for new endpoints +- Manual two-tab validation for each scenario +- Run pre-evaluation check before opening PR + +## File Conventions + +- Backend: `src/api/` (routes), `src/services/` (logic), `src/models/` (types) +- Frontend: `src/pages/` (screens), `src/components/` (UI), `src/state/` (store), `src/services/` (API) +- Artifacts: `.specify/memory/constitution.md`, `specs/NNN-feature-name/{spec,plan,tasks}.md` +- Discovery: `docs/discovery.md` +- Reflection: `reflection.md` at repo root diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..c04102e7 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,39 @@ +# Discovery Notes + +Discovery performed against the Scribble starter on a clean clone before any implementation work. + +## Incomplete Behaviors (≥3) + +1. **No host tracking** — `createRoom` adds a participant but never records who created the room. The lobby shows a "Start Game" button to every player with no permission check. +2. **No automatic polling** — the lobby only updates participant lists when the user clicks "Refresh Room". Multi-player sync requires manual action. +3. **Viewer-agnostic room snapshots** — `toRoomSnapshot` accepts `viewerParticipantId` but ignores it (`void viewerParticipantId`), so role-specific data (e.g. secret word for drawer only) cannot be implemented without changes. +4. **Empty player names silently default** — `displayName()` in `roomStore.ts` converts blank/undefined names to `"Player"` with no validation or user feedback. +5. **Game screen is entirely placeholder** — canvas is a styled div, guess form submit is a no-op, scoreboard and result panel show static text. + +## Assumptions + +1. **HTTP polling is the sole sync mechanism** — all room state (lobby participants, game drawing, guesses, scores) will sync via periodic `GET /rooms/:code?participantId=...` calls at ~2 second intervals. No WebSockets. +2. **Host equals room creator** — the participant returned by `createRoom` is permanently the host for that room's lifetime. Host-only actions: start game, restart game. +3. **Deterministic word selection** — the secret word will be chosen deterministically from the starter word list (index derived from room code hash), not randomly on each request, so all clients converge on the same word. +4. **Single round per game session** — one drawer, one word, one round. No rotation or multi-round logic (explicitly out of scope). + +## Relevant Files + +| Area | Path | Role | +|------|------|------| +| Backend model | `backend/src/models/game.ts` | Room, Participant, RoomSnapshot types | +| Backend store | `backend/src/services/roomStore.ts` | In-memory room CRUD, snapshot builder | +| Backend routes | `backend/src/api/rooms.ts` | REST endpoints | +| Backend validation | `backend/src/api/schemas.ts` | Zod request schemas | +| Backend seed | `backend/src/seed/starterData.ts` | Word list and roles | +| Frontend API | `frontend/src/services/api.ts` | HTTP client | +| Frontend state | `frontend/src/state/roomStore.ts` | Room session store | +| Frontend pages | `frontend/src/pages/LobbyPage.tsx` | Lobby UI | +| Frontend pages | `frontend/src/pages/CreateRoomPage.tsx` | Create room form | +| Frontend pages | `frontend/src/pages/JoinRoomPage.tsx` | Join room form | +| Frontend pages | `frontend/src/pages/GamePage.tsx` | Game screen (placeholder) | +| Frontend components | `frontend/src/components/GuessForm.tsx` | Guess input (no-op) | + +## Starter Bug Found + +- `frontend/src/services/api.ts` defaults API base URL to `http://localhost:3001/bug` instead of `http://localhost:3001`. Will fix in Phase 1. From e13df5d753dde054e01d3530e95a5a300e822b1b Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:43:19 +0800 Subject: [PATCH 02/16] docs: add phase 1 spec, plan, and tasks for room setup Define acceptance criteria for host tracking, join validation, lobby polling, and host-only game start (Scenario 1). Co-authored-by: Cursor --- specs/001-room-setup/plan.md | 84 ++++++++++++++++++++++++++++++ specs/001-room-setup/spec.md | 98 +++++++++++++++++++++++++++++++++++ specs/001-room-setup/tasks.md | 72 +++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 specs/001-room-setup/plan.md create mode 100644 specs/001-room-setup/spec.md create mode 100644 specs/001-room-setup/tasks.md diff --git a/specs/001-room-setup/plan.md b/specs/001-room-setup/plan.md new file mode 100644 index 00000000..ee1b8260 --- /dev/null +++ b/specs/001-room-setup/plan.md @@ -0,0 +1,84 @@ +# Plan: Room Setup & Lobby (Scenario 1) + +## Findings from Discovery + +The starter provides working create/join/fetch flows with in-memory storage. Key gaps: no hostId, no polling, no start endpoint, viewerParticipantId ignored in snapshots. Frontend API URL has a `/bug` suffix bug. + +## State Model Changes + +### Room (backend) +```typescript +interface Room { + code: string; + status: "lobby" | "playing" | "result"; // extend from "lobby" only + hostId: string; // NEW: creator's participantId + participants: Participant[]; + createdAt: string; + updatedAt: string; +} +``` + +### RoomSnapshot (shared) +Add `hostId: string` to snapshot. Status union extended. No game-round fields yet (Phase 2). + +## Data Flow + +``` +Create Room: + POST /rooms { playerName } → createRoom() → sets hostId = participant.id → snapshot + +Join Room: + POST /rooms/:code/join { playerName } → validates code exists → adds participant + +Lobby Polling (frontend): + useEffect interval 2000ms → fetchRoom(code, participantId) → update store → re-render participant list + +Start Game: + POST /rooms/:code/start { participantId } → validates host + lobby + ≥2 players + → room.status = "playing" → all pollers detect status change → navigate to /game +``` + +## File-Level Changes + +### Backend +| File | Change | +|------|--------| +| `models/game.ts` | Add `hostId` to Room/RoomSnapshot; extend RoomStatus | +| `services/roomStore.ts` | Set hostId on create; add `startGame(code, participantId)` | +| `api/schemas.ts` | Add room code format validation; add startGameSchema | +| `api/rooms.ts` | Add POST `/:code/start`; improve join error messages | + +### Frontend +| File | Change | +|------|--------| +| `services/api.ts` | Fix API URL; add `startRoom()`; extend RoomSnapshot type | +| `state/roomStore.ts` | Add `startGame()` method | +| `pages/LobbyPage.tsx` | Add 2s polling; host-only start button; host badge; auto-navigate on status change | +| `pages/JoinRoomPage.tsx` | Validate room code format before submit | + +## Implementation Sequence + +1. Fix frontend API base URL +2. Extend backend model with hostId and status union +3. Update createRoom to set hostId +4. Add startGame service function with validation +5. Add start endpoint and improve join validation +6. Update backend tests +7. Extend frontend types and API client +8. Add lobby polling and host-only start UI +9. Add join form validation +10. Manual two-tab validation + +## Risks + +| Risk | Mitigation | +|------|------------| +| Polling continues after unmount | Clear interval in useEffect cleanup | +| Race: start + poll | Status check is atomic in single-threaded Node | +| Type drift frontend/backend | Mirror types in api.ts from backend model | + +## Testing Strategy + +- Backend unit tests: hostId set on create, startGame rejects non-host, startGame rejects <2 players +- Frontend: existing API tests extended for startRoom +- Manual: two-tab lobby flow per acceptance criteria diff --git a/specs/001-room-setup/spec.md b/specs/001-room-setup/spec.md new file mode 100644 index 00000000..7f93a25c --- /dev/null +++ b/specs/001-room-setup/spec.md @@ -0,0 +1,98 @@ +# Feature Spec: Room Setup & Lobby (Scenario 1) + +## Overview + +Enhance the starter's room creation and lobby flow with host tracking, input validation, automatic polling, and host-only game start gated on minimum player count. + +## User Stories + +### US-1: Host is room creator +**As** a player who creates a room, **I want** to be automatically designated as the host, **so that** only I can start the game. + +**Acceptance Criteria:** +- [ ] `POST /rooms` sets `hostId` to the creator's `participantId` +- [ ] Room snapshot includes `hostId` field visible to all participants +- [ ] Lobby UI identifies the host participant (badge or label) + +### US-2: Join validation with clear feedback +**As** a player joining a room, **I want** invalid inputs rejected with clear messages, **so that** I know what went wrong. + +**Acceptance Criteria:** +- [ ] Empty or whitespace-only room code rejected on frontend before API call with message "Room code is required" +- [ ] Room code must be exactly 4 characters (matching generated format); invalid format rejected with clear message +- [ ] Non-existent room code returns 404 with message "Room not found" (not generic error) +- [ ] Frontend displays API error messages to the user + +### US-3: Room isolation +**As** multiple groups of players, **I want** rooms to be fully isolated, **so that** joining one room does not affect another. + +**Acceptance Criteria:** +- [ ] Each room has a unique 4-character code +- [ ] Participants in room A are not visible in room B +- [ ] Creating/joining room A does not modify room B state + +### US-4: Automatic lobby polling +**As** a player in the lobby, **I want** the participant list to update automatically, **so that** I see new players without clicking refresh. + +**Acceptance Criteria:** +- [ ] Lobby polls `GET /rooms/:code?participantId=...` every ~2 seconds while on lobby page +- [ ] Polling stops when navigating away from lobby +- [ ] Manual refresh button still works as fallback +- [ ] New participants appear within ~2 seconds of joining + +### US-5: Host-only start with minimum players +**As** the host, **I want** to start the game when at least 2 players are present, **so that** the game can begin. + +**Acceptance Criteria:** +- [ ] Only the host sees an enabled "Start Game" button +- [ ] Non-host players see a waiting message instead of start button +- [ ] Start button disabled when fewer than 2 participants +- [ ] `POST /rooms/:code/start` validates: caller is host, room status is "lobby", ≥2 participants +- [ ] Non-host calling start returns 403 with clear message +- [ ] Successful start changes room status to "playing" and all polling clients detect the transition +- [ ] All clients navigate to game screen when status becomes "playing" + +## Edge Cases + +| Case | Expected Behavior | +|------|-------------------| +| Host leaves room | Out of scope for Phase 1; host remains in participant list | +| Duplicate join by same browser tab | Each join creates a new participant (no dedup in starter) | +| Backend restart | All rooms lost (in-memory); expected behavior | +| Poll during start transition | Client detects status change on next poll and navigates | +| Invalid room code format "AB" | Rejected before API call | +| Room code with lowercase "abcd" | Normalized to uppercase before lookup | + +## Out of Scope (This Phase) + +- Player name trim/validation (Phase 2) +- Drawer assignment and secret word (Phase 2) +- Drawing, guessing, scoring (Phase 3) +- Result and restart (Phase 4) + +## API Changes + +### Extended Room Snapshot +```typescript +{ + code: string; + status: "lobby" | "playing" | "result"; + hostId: string; + participants: Participant[]; + availableWords: string[]; + roles: ParticipantRole[]; +} +``` + +### New Endpoint +- `POST /rooms/:code/start` — body: `{ participantId: string }` — host-only start + +## Validation Strategy + +1. Tab 1: Create room → verify host badge shown +2. Tab 2: Join with valid code → verify appears in Tab 1 within ~2s without manual refresh +3. Tab 2: Verify no Start Game button (non-host) +4. Tab 1: Verify Start Game disabled with 1 player, enabled with 2 +5. Tab 1: Click Start → both tabs navigate to game screen +6. Tab 3: Try joining invalid code "XY" → see error message +7. Create two separate rooms → verify isolation diff --git a/specs/001-room-setup/tasks.md b/specs/001-room-setup/tasks.md new file mode 100644 index 00000000..dee3d471 --- /dev/null +++ b/specs/001-room-setup/tasks.md @@ -0,0 +1,72 @@ +# Tasks: Room Setup & Lobby (Scenario 1) + +## Dependencies + +``` +T1 (model) → T2 (createRoom host) → T3 (startGame) → T4 (routes) +T5 (API fix) → T6 (frontend types) → T7 (lobby UI) +T4 + T7 → T8 (validation) +``` + +## Task List + +### T1: Extend backend model +- [ ] Add `hostId: string` to `Room` interface +- [ ] Extend `RoomStatus` to `"lobby" | "playing" | "result"` +- [ ] Add `hostId` to `RoomSnapshot` interface +- **Files:** `backend/src/models/game.ts` + +### T2: Set host on room creation +- [ ] In `createRoom`, set `hostId = participant.id` +- [ ] Include `hostId` in `toRoomSnapshot` output +- **Files:** `backend/src/services/roomStore.ts` + +### T3: Implement startGame service +- [ ] Add `startGame(code, participantId)` function +- [ ] Validate: room exists, caller is host, status is "lobby", ≥2 participants +- [ ] Set status to "playing", update timestamp +- [ ] Return updated room or throw appropriate errors +- **Files:** `backend/src/services/roomStore.ts` + +### T4: Add backend routes and validation +- [ ] Add room code format validation (4 alphanumeric chars) in schemas +- [ ] Add `startGameSchema` with participantId +- [ ] Add `POST /:code/start` route +- [ ] Improve join 404 message to "Room not found" +- **Files:** `backend/src/api/schemas.ts`, `backend/src/api/rooms.ts` + +### T5: Fix frontend API base URL +- [ ] Change default from `http://localhost:3001/bug` to `http://localhost:3001` +- **Files:** `frontend/src/services/api.ts` + +### T6: Extend frontend API and store +- [ ] Add `hostId` and extended status to RoomSnapshot type +- [ ] Add `startRoom(code, participantId)` API method +- [ ] Add `startGame()` to RoomStore +- **Files:** `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts` + +### T7: Lobby polling and host-only start UI +- [ ] Add useEffect with 2000ms setInterval for fetchRoom +- [ ] Clear interval on unmount +- [ ] Show host badge next to host participant +- [ ] Show Start Game button only to host; disable if <2 players +- [ ] Non-host sees "Waiting for the host to start the game" +- [ ] Auto-navigate to /game when room.status becomes "playing" +- **Files:** `frontend/src/pages/LobbyPage.tsx` + +### T8: Join form validation +- [ ] Reject empty/whitespace room code with "Room code is required" +- [ ] Reject invalid format with clear message +- **Files:** `frontend/src/pages/JoinRoomPage.tsx` + +### T9: Backend tests +- [ ] Test hostId set on createRoom +- [ ] Test startGame rejects non-host +- [ ] Test startGame rejects when <2 players +- [ ] Test startGame succeeds with host + 2 players +- **Files:** `backend/src/services/roomStore.test.ts` + +### T10: Manual validation +- [ ] Two-tab lobby flow per spec acceptance criteria +- [ ] Invalid code rejection +- [ ] Multi-room isolation spot check From 62038ec9240aedbc7e5b52011cd1949914d0b866 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:43:53 +0800 Subject: [PATCH 03/16] feat(backend): track host and add startGame service Set hostId on room creation and validate host-only game start with a minimum of two players before transitioning to playing. Co-authored-by: Cursor --- backend/src/models/game.ts | 4 +++- backend/src/services/roomStore.ts | 37 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..8fbecdd7 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..6d247017 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -2,6 +2,15 @@ import { randomUUID } from "node:crypto"; import type { Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +export class RoomStoreError extends Error { + statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + const rooms = new Map(); function now() { @@ -54,6 +63,7 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], createdAt: now(), updatedAt: now() @@ -90,6 +100,32 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + throw new RoomStoreError(404, "Room not found"); + } + + if (room.hostId !== participantId) { + throw new RoomStoreError(403, "Only the host can start the game"); + } + + if (room.status !== "lobby") { + throw new RoomStoreError(409, "Game has already started"); + } + + if (room.participants.length < 2) { + throw new RoomStoreError(400, "At least 2 players are required to start"); + } + + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -102,6 +138,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] From 83e21f72a688948e20f7b80e92091a81a6c89af5 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:44:00 +0800 Subject: [PATCH 04/16] feat(backend): add start endpoint and room code validation Expose POST /rooms/:code/start for host-only game start. Validate room code format and return clearer join/not-found error messages. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 38 ++++++++++++++++++++++---- backend/src/api/schemas.test.ts | 10 +++++++ backend/src/api/schemas.ts | 13 ++++++++- backend/src/services/roomStore.test.ts | 36 +++++++++++++++++++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..4ba80e3d 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,17 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + createRoom, + getRoom, + joinRoom, + RoomStoreError, + startGame, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -29,10 +37,10 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { playerName } = joinRoomSchema.parse(request.body); - const result = joinRoom(code.toUpperCase(), playerName); + const result = joinRoom(code, playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found"); } response.json({ @@ -48,10 +56,10 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); - const room = getRoom(code.toUpperCase()); + const room = getRoom(code); if (!room) { - throw new HttpError(404, "Unable to load room"); + throw new HttpError(404, "Room not found"); } response.json({ @@ -62,5 +70,23 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const room = startGame(code, participantId); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + if (error instanceof RoomStoreError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea3..6a9f72ee 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -11,4 +11,14 @@ describe("schemas", () => { it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); + + it("roomCodeParamsSchema rejects invalid format", () => { + expect(() => roomCodeParamsSchema.parse({ code: "AB" })).toThrow(); + }); + + it("roomCodeParamsSchema normalizes lowercase codes", () => { + const result = roomCodeParamsSchema.parse({ code: "abcd" }); + + expect(result.code).toBe("ABCD"); + }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..6b37814d 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +const roomCodePattern = /^[A-Z0-9]{4}$/; + export const createRoomSchema = z.object({ playerName: z.string().optional() }); @@ -9,13 +11,22 @@ export const joinRoomSchema = z.object({ }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: z + .string() + .transform((value) => value.toUpperCase()) + .refine((value) => roomCodePattern.test(value), { + message: "Room code must be exactly 4 characters" + }) }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameSchema = z.object({ + participantId: z.string().min(1) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b..517ea291 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, startGame } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -11,9 +11,43 @@ describe("roomStore", () => { expect(result.participantId).toBeDefined(); }); + it("createRoom sets the creator as host", () => { + const result = createRoom("Alice"); + + expect(result.room.hostId).toBe(result.participantId); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + + it("startGame rejects non-host participant", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + expect(guest).not.toBeNull(); + + expect(() => startGame(room.code, guest!.participantId)).toThrow( + "Only the host can start the game" + ); + void hostId; + }); + + it("startGame rejects when fewer than 2 players", () => { + const { room, participantId: hostId } = createRoom("Alice"); + + expect(() => startGame(room.code, hostId)).toThrow( + "At least 2 players are required to start" + ); + }); + + it("startGame succeeds with host and 2 players", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code, hostId); + + expect(updated.status).toBe("playing"); + }); }); From 6fa5ed6ee0170f2e23727bd4ec3e7d7965a3d90e Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:44:26 +0800 Subject: [PATCH 05/16] fix(frontend): correct API URL and add startRoom client Fix default base URL and extend room snapshot types with hostId. Add startGame store method backed by POST /rooms/:code/start. Co-authored-by: Cursor --- frontend/src/services/api.ts | 11 +++++++++-- frontend/src/state/roomStore.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..2efdeb35 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,8 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing" | "result"; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -19,7 +20,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -57,5 +58,11 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..62988956 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,18 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.startRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); From 0d2e35732ee32096c94dfba7d2e1cb09c6435ecc Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:44:31 +0800 Subject: [PATCH 06/16] feat(frontend): add lobby polling and host-only game start Poll room state every 2 seconds, show host badge, gate start button to host with two-player minimum, and navigate all clients to game. Co-authored-by: Cursor --- frontend/src/pages/LobbyPage.tsx | 74 ++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..2b6c5f9b 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,15 +1,21 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { useRoomState, useRoomStore } from "../state/roomStore"; +const POLL_INTERVAL_MS = 2000; + export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, participantId, error, isLoading } = useRoomState(); const [refreshError, setRefreshError] = useState(null); + const [startError, setStartError] = useState(null); + + const isHost = room && participantId ? room.hostId === participantId : false; + const canStart = isHost && room && room.participants.length >= 2; useEffect(() => { if (!room) { @@ -17,13 +23,47 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { + useEffect(() => { + if (room?.status === "playing") { + navigate("/game", { replace: true }); + } + }, [navigate, room?.status]); + + const pollRoom = useCallback(async () => { try { setRefreshError(null); await roomStore.fetchRoom(); } catch (caughtError) { setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); } + }, [roomStore]); + + useEffect(() => { + if (!room || room.status !== "lobby") { + return; + } + + const intervalId = window.setInterval(() => { + void pollRoom(); + }, POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [room, pollRoom]); + + async function handleRefresh() { + await pollRoom(); + } + + async function handleStartGame() { + try { + setStartError(null); + await roomStore.startGame(); + navigate("/game"); + } catch (caughtError) { + setStartError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } } if (!room) { @@ -50,7 +90,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostId ? "host" : "joined"} +
  • ))} @@ -59,9 +101,17 @@ export function LobbyPage() {

    - {isLoading ? "Refreshing players..." : "Ready to play"} + {isLoading ? "Starting game..." : "Ready to play"} +

    +

    + {startError ?? error ?? refreshError ?? ( + isHost + ? room.participants.length < 2 + ? "Waiting for at least one more player to join." + : "You can start the game when ready." + : "Waiting for the host to start the game." + )}

    -

    {error ?? refreshError ?? "Waiting for the host to start the game."}

    @@ -69,9 +119,15 @@ export function LobbyPage() { - + {isHost ? ( + + ) : null} ); From 02c980be8cbea302d9ce4f8ed11f583dbb3c5c12 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:44:31 +0800 Subject: [PATCH 07/16] feat(frontend): validate room code before join request Reject empty or malformed room codes with clear messages before calling the API. Add startRoom API test coverage. Co-authored-by: Cursor --- frontend/src/pages/JoinRoomPage.tsx | 28 +++++++++++++++++++++++++++- frontend/src/services/api.test.ts | 21 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..a2b224fd 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -3,6 +3,24 @@ import { useNavigate } from "react-router-dom"; import { PageHeader } from "../components/PageHeader"; import { useRoomStore } from "../state/roomStore"; +const ROOM_CODE_PATTERN = /^[A-Z0-9]{4}$/; + +function validateRoomCode(code: string): string | null { + const trimmed = code.trim(); + + if (!trimmed) { + return "Room code is required"; + } + + const normalized = trimmed.toUpperCase(); + + if (!ROOM_CODE_PATTERN.test(normalized)) { + return "Room code must be exactly 4 characters"; + } + + return null; +} + export function JoinRoomPage() { const [playerName, setPlayerName] = useState(""); const [roomCode, setRoomCode] = useState(""); @@ -13,9 +31,16 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const codeError = validateRoomCode(roomCode); + + if (codeError) { + setError(codeError); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); @@ -47,6 +72,7 @@ export function JoinRoomPage() { value={roomCode} onChange={(event) => setRoomCode(event.target.value.toUpperCase())} placeholder="ABCD" + maxLength={4} /> {error ?

    {error}

    : null} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 67601f5d..c878c9d5 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -45,4 +45,25 @@ describe("api service", () => { expect.anything() ); }); + + it("startRoom sends POST to /rooms/:code/start with participantId", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", hostId: "p1", participants: [] }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.startRoom("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/start"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + }); }); From 8c716e7fe5500f5b4bde78124aaddda8bc5ad8cf Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:45:35 +0800 Subject: [PATCH 08/16] docs: add phase 2 spec, plan, and tasks for game start Define acceptance criteria for name validation, drawer assignment, deterministic word selection, and drawer-only word visibility. Co-authored-by: Cursor --- specs/002-game-start/plan.md | 83 ++++++++++++++++++++++++++++++++++ specs/002-game-start/spec.md | 84 +++++++++++++++++++++++++++++++++++ specs/002-game-start/tasks.md | 46 +++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 specs/002-game-start/plan.md create mode 100644 specs/002-game-start/spec.md create mode 100644 specs/002-game-start/tasks.md diff --git a/specs/002-game-start/plan.md b/specs/002-game-start/plan.md new file mode 100644 index 00000000..f37fe982 --- /dev/null +++ b/specs/002-game-start/plan.md @@ -0,0 +1,83 @@ +# Plan: Game Start & Drawer Flow (Scenario 2) + +## State Model Changes + +### Room (backend, server-only fields) +```typescript +interface Room { + // existing fields... + drawerId: string | null; + secretWord: string | null; + scores: Record; + guesses: Guess[]; // prepare for Phase 3, init empty +} +``` + +### Participant in snapshot (viewer-enriched) +```typescript +interface ParticipantView { + id: string; + name: string; + joinedAt: string; + role: ParticipantRole; + score: number; +} +``` + +### RoomSnapshot additions +- `drawerId: string | null` +- `secretWord?: string` (drawer viewer only) +- participants include role and score + +## Deterministic Word Selection + +```typescript +function selectWord(roomCode: string): string { + const hash = roomCode.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); + return STARTER_WORDS[hash % STARTER_WORDS.length]; +} +``` + +## Data Flow + +``` +startGame(): + drawerId = hostId + secretWord = selectWord(room.code) + scores = { [p.id]: 0 for each participant } + status = "playing" + +toRoomSnapshot(room, viewerParticipantId): + for each participant: role = id === drawerId ? "drawer" : "guesser" + if viewerParticipantId === drawerId: include secretWord + else: omit secretWord +``` + +## File-Level Changes + +### Backend +| File | Change | +|------|--------| +| `models/game.ts` | Add drawerId, secretWord, scores, Guess; extend ParticipantView | +| `services/roomStore.ts` | Name validation, word selection, startGame assigns drawer/word/scores | +| `api/schemas.ts` | Validate playerName trim/non-empty | +| `api/rooms.ts` | No new routes | + +### Frontend +| File | Change | +|------|--------| +| `services/api.ts` | Extend types | +| `pages/CreateRoomPage.tsx` | Trim + validate name | +| `pages/JoinRoomPage.tsx` | Trim + validate name | +| `pages/GamePage.tsx` | Show drawer role, secret word for drawer, poll while playing | + +## Implementation Sequence + +1. Backend name validation in create/join +2. Extend Room model with game-round fields +3. Update startGame to assign drawer, word, scores +4. Implement viewer-specific toRoomSnapshot +5. Frontend name validation on forms +6. GamePage shows role and secret word +7. GamePage polling while status is "playing" +8. Tests for word selection and snapshot visibility diff --git a/specs/002-game-start/spec.md b/specs/002-game-start/spec.md new file mode 100644 index 00000000..d08571f4 --- /dev/null +++ b/specs/002-game-start/spec.md @@ -0,0 +1,84 @@ +# Feature Spec: Game Start & Drawer Flow (Scenario 2) + +## Overview + +When the host starts the game, assign the drawer role, select a deterministic secret word, and expose the word only to the drawer via viewer-specific room snapshots. Validate player names on create and join. + +## User Stories + +### US-1: Player name validation +**As** a player creating or joining a room, **I want** my name trimmed and validated, **so that** empty names are rejected with a clear message. + +**Acceptance Criteria:** +- [ ] Player names are trimmed before submission +- [ ] Empty or whitespace-only names rejected with "Player name is required" +- [ ] Valid names stored as trimmed value +- [ ] Validation on both create and join forms (frontend) and API (backend) + +### US-2: Drawer assignment on game start +**As** a player in a starting game, **I want** the host clearly identified as the drawer, **so that** everyone knows who is drawing. + +**Acceptance Criteria:** +- [ ] On start, `drawerId` set to host's participantId (host is first drawer) +- [ ] Room snapshot includes `drawerId` visible to all players +- [ ] Game UI identifies the drawer by name/role label +- [ ] Each participant snapshot includes their `role` ("drawer" or "guesser") + +### US-3: Deterministic secret word selection +**As** the system, **I want** the secret word chosen deterministically from the starter list, **so that** all polls return the same word. + +**Acceptance Criteria:** +- [ ] Word selected from STARTER_WORDS using deterministic index derived from room code +- [ ] Same room code always yields same word +- [ ] Word stored on room as `secretWord` (server-side only, not in public snapshot) + +### US-4: Drawer-only word visibility +**As** the drawer, **I want** to see the secret word; **as** a guesser, **I want** it hidden. + +**Acceptance Criteria:** +- [ ] `GET /rooms/:code?participantId=X` includes `secretWord` field only when X is the drawer +- [ ] Guessers receive snapshot without `secretWord` (or null/undefined) +- [ ] Drawer UI shows the secret word prominently +- [ ] Guesser UI shows no secret word + +## Edge Cases + +| Case | Expected Behavior | +|------|-------------------| +| Name with leading/trailing spaces " Alice " | Trimmed to "Alice" | +| Name with only spaces | Rejected | +| Poll as drawer vs guesser | Different snapshot fields | +| Re-start not in scope yet | Phase 4 handles restart word re-selection | + +## API Changes + +### Extended Room (server-side) +```typescript +{ + drawerId: string; + secretWord: string; + scores: Record; // participantId -> score, init 0 +} +``` + +### Extended RoomSnapshot (viewer-specific) +```typescript +{ + drawerId: string; + secretWord?: string; // only for drawer viewer + participants: Array; +} +``` + +## Out of Scope (This Phase) + +- Drawing canvas interaction (Phase 3) +- Guess submission (Phase 3) +- Result/restart (Phase 4) + +## Validation Strategy + +1. Try create/join with empty name → see error +2. Start game with 2 players → host shown as drawer +3. Drawer tab sees secret word; guesser tab does not +4. Same room code always gets same word (verify via backend test) diff --git a/specs/002-game-start/tasks.md b/specs/002-game-start/tasks.md new file mode 100644 index 00000000..7d76f0bc --- /dev/null +++ b/specs/002-game-start/tasks.md @@ -0,0 +1,46 @@ +# Tasks: Game Start & Drawer Flow (Scenario 2) + +## Task List + +### T1: Backend player name validation +- [ ] Add `validatePlayerName(name?: string): string` — trim, reject empty +- [ ] Use in createRoom and joinRoom +- [ ] Update Zod schemas to require non-empty trimmed name +- **Files:** `roomStore.ts`, `schemas.ts` + +### T2: Extend Room model for game round +- [ ] Add drawerId, secretWord, scores, guesses to Room +- [ ] Add ParticipantView with role and score to snapshot types +- [ ] Add optional secretWord to RoomSnapshot +- **Files:** `models/game.ts` + +### T3: Deterministic word selection +- [ ] Add `selectWord(code: string)` pure function +- [ ] Unit test: same code → same word +- **Files:** `roomStore.ts`, `roomStore.test.ts` + +### T4: Update startGame and toRoomSnapshot +- [ ] startGame sets drawerId=hostId, secretWord, scores init 0 +- [ ] toRoomSnapshot assigns roles, scores, conditional secretWord +- **Files:** `roomStore.ts` + +### T5: Frontend name validation +- [ ] Trim and validate on CreateRoomPage and JoinRoomPage +- **Files:** `CreateRoomPage.tsx`, `JoinRoomPage.tsx` + +### T6: GamePage role and word display +- [ ] Show drawer/guesser role for viewer +- [ ] Show secret word card for drawer only +- [ ] Identify drawer in UI +- [ ] Poll every 2s while on game page with status "playing" +- **Files:** `GamePage.tsx`, `api.ts` + +### T7: Tests +- [ ] Backend: name validation rejects empty +- [ ] Backend: snapshot includes secretWord for drawer only +- [ ] Backend: deterministic word selection +- **Files:** `roomStore.test.ts`, `schemas.test.ts` + +### T8: Manual validation +- [ ] Two-tab: drawer sees word, guesser does not +- [ ] Empty name rejected on create/join From f8f55569af2ed7d008a1dfebf6737afb91af2c93 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:45:36 +0800 Subject: [PATCH 09/16] feat(backend): assign drawer and secret word on game start Validate trimmed player names, select word deterministically from room code, and expose secretWord only to the drawer in snapshots. Co-authored-by: Cursor --- backend/src/api/schemas.ts | 12 +++++- backend/src/models/game.ts | 37 +++++++++++++++++- backend/src/services/roomStore.ts | 65 ++++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 6b37814d..79ccb7b7 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -3,11 +3,19 @@ import { z } from "zod"; const roomCodePattern = /^[A-Z0-9]{4}$/; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z + .string() + .optional() + .transform((value) => value?.trim() ?? "") + .refine((value) => value.length > 0, { message: "Player name is required" }) }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z + .string() + .optional() + .transform((value) => value?.trim() ?? "") + .refine((value) => value.length > 0, { message: "Player name is required" }) }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 8fbecdd7..c5fd9c48 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -7,20 +7,55 @@ export interface Participant { joinedAt: string; } +export interface Guess { + id: string; + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + createdAt: string; +} + +export interface ParticipantView extends Participant { + role: ParticipantRole; + score: number; +} + export interface Room { code: string; status: RoomStatus; hostId: string; participants: Participant[]; + drawerId: string | null; + secretWord: string | null; + scores: Record; + guesses: Guess[]; + drawingStrokes: DrawingStroke[]; createdAt: string; updatedAt: string; } +export interface DrawingPoint { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + color: string; + width: number; + points: DrawingPoint[]; +} + export interface RoomSnapshot { code: string; status: RoomStatus; hostId: string; - participants: Participant[]; + drawerId: string | null; + secretWord?: string; + participants: ParticipantView[]; + guesses: Guess[]; + drawingStrokes: DrawingStroke[]; availableWords: string[]; roles: ParticipantRole[]; } diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 6d247017..17dfb3c0 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; export class RoomStoreError extends Error { @@ -38,18 +38,39 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +export function validatePlayerName(name?: string): string { + const trimmed = name?.trim() ?? ""; + + if (!trimmed) { + throw new RoomStoreError(400, "Player name is required"); + } + + return trimmed; } -function createParticipant(name?: string): Participant { +export function selectWord(roomCode: string): string { + const hash = roomCode.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); + return STARTER_WORDS[hash % STARTER_WORDS.length] ?? STARTER_WORDS[0]; +} + +function createParticipant(name: string): Participant { return { id: randomUUID(), - name: displayName(name), + name, joinedAt: now() }; } +function createEmptyRoomFields() { + return { + drawerId: null, + secretWord: null, + scores: {} as Record, + guesses: [] as Guess[], + drawingStrokes: [] as Room["drawingStrokes"] + }; +} + function cloneRoom(room: Room) { return structuredClone(room); } @@ -59,12 +80,14 @@ export function listWords() { } export function createRoom(playerName?: string) { - const participant = createParticipant(playerName); + const name = validatePlayerName(playerName); + const participant = createParticipant(name); const room: Room = { code: generateUniqueCode(), status: "lobby", hostId: participant.id, participants: [participant], + ...createEmptyRoomFields(), createdAt: now(), updatedAt: now() }; @@ -84,7 +107,8 @@ export function joinRoom(code: string, playerName?: string) { return null; } - const participant = createParticipant(playerName); + const name = validatePlayerName(playerName); + const participant = createParticipant(name); room.participants.push(participant); room.updatedAt = now(); rooms.set(room.code, room); @@ -120,6 +144,11 @@ export function startGame(code: string, participantId: string) { } room.status = "playing"; + room.drawerId = room.hostId; + room.secretWord = selectWord(room.code); + room.scores = Object.fromEntries(room.participants.map((p) => [p.id, 0])); + room.guesses = []; + room.drawingStrokes = []; room.updatedAt = now(); rooms.set(room.code, room); @@ -133,14 +162,30 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawer = viewerParticipantId != null && viewerParticipantId === room.drawerId; - return { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, hostId: room.hostId, - participants: room.participants.map((participant) => ({ ...participant })), + drawerId: room.drawerId, + participants: room.participants.map((participant) => ({ + ...participant, + role: participant.id === room.drawerId ? "drawer" : "guesser", + score: room.scores[participant.id] ?? 0 + })), + guesses: room.guesses.map((guess) => ({ ...guess })), + drawingStrokes: room.drawingStrokes.map((stroke) => ({ + ...stroke, + points: stroke.points.map((point) => ({ ...point })) + })), availableWords: listWords(), roles: [...STARTER_ROLES] }; + + if (isDrawer && room.secretWord) { + snapshot.secretWord = room.secretWord; + } + + return snapshot; } From 99d9d8b037ca4be399933dcb8a2427d8ad617eec Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:45:36 +0800 Subject: [PATCH 10/16] test(backend): cover name validation and drawer-only snapshots Co-authored-by: Cursor --- backend/src/api/schemas.test.ts | 10 +++++ backend/src/services/roomStore.test.ts | 54 ++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 6a9f72ee..0f8e66b9 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -8,6 +8,16 @@ describe("schemas", () => { expect(result.playerName).toBe("Alice"); }); + it("createRoomSchema trims playerName", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); + + expect(result.playerName).toBe("Alice"); + }); + + it("createRoomSchema rejects empty playerName", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow(); + }); + it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 517ea291..a414aea0 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, startGame } from "./roomStore.js"; +import { createRoom, joinRoom, selectWord, startGame, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -17,12 +17,22 @@ describe("roomStore", () => { expect(result.room.hostId).toBe(result.participantId); }); + it("createRoom rejects empty player name", () => { + expect(() => createRoom(" ")).toThrow("Player name is required"); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + it("joinRoom rejects empty player name", () => { + const { room } = createRoom("Alice"); + + expect(() => joinRoom(room.code, " ")).toThrow("Player name is required"); + }); + it("startGame rejects non-host participant", () => { const { room, participantId: hostId } = createRoom("Alice"); const guest = joinRoom(room.code, "Bob"); @@ -42,12 +52,50 @@ describe("roomStore", () => { ); }); - it("startGame succeeds with host and 2 players", () => { + it("startGame assigns drawer, word, and zero scores", () => { const { room, participantId: hostId } = createRoom("Alice"); - joinRoom(room.code, "Bob"); + const guest = joinRoom(room.code, "Bob"); + expect(guest).not.toBeNull(); const updated = startGame(room.code, hostId); expect(updated.status).toBe("playing"); + expect(updated.drawerId).toBe(hostId); + expect(updated.secretWord).toBeTruthy(); + expect(updated.scores[hostId]).toBe(0); + expect(updated.scores[guest!.participantId]).toBe(0); + }); + + it("selectWord is deterministic for a given room code", () => { + const first = selectWord("ABCD"); + const second = selectWord("ABCD"); + + expect(first).toBe(second); + expect(first).toMatch(/^(rocket|pizza|castle|guitar|sunflower)$/); + }); + + it("toRoomSnapshot includes secretWord only for drawer viewer", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + const playing = startGame(room.code, hostId); + + const drawerView = toRoomSnapshot(playing, hostId); + const guesserView = toRoomSnapshot(playing, guest!.participantId); + + expect(drawerView.secretWord).toBe(playing.secretWord); + expect(guesserView.secretWord).toBeUndefined(); + }); + + it("toRoomSnapshot assigns participant roles", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + const playing = startGame(room.code, hostId); + + const snapshot = toRoomSnapshot(playing, hostId); + const drawer = snapshot.participants.find((p) => p.id === hostId); + const guesser = snapshot.participants.find((p) => p.id !== hostId); + + expect(drawer?.role).toBe("drawer"); + expect(guesser?.role).toBe("guesser"); }); }); From bcec63763b395bed091181b19656797365e3f640 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:45:36 +0800 Subject: [PATCH 11/16] feat(frontend): show drawer role and secret word on game screen Validate player names on create/join forms, poll during gameplay, and display role-specific UI including drawer-only secret word. Co-authored-by: Cursor --- frontend/src/components/Scoreboard.tsx | 26 +++++++--- frontend/src/pages/CreateRoomPage.tsx | 17 +++++- frontend/src/pages/GamePage.tsx | 71 +++++++++++++++++++++++--- frontend/src/pages/JoinRoomPage.tsx | 17 +++++- frontend/src/services/api.ts | 32 +++++++++++- 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..83a6abdc 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,28 @@ import { Card } from "./Card"; +import type { ParticipantView } from "../services/api"; -export function Scoreboard() { +interface ScoreboardProps { + participants: ParticipantView[]; +} + +export function Scoreboard({ participants }: ScoreboardProps) { return ( -
    -
    - Waiting for players... - 0 + {participants.length === 0 ? ( +

    Waiting for players...

    + ) : ( +
    + {participants.map((participant) => ( +
    + + {participant.name} + {participant.role === "drawer" ? " (drawer)" : ""} + + {participant.score} +
    + ))}
    -
    + )} ); } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..c3cdb628 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -3,6 +3,14 @@ import { useNavigate } from "react-router-dom"; import { PageHeader } from "../components/PageHeader"; import { useRoomStore } from "../state/roomStore"; +function validatePlayerName(name: string): string | null { + if (!name.trim()) { + return "Player name is required"; + } + + return null; +} + export function CreateRoomPage() { const [playerName, setPlayerName] = useState(""); const [error, setError] = useState(null); @@ -12,9 +20,16 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const nameError = validatePlayerName(playerName); + + if (nameError) { + setError(nameError); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..7a7a7b3c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,19 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; + +const POLL_INTERVAL_MS = 2000; export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); + const [pollError, setPollError] = useState(null); useEffect(() => { if (!room) { @@ -17,11 +21,48 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + if (room?.status === "result") { + // Phase 4 will show result UI; stay on game page for now + } + }, [room?.status]); + + const pollRoom = useCallback(async () => { + try { + setPollError(null); + await roomStore.fetchRoom(); + } catch (caughtError) { + setPollError(caughtError instanceof Error ? caughtError.message : "Unable to refresh game"); + } + }, [roomStore]); + + useEffect(() => { + if (!room || room.status !== "playing") { + return; + } + + const intervalId = window.setInterval(() => { + void pollRoom(); + }, POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [room, pollRoom]); + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const drawer = room.participants.find((participant) => participant.id === room.drawerId) ?? null; + const isDrawer = viewer?.role === "drawer"; return (
    @@ -33,16 +74,26 @@ export function GamePage() {
    + {pollError ?

    {pollError}

    : null} +
    + {isDrawer && room.secretWord ? ( + +

    + Draw: {room.secretWord} +

    +
    + ) : null} + -
    - Waiting for drawer... +
    + {drawer ? `${drawer.name} is drawing...` : "Waiting for drawer..."}
    @@ -55,14 +106,18 @@ export function GamePage() {
    {viewer?.name ?? "Unknown player"}
    -
    Status
    -
    Playing
    +
    Role
    +
    {viewer?.role === "drawer" ? "Drawer" : "Guesser"}
    +
    +
    +
    Drawer
    +
    {drawer?.name ?? "Unknown"}
    - +
    diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index a2b224fd..3d13c54a 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -21,6 +21,14 @@ function validateRoomCode(code: string): string | null { return null; } +function validatePlayerName(name: string): string | null { + if (!name.trim()) { + return "Player name is required"; + } + + return null; +} + export function JoinRoomPage() { const [playerName, setPlayerName] = useState(""); const [roomCode, setRoomCode] = useState(""); @@ -38,9 +46,16 @@ export function JoinRoomPage() { return; } + const nameError = validatePlayerName(playerName); + + if (nameError) { + setError(nameError); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName); + await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2efdeb35..bbdc117d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -6,11 +6,41 @@ export interface Participant { joinedAt: string; } +export interface ParticipantView extends Participant { + role: ParticipantRole; + score: number; +} + +export interface Guess { + id: string; + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + createdAt: string; +} + +export interface DrawingPoint { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + color: string; + width: number; + points: DrawingPoint[]; +} + export interface RoomSnapshot { code: string; status: "lobby" | "playing" | "result"; hostId: string; - participants: Participant[]; + drawerId: string | null; + secretWord?: string; + participants: ParticipantView[]; + guesses: Guess[]; + drawingStrokes: DrawingStroke[]; availableWords: string[]; roles: ParticipantRole[]; } From 5e73a48fedccdfcfc45b850bee8f162199e2f8e8 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:47:01 +0800 Subject: [PATCH 12/16] docs: add phase 3 and 4 spec, plan, and tasks Cover gameplay interaction (drawing, guesses, scoring) and result state with host-only restart back to lobby (Scenarios 3 and 4). Co-authored-by: Cursor --- specs/003-gameplay/plan.md | 32 +++++++++++++++++ specs/003-gameplay/spec.md | 60 +++++++++++++++++++++++++++++++ specs/003-gameplay/tasks.md | 9 +++++ specs/004-result-restart/plan.md | 21 +++++++++++ specs/004-result-restart/spec.md | 32 +++++++++++++++++ specs/004-result-restart/tasks.md | 8 +++++ 6 files changed, 162 insertions(+) create mode 100644 specs/003-gameplay/plan.md create mode 100644 specs/003-gameplay/spec.md create mode 100644 specs/003-gameplay/tasks.md create mode 100644 specs/004-result-restart/plan.md create mode 100644 specs/004-result-restart/spec.md create mode 100644 specs/004-result-restart/tasks.md diff --git a/specs/003-gameplay/plan.md b/specs/003-gameplay/plan.md new file mode 100644 index 00000000..a2da56b3 --- /dev/null +++ b/specs/003-gameplay/plan.md @@ -0,0 +1,32 @@ +# Plan: Gameplay Interaction (Scenario 3) + +## Backend Service Functions + +- `addStroke(code, participantId, stroke)` — drawer only, status playing +- `clearDrawing(code, participantId)` — drawer only +- `submitGuess(code, participantId, text)` — guesser only, trim, compare, score, maybe end round + +## File Changes + +| File | Change | +|------|--------| +| `roomStore.ts` | addStroke, clearDrawing, submitGuess | +| `rooms.ts` | 3 new POST routes | +| `schemas.ts` | stroke and guess schemas | +| `DrawingCanvas.tsx` | NEW interactive canvas component | +| `GamePage.tsx` | wire canvas, guess form, guess history | +| `GuessForm.tsx` | call API on submit | +| `api.ts` | new methods | + +## Canvas Sync + +Drawer sends strokes on pointer-up. All clients poll `drawingStrokes` and render via SVG/canvas paths. + +## Scoring + +```typescript +if (trimmedGuess.toLowerCase() === secretWord.toLowerCase()) { + scores[participantId] += 100; + status = "result"; +} +``` diff --git a/specs/003-gameplay/spec.md b/specs/003-gameplay/spec.md new file mode 100644 index 00000000..96b4ba9f --- /dev/null +++ b/specs/003-gameplay/spec.md @@ -0,0 +1,60 @@ +# Feature Spec: Gameplay Interaction (Scenario 3) + +## Overview + +Enable interactive drawing for the drawer, guess submission for guessers, synced state via polling, and deterministic scoring. + +## User Stories + +### US-1: Interactive drawing +**As** the drawer, **I want** to draw on a canvas, **so that** my drawing is visible on my screen. + +**Acceptance Criteria:** +- [ ] Drawer can draw with mouse on canvas +- [ ] Strokes stored on server and synced to all clients via polling +- [ ] Non-drawers see the drawing update via polling +- [ ] Drawer cannot submit guesses (form disabled) + +### US-2: Clear canvas +**As** the drawer, **I want** to clear the canvas, **so that** I can start over. + +**Acceptance Criteria:** +- [ ] Clear button visible only to drawer +- [ ] Clears all strokes server-side +- [ ] All clients see empty canvas on next poll + +### US-3: Guess submission +**As** a guesser, **I want** to submit guesses, **so that** I can try to identify the word. + +**Acceptance Criteria:** +- [ ] Guess text trimmed; empty rejected with message +- [ ] Case-insensitive comparison against secret word +- [ ] Correct guess adds 100 to guesser's score; incorrect adds 0 +- [ ] Guess history appended and synced to all via polling +- [ ] Guessers only (not drawer) can submit + +### US-4: Round end on correct guess +**As** all players, **I want** the round to end when someone guesses correctly. + +**Acceptance Criteria:** +- [ ] Correct guess transitions room status to "result" +- [ ] No further guesses or drawing accepted after result + +## Edge Cases + +| Case | Expected Behavior | +|------|-------------------| +| Drawer tries to guess | Disabled / rejected | +| Duplicate wrong guesses | Allowed, score unchanged | +| Empty guess | Rejected | +| Guess "ROCKET" vs word "rocket" | Correct (case-insensitive) | + +## API Changes + +- `POST /rooms/:code/drawing/strokes` — drawer adds stroke +- `POST /rooms/:code/drawing/clear` — drawer clears canvas +- `POST /rooms/:code/guesses` — guesser submits guess + +## Validation Strategy + +Two-tab test: drawer draws, guesser sees strokes; guesser submits wrong then correct guess; scores update; status becomes result. diff --git a/specs/003-gameplay/tasks.md b/specs/003-gameplay/tasks.md new file mode 100644 index 00000000..e4e4ca22 --- /dev/null +++ b/specs/003-gameplay/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Gameplay Interaction (Scenario 3) + +- [ ] T1: Backend stroke/guess/clear service functions +- [ ] T2: Backend routes and schemas +- [ ] T3: Backend tests +- [ ] T4: DrawingCanvas component +- [ ] T5: GuessForm wired to API +- [ ] T6: GamePage guess history and canvas integration +- [ ] T7: Manual two-tab validation diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 00000000..9e175cf2 --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,21 @@ +# Plan: Result, Restart & Final Validation (Scenario 4) + +## Backend + +- `restartGame(code, participantId)` — host only, reset round fields, status lobby +- `toRoomSnapshot` includes `secretWord` for all viewers when status is "result" + +## Frontend + +- `ResultPanel` shows word, scores, guesses when status is "result" +- GamePage polls during "result", host restart button +- Auto-navigate to lobby when status returns to "lobby" after restart + +## File Changes + +| File | Change | +|------|--------| +| `roomStore.ts` | restartGame, snapshot reveals word on result | +| `rooms.ts` | POST restart route | +| `ResultPanel.tsx` | real result UI | +| `GamePage.tsx` | restart button, result polling | diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 00000000..3aed0887 --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,32 @@ +# Feature Spec: Result, Restart & Final Validation (Scenario 4) + +## Overview + +Display shared result state when a round ends and allow the host to restart back to lobby with round state cleared. + +## User Stories + +### US-1: Result display +**As** any player, **I want** to see the correct word, scores, and guess history when the round ends. + +**Acceptance Criteria:** +- [ ] Result panel shows revealed secret word to all players +- [ ] Final scores displayed for all participants +- [ ] Full guess history visible + +### US-2: Host restart +**As** the host, **I want** to restart the game, **so that** we can play again. + +**Acceptance Criteria:** +- [ ] Only host sees restart button on result screen +- [ ] `POST /rooms/:code/restart` clears round state (drawer, word, scores, guesses, strokes) +- [ ] Participants preserved; status returns to "lobby" +- [ ] All clients navigate to lobby via polling + +## API Changes + +- `POST /rooms/:code/restart` — host only, body `{ participantId }` + +## Validation Strategy + +Complete round → verify result on both tabs → host restarts → both return to lobby with players intact. diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 00000000..9205655d --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Result, Restart & Final Validation (Scenario 4) + +- [ ] T1: Backend restartGame and result snapshot word reveal +- [ ] T2: Backend restart route and tests +- [ ] T3: ResultPanel component with word/scores/history +- [ ] T4: GamePage restart button and lobby navigation +- [ ] T5: reflection.md +- [ ] T6: End-to-end manual validation From 782dcfb736ac27be19d2fa171a6efa0cb0c23c13 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:47:01 +0800 Subject: [PATCH 13/16] feat(backend): add drawing, guess, and restart endpoints Support stroke sync, canvas clear, guess submission with scoring, and host-only restart. Reveal secret word to all on result state. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 86 ++++++++++++++++++--- backend/src/api/schemas.ts | 24 ++++++ backend/src/services/roomStore.ts | 124 +++++++++++++++++++++++++++++- 3 files changed, 223 insertions(+), 11 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 4ba80e3d..132dc88f 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,21 +1,53 @@ import { Router } from "express"; import { + addStrokeSchema, createRoomSchema, HttpError, joinRoomSchema, + participantActionSchema, roomCodeParamsSchema, roomViewerQuerySchema, - startGameSchema + startGameSchema, + submitGuessSchema } from "./schemas.js"; import { + addStroke, + clearDrawing, createRoom, getRoom, joinRoom, + restartGame, RoomStoreError, startGame, + submitGuess, toRoomSnapshot } from "../services/roomStore.js"; +function handleRoomStoreRoute( + handler: () => ReturnType, + participantId: string | undefined, + response: import("express").Response, + next: import("express").NextFunction +) { + try { + const room = handler(); + + if (!room) { + throw new HttpError(404, "Room not found"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + if (error instanceof RoomStoreError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + next(error); + } +} + export function createRoomsRouter() { const router = Router(); @@ -29,6 +61,10 @@ export function createRoomsRouter() { room: toRoomSnapshot(result.room, result.participantId) }); } catch (error) { + if (error instanceof RoomStoreError) { + next(new HttpError(error.statusCode, error.message)); + return; + } next(error); } }); @@ -74,16 +110,48 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = startGameSchema.parse(request.body); - const room = startGame(code, participantId); + handleRoomStoreRoute(() => startGame(code, participantId), participantId, response, next); + } catch (error) { + next(error); + } + }); - response.json({ - room: toRoomSnapshot(room, participantId) - }); + router.post("/:code/drawing/strokes", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, stroke } = addStrokeSchema.parse(request.body); + handleRoomStoreRoute(() => addStroke(code, participantId, stroke), participantId, response, next); + } catch (error) { + next(error); + } + }); + + router.post("/:code/drawing/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + handleRoomStoreRoute(() => clearDrawing(code, participantId), participantId, response, next); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guesses", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, text } = submitGuessSchema.parse(request.body); + handleRoomStoreRoute(() => submitGuess(code, participantId, text), participantId, response, next); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + handleRoomStoreRoute(() => restartGame(code, participantId), participantId, response, next); } catch (error) { - if (error instanceof RoomStoreError) { - next(new HttpError(error.statusCode, error.message)); - return; - } next(error); } }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 79ccb7b7..f3a29700 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -35,6 +35,30 @@ export const startGameSchema = z.object({ participantId: z.string().min(1) }); +export const participantActionSchema = z.object({ + participantId: z.string().min(1) +}); + +const drawingPointSchema = z.object({ + x: z.number(), + y: z.number() +}); + +export const addStrokeSchema = z.object({ + participantId: z.string().min(1), + stroke: z.object({ + id: z.string().min(1), + color: z.string().min(1), + width: z.number().positive(), + points: z.array(drawingPointSchema).min(1) + }) +}); + +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + text: z.string() +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 17dfb3c0..e6dfa32a 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { DrawingStroke, Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; export class RoomStoreError extends Error { @@ -155,6 +155,125 @@ export function startGame(code: string, participantId: string) { return cloneRoom(room); } +function requirePlayingRoom(code: string) { + const room = rooms.get(code); + + if (!room) { + throw new RoomStoreError(404, "Room not found"); + } + + if (room.status !== "playing") { + throw new RoomStoreError(409, "Round is not active"); + } + + return room; +} + +function requireDrawer(room: Room, participantId: string) { + if (room.drawerId !== participantId) { + throw new RoomStoreError(403, "Only the drawer can perform this action"); + } +} + +function requireGuesser(room: Room, participantId: string) { + if (room.drawerId === participantId) { + throw new RoomStoreError(403, "The drawer cannot submit guesses"); + } +} + +export function addStroke(code: string, participantId: string, stroke: DrawingStroke) { + const room = requirePlayingRoom(code); + requireDrawer(room, participantId); + + room.drawingStrokes.push({ + ...stroke, + points: stroke.points.map((point) => ({ ...point })) + }); + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function clearDrawing(code: string, participantId: string) { + const room = requirePlayingRoom(code); + requireDrawer(room, participantId); + + room.drawingStrokes = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function submitGuess(code: string, participantId: string, text: string) { + const room = requirePlayingRoom(code); + requireGuesser(room, participantId); + + const trimmed = text.trim(); + + if (!trimmed) { + throw new RoomStoreError(400, "Guess is required"); + } + + const participant = room.participants.find((p) => p.id === participantId); + + if (!participant) { + throw new RoomStoreError(404, "Participant not found"); + } + + const isCorrect = + room.secretWord != null && trimmed.toLowerCase() === room.secretWord.toLowerCase(); + + const guess: Guess = { + id: randomUUID(), + participantId, + participantName: participant.name, + text: trimmed, + isCorrect, + createdAt: now() + }; + + room.guesses.push(guess); + + if (isCorrect) { + room.scores[participantId] = (room.scores[participantId] ?? 0) + 100; + room.status = "result"; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + throw new RoomStoreError(404, "Room not found"); + } + + if (room.hostId !== participantId) { + throw new RoomStoreError(403, "Only the host can restart the game"); + } + + if (room.status !== "result") { + throw new RoomStoreError(409, "Game can only be restarted from result state"); + } + + room.status = "lobby"; + room.drawerId = null; + room.secretWord = null; + room.scores = {}; + room.guesses = []; + room.drawingStrokes = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -163,6 +282,7 @@ export function saveRoom(room: Room) { export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { const isDrawer = viewerParticipantId != null && viewerParticipantId === room.drawerId; + const revealWord = room.status === "result"; const snapshot: RoomSnapshot = { code: room.code, @@ -183,7 +303,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn roles: [...STARTER_ROLES] }; - if (isDrawer && room.secretWord) { + if (room.secretWord && (isDrawer || revealWord)) { snapshot.secretWord = room.secretWord; } From 67752484a7d94d9b9dba77fc7b8d4b2eaca02cdd Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:47:02 +0800 Subject: [PATCH 14/16] test(backend): cover guess scoring, restart, and result snapshots Co-authored-by: Cursor --- backend/src/services/roomStore.test.ts | 50 +++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index a414aea0..3082b25f 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, selectWord, startGame, toRoomSnapshot } from "./roomStore.js"; +import { createRoom, joinRoom, restartGame, selectWord, startGame, submitGuess, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -98,4 +98,52 @@ describe("roomStore", () => { expect(drawer?.role).toBe("drawer"); expect(guesser?.role).toBe("guesser"); }); + + it("submitGuess scores correct guess and ends round", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + const playing = startGame(room.code, hostId); + const word = playing.secretWord!; + + const updated = submitGuess(room.code, guest!.participantId, word.toUpperCase()); + + expect(updated.status).toBe("result"); + expect(updated.scores[guest!.participantId]).toBe(100); + expect(updated.guesses).toHaveLength(1); + expect(updated.guesses[0].isCorrect).toBe(true); + }); + + it("submitGuess rejects empty guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + expect(() => submitGuess(room.code, guest!.participantId, " ")).toThrow("Guess is required"); + }); + + it("restartGame clears round state and returns to lobby", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + const playing = startGame(room.code, hostId); + submitGuess(room.code, guest!.participantId, playing.secretWord!); + + const restarted = restartGame(room.code, hostId); + + expect(restarted.status).toBe("lobby"); + expect(restarted.drawerId).toBeNull(); + expect(restarted.secretWord).toBeNull(); + expect(restarted.participants).toHaveLength(2); + expect(restarted.guesses).toHaveLength(0); + }); + + it("toRoomSnapshot reveals secret word to all on result", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guest = joinRoom(room.code, "Bob"); + const playing = startGame(room.code, hostId); + const result = submitGuess(room.code, guest!.participantId, playing.secretWord!); + + const guesserView = toRoomSnapshot(result, guest!.participantId); + + expect(guesserView.secretWord).toBe(playing.secretWord); + }); }); From faf09ec049ed5cdd20e4bd996ff4ec15b12532d3 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:47:02 +0800 Subject: [PATCH 15/16] feat(frontend): implement drawing canvas, guesses, and results Add interactive canvas with stroke sync, guess form with validation, guess history display, result panel, and host restart flow. Co-authored-by: Cursor --- frontend/src/components/DrawingCanvas.tsx | 132 ++++++++++++++++++++++ frontend/src/components/GuessForm.tsx | 30 ++++- frontend/src/components/ResultPanel.tsx | 52 ++++++++- frontend/src/pages/GamePage.tsx | 103 ++++++++++++++--- frontend/src/services/api.ts | 24 ++++ frontend/src/state/roomStore.ts | 44 +++++++- frontend/src/styles/app.css | 9 ++ 7 files changed, 365 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 00000000..82786bbb --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { DrawingPoint, DrawingStroke } from "../services/api"; + +interface DrawingCanvasProps { + strokes: DrawingStroke[]; + canDraw: boolean; + onStrokeComplete: (stroke: DrawingStroke) => void; + onClear: () => void; +} + +const STROKE_COLOR = "#111827"; +const STROKE_WIDTH = 3; + +function pointsToPath(points: DrawingPoint[]) { + if (points.length === 0) { + return ""; + } + + const [first, ...rest] = points; + return `M ${first.x} ${first.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`; +} + +function getRelativePoint( + event: React.PointerEvent, + svg: SVGSVGElement +): DrawingPoint { + const rect = svg.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; +} + +export function DrawingCanvas({ strokes, canDraw, onStrokeComplete, onClear }: DrawingCanvasProps) { + const svgRef = useRef(null); + const activePointsRef = useRef([]); + const isDrawingRef = useRef(false); + + const finishStroke = useCallback(() => { + if (activePointsRef.current.length < 2) { + activePointsRef.current = []; + isDrawingRef.current = false; + return; + } + + onStrokeComplete({ + id: crypto.randomUUID(), + color: STROKE_COLOR, + width: STROKE_WIDTH, + points: activePointsRef.current + }); + + activePointsRef.current = []; + isDrawingRef.current = false; + }, [onStrokeComplete]); + + useEffect(() => { + if (!canDraw) { + return; + } + + function handleWindowPointerUp() { + if (isDrawingRef.current) { + finishStroke(); + } + } + + window.addEventListener("pointerup", handleWindowPointerUp); + return () => { + window.removeEventListener("pointerup", handleWindowPointerUp); + }; + }, [canDraw, finishStroke]); + + function handlePointerDown(event: React.PointerEvent) { + if (!canDraw || !svgRef.current) { + return; + } + + event.preventDefault(); + svgRef.current.setPointerCapture(event.pointerId); + isDrawingRef.current = true; + activePointsRef.current = [getRelativePoint(event, svgRef.current)]; + } + + function handlePointerMove(event: React.PointerEvent) { + if (!canDraw || !isDrawingRef.current || !svgRef.current) { + return; + } + + activePointsRef.current.push(getRelativePoint(event, svgRef.current)); + } + + function handlePointerUp() { + if (isDrawingRef.current) { + finishStroke(); + } + } + + return ( +
    + + {strokes.map((stroke) => ( + + ))} + + {canDraw ? ( +
    + +
    + ) : null} +
    + ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..43302ee2 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -2,13 +2,32 @@ import { useState } from "react"; interface GuessFormProps { disabled?: boolean; + onSubmitGuess: (text: string) => Promise; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ disabled = false, onSubmitGuess }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + if (!guessText.trim()) { + setError("Guess is required"); + return; + } + + try { + setError(null); + setIsSubmitting(true); + await onSubmitGuess(guessText.trim()); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } finally { + setIsSubmitting(false); + } } return ( @@ -19,12 +38,13 @@ export function GuessForm({ disabled = false }: GuessFormProps) { value={guessText} onChange={(event) => setGuessText(event.target.value)} placeholder="Type your guess here..." - disabled={disabled} + disabled={disabled || isSubmitting} /> + {error ?

    {error}

    : null}
    -
    diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..59102179 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,53 @@ import { Card } from "./Card"; +import type { Guess, ParticipantView } from "../services/api"; + +interface ResultPanelProps { + status: "lobby" | "playing" | "result"; + secretWord?: string; + participants: ParticipantView[]; + guesses: Guess[]; +} + +export function ResultPanel({ status, secretWord, participants, guesses }: ResultPanelProps) { + if (status !== "result") { + return ( + +

    Results will appear here when the round ends.

    +
    + ); + } -export function ResultPanel() { return ( - -
    -

    Game activity and guesses will appear here.

    -
    + +

    + The word was: {secretWord ?? "Unknown"} +

    + +

    Final Scores

    +
      + {participants.map((participant) => ( +
    • + {participant.name} + {participant.score} pts +
    • + ))} +
    + +

    Guess History

    + {guesses.length === 0 ? ( +

    No guesses were submitted.

    + ) : ( +
      + {guesses.map((guess) => ( +
    • + + {guess.participantName}: {guess.text} + + {guess.isCorrect ? "correct" : "wrong"} +
    • + ))} +
    + )}
    ); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 7a7a7b3c..d367ff54 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; +import { DrawingCanvas } from "../components/DrawingCanvas"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; +import type { DrawingStroke } from "../services/api"; import { useRoomState, useRoomStore } from "../state/roomStore"; const POLL_INTERVAL_MS = 2000; @@ -12,8 +14,11 @@ const POLL_INTERVAL_MS = 2000; export function GamePage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, participantId } = useRoomState(); + const { room, participantId, isLoading } = useRoomState(); const [pollError, setPollError] = useState(null); + const [actionError, setActionError] = useState(null); + + const isHost = room && participantId ? room.hostId === participantId : false; useEffect(() => { if (!room) { @@ -27,12 +32,6 @@ export function GamePage() { } }, [navigate, room?.status]); - useEffect(() => { - if (room?.status === "result") { - // Phase 4 will show result UI; stay on game page for now - } - }, [room?.status]); - const pollRoom = useCallback(async () => { try { setPollError(null); @@ -43,7 +42,7 @@ export function GamePage() { }, [roomStore]); useEffect(() => { - if (!room || room.status !== "playing") { + if (!room || (room.status !== "playing" && room.status !== "result")) { return; } @@ -56,6 +55,38 @@ export function GamePage() { }; }, [room, pollRoom]); + async function handleStrokeComplete(stroke: DrawingStroke) { + try { + setActionError(null); + await roomStore.addStroke(stroke); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to save stroke"); + } + } + + async function handleClearDrawing() { + try { + setActionError(null); + await roomStore.clearDrawing(); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to clear canvas"); + } + } + + async function handleSubmitGuess(text: string) { + await roomStore.submitGuess(text); + } + + async function handleRestart() { + try { + setActionError(null); + await roomStore.restartGame(); + navigate("/lobby"); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to restart game"); + } + } + if (!room) { return null; } @@ -63,27 +94,36 @@ export function GamePage() { const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; const drawer = room.participants.find((participant) => participant.id === room.drawerId) ?? null; const isDrawer = viewer?.role === "drawer"; + const isPlaying = room.status === "playing"; return (
    - Round 1 -

    Guess the Word!

    + {room.status === "result" ? "Round Complete" : "Round 1"} +

    + {room.status === "result" ? "Results" : "Guess the Word!"} +

    {pollError ?

    {pollError}

    : null} + {actionError ?

    {actionError}

    : null}
    - {isDrawer && room.secretWord ? ( + {isDrawer && room.secretWord && isPlaying ? (

    Draw: {room.secretWord} @@ -92,9 +132,29 @@ export function GamePage() { ) : null} -

    - {drawer ? `${drawer.name} is drawing...` : "Waiting for drawer..."} -
    + +
    + + + {room.guesses.length === 0 ? ( +

    No guesses yet.

    + ) : ( +
      + {room.guesses.map((guess) => ( +
    • + + {guess.participantName}: {guess.text} + + {guess.isCorrect ? "correct" : "wrong"} +
    • + ))} +
    + )}
    @@ -116,13 +176,20 @@ export function GamePage() { - - - + {isPlaying ? ( + + + + ) : null}
    + {room.status === "result" && isHost ? ( + + ) : null} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index bbdc117d..825a5d93 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -94,5 +94,29 @@ export const api = { method: "POST", body: JSON.stringify({ participantId }) }); + }, + addStroke(code: string, participantId: string, stroke: DrawingStroke) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing/strokes`, { + method: "POST", + body: JSON.stringify({ participantId, stroke }) + }); + }, + clearDrawing(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guesses`, { + method: "POST", + body: JSON.stringify({ participantId, text }) + }); + }, + restartRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 62988956..456bf33b 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -7,7 +7,7 @@ import { useSyncExternalStore, type PropsWithChildren } from "react"; -import { api, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; +import { api, type DrawingStroke, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -110,6 +110,48 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async addStroke(stroke: DrawingStroke) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await api.addStroke(this.state.room.code, this.state.participantId, stroke); + this.setRoomSnapshot(response.room); + return response.room; + } + + async clearDrawing() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await api.clearDrawing(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await api.submitGuess(this.state.room.code, this.state.participantId, text); + this.setRoomSnapshot(response.room); + return response.room; + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.restartRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6dd..928d5784 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -451,6 +451,15 @@ input { font-weight: 500; } +.drawing-canvas__surface { + width: 100%; + min-height: 500px; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + display: block; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { From 3e089a7ddb9b3f52b42a015d9f15415841c34046 Mon Sep 17 00:00:00 2001 From: anazri-seek Date: Wed, 24 Jun 2026 13:47:02 +0800 Subject: [PATCH 16/16] docs: add reflection report for lab submission Co-authored-by: Cursor --- reflection.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 reflection.md diff --git a/reflection.md b/reflection.md new file mode 100644 index 00000000..8496a25e --- /dev/null +++ b/reflection.md @@ -0,0 +1,53 @@ +# Reflection Report + +## What did the starter app already have? + +The starter provided a runnable Scribble-branded shell with React Router pages for Start, Create Room, Join Room, Lobby, and Game. The backend exposed `GET /health`, `POST /rooms`, `POST /rooms/:code/join`, and `GET /rooms/:code` with in-memory room storage. Create and join flows worked for the happy path, and the lobby displayed participants with a manual refresh button. The game screen had placeholder UI for canvas, guesses, scoreboard, and results but no functional gameplay. + +## What did you add? + +### Spec Kit artifacts +- Discovery notes documenting starter gaps, assumptions, and relevant files +- Constitution covering engineering principles, polling-only sync, and AI usage rules +- Four feature spec/plan/tasks sets aligned to Scenarios 1–4 + +### Scenario 1 — Room Setup & Lobby +- Host tracking (`hostId`) assigned on room creation +- Room code validation with clear error messages +- Automatic lobby polling every ~2 seconds +- Host-only start game with 2-player minimum via `POST /rooms/:code/start` +- Fixed frontend API base URL bug (`/bug` suffix removed) + +### Scenario 2 — Game Start & Drawer Flow +- Trimmed player name validation on create/join (frontend and backend) +- Drawer assignment (host becomes drawer) and deterministic secret word selection +- Viewer-specific snapshots: secret word visible only to drawer during play + +### Scenario 3 — Gameplay Interaction +- Interactive SVG drawing canvas with stroke sync via polling +- Clear canvas action for drawer +- Guess submission with trim, case-insensitive matching, and synced guess history +- Correct guesses award 100 points and end the round + +### Scenario 4 — Result, Restart & Final Validation +- Result panel showing revealed word, final scores, and full guess history +- Host-only restart via `POST /rooms/:code/restart` returning all players to lobby with round state cleared + +## AI-assisted workflow decisions + +- Worked phase-by-phase: specify → plan → tasks → implement → validate before moving on +- Kept commits granular and mapped to logical slices (artifacts, backend service, routes, frontend UI) +- Extended the starter's existing patterns (Zod validation, roomStore class, page structure) rather than rewriting +- Used HTTP polling exclusively for all multi-player sync as required by the lab constraints + +## Tradeoffs + +- **Stroke-based drawing over pixel canvas**: Strokes sync efficiently as JSON over REST but may produce large payloads for long drawings. Acceptable for a single-round lab scope. +- **Deterministic word from room code**: Simple and testable; same room always gets the same word, which aids debugging but is predictable if codes are reused after restart (word changes only when a new game starts with fresh assignment). +- **Polling at 2s**: Simple and within spec, but drawing and guesses can appear slightly delayed compared to real-time push (intentionally out of scope). + +## Validation performed + +- Backend and frontend unit tests pass (`npm test`) +- Production builds succeed (`npm run build`) +- Pre-evaluation artifact structure verified (constitution, 4 spec folders, reflection)