From 237b9f1275d271e4278d978eeeba9397b5f7d1ff Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:15:21 +0800 Subject: [PATCH 1/6] docs: add discovery notes and constitution Co-authored-by: Cursor --- .specify/memory/constitution.md | 32 +++++++++++++++++++++++++++++++ .specify/memory/discovery.md | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .specify/memory/constitution.md create mode 100644 .specify/memory/discovery.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..fa223aea --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,32 @@ +# Scribble Lab Constitution + +## Engineering Principles + +1. **Brownfield first** — Extend the starter's patterns (Express routes, Zod validation, custom RoomStore with `useSyncExternalStore`). Do not rewrite or replace working scaffolding. +2. **HTTP polling only** — All multiplayer sync uses periodic `GET /rooms/:code` requests (~2s). No WebSockets, Socket.io, or push protocols. +3. **In-memory state** — All room data lives in the backend `Map`. No databases, files, or external persistence. +4. **Deterministic game rules** — Word selection, scoring (+100 correct, 0 incorrect), and role assignment must produce the same outcome for the same inputs. Document the algorithm in spec and plan. +5. **Fail fast with clear errors** — Invalid inputs return 400 with human-readable messages. Unknown rooms return 404. Unauthorized actions (non-host start, drawer guessing) return 403. +6. **TypeScript strictness** — No `any`. Shared concepts typed in backend models and mirrored in frontend API types. + +## AI Usage Rules + +1. **Spec before code** — Each feature group gets spec → plan → tasks before implementation commits. +2. **Review all AI output** — Verify generated code against acceptance criteria in two browser tabs before committing. +3. **No scope creep** — Do not add timers, multi-round rotation, auth, databases, or new dependencies unless the spec justifies them. +4. **Traceable commits** — Each commit maps to a task in `tasks.md`. Commit messages use conventional prefixes (`feat`, `fix`, `docs`, `test`). +5. **Artifacts stay aligned** — If implementation deviates from spec, update the spec and document the reason. + +## Review Discipline + +1. **Two-tab validation** — Every scenario is verified with two browser tabs simulating host and joiner. +2. **Edge cases required** — Empty names, invalid room codes, case-insensitive guesses, multi-room isolation. +3. **Build must pass** — Run `npm run build` and `npm test` in both backend and frontend before PR. +4. **Pre-evaluation check** — Ensure constitution (≥800 chars), 4 feature folders with spec/plan/tasks, and reflection (≥500 chars) exist. +5. **Self-review checklist** — Before each commit: Does behavior match spec? Are forbidden technologies absent? Is viewer-specific data filtered on the backend? + +## Testing Expectations + +- Extend existing Vitest tests for roomStore and API schemas. +- Test name validation, host assignment, start preconditions, guess scoring, and restart reset in backend unit tests. +- Manual two-tab testing is the primary integration validation method for this lab. diff --git a/.specify/memory/discovery.md b/.specify/memory/discovery.md new file mode 100644 index 00000000..0c7698bd --- /dev/null +++ b/.specify/memory/discovery.md @@ -0,0 +1,34 @@ +# Discovery Notes — Scribble Starter + +## Incomplete Behaviors (≥3) + +1. **No host tracking** — Room creator is not marked as host; any participant can click "Start Game" and navigate to `/game` without a backend call. +2. **No automatic lobby polling** — Participants only appear after manual "Refresh Room"; the assignment requires ~2s HTTP polling. +3. **Game screen is placeholder-only** — Canvas is a static div, guess form is a no-op, scoreboard and result panel show hardcoded placeholder text. +4. **No viewer-specific API responses** — `toRoomSnapshot` ignores `viewerParticipantId`; secret word cannot be hidden from guessers. +5. **Player name validation missing** — Empty or whitespace-only names silently become `"Player"` on both backend and frontend. +6. **Broken default API URL** — Frontend defaults to `http://localhost:3001/bug` instead of `http://localhost:3001`. + +## Assumptions (≥2) + +1. **Single round per game session** — The lab scope is one round: start → draw/guess → result → restart to lobby. No drawer rotation or multi-round logic. +2. **Round ends on first correct guess** — When a guesser submits a case-insensitive match, status transitions to `result` and scoring is final. +3. **Host is always the drawer** — On game start, the room creator (host) becomes the drawer for the single round. +4. **Deterministic word selection** — Secret word is chosen by hashing the room code against the starter word list (same word for the same room code every time). +5. **Polling is the sole sync mechanism** — All state (participants, strokes, guesses, scores, status) syncs via `GET /rooms/:code?participantId=...` at ~2s intervals. + +## Relevant Files + +### Backend +- `backend/src/models/game.ts` — Room and participant types +- `backend/src/services/roomStore.ts` — In-memory store and snapshot builder +- `backend/src/api/rooms.ts` — HTTP route handlers +- `backend/src/api/schemas.ts` — Zod request validation +- `backend/src/seed/starterData.ts` — Word list and roles + +### Frontend +- `frontend/src/services/api.ts` — REST client and types +- `frontend/src/state/roomStore.ts` — Global room state +- `frontend/src/pages/LobbyPage.tsx` — Lobby UI (manual refresh only) +- `frontend/src/pages/GamePage.tsx` — Placeholder game UI +- `frontend/src/components/GuessForm.tsx`, `Scoreboard.tsx`, `ResultPanel.tsx` — Non-functional placeholders From 47133e8b0ebf2ddd05a2c951283d9e9c988577ba Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:15:27 +0800 Subject: [PATCH 2/6] docs: add feature specs, plans, and tasks for all four scenarios Co-authored-by: Cursor --- specs/001-room-lobby/plan.md | 54 +++++++++++++++++++++++++++++++ specs/001-room-lobby/spec.md | 43 ++++++++++++++++++++++++ specs/001-room-lobby/tasks.md | 21 ++++++++++++ specs/002-game-start/plan.md | 54 +++++++++++++++++++++++++++++++ specs/002-game-start/spec.md | 42 ++++++++++++++++++++++++ specs/002-game-start/tasks.md | 19 +++++++++++ specs/003-gameplay/plan.md | 44 +++++++++++++++++++++++++ specs/003-gameplay/spec.md | 47 +++++++++++++++++++++++++++ specs/003-gameplay/tasks.md | 22 +++++++++++++ specs/004-result-restart/plan.md | 49 ++++++++++++++++++++++++++++ specs/004-result-restart/spec.md | 39 ++++++++++++++++++++++ specs/004-result-restart/tasks.md | 19 +++++++++++ 12 files changed, 453 insertions(+) create mode 100644 specs/001-room-lobby/plan.md create mode 100644 specs/001-room-lobby/spec.md create mode 100644 specs/001-room-lobby/tasks.md 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 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/001-room-lobby/plan.md b/specs/001-room-lobby/plan.md new file mode 100644 index 00000000..b963098e --- /dev/null +++ b/specs/001-room-lobby/plan.md @@ -0,0 +1,54 @@ +# Implementation Plan: Room Setup & Lobby + +## Findings + +Starter provides create/join/fetch endpoints and manual lobby refresh. Missing: `hostId`, start endpoint, polling, join validation. + +## State Model Changes + +```typescript +interface Room { + hostId: string; // participantId of creator + // existing fields unchanged for lobby +} +interface RoomSnapshot { + hostId: string; + isHost: boolean; // computed: viewerParticipantId === hostId +} +``` + +## Endpoints + +| Method | Path | Notes | +|--------|------|-------| +| POST | `/rooms/:code/start` | Body: `{ participantId }`. Host only, ≥2 players. Returns updated snapshot. | + +## File Changes + +### Backend +- `models/game.ts` — add `hostId` to Room and RoomSnapshot +- `services/roomStore.ts` — set `hostId` on create; add `startGame(code, participantId)` +- `api/schemas.ts` — validate room code non-empty; add `startGameSchema` +- `api/rooms.ts` — add start route + +### Frontend +- `services/api.ts` — fix API base URL; add `startGame`; extend types +- `hooks/useRoomPolling.ts` — new hook, 2000ms interval +- `pages/LobbyPage.tsx` — polling, host badge, conditional start button +- `pages/CreateRoomPage.tsx`, `JoinRoomPage.tsx` — client-side code validation +- `state/roomStore.ts` — add `startGame()` action + +## Data Flow + +1. Create room → backend sets `hostId = participantId` → frontend stores session → navigate lobby +2. Lobby mounts → `useRoomPolling(2000)` → repeated `GET /rooms/:code?participantId=...` +3. Host clicks Start → `POST /rooms/:code/start` → navigate `/game` (full game logic in phase 2) + +## Testing Strategy + +- Unit: `createRoom` sets hostId; `startGame` rejects non-host and <2 players +- Manual: two tabs, verify auto-join visibility and host-only start + +## Risks + +- Polling continues after unmount if cleanup missed → use `useEffect` return cleanup diff --git a/specs/001-room-lobby/spec.md b/specs/001-room-lobby/spec.md new file mode 100644 index 00000000..3fa56310 --- /dev/null +++ b/specs/001-room-lobby/spec.md @@ -0,0 +1,43 @@ +# Feature Specification: Room Setup & Lobby (Scenario 1) + +## Overview + +Players create or join isolated rooms via a unique 4-character code. The creator is the host. The lobby auto-refreshes via polling. Only the host can start the game when at least 2 players are present. + +## User Stories + +- **US-1.1** As a host, I create a room and am identified as host in the lobby. +- **US-1.2** As a player, I join a room with a valid code and see myself in the participant list within ~2 seconds. +- **US-1.3** As a player, I receive clear feedback when I enter an invalid or empty room code. +- **US-1.4** As a non-host, I cannot start the game and see a waiting message. +- **US-1.5** As a host with 2+ players, I can start the game via the backend. + +## Acceptance Criteria + +### AC-1.1 Host on create +- **Given** a player creates a room, **When** the room is created, **Then** that player's `participantId` equals `hostId` in the room snapshot. + +### AC-1.2 Join validation +- **Given** a player submits an empty or whitespace-only room code, **When** they attempt to join, **Then** the frontend shows a validation error without calling the API. +- **Given** a player submits a code for a non-existent room, **When** they join, **Then** the API returns 404 with message "Unable to join room". + +### AC-1.3 Room isolation +- **Given** two rooms A and B exist, **When** a player joins room A, **Then** they only see participants from room A. + +### AC-1.4 Lobby polling +- **Given** a player is on the lobby page, **When** another player joins, **Then** the participant list updates within ~2 seconds without manual refresh. + +### AC-1.5 Host-only start +- **Given** a room with fewer than 2 players, **When** the host views the lobby, **Then** the Start Game button is disabled. +- **Given** a non-host player, **When** they view the lobby, **Then** no Start Game button is shown. +- **Given** a host with 2+ players, **When** they click Start Game, **Then** `POST /rooms/:code/start` is called and succeeds. + +## Edge Cases + +- Room code param with only whitespace → 400 "Room code is required" +- Host leaves (out of scope — no leave endpoint) +- Concurrent joins to same room → both appear in participant list on next poll + +## Out of Scope + +- WebSockets, auth, room passwords, kick/mute diff --git a/specs/001-room-lobby/tasks.md b/specs/001-room-lobby/tasks.md new file mode 100644 index 00000000..9044f5d0 --- /dev/null +++ b/specs/001-room-lobby/tasks.md @@ -0,0 +1,21 @@ +# Tasks: Room Setup & Lobby + +## Dependencies + +None — first feature group. + +## Ordered Tasks + +1. [ ] Fix frontend API base URL (`/bug` → correct port) +2. [ ] Add `hostId` to backend Room model and set on `createRoom` +3. [ ] Extend `toRoomSnapshot` with `hostId` and computed `isHost` +4. [ ] Add Zod validation for non-empty room code param +5. [ ] Implement `startGame(code, participantId)` in roomStore (host check, ≥2 players) +6. [ ] Add `POST /rooms/:code/start` route +7. [ ] Extend frontend API types and `startGame` method +8. [ ] Add `startGame` to frontend roomStore +9. [ ] Create `useRoomPolling` hook (2000ms, cleanup on unmount) +10. [ ] Update LobbyPage: polling, host badge, host-only start button +11. [ ] Add client-side empty room code validation on JoinRoomPage +12. [ ] Add backend unit tests for hostId and startGame preconditions +13. [ ] Validate with two browser tabs diff --git a/specs/002-game-start/plan.md b/specs/002-game-start/plan.md new file mode 100644 index 00000000..63552c53 --- /dev/null +++ b/specs/002-game-start/plan.md @@ -0,0 +1,54 @@ +# Implementation Plan: Game Start & Drawer Flow + +## State Model Changes + +```typescript +type RoomStatus = "lobby" | "playing" | "result"; + +interface Participant { score: number; /* existing */ } + +interface Room { + status: RoomStatus; + drawerId?: string; + secretWord?: string; + strokes: Stroke[]; + guesses: Guess[]; +} + +interface RoomSnapshot { + status: RoomStatus; + drawerId?: string; + secretWord?: string; // only when viewer is drawer and status is playing +} +``` + +## Word Selection Algorithm + +```typescript +function selectWord(code: string): string { + const sum = code.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); + return STARTER_WORDS[sum % STARTER_WORDS.length]; +} +``` + +## File Changes + +### Backend +- `models/game.ts` — extend status, add score, strokes, guesses types +- `services/roomStore.ts` — name trim/reject; startGame sets drawerId, secretWord, status playing +- `toRoomSnapshot` — filter secretWord by viewer +- `api/schemas.ts` — required trimmed playerName + +### Frontend +- CreateRoomPage, JoinRoomPage — trim name, reject empty client-side +- GamePage — show drawer badge, secret word panel for drawer only +- LobbyPage — auto-navigate to /game when status playing + +## Data Flow + +Start game → backend: status=playing, drawerId=hostId, secretWord=selectWord(code), scores=0 → all clients poll → navigate to game → drawer sees word + +## Testing + +- Backend: empty name rejected; secretWord absent for non-drawer snapshot +- Manual: drawer tab sees word, guesser tab does not diff --git a/specs/002-game-start/spec.md b/specs/002-game-start/spec.md new file mode 100644 index 00000000..15142c7b --- /dev/null +++ b/specs/002-game-start/spec.md @@ -0,0 +1,42 @@ +# Feature Specification: Game Start & Drawer Flow (Scenario 2) + +## Overview + +When the host starts the game, player names are validated (trim, reject empty). The host becomes the drawer. A secret word is deterministically selected and visible only to the drawer. + +## User Stories + +- **US-2.1** As a player, I must enter a non-empty trimmed name to create or join a room. +- **US-2.2** As the host/drawer, I see the secret word on the game screen. +- **US-2.3** As a guesser, I do not see the secret word. +- **US-2.4** As any player, I can identify who is the drawer. + +## Acceptance Criteria + +### AC-2.1 Name validation +- **Given** a player submits whitespace-only or empty name, **When** they create or join, **Then** API returns 400 "Player name is required" and frontend shows the error. + +### AC-2.2 Game start transition +- **Given** host starts game with 2+ players, **When** start succeeds, **Then** room status becomes `playing`, all scores reset to 0. + +### AC-2.3 Drawer assignment +- **Given** game starts, **When** snapshot is fetched, **Then** `drawerId` equals `hostId` (host is drawer). + +### AC-2.4 Secret word — drawer only +- **Given** a drawer fetches the room, **When** status is `playing`, **Then** snapshot includes `secretWord`. +- **Given** a guesser fetches the room, **When** status is `playing`, **Then** snapshot does NOT include `secretWord`. + +### AC-2.5 Deterministic word +- **Given** room code "ABCD", **When** game starts, **Then** word is always the same index from STARTER_WORDS (hash of code % length). + +### AC-2.6 Auto-navigation +- **Given** a player is in lobby, **When** polled snapshot shows `status === "playing"`, **Then** they are navigated to `/game`. + +## Edge Cases + +- Start called twice → 400 "Game already started" +- Name with leading/trailing spaces → trimmed before validation + +## Out of Scope + +- Drawer rotation, custom word packs, timers diff --git a/specs/002-game-start/tasks.md b/specs/002-game-start/tasks.md new file mode 100644 index 00000000..65b7ba63 --- /dev/null +++ b/specs/002-game-start/tasks.md @@ -0,0 +1,19 @@ +# Tasks: Game Start & Drawer Flow + +## Dependencies + +- 001-room-lobby (hostId, start endpoint skeleton) + +## Ordered Tasks + +1. [ ] Extend RoomStatus and Participant.score in models +2. [ ] Add name trim/reject validation in schemas and roomStore +3. [ ] Implement deterministic word selection in roomStore +4. [ ] Update startGame to set playing status, drawerId, secretWord, reset scores +5. [ ] Implement viewer-specific secretWord in toRoomSnapshot +6. [ ] Update frontend types for playing status, drawerId, secretWord +7. [ ] Add client-side name validation on create/join pages +8. [ ] Update GamePage: drawer label, secret word panel (drawer only) +9. [ ] Add lobby auto-navigate to /game when status is playing +10. [ ] Add backend tests for name validation and word visibility +11. [ ] Validate drawer/guesser views in two tabs diff --git a/specs/003-gameplay/plan.md b/specs/003-gameplay/plan.md new file mode 100644 index 00000000..b42e0924 --- /dev/null +++ b/specs/003-gameplay/plan.md @@ -0,0 +1,44 @@ +# Implementation Plan: Gameplay Interaction + +## Endpoints + +| Method | Path | Body | Auth | +|--------|------|------|------| +| POST | `/rooms/:code/strokes` | `{ participantId, stroke: { points } }` | drawer only | +| POST | `/rooms/:code/clear` | `{ participantId }` | drawer only | +| POST | `/rooms/:code/guess` | `{ participantId, guess }` | guessers only | + +## Stroke Model + +```typescript +interface Stroke { + points: { x: number; y: number }[]; +} +``` + +Coordinates normalized 0–1 relative to canvas size for resolution independence. + +## File Changes + +### Backend +- `roomStore.ts` — `addStroke`, `clearCanvas`, `submitGuess` (trim, compare, score, end round) +- `api/rooms.ts` — three new routes +- `api/schemas.ts` — stroke and guess schemas + +### Frontend +- `components/DrawingCanvas.tsx` — HTML5 canvas, mouse events, POST strokes on pointer up +- `components/GuessForm.tsx` — wired to API +- `components/Scoreboard.tsx` — live scores from snapshot +- `components/ResultPanel.tsx` — guess history list +- `GamePage.tsx` — compose canvas, polling, role-based UI +- `hooks/useRoomPolling.ts` — reuse on game page + +## Data Flow + +Drawer draws → pointer up → POST stroke → poll → all clients render strokes from snapshot +Guesser guesses → POST guess → poll → history + score update → correct → status result + +## Testing + +- Unit: correct guess +100, wrong +0, case insensitive, empty rejected +- Manual: two tabs draw/guess sync diff --git a/specs/003-gameplay/spec.md b/specs/003-gameplay/spec.md new file mode 100644 index 00000000..c4bcfae4 --- /dev/null +++ b/specs/003-gameplay/spec.md @@ -0,0 +1,47 @@ +# Feature Specification: Gameplay Interaction (Scenario 3) + +## Overview + +During an active round, the drawer draws on an interactive canvas and can clear it. Guessers submit guesses. All state syncs via polling. Correct guesses score 100; incorrect score 0. + +## User Stories + +- **US-3.1** As the drawer, I can draw on the canvas and my strokes appear on my screen. +- **US-3.2** As a guesser, I see the drawer's strokes after the next poll. +- **US-3.3** As the drawer, I can clear the canvas for all players. +- **US-3.4** As a guesser, I submit guesses that appear in synced history. +- **US-3.5** As a guesser, a correct guess (case-insensitive) awards 100 points. + +## Acceptance Criteria + +### AC-3.1 Drawing +- **Given** the drawer draws on canvas, **When** strokes are POSTed, **Then** they are stored and returned in subsequent GET snapshots for all players. + +### AC-3.2 Clear canvas +- **Given** the drawer clicks Clear, **When** clear is POSTed, **Then** strokes array is empty for all players on next poll. + +### AC-3.3 Guess validation +- **Given** a guesser submits empty/whitespace guess, **When** submitted, **Then** 400 "Guess is required". +- **Given** a drawer tries to guess, **When** submitted, **Then** 403 "Drawer cannot guess". + +### AC-3.4 Guess comparison +- **Given** secret word is "rocket", **When** guesser submits "Rocket", **Then** marked correct, +100 score. + +### AC-3.5 Guess history sync +- **Given** a guess is submitted, **When** any player polls, **Then** guess appears in history with player name, text, and correct flag. + +### AC-3.6 Game polling +- **Given** status is playing, **When** on game page, **Then** room polls every ~2s. + +### AC-3.7 Round end +- **Given** a correct guess, **When** processed, **Then** status becomes `result`. + +## Edge Cases + +- Wrong guess adds 0 points but appears in history +- Multiple wrong guesses allowed until correct +- Non-drawer cannot POST strokes or clear + +## Out of Scope + +- Stroke smoothing libraries, undo, color picker, eraser modes diff --git a/specs/003-gameplay/tasks.md b/specs/003-gameplay/tasks.md new file mode 100644 index 00000000..0922c960 --- /dev/null +++ b/specs/003-gameplay/tasks.md @@ -0,0 +1,22 @@ +# Tasks: Gameplay Interaction + +## Dependencies + +- 002-game-start (playing status, drawerId, secretWord) + +## Ordered Tasks + +1. [ ] Add Stroke and Guess types to backend models +2. [ ] Implement addStroke, clearCanvas, submitGuess in roomStore +3. [ ] Add Zod schemas for stroke, clear, guess payloads +4. [ ] Add POST routes for strokes, clear, guess +5. [ ] Extend toRoomSnapshot with strokes, guesses, participant scores +6. [ ] Extend frontend API with addStroke, clearCanvas, submitGuess +7. [ ] Extend roomStore with game actions +8. [ ] Create DrawingCanvas component with normalized coordinates +9. [ ] Wire GuessForm to submitGuess with validation +10. [ ] Update Scoreboard with live scores +11. [ ] Update ResultPanel with guess history +12. [ ] Update GamePage: role-based canvas/guess UI, polling while playing +13. [ ] Add backend tests for scoring and guess validation +14. [ ] Validate draw/guess/score sync in two tabs diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 00000000..4c48af66 --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,49 @@ +# Implementation Plan: Result, Restart & Final Validation + +## Endpoints + +| Method | Path | Body | Auth | +|--------|------|------|------| +| POST | `/rooms/:code/restart` | `{ participantId }` | host only, status must be result | + +## Snapshot Changes for Result + +When `status === "result"`: include `secretWord` for ALL viewers (not just drawer). + +## restartGame Logic + +```typescript +function restartGame(code, participantId) { + // verify host, status === result + room.status = "lobby"; + room.drawerId = undefined; + room.secretWord = undefined; + room.strokes = []; + room.guesses = []; + room.participants.forEach(p => p.score = 0); +} +``` + +## File Changes + +### Backend +- `roomStore.ts` — restartGame; toRoomSnapshot exposes secretWord in result for all +- `api/rooms.ts` — restart route + +### Frontend +- `GamePage.tsx` — result view UI, host restart button +- `LobbyPage.tsx` — auto-navigate from game when status returns to lobby +- `roomStore.ts` — restartGame action + +## Full Flow Validation + +1. Tab1 host creates, Tab2 joins +2. Host starts → drawer sees word +3. Drawer draws, guesser guesses wrong then correct +4. Both see result with word/scores/history +5. Host restarts → both in lobby, 2 players, clean state + +## Testing + +- Unit: restart clears round state, preserves participants +- Manual: full end-to-end two-tab flow diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 00000000..2afa636a --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,39 @@ +# Feature Specification: Result, Restart & Final Validation (Scenario 4) + +## Overview + +When a round ends (correct guess), all players see the result: correct word, final scores, full guess history. The host can restart, returning everyone to lobby with players preserved and round state cleared. + +## User Stories + +- **US-4.1** As any player, I see the correct word when the round ends. +- **US-4.2** As any player, I see final scores and complete guess history on the result screen. +- **US-4.3** As the host, I can restart the game back to lobby. +- **US-4.4** As any player, after restart I return to lobby with all players still present. + +## Acceptance Criteria + +### AC-4.1 Result state +- **Given** a correct guess ends the round, **When** any player polls, **Then** status is `result`, `secretWord` visible to all, scores and guesses preserved. + +### AC-4.2 Result UI +- **Given** status is `result`, **When** on game page, **Then** UI shows word, scores, and guess history prominently. + +### AC-4.3 Host restart +- **Given** host on result screen, **When** they click Restart, **Then** `POST /rooms/:code/restart` succeeds. +- **Given** non-host, **When** on result screen, **Then** no Restart button shown. + +### AC-4.4 Restart reset +- **Given** restart succeeds, **When** any player polls, **Then** status is `lobby`, strokes/guesses/secretWord/drawerId cleared, participants and hostId preserved, scores reset to 0. + +### AC-4.5 Restart navigation +- **Given** restart succeeds, **When** clients poll, **Then** they navigate to `/lobby`. + +## Edge Cases + +- Restart called when not in result → 400 +- Non-host restart attempt → 403 + +## Out of Scope + +- Multiple rounds without restart, persistent score across rounds diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 00000000..a1f9602f --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,19 @@ +# Tasks: Result, Restart & Final Validation + +## Dependencies + +- 003-gameplay (correct guess → result status) + +## Ordered Tasks + +1. [ ] Update toRoomSnapshot: expose secretWord to all when status is result +2. [ ] Implement restartGame in roomStore (host check, status check, reset) +3. [ ] Add restart route and schema +4. [ ] Extend frontend API and roomStore with restartGame +5. [ ] Update GamePage: result view (word, scores, history) +6. [ ] Add host-only Restart button on result screen +7. [ ] Auto-navigate to /lobby when polled status returns to lobby +8. [ ] Add backend tests for restart reset behavior +9. [ ] Run full two-tab end-to-end validation +10. [ ] Run backend/frontend build and test +11. [ ] Write reflection.md From 075b3235766963cefeb329c2271a5245175aef48 Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:15:27 +0800 Subject: [PATCH 3/6] fix(frontend): correct default API base URL Co-authored-by: Cursor --- frontend/src/services/api.ts | 60 ++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..a467ac9d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,15 +1,41 @@ export type ParticipantRole = "drawer" | "guesser"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface StrokePoint { + x: number; + y: number; +} + +export interface Stroke { + points: StrokePoint[]; +} + +export interface Guess { + id: string; + participantId: string; + participantName: string; + text: string; + correct: boolean; + createdAt: string; } export interface RoomSnapshot { code: string; - status: "lobby"; + status: RoomStatus; + hostId: string; + isHost: boolean; + drawerId?: string; + secretWord?: string; participants: Participant[]; + strokes: Stroke[]; + guesses: Guess[]; availableWords: string[]; roles: ParticipantRole[]; } @@ -19,7 +45,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 +83,35 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + addStroke(code: string, participantId: string, stroke: Stroke) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/strokes`, { + method: "POST", + body: JSON.stringify({ participantId, stroke }) + }); + }, + clearCanvas(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, guess }) + }); + }, + restartGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; From 622cf081324af98bb51c7f979420e9b5e66dc524 Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:15:56 +0800 Subject: [PATCH 4/6] feat(backend): implement room host, game flow, drawing, guesses, and restart Co-authored-by: Cursor --- backend/src/api/rooms.ts | 94 +++++++++++- backend/src/api/schemas.ts | 68 ++++++++- backend/src/models/game.ts | 32 ++++- backend/src/services/roomStore.test.ts | 95 +++++++++++- backend/src/services/roomStore.ts | 192 +++++++++++++++++++++++-- 5 files changed, 463 insertions(+), 18 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..0bf67f13 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,26 @@ import { Router } from "express"; import { + addStrokeSchema, createRoomSchema, HttpError, joinRoomSchema, + mapStoreError, + participantActionSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + addStroke, + clearCanvas, + createRoom, + getRoom, + joinRoom, + restartGame, + startGame, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -21,7 +35,7 @@ export function createRoomsRouter() { room: toRoomSnapshot(result.room, result.participantId) }); } catch (error) { - next(error); + next(mapStoreError(error)); } }); @@ -40,7 +54,7 @@ export function createRoomsRouter() { room: toRoomSnapshot(result.room, result.participantId) }); } catch (error) { - next(error); + next(error instanceof HttpError ? error : mapStoreError(error)); } }); @@ -58,7 +72,77 @@ export function createRoomsRouter() { room: toRoomSnapshot(room, participantId) }); } catch (error) { - next(error); + next(error instanceof HttpError ? error : mapStoreError(error)); + } + }); + + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = startGame(code.toUpperCase(), participantId); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(mapStoreError(error)); + } + }); + + router.post("/:code/strokes", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, stroke } = addStrokeSchema.parse(request.body); + const room = addStroke(code.toUpperCase(), participantId, stroke); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(mapStoreError(error)); + } + }); + + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = clearCanvas(code.toUpperCase(), participantId); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(mapStoreError(error)); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const room = submitGuess(code.toUpperCase(), participantId, guess); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(mapStoreError(error)); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = restartGame(code.toUpperCase(), participantId); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(mapStoreError(error)); } }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..36da0840 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -9,13 +9,39 @@ export const joinRoomSchema = z.object({ }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: z + .string() + .trim() + .min(1, "Room code is required") }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const participantActionSchema = z.object({ + participantId: z.string().min(1, "Participant ID is required") +}); + +export const strokePointSchema = z.object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1) +}); + +export const strokeSchema = z.object({ + points: z.array(strokePointSchema).min(1) +}); + +export const addStrokeSchema = z.object({ + participantId: z.string().min(1), + stroke: strokeSchema +}); + +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + guess: z.string() +}); + export class HttpError extends Error { statusCode: number; @@ -24,3 +50,43 @@ export class HttpError extends Error { this.statusCode = statusCode; } } + +export function mapStoreError(error: unknown): HttpError { + if (error instanceof HttpError) { + return error; + } + + const message = error instanceof Error ? error.message : "Unexpected error"; + + if (message === "Player name is required" || message === "Guess is required") { + return new HttpError(400, message); + } + + if ( + message === "Only the host can start the game" || + message === "Only the host can restart the game" || + message === "Only the drawer can draw" || + message === "Only the drawer can clear the canvas" || + message === "Drawer cannot guess" + ) { + return new HttpError(403, message); + } + + if ( + message === "Unable to load room" || + message === "Participant not found" + ) { + return new HttpError(404, message); + } + + if ( + message === "Game already started" || + message === "At least 2 players are required to start" || + message === "Game is not in progress" || + message === "Game is not in result state" + ) { + return new HttpError(400, message); + } + + return new HttpError(500, message); +} diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..56facece 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,16 +1,40 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface StrokePoint { + x: number; + y: number; +} + +export interface Stroke { + points: StrokePoint[]; +} + +export interface Guess { + id: string; + participantId: string; + participantName: string; + text: string; + correct: boolean; + createdAt: string; } export interface Room { code: string; status: RoomStatus; + hostId: string; + drawerId?: string; + secretWord?: string; participants: Participant[]; + strokes: Stroke[]; + guesses: Guess[]; createdAt: string; updatedAt: string; } @@ -18,7 +42,13 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; + isHost: boolean; + drawerId?: string; + secretWord?: string; participants: Participant[]; + strokes: Stroke[]; + guesses: Guess[]; availableWords: string[]; roles: ParticipantRole[]; } diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b..08911105 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,110 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { + addStroke, + clearCanvas, + createRoom, + joinRoom, + restartGame, + selectWord, + startGame, + submitGuess, + toRoomSnapshot +} from "./roomStore.js"; describe("roomStore", () => { - it("createRoom returns a room with a 4-character uppercase code", () => { + it("createRoom returns a room with a 4-character uppercase code and hostId", () => { const result = createRoom("Alice"); expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); + expect(result.room.hostId).toBe(result.participantId); expect(result.participantId).toBeDefined(); }); + 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("startGame requires host and at least 2 players", () => { + const host = createRoom("Host"); + expect(() => startGame(host.room.code, host.participantId)).toThrow( + "At least 2 players are required to start" + ); + + const guest = joinRoom(host.room.code, "Guest"); + expect(guest).not.toBeNull(); + + const started = startGame(host.room.code, host.participantId); + expect(started.status).toBe("playing"); + expect(started.drawerId).toBe(host.participantId); + expect(started.secretWord).toBe(selectWord(host.room.code)); + }); + + it("toRoomSnapshot hides secret word from guessers during play", () => { + const host = createRoom("Host"); + joinRoom(host.room.code, "Guest"); + const playing = startGame(host.room.code, host.participantId); + const guestId = playing.participants.find((participant) => participant.name === "Guest")!.id; + + const drawerView = toRoomSnapshot(playing, host.participantId); + const guestView = toRoomSnapshot(playing, guestId); + + expect(drawerView.secretWord).toBeDefined(); + expect(guestView.secretWord).toBeUndefined(); + }); + + it("submitGuess scores correct guesses and ends the round", () => { + const host = createRoom("Host"); + joinRoom(host.room.code, "Guest"); + const playing = startGame(host.room.code, host.participantId); + const guestId = playing.participants.find((participant) => participant.name === "Guest")!.id; + const word = playing.secretWord!; + + const afterGuess = submitGuess(host.room.code, guestId, word.toUpperCase()); + + expect(afterGuess.status).toBe("result"); + expect(afterGuess.guesses).toHaveLength(1); + expect(afterGuess.guesses[0].correct).toBe(true); + expect(afterGuess.participants.find((participant) => participant.id === guestId)?.score).toBe(100); + }); + + it("addStroke and clearCanvas update drawing state for drawer", () => { + const host = createRoom("Host"); + joinRoom(host.room.code, "Guest"); + startGame(host.room.code, host.participantId); + + const withStroke = addStroke(host.room.code, host.participantId, { + points: [ + { x: 0.1, y: 0.1 }, + { x: 0.2, y: 0.2 } + ] + }); + expect(withStroke.strokes).toHaveLength(1); + + const cleared = clearCanvas(host.room.code, host.participantId); + expect(cleared.strokes).toHaveLength(0); + }); + + it("restartGame resets round state and preserves participants", () => { + const host = createRoom("Host"); + joinRoom(host.room.code, "Guest"); + const playing = startGame(host.room.code, host.participantId); + const guestParticipantId = playing.participants.find((participant) => participant.name === "Guest")!.id; + submitGuess(host.room.code, guestParticipantId, playing.secretWord!); + + const restarted = restartGame(host.room.code, host.participantId); + expect(restarted.status).toBe("lobby"); + expect(restarted.participants).toHaveLength(2); + expect(restarted.strokes).toHaveLength(0); + expect(restarted.guesses).toHaveLength(0); + expect(restarted.secretWord).toBeUndefined(); + expect(restarted.participants.every((participant) => participant.score === 0)).toBe(true); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..199d3192 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, Stroke } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -29,15 +29,20 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +export function normalizePlayerName(name?: string) { + const trimmed = name?.trim() ?? ""; + if (!trimmed) { + throw new Error("Player name is required"); + } + return trimmed; } -function createParticipant(name?: string): Participant { +function createParticipant(name: string): Participant { return { id: randomUUID(), - name: displayName(name), - joinedAt: now() + name, + joinedAt: now(), + score: 0 }; } @@ -49,12 +54,21 @@ export function listWords() { return [...STARTER_WORDS]; } +export function selectWord(code: string) { + const sum = code.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); + return STARTER_WORDS[sum % STARTER_WORDS.length]; +} + export function createRoom(playerName?: string) { - const participant = createParticipant(playerName); + const name = normalizePlayerName(playerName); + const participant = createParticipant(name); const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], + strokes: [], + guesses: [], createdAt: now(), updatedAt: now() }; @@ -74,7 +88,8 @@ export function joinRoom(code: string, playerName?: string) { return null; } - const participant = createParticipant(playerName); + const name = normalizePlayerName(playerName); + const participant = createParticipant(name); room.participants.push(participant); room.updatedAt = now(); rooms.set(room.code, room); @@ -96,13 +111,172 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +function getParticipant(room: Room, participantId: string) { + return room.participants.find((participant) => participant.id === participantId); +} + +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + throw new Error("Unable to load room"); + } + if (room.hostId !== participantId) { + throw new Error("Only the host can start the game"); + } + if (room.status !== "lobby") { + throw new Error("Game already started"); + } + if (room.participants.length < 2) { + throw new Error("At least 2 players are required to start"); + } + + room.status = "playing"; + room.drawerId = room.hostId; + room.secretWord = selectWord(room.code); + room.strokes = []; + room.guesses = []; + room.participants = room.participants.map((participant) => ({ + ...participant, + score: 0 + })); + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function addStroke(code: string, participantId: string, stroke: Stroke) { + const room = rooms.get(code); + if (!room) { + throw new Error("Unable to load room"); + } + if (room.status !== "playing") { + throw new Error("Game is not in progress"); + } + if (room.drawerId !== participantId) { + throw new Error("Only the drawer can draw"); + } + + room.strokes.push(stroke); + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function clearCanvas(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + throw new Error("Unable to load room"); + } + if (room.status !== "playing") { + throw new Error("Game is not in progress"); + } + if (room.drawerId !== participantId) { + throw new Error("Only the drawer can clear the canvas"); + } + + room.strokes = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function submitGuess(code: string, participantId: string, guessText: string) { + const room = rooms.get(code); + if (!room) { + throw new Error("Unable to load room"); + } + if (room.status !== "playing") { + throw new Error("Game is not in progress"); + } + if (room.drawerId === participantId) { + throw new Error("Drawer cannot guess"); + } + + const trimmed = guessText.trim(); + if (!trimmed) { + throw new Error("Guess is required"); + } + + const participant = getParticipant(room, participantId); + if (!participant) { + throw new Error("Participant not found"); + } + + const correct = + room.secretWord !== undefined && + trimmed.toLowerCase() === room.secretWord.toLowerCase(); + + const guess: Guess = { + id: randomUUID(), + participantId, + participantName: participant.name, + text: trimmed, + correct, + createdAt: now() + }; + + room.guesses.push(guess); + + if (correct) { + participant.score += 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 Error("Unable to load room"); + } + if (room.hostId !== participantId) { + throw new Error("Only the host can restart the game"); + } + if (room.status !== "result") { + throw new Error("Game is not in result state"); + } + + room.status = "lobby"; + room.drawerId = undefined; + room.secretWord = undefined; + room.strokes = []; + room.guesses = []; + room.participants = room.participants.map((participant) => ({ + ...participant, + score: 0 + })); + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isHost = viewerParticipantId === room.hostId; + const isDrawer = viewerParticipantId === room.drawerId; + const showSecretWord = + room.secretWord !== undefined && + (room.status === "result" || (room.status === "playing" && isDrawer)); return { code: room.code, status: room.status, + hostId: room.hostId, + isHost, + drawerId: room.drawerId, + secretWord: showSecretWord ? room.secretWord : undefined, participants: room.participants.map((participant) => ({ ...participant })), + strokes: room.strokes.map((stroke) => ({ + points: stroke.points.map((point) => ({ ...point })) + })), + guesses: room.guesses.map((guess) => ({ ...guess })), availableWords: listWords(), roles: [...STARTER_ROLES] }; From ff3ada63fb133a201434eaa7b6719480bc57f42e Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:16:03 +0800 Subject: [PATCH 5/6] feat(frontend): implement lobby polling, canvas, guesses, and result UI Co-authored-by: Cursor --- frontend/src/components/DrawingCanvas.tsx | 170 ++++++++++++++++++++++ frontend/src/components/GuessForm.tsx | 37 +++-- frontend/src/components/ResultPanel.tsx | 26 +++- frontend/src/components/Scoreboard.tsx | 28 ++-- frontend/src/hooks/useRoomPolling.ts | 23 +++ frontend/src/pages/CreateRoomPage.tsx | 14 ++ frontend/src/pages/GamePage.tsx | 127 +++++++++++++--- frontend/src/pages/JoinRoomPage.tsx | 28 ++++ frontend/src/pages/LobbyPage.tsx | 51 +++++-- frontend/src/state/roomStore.ts | 64 +++++++- frontend/src/styles/app.css | 97 ++++++++++++ 11 files changed, 599 insertions(+), 66 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx create mode 100644 frontend/src/hooks/useRoomPolling.ts diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 00000000..5714d08b --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "react"; +import type { Stroke } from "../services/api"; +import { useRoomStore } from "../state/roomStore"; + +interface DrawingCanvasProps { + strokes: Stroke[]; + canDraw: boolean; +} + +export function DrawingCanvas({ strokes, canDraw }: DrawingCanvasProps) { + const canvasRef = useRef(null); + const roomStore = useRoomStore(); + const [isDrawing, setIsDrawing] = useState(false); + const currentPoints = useRef<{ x: number; y: number }[]>([]); + + function getNormalizedPoint(event: React.PointerEvent) { + const canvas = canvasRef.current; + if (!canvas) { + return null; + } + + const rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) / rect.width, + y: (event.clientY - rect.top) / rect.height + }; + } + + function renderStrokes() { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = "#111827"; + context.lineWidth = 3; + context.lineCap = "round"; + context.lineJoin = "round"; + + for (const stroke of strokes) { + if (stroke.points.length === 0) { + continue; + } + + context.beginPath(); + stroke.points.forEach((point, index) => { + const x = point.x * canvas.width; + const y = point.y * canvas.height; + if (index === 0) { + context.moveTo(x, y); + } else { + context.lineTo(x, y); + } + }); + context.stroke(); + } + } + + useEffect(() => { + renderStrokes(); + }, [strokes]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return undefined; + } + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + renderStrokes(); + }; + + resize(); + window.addEventListener("resize", resize); + + return () => { + window.removeEventListener("resize", resize); + }; + }, []); + + async function finishStroke() { + if (currentPoints.current.length === 0) { + return; + } + + const stroke: Stroke = { + points: [...currentPoints.current] + }; + currentPoints.current = []; + + try { + await roomStore.addStroke(stroke); + } catch { + // polling will resync canvas state + } + } + + function handlePointerDown(event: React.PointerEvent) { + if (!canDraw) { + return; + } + + const point = getNormalizedPoint(event); + if (!point) { + return; + } + + setIsDrawing(true); + currentPoints.current = [point]; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: React.PointerEvent) { + if (!canDraw || !isDrawing) { + return; + } + + const point = getNormalizedPoint(event); + if (!point) { + return; + } + + currentPoints.current.push(point); + renderStrokes(); + + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context || currentPoints.current.length < 2) { + return; + } + + const points = currentPoints.current; + const last = points[points.length - 2]; + const current = points[points.length - 1]; + context.beginPath(); + context.moveTo(last.x * canvas.width, last.y * canvas.height); + context.lineTo(current.x * canvas.width, current.y * canvas.height); + context.stroke(); + } + + async function handlePointerUp(event: React.PointerEvent) { + if (!canDraw || !isDrawing) { + return; + } + + setIsDrawing(false); + event.currentTarget.releasePointerCapture(event.pointerId); + await finishStroke(); + } + + return ( + + ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..cf0f5329 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,32 +1,49 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; interface GuessFormProps { disabled?: boolean; } export function GuessForm({ disabled = false }: GuessFormProps) { - const [guessText, setGuessText] = useState(""); + const [guess, setGuess] = useState(""); + const [error, setError] = useState(null); + const roomStore = useRoomStore(); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + const trimmed = guess.trim(); + if (!trimmed) { + setError("Guess is required"); + return; + } + + try { + setError(null); + await roomStore.submitGuess(trimmed); + setGuess(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } } return (
-
- -
+ {error ?

{error}

: null} +
); } diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..42784a4e 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,23 @@ -import { Card } from "./Card"; +import type { Guess } from "../services/api"; + +interface ResultPanelProps { + guesses: Guess[]; +} + +export function ResultPanel({ guesses }: ResultPanelProps) { + if (guesses.length === 0) { + return

No guesses yet. Activity will appear here.

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

Game activity and guesses will appear here.

-
-
+
    + {guesses.map((guess) => ( +
  • + {guess.participantName} + {guess.text} + {guess.correct ? Correct! : null} +
  • + ))} +
); } diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..307f7847 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,22 @@ -import { Card } from "./Card"; +import type { Participant } from "../services/api"; + +interface ScoreboardProps { + participants: Participant[]; +} + +export function Scoreboard({ participants }: ScoreboardProps) { + if (participants.length === 0) { + return

Waiting for players...

; + } -export function Scoreboard() { return ( - -
-
- Waiting for players... - 0 -
-
-
+
    + {participants.map((participant) => ( +
  • + {participant.name} + {participant.score} +
  • + ))} +
); } diff --git a/frontend/src/hooks/useRoomPolling.ts b/frontend/src/hooks/useRoomPolling.ts new file mode 100644 index 00000000..e4922736 --- /dev/null +++ b/frontend/src/hooks/useRoomPolling.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useRoomStore } from "../state/roomStore"; + +export function useRoomPolling(intervalMs: number, enabled = true) { + const roomStore = useRoomStore(); + + useEffect(() => { + if (!enabled) { + return undefined; + } + + const poll = () => { + void roomStore.fetchRoom().catch(() => undefined); + }; + + poll(); + const timerId = window.setInterval(poll, intervalMs); + + return () => { + window.clearInterval(timerId); + }; + }, [enabled, intervalMs, roomStore]); +} diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..0206fc0d 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) { + const trimmed = name.trim(); + if (!trimmed) { + return "Player name is required"; + } + return null; +} + export function CreateRoomPage() { const [playerName, setPlayerName] = useState(""); const [error, setError] = useState(null); @@ -12,6 +20,12 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const validationError = validatePlayerName(playerName); + if (validationError) { + setError(validationError); + return; + } + try { setError(null); await roomStore.createRoom(playerName); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..ce9365f7 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,49 +1,114 @@ -import { useEffect } from "react"; +import { 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 { useRoomState } from "../state/roomStore"; +import { useRoomPolling } from "../hooks/useRoomPolling"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); - const { room, participantId } = useRoomState(); + const roomStore = useRoomStore(); + const { room, participantId, isLoading } = useRoomState(); + const [actionError, setActionError] = useState(null); + + const isPlaying = room?.status === "playing"; + const isResult = room?.status === "result"; + const shouldPoll = Boolean(room && (isPlaying || isResult)); + + useRoomPolling(2000, shouldPoll); useEffect(() => { if (!room) { navigate("/", { replace: true }); + return; + } + + if (room.status === "lobby") { + navigate("/lobby", { replace: true }); } }, [navigate, room]); - if (!room) { + if (!room || !participantId) { return null; } - const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.drawerId === participantId; + const isHost = room.isHost; + const drawer = room.participants.find((participant) => participant.id === room.drawerId); + + async function handleClearCanvas() { + try { + setActionError(null); + await roomStore.clearCanvas(); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to clear canvas"); + } + } + + async function handleRestart() { + try { + setActionError(null); + await roomStore.restartGame(); + navigate("/lobby"); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to restart game"); + } + } return (
Round 1 -

Guess the Word!

+

+ {isResult ? "Round Complete" : "Guess the Word!"} +

+ {isResult && room.secretWord ? ( +
+

+ The word was: {room.secretWord} +

+
+ ) : null} + + {actionError ?

{actionError}

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

+ Draw: {room.secretWord} +

+ ) : null} + {!isDrawer && isPlaying ? ( +

Watch the drawing and guess!

+ ) : null} + + {isDrawer && isPlaying ? ( +
+ +
+ ) : null}
@@ -52,25 +117,43 @@ export function GamePage() {
Name
-
{viewer?.name ?? "Unknown player"}
+
{room.participants.find((p) => p.id === participantId)?.name ?? "Unknown"}
+
+
+
Role
+
{isDrawer ? "Drawer" : "Guesser"}
+
+
+
Drawer
+
{drawer?.name ?? "Unknown"}
Status
-
Playing
+
{room.status}
- - - - -
+ {isPlaying && !isDrawer ? ( + + + + ) : null} + + {isResult && isHost ? ( + + + + ) : null} -
- + {isResult && !isHost ? ( + +

Waiting for the host to restart...

+
+ ) : null} +
); diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..b94f8b3a 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -3,6 +3,22 @@ import { useNavigate } from "react-router-dom"; import { PageHeader } from "../components/PageHeader"; import { useRoomStore } from "../state/roomStore"; +function validatePlayerName(name: string) { + const trimmed = name.trim(); + if (!trimmed) { + return "Player name is required"; + } + return null; +} + +function validateRoomCode(code: string) { + const trimmed = code.trim(); + if (!trimmed) { + return "Room code is required"; + } + return null; +} + export function JoinRoomPage() { const [playerName, setPlayerName] = useState(""); const [roomCode, setRoomCode] = useState(""); @@ -13,6 +29,18 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const nameError = validatePlayerName(playerName); + if (nameError) { + setError(nameError); + return; + } + + const codeError = validateRoomCode(roomCode); + if (codeError) { + setError(codeError); + return; + } + try { setError(null); await roomStore.joinRoom(roomCode.toUpperCase(), playerName); diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..4d3cb36e 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -3,26 +3,35 @@ import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; +import { useRoomPolling } from "../hooks/useRoomPolling"; import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); + const [actionError, setActionError] = useState(null); + + useRoomPolling(2000, Boolean(room && room.status === "lobby")); useEffect(() => { if (!room) { navigate("/", { replace: true }); + return; + } + + if (room.status === "playing" || room.status === "result") { + navigate("/game", { replace: true }); } }, [navigate, room]); - async function handleRefresh() { + async function handleStartGame() { try { - setRefreshError(null); - await roomStore.fetchRoom(); + setActionError(null); + await roomStore.startGame(); + navigate("/game"); } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); } } @@ -30,6 +39,8 @@ export function LobbyPage() { return null; } + const canStart = room.isHost && room.participants.length >= 2; + return (
@@ -49,7 +60,12 @@ export function LobbyPage() {
    {room.participants.map((participant) => (
  • - {participant.name} + + {participant.name} + {participant.id === room.hostId ? ( + Host + ) : null} + joined
  • ))} @@ -58,20 +74,27 @@ export function LobbyPage() { -

    +

    {isLoading ? "Refreshing players..." : "Ready to play"}

    -

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

    +

    + {error ?? actionError ?? (room.isHost ? "You are the host." : "Waiting for the host to start the game.")} +

- - + {room.isHost ? ( + + ) : ( +

Waiting for the host to start the game...

+ )}
); diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..8b09b46c 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 RoomSessionResponse, type RoomSnapshot, type Stroke } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -78,13 +78,13 @@ class RoomStore { } async createRoom(playerName: string) { - const response = await this.withLoading(() => api.createRoom(playerName)); + const response = await this.withLoading(() => api.createRoom(playerName.trim())); this.setRoomSession(response); return response; } async joinRoom(code: string, playerName: string) { - const response = await this.withLoading(() => api.joinRoom(code, playerName)); + const response = await this.withLoading(() => api.joinRoom(code, playerName.trim())); this.setRoomSession(response); return response; } @@ -98,6 +98,64 @@ 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.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async addStroke(stroke: Stroke) { + 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 clearCanvas() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.clearCanvas(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(guess: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.submitGuess(this.state.room!.code, this.state.participantId!, guess) + ); + 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.restartGame(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..ddc90e66 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -543,6 +543,103 @@ input { font-weight: 500; } +.player-list__badge { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-line--lobby { + background-color: #e0e7ff; + color: #3730a3; +} + +.lobby-waiting { + margin: 0; + color: var(--ink-soft); +} + +.drawing-canvas { + display: block; + width: 100%; + min-height: 500px; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + touch-action: none; +} + +.drawing-canvas--active { + cursor: crosshair; +} + +.secret-word, +.canvas-hint { + margin: 0 0 12px; + color: var(--ink-soft); +} + +.result-banner { + margin-bottom: 16px; + padding: 16px; + border-radius: 12px; + background: #ecfdf5; + color: #065f46; +} + +.scoreboard-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.scoreboard-list__item { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.scoreboard-list__score { + font-weight: 700; +} + +.guess-history { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.guess-history__item { + display: grid; + gap: 4px; + padding: 8px 0; + border-bottom: 1px solid var(--line); +} + +.guess-history__item--correct { + color: #065f46; +} + +.guess-history__name { + font-weight: 600; +} + +.guess-history__badge { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; From 27f5d84c3a7838e88084a46f6e7ed7aa124e616e Mon Sep 17 00:00:00 2001 From: Kathiressan Sivanes Date: Thu, 25 Jun 2026 16:16:04 +0800 Subject: [PATCH 6/6] docs: add reflection report Co-authored-by: Cursor --- reflection.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 reflection.md diff --git a/reflection.md b/reflection.md new file mode 100644 index 00000000..98008ef6 --- /dev/null +++ b/reflection.md @@ -0,0 +1,38 @@ +# Reflection — Scribble Lab + +## What did the starter app already have? + +The starter provided a runnable React + Express scaffold with routing across Start, Create Room, Join Room, Lobby, and Game screens. The backend exposed `POST /rooms`, `POST /rooms/:code/join`, and `GET /rooms/:code` with an in-memory room store. Players could create a room, join by code, and manually refresh the lobby participant list. The game screen had placeholder UI for canvas, guesses, scoreboard, and results but no functional gameplay. + +## What did you add? + +I added Spec Kit artifacts (constitution, four feature spec/plan/task groups, discovery notes) and implemented all four business scenarios: + +1. **Room & lobby** — Host tracking, name/code validation, ~2s lobby polling, host-only start with 2-player minimum. +2. **Game start** — Deterministic secret word selection, drawer assignment (host), viewer-specific word visibility. +3. **Gameplay** — HTML5 drawing canvas with stroke sync, clear canvas, guess submission with case-insensitive scoring (+100 correct), synced guess history via polling. +4. **Result & restart** — Shared result state for all players, host restart back to lobby with round state cleared and participants preserved. + +Backend changes extended the room model and added five new endpoints (`start`, `strokes`, `clear`, `guess`, `restart`). Frontend changes added `useRoomPolling`, `DrawingCanvas`, and wired all pages to the API. + +## Spec Kit workflow + +Each feature group followed specify → plan → tasks → implement → validate. The constitution constrained scope (no WebSockets, no DB, polling-only sync). Commits map to individual tasks — artifacts were written before or alongside their corresponding implementation slice. + +## AI-assisted workflow + +Cursor/AI helped explore the starter quickly and draft initial artifact structure. I manually reviewed and adjusted: + +- **Word selection algorithm** — Chose stable hash of room code modulo word list length for determinism. +- **Stroke model** — Normalized 0–1 coordinates so canvas renders consistently across screen sizes; strokes POST on pointer-up to limit request volume. +- **Viewer filtering** — Kept secret-word filtering on the backend in `toRoomSnapshot`, not the frontend, so guessers cannot inspect network responses for the answer during play. + +## Tradeoffs + +- Polling at 2s introduces visible latency for strokes and guesses but matches lab constraints and keeps implementation simple. +- Single stroke POST per drag gesture balances sync fidelity with request count. +- Host-always-drawer simplifies role logic for a single-round game. + +## What I would do differently + +Add a lightweight integration test script that simulates two-player API flows end-to-end, reducing reliance on manual two-tab testing alone.