From 7b48abfd24699b42777b5cd8094009068a9fbfaa Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 21:52:05 +0530 Subject: [PATCH 01/27] docs: add discovery notes --- speckit.discovery.md | 87 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 speckit.discovery.md diff --git a/speckit.discovery.md b/speckit.discovery.md new file mode 100644 index 00000000..60ab73f4 --- /dev/null +++ b/speckit.discovery.md @@ -0,0 +1,87 @@ +# Discovery Notes — Scribble Starter (Phase 0) + +## What the starter already provides + +- Frontend routes/screens for Start, Create Room, Join Room, Lobby, and Game. +- Minimal REST backend with in-memory room store: + - `GET /health` + - `POST /rooms` + - `POST /rooms/:code/join` + - `GET /rooms/:code` +- Room snapshot includes participant list plus starter seed lists: + - words: `rocket`, `pizza`, `castle`, `guitar`, `sunflower` + - roles: `drawer`, `guesser` + +## Key incomplete behaviors observed (gaps vs README scenarios) + +- **No host concept / permissions** + - Room creation does not record a host participant id. + - Lobby “Start Game” is just a navigation button; there is no backend “start game” action, no host-only gating, and no 2-player minimum enforcement. + +- **No automatic polling** + - Lobby state updates only via a manual “Refresh Room” button; no ~2s polling loop. + - There is no game-state polling at all (guesses, results, etc.). + +- **Gameplay state model is missing** + - `RoomStatus` is only `"lobby"`; there is no `"playing"` or `"results"` state. + - No drawer assignment, no secret word selection/storage, and no “drawer-only visibility”. + - No guess submission endpoint/state, no guess history, no scoring, no result state, no restart flow. + +- **Validation is permissive / not aligned to scenarios** + - `playerName` is optional on create/join; backend coerces missing to `"Player"` and does not trim or reject whitespace-only names. + - `roomCode` param schema is `z.string()` (no length/format validation), so “invalid/empty codes rejected with clear feedback” is not implemented. + +- **Viewer-specific snapshot logic is not implemented** + - Backend `toRoomSnapshot(room, viewerParticipantId)` currently ignores `viewerParticipantId`. + - This will matter for “secret word visible only to drawer”. + +- **Potential API base URL bug in frontend** + - `frontend/src/services/api.ts` defaults `VITE_API_URL` to `http://localhost:3001/bug` (appending `/bug`). + - Without a proxy, this would call `/bug/rooms` which the backend does not serve. The README “Quick Verification” implies the starter works, so this may rely on an environment override or be an intentional scaffold flaw. + +## Assumptions (to resolve ambiguity while staying deterministic) + +- **A1 — Host definition** + - The creator of the room is the host. + - Store `hostParticipantId` on the room and expose it in snapshots so the frontend can gate host-only actions. + +- **A2 — Polling strategy** + - Implement client polling with `setInterval` (about every 2000ms) in screens that must stay fresh (Lobby and Game). + - Polling will call `GET /rooms/:code?participantId=...` and replace the room snapshot in the store. + +- **A3 — Deterministic word selection** + - Select the secret word deterministically from the starter word list using a stable input (e.g., room code) so it is repeatable and testable without randomness. + +- **A4 — Drawer assignment** + - Drawer for the single-round game is the host (or, if host is missing for any reason, the first participant in the room). + +## Relevant files inspected (likely to be touched later) + +### Backend +- Routes and validation: + - `backend/src/api/rooms.ts` + - `backend/src/api/schemas.ts` + - `backend/src/api/router.ts` +- In-memory room store + snapshot: + - `backend/src/services/roomStore.ts` +- Types + state model: + - `backend/src/models/game.ts` +- Seed data: + - `backend/src/seed/starterData.ts` + +### Frontend +- Room client + types: + - `frontend/src/services/api.ts` + - `frontend/src/state/roomStore.ts` +- Screens: + - `frontend/src/pages/CreateRoomPage.tsx` + - `frontend/src/pages/JoinRoomPage.tsx` + - `frontend/src/pages/LobbyPage.tsx` + - `frontend/src/pages/GamePage.tsx` + +## Notes / risks to track + +- Ensure all “sync” remains HTTP polling (explicitly no websockets). +- Backend is in-memory only; restarting backend wipes all rooms (expected). +- If the API base URL default really is wrong, we will need to fix it early (otherwise later work can’t be verified in two tabs). + From f68329875634721ee407c8f98791543f32871513 Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:18:47 +0530 Subject: [PATCH 02/27] docs: add speckit constitution --- speckit.constitution | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 speckit.constitution diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 00000000..c553da21 --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,66 @@ +# Spec Kit Constitution — Scribble Lab + +## Purpose + +This constitution constrains how we specify, plan, implement, and validate changes in this repo so the delivered behavior matches the README business scenarios and remains easy to review. + +## Non-negotiable boundaries (must match README) + +- **No WebSockets / real-time push**: all synchronization is HTTP + polling only. +- **No database / persistence**: backend state is in-memory only; restarts wipe rooms. +- **No authentication/accounts/sessions**. +- **No scope creep**: do not add multi-rounds, timers, bonuses, spectator mode, moderation, passwords/invites, custom word packs, new routing/state libraries, or unrelated refactors. +- **No “rewrite the starter”**: enhance incrementally; prefer minimal, local changes. + +## Engineering principles + +- **TypeScript-first**: avoid `any`; use `unknown` and narrow when needed. +- **Deterministic game rules**: outcomes must be repeatable across runs and tabs (no random word selection or random scoring behavior). +- **Backwards-compatible increments**: each commit should keep the app runnable. +- **Keep state minimal**: room state should store only what is required for the scenarios; remove/clear round state on restart. +- **Fail fast with clear errors**: + - Backend returns consistent JSON errors with useful messages. + - Frontend shows clear, user-facing validation feedback (not stack traces). + +## AI usage rules + +- **AI is allowed for**: exploration help, drafting specs/plans, proposing code changes, writing tests, and suggesting refactors that are directly justified by scenarios. +- **AI is not allowed to decide** ambiguous product rules silently: + - If a rule is unclear, record an assumption explicitly in `/speckit.specify` (and/or discovery notes) before implementing. +- **No blind copy/paste**: every AI-proposed change must be read, understood, and matched against acceptance criteria before committing. + +## Spec → Plan → Tasks → Code traceability + +- Every scenario must have: + - **Specification**: acceptance criteria + edge cases. + - **Plan**: state model + endpoints + file-level change list. + - **Tasks**: ordered, testable slices with dependencies. +- Implementation must remain consistent with artifacts; if implementation deviates, update the spec/plan/tasks in the same feature group. +- Complete **at least 4 specify iterations** (one per scenario group minimum). + +## Validation expectations (manual) + +All scenarios must be validated with **two browser tabs** (or two browsers) to simulate multiplayer. + +- **Polling**: observable refresh roughly every ~2 seconds where required (Lobby and Game sync). +- **Input validation**: + - Names and guesses are **trimmed**; empty/whitespace-only is rejected with a clear message. + - Guess matching is **case-insensitive**. + - Room code invalid/empty is rejected with clear feedback. +- **Isolation**: actions in one room must not affect another room. +- **Determinism**: word selection and scoring behave predictably and repeatably. + +## Commit discipline + +- Keep commits **granular and meaningful**: + - Prefer one behavioral slice per commit (backend + frontend together if needed for a slice). + - Include artifact updates in their own commits (docs-only) when possible. +- Before committing: + - Verify the slice manually against its acceptance criteria. + - Ensure no unrelated files (like lockfiles) are included unless intentionally changed. + +## Build verification (required before final handoff) + +- `cd backend && npm run build` +- `cd frontend && npm run build` + From 7cbab53ef00f18ef7337bdc2f78930be1e7d3ee6 Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:34:57 +0530 Subject: [PATCH 03/27] docs: specify scenario 1 lobby --- speckit.specify | 109 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 speckit.specify diff --git a/speckit.specify b/speckit.specify new file mode 100644 index 00000000..8279a0ff --- /dev/null +++ b/speckit.specify @@ -0,0 +1,109 @@ +# Spec — Iteration 1 (Scenario 1: Room setup & lobby) + +## Scope (in) + +Implement the Scenario 1 behavior end-to-end: + +- Create room + join room by code +- Host tracking (creator is host) +- Invalid/empty code rejection with clear feedback +- Room isolation +- Lobby automatic polling (about every 2 seconds) +- Host-only start game, only allowed when at least 2 players are present + +## Scope (out) + +- No websockets (polling only) +- No persistence/database +- No auth/accounts +- No gameplay (drawer/word/guesses/scoring/results/restart) beyond transitioning out of lobby + +## Definitions + +- **Room**: identified by a 4-character code (case-insensitive input; normalized to uppercase). +- **Participant**: a player instance associated with a room; has an id and display name. +- **Host**: the participant who created the room. There is exactly one host per room. +- **Lobby polling**: the Lobby screen refreshes its room snapshot automatically every ~2 seconds. +- **Start game**: a state transition initiated by host that moves the room from lobby into the next state (details of gameplay are handled in later scenarios). + +## Assumptions (to remove ambiguity) + +- **Host selection**: the room creator is host; host identity is stable for the lifetime of the room. +- **Polling cadence**: target interval is **2000ms**; minor drift is acceptable; polling stops when leaving the Lobby route. +- **Start game semantics (Scenario 1 only)**: starting the game changes room status away from `"lobby"` (exact downstream game state is specified in Scenario 2+). + +## Acceptance criteria + +### A. Host tracking on create + +- When a player creates a room, the returned room snapshot includes enough information to identify the host. +- In the Lobby UI, the host is determinable (e.g., “You are the host” and/or host badge on participant list). +- Host identity is consistent across refreshes/polls. + +### B. Invalid/empty room codes rejected with clear feedback + +- Join flow rejects: + - empty string + - whitespace-only + - wrong length (not 4 chars after trimming) + - invalid characters (non-alphanumeric; optionally restrict to the backend’s generation alphabet) +- The user sees a clear, actionable message (examples): + - “Enter a 4‑letter room code.” + - “Room not found. Check the code and try again.” +- Backend returns an appropriate status code: + - **400** for invalid input format + - **404** when the room code is well-formed but not found + +### C. Room isolation + +- Two different room codes represent isolated rooms: + - participants in room A never appear in room B + - host-only actions in room A have no effect on room B +- Polling for room A cannot leak room B state. + +### D. Lobby polling (~2s) + +- After joining/creating a room, the Lobby automatically refreshes the participant list without requiring a manual “Refresh” button. +- With two tabs in the same room: + - when tab 2 joins, tab 1’s participant list updates within ~2 seconds + - the UI does not flicker or duplicate participants during repeated polls +- Polling error behavior: + - transient network errors show a non-crashing message and continue attempting subsequent polls + - if the room is not found (404), the user is redirected out of the Lobby (or shown a clear “room no longer exists” message) and polling stops + +### E. Host-only start game, minimum 2 players + +- Start is only enabled/allowed when: + - viewer is the host + - room has **at least 2** participants +- Non-host behavior: + - the “Start game” control is disabled or hidden, and the UI explains “Waiting for host…” + - if a non-host attempts to start (e.g., via a direct API call), the backend rejects it with a clear error (e.g., **403**) +- Minimum players enforcement: + - if host attempts to start with <2 players, backend rejects with a clear error (e.g., **409** or **400**) and the UI displays it +- Once start succeeds: + - the room status updates away from lobby + - all tabs observe the status change via polling and navigate/transition accordingly + +## Edge cases + +- **Code normalization**: `abcd`, `ABCD`, and ` a b c d ` (after trimming) should behave consistently (normalize to `ABCD`). +- **Refreshing as unknown viewer**: if `participantId` is missing/invalid, backend must still return a safe room snapshot (no crash); UI should prompt re-join if needed. +- **Duplicate names**: allowed in Scenario 1; participants are uniquely identified by id. +- **Rapid joins**: multiple join requests in quick succession still result in a stable participant list with no duplicates. + +## Manual verification checklist (Scenario 1) + +- **Create room**: + - You land in Lobby and can identify “host” in UI. +- **Join room (2nd tab)**: + - Join succeeds with a valid code; Lobby in tab 1 updates within ~2 seconds without clicking refresh. +- **Invalid joins**: + - Try empty / whitespace / short / long / special characters → see clear validation. + - Try well-formed but nonexistent code → see “room not found” style error. +- **Isolation**: + - Create room A and room B; joining A does not change B. +- **Start game gating**: + - With 1 player: host cannot start (clear message). + - With 2 players: only host can start; non-host cannot. + From 669d2e3759b0084530c7ee9faec0bbcc55e7a97e Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:37:34 +0530 Subject: [PATCH 04/27] docs: plan scenario 1 implementation --- speckit.plan | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 speckit.plan diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 00000000..1d56a178 --- /dev/null +++ b/speckit.plan @@ -0,0 +1,166 @@ +# Plan — Iteration 1 (Scenario 1: Room setup & lobby) + +This plan implements Scenario 1 from `speckit.specify` and stays within `speckit.constitution` constraints (HTTP polling only, in-memory backend, no auth). + +## Current state (starter) + +- Backend supports: + - `POST /rooms` → creates a room with 1 participant + - `POST /rooms/:code/join` → adds a participant + - `GET /rooms/:code?participantId=...` → returns a snapshot (viewer id currently ignored) +- Room model: + - `RoomStatus` is `"lobby"` only + - no host tracking +- Frontend: + - Lobby updates via manual “Refresh Room” + - “Start Game” button just navigates to `/game` (no backend action, no gating) + +## Target end state (Scenario 1) + +- Creating a room assigns a **host participant** and exposes host identity in the snapshot. +- Joining validates room codes; invalid input yields clear **400** errors; well-formed but missing room yields **404**. +- Lobby **polls every ~2 seconds** and updates participants automatically. +- Only the **host** can start the game, and only when **≥2 participants** are present. +- Start game is a backend-controlled state transition; all tabs observe it via polling and transition UI accordingly. + +## Backend plan + +### Data model changes (`backend/src/models/game.ts`) + +- Extend `RoomStatus` to support leaving the lobby: + - `type RoomStatus = "lobby" | "playing";` + - (Further statuses like `"results"` are deferred to later scenarios.) +- Add host tracking: + - `Room.hostParticipantId: string` +- Expose host identity to clients: + - `RoomSnapshot.hostParticipantId: string` + +### Room store changes (`backend/src/services/roomStore.ts`) + +- On `createRoom`: + - create participant as today + - set `hostParticipantId` to created participant’s id +- On `joinRoom`: + - no host changes +- Update `toRoomSnapshot`: + - include `hostParticipantId` + - keep `viewerParticipantId` parameter (will be used in later scenarios) +- Add a “start game” operation (new function, e.g. `startGame(code, requesterParticipantId)`): + - validate room exists + - validate requester is host (`requesterParticipantId === room.hostParticipantId`) + - validate `room.participants.length >= 2` + - set `room.status = "playing"` and `saveRoom(room)` + +### API changes + +#### Validation (`backend/src/api/schemas.ts`) + +- Strengthen room code validation: + - `code` must be a **trimmed** 4-character string + - reject empty/whitespace-only and wrong length as **400** + - optionally restrict characters to `[A-Z0-9]` (aligning with generated alphabet) +- Add schema for start request: + - body contains `participantId: string` + +#### Routes (`backend/src/api/rooms.ts`) + +- Add endpoint: + - `POST /rooms/:code/start` + - body: `{ participantId }` + - errors: + - 400 for invalid payload + - 404 if room not found + - 403 if requester is not host + - 409 (or 400) if fewer than 2 participants + - success: returns updated snapshot (or `{ room: snapshot }`) for immediate UI update + +### Backend tests (optional but recommended) + +- Update / add tests in: + - `backend/src/services/roomStore.test.ts` (host assignment, start gating) + - `backend/src/api/schemas.test.ts` (room code validation rules) + +## Frontend plan + +### State model (`frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`) + +- Extend `RoomSnapshot` type to include: + - `hostParticipantId: string` + - updated `status` union to include `"playing"` +- Add API method: + - `api.startGame(code: string, participantId: string)` +- Add store method: + - `roomStore.startGame()` that calls `api.startGame(room.code, participantId)` + - on success, updates the stored snapshot + +### Lobby polling (`frontend/src/pages/LobbyPage.tsx`) + +- Add a `useEffect` interval that calls `roomStore.fetchRoom()` every ~2000ms while: + - the user is on the Lobby route + - and `room` + `participantId` exist +- Ensure cleanup on unmount (clear interval). +- UI updates: + - Identify host in participant list and/or show “You are the host” if `participantId === room.hostParticipantId`. + - “Start Game” button: + - enabled only if viewer is host and `participants.length >= 2` + - otherwise disabled with clear “waiting” messaging + - Replace navigation-only start with calling `roomStore.startGame()`. + +### Transition on start (`frontend/src/pages/LobbyPage.tsx` and/or routing) + +- When polling detects `room.status === "playing"`: + - automatically navigate to `/game` in all tabs. +- If Lobby polling receives 404 (room not found): + - navigate to `/` with a clear message (or show a non-crashing error and stop polling). + +### Known risk: API base URL default (`frontend/src/services/api.ts`) + +- The current default `VITE_API_URL` includes `/bug`. +- If this prevents the starter from working in this repo, fix early to: + - default to `http://localhost:3001` + - keep `VITE_API_URL` override behavior intact + +## Files expected to change (Scenario 1) + +### Backend + +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- (maybe) `backend/src/services/roomStore.test.ts` +- (maybe) `backend/src/api/schemas.test.ts` + +### Frontend + +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/LobbyPage.tsx` +- (maybe) `frontend/src/pages/GamePage.tsx` (only to react to status/display; gameplay is later) + +## Data flow (Scenario 1) + +- Create/Join: + - frontend calls `POST /rooms` or `POST /rooms/:code/join` + - store saves `{ participantId, room }` +- Lobby refresh: + - polling calls `GET /rooms/:code?participantId=...` + - store replaces `room` snapshot +- Start: + - host calls `POST /rooms/:code/start` with `participantId` + - backend transitions `status` to `"playing"` + - all clients observe `"playing"` via polling and navigate to `/game` + +## Manual verification checklist (Scenario 1) + +- **Host tracking**: creator is host; non-host is not host; consistent across polls. +- **Join validation**: + - empty/whitespace/invalid length/invalid chars rejected with clear message (400) + - well-formed but missing room returns not found (404) +- **Polling**: Lobby participant list updates within ~2 seconds after a second tab joins. +- **Isolation**: two rooms remain isolated across joins and polls. +- **Start gating**: + - host cannot start with <2 players + - non-host cannot start (403) + - with 2 players, host start transitions all tabs to `/game` via polling + From a79f704091544b85f7a4758eb70e785f3a0325c9 Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:40:00 +0530 Subject: [PATCH 05/27] docs: tasks for scenario 1 --- speckit.tasks | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 speckit.tasks diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 00000000..160ea2f1 --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,136 @@ +# Tasks — Iteration 1 (Scenario 1: Room setup & lobby) + +Goal: implement Scenario 1 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Discovery + alignment (quick) + +- [ ] Confirm current starter endpoints still match the plan: + - `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code` +- [ ] Confirm we remain within constitution boundaries (HTTP polling only, in-memory, no auth). + +## Backend tasks (do first) + +### B1 — Update backend types for Scenario 1 + +- **Depends on**: none +- **Change**: + - `backend/src/models/game.ts` + - extend `RoomStatus` to include `"playing"` + - add `Room.hostParticipantId: string` + - add `RoomSnapshot.hostParticipantId: string` +- **Manual check**: `npm run build` (backend) still compiles after type changes. + +### B2 — Host tracking on room creation + snapshot field + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts` + - set `hostParticipantId` when creating a room + - include `hostParticipantId` in `toRoomSnapshot` +- **Manual check**: + - Create a room → response includes a host field (via snapshot) and it remains stable on `GET /rooms/:code`. + +### B3 — Strengthen room code validation + +- **Depends on**: none (can be done before/after B1/B2) +- **Change**: + - `backend/src/api/schemas.ts`: + - enforce 4-char trimmed room code + - optionally enforce allowed characters + - `backend/src/api/rooms.ts`: + - ensure invalid format yields 400 + - ensure missing room yields 404 +- **Manual check**: + - Join with empty/whitespace/invalid length/invalid chars → 400 with clear message + - Join with well-formed but nonexistent code → 404 with clear message + +### B4 — Add start game backend operation + route + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/services/roomStore.ts`: + - add `startGame(code, requesterParticipantId)` with: + - 404 if room missing + - 403 if requester not host + - 409 (or 400) if participants < 2 + - set `status = "playing"` on success + - `backend/src/api/schemas.ts`: + - add request schema for start game body `{ participantId }` + - `backend/src/api/rooms.ts`: + - add `POST /rooms/:code/start` + - return updated snapshot +- **Manual check** (use REST client or browser devtools): + - With 1 participant, host start → rejected with clear message + - Non-host start → rejected with clear message + - With 2 participants, host start → success; `GET /rooms/:code` shows `status: "playing"` + +### B5 — Backend tests (optional but recommended) + +- **Depends on**: B2, B3, B4 +- **Change**: + - Add/update tests in `backend/src/services/roomStore.test.ts` and `backend/src/api/schemas.test.ts` +- **Manual check**: `npm test` (backend) passes if tests exist in this repo. + +## Frontend tasks (after backend) + +### F1 — Update frontend RoomSnapshot types and API base URL if needed + +- **Depends on**: B1, B2 (for new snapshot fields), plus “starter runs locally” +- **Change**: + - `frontend/src/services/api.ts`: + - extend `RoomSnapshot` to include `hostParticipantId` and `"playing"` status + - fix `VITE_API_URL` default if it prevents local usage (remove `/bug`) +- **Manual check**: + - Create room works in browser without console errors; network calls hit the backend correctly. + +### F2 — Add start game client API + store method + +- **Depends on**: B4, F1 +- **Change**: + - `frontend/src/services/api.ts`: add `startGame(code, participantId)` + - `frontend/src/state/roomStore.ts`: add `startGame()` which updates stored snapshot +- **Manual check**: + - Clicking “Start Game” (once wired) results in `status` changing in store snapshot. + +### F3 — Implement Lobby polling (~2s) + cleanup + +- **Depends on**: F1 (for typing), existing `fetchRoom()` +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - add `useEffect` interval calling `roomStore.fetchRoom()` every ~2000ms + - clear interval on unmount + - handle errors without crashing (surface message; stop on 404) +- **Manual check (two tabs)**: + - Tab A creates room and waits in Lobby + - Tab B joins → Tab A participant list updates within ~2 seconds without clicking refresh + +### F4 — Host UI + host-only start gating + +- **Depends on**: B2, F1, F2 +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - display host indicator and/or “You are the host” + - disable/hide start button for non-host + - disable start when participants < 2 and show reason + - call `roomStore.startGame()` instead of navigation-only +- **Manual check (two tabs)**: + - With 1 player, host cannot start (clear message) + - With 2 players, only host can start; non-host sees “waiting for host” + +### F5 — Transition all players to Game when status becomes playing + +- **Depends on**: B4, F3 +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - if snapshot becomes `status === "playing"`, navigate to `/game` +- **Manual check (two tabs)**: + - Host starts game → both tabs navigate to `/game` via polling within ~2 seconds + +## Scenario 1 completion check (must pass) + +- Host tracking works and is stable across polls. +- Join validation has clear 400 vs 404 behavior. +- Lobby polls automatically ~2 seconds and updates participants. +- Rooms are isolated. +- Host-only start is enforced in backend and reflected in frontend UI. + From f144eb0d54616d53d1aa340d0d87c6edd53b4d70 Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:42:29 +0530 Subject: [PATCH 06/27] feat(rooms): track host and expose in lobby Store host participant id on room creation, include it in room snapshots, and label the host in the lobby participant list. Co-authored-by: Cursor --- backend/src/models/game.ts | 2 ++ backend/src/services/roomStore.ts | 2 ++ frontend/src/pages/LobbyPage.tsx | 4 +++- frontend/src/services/api.ts | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..205f7f88 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostParticipantId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostParticipantId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..3ea176b3 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -54,6 +54,7 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostParticipantId: participant.id, participants: [participant], createdAt: now(), updatedAt: now() @@ -102,6 +103,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn return { code: room.code, status: room.status, + hostParticipantId: room.hostParticipantId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..e5d0ca0a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -50,7 +50,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostParticipantId ? "host" : "joined"} +
  • ))} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..ab0e6edb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,6 +9,7 @@ export interface Participant { export interface RoomSnapshot { code: string; status: "lobby"; + hostParticipantId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; From 243029116b2afdfe76305d02693bd9d88a4f49bc Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:43:46 +0530 Subject: [PATCH 07/27] feat(join): validate room code and show errors Validate and normalize room codes on both server and client, return clearer 400/404 messages, and fix the frontend default API base URL so join requests reach the backend. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 8 ++++---- backend/src/api/router.ts | 7 +++++-- backend/src/api/schemas.ts | 10 +++++++++- frontend/src/pages/JoinRoomPage.tsx | 19 ++++++++++++++++++- frontend/src/services/api.ts | 2 +- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..5127910c 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -29,10 +29,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. Check the code and try again."); } response.json({ @@ -48,10 +48,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({ diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 12705954..5514c277 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; +import { ZodError } from "zod"; import { createRoomsRouter } from "./rooms.js"; export function createApiRouter() { @@ -29,8 +30,10 @@ export function errorHandler( response: Response, _next: NextFunction ) { - if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + if (error instanceof ZodError) { + response.status(400).json({ + message: error.issues[0]?.message ?? "Invalid request" + }); return; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..28b89650 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +const roomCodeSchema = z + .string() + .trim() + .min(1, { message: "Enter a room code." }) + .length(4, { message: "Room code must be 4 characters." }) + .regex(/^[a-z0-9]{4}$/i, { message: "Room code must use only letters and numbers." }) + .transform((value) => value.toUpperCase()); + export const createRoomSchema = z.object({ playerName: z.string().optional() }); @@ -9,7 +17,7 @@ export const joinRoomSchema = z.object({ }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: roomCodeSchema }); export const roomViewerQuerySchema = z.object({ diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..7e95746d 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -15,7 +15,24 @@ export function JoinRoomPage() { try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + const normalizedCode = roomCode.trim().toUpperCase(); + + if (!normalizedCode) { + setError("Enter a room code."); + return; + } + + if (normalizedCode.length !== 4) { + setError("Room code must be 4 characters."); + return; + } + + if (!/^[A-Z0-9]{4}$/.test(normalizedCode)) { + setError("Room code must use only letters and numbers."); + return; + } + + await roomStore.joinRoom(normalizedCode, playerName); 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 ab0e6edb..49860af8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -20,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}`, { From 5da6f610eebb3c70241afc23825903cdc5fa7dda Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:44:29 +0530 Subject: [PATCH 08/27] feat(lobby): poll room snapshot Add ~2s lobby polling to refresh the room snapshot automatically and surface non-fatal refresh errors. Co-authored-by: Cursor --- frontend/src/pages/LobbyPage.tsx | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index e5d0ca0a..771ce44a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -17,6 +17,45 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (!room?.code) { + return; + } + + let isActive = true; + + async function tick() { + try { + await roomStore.fetchRoom(); + if (isActive) { + setRefreshError(null); + } + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : "Unable to refresh room"; + if (!isActive) { + return; + } + + setRefreshError(message); + + if (message.toLowerCase().includes("not found")) { + navigate("/", { replace: true }); + } + } + } + + void tick(); + + const intervalId = window.setInterval(() => { + void tick(); + }, 2000); + + return () => { + isActive = false; + window.clearInterval(intervalId); + }; + }, [navigate, room?.code, roomStore]); + async function handleRefresh() { try { setRefreshError(null); From 6b71006fd6d0915589f1cbbd3e64b58c09a90358 Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:45:49 +0530 Subject: [PATCH 09/27] feat(game): host-only start with minimum players Add backend start-game endpoint with host and 2-player gating, expose playing status via snapshots, and wire the lobby UI to enable starting only for the host once enough players have joined. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 31 +++++++++++++++++++++++++++++-- backend/src/api/schemas.ts | 4 ++++ backend/src/models/game.ts | 2 +- backend/src/services/roomStore.ts | 22 ++++++++++++++++++++++ frontend/src/pages/LobbyPage.tsx | 28 +++++++++++++++++++++++++--- frontend/src/services/api.ts | 8 +++++++- frontend/src/state/roomStore.ts | 12 ++++++++++++ 7 files changed, 100 insertions(+), 7 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 5127910c..92b3e9ce 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,10 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -62,5 +63,31 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const result = startGame(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_host") { + throw new HttpError(403, "Only the host can start the game."); + } + + throw new HttpError(409, "At least 2 players are required to start the game."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 28b89650..ff56c5db 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -24,6 +24,10 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 205f7f88..37cbdf02 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"; export interface Participant { id: string; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 3ea176b3..13cabf28 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -91,6 +91,28 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } +export function startGame(code: string, requesterParticipantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (requesterParticipantId !== room.hostParticipantId) { + return { ok: false as const, reason: "not_host" as const }; + } + + if (room.participants.length < 2) { + return { ok: false as const, reason: "not_enough_players" as const }; + } + + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { ok: true as const, room: cloneRoom(room) }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 771ce44a..53fdc026 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,7 +8,7 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; 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); useEffect(() => { @@ -17,6 +17,12 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "playing") { + navigate("/game", { replace: true }); + } + }, [navigate, room?.status]); + useEffect(() => { if (!room?.code) { return; @@ -69,6 +75,18 @@ export function LobbyPage() { return null; } + const isHost = participantId === room.hostParticipantId; + const canStart = isHost && room.participants.length >= 2; + + async function handleStartGame() { + try { + setRefreshError(null); + await roomStore.startGame(); + } catch (caughtError) { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + } + return (
    @@ -110,8 +128,12 @@ export function LobbyPage() { -
    diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 49860af8..215aee95 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,7 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing"; hostParticipantId: string; participants: Participant[]; availableWords: string[]; @@ -55,6 +55,12 @@ export const api = { body: JSON.stringify({ playerName }) }); }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..cfa5427a 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("Missing room session"); + } + + const response = await this.withLoading(() => + api.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); From 47be796d21635cd77e1a4684a9a079d119212c5c Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 22:46:29 +0530 Subject: [PATCH 10/27] fix(lobby): clarify start prerequisites Show clearer lobby status messaging and button labels for host vs guest and 2-player minimum. Co-authored-by: Cursor --- frontend/src/pages/LobbyPage.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 53fdc026..41560df1 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -77,6 +77,14 @@ export function LobbyPage() { const isHost = participantId === room.hostParticipantId; const canStart = isHost && room.participants.length >= 2; + const statusMessage = + error ?? + refreshError ?? + (isHost + ? room.participants.length >= 2 + ? "You can start the game when you're ready." + : "Waiting for at least one more player to join." + : "Waiting for the host to start the game."); async function handleStartGame() { try { @@ -120,7 +128,7 @@ export function LobbyPage() {

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

    -

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

    +

    {statusMessage}

    @@ -133,7 +141,7 @@ export function LobbyPage() { disabled={!canStart || isLoading} onClick={handleStartGame} > - {isHost ? "Start Game" : "Waiting for host..."} + {isHost ? (canStart ? "Start Game" : "Waiting for players...") : "Waiting for host..."} From 5c5d1c52c30061b310f63e03e62f2130f75e560f Mon Sep 17 00:00:00 2001 From: Radha Date: Tue, 9 Jun 2026 23:13:04 +0530 Subject: [PATCH 11/27] docs: scenario 2 artifacts --- speckit.plan | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ speckit.specify | 89 +++++++++++++++++++++++++++++++++++++++ speckit.tasks | 97 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) diff --git a/speckit.plan b/speckit.plan index 1d56a178..730037f6 100644 --- a/speckit.plan +++ b/speckit.plan @@ -164,3 +164,113 @@ This plan implements Scenario 1 from `speckit.specify` and stays within `speckit - non-host cannot start (403) - with 2 players, host start transitions all tabs to `/game` via polling +--- + +# Plan — Iteration 2 (Scenario 2: Game start & drawer flow) + +This plan extends the existing Scenario 1 implementation to satisfy Scenario 2 requirements: name validation, drawer assignment, deterministic word selection, and drawer-only word visibility. + +## Backend plan (Scenario 2) + +### Data model changes (`backend/src/models/game.ts`) + +Add minimal fields required for a single round start: + +- `Room.drawerParticipantId: string | null` +- `Room.secretWord: string | null` +- Extend `RoomSnapshot` to include: + - `drawerParticipantId: string | null` + - `viewerRole: "drawer" | "guesser"` (derived from viewer id) + - `secretWord?: string` (present only for drawer viewer) + +Rationale: +- Storing `drawerParticipantId` and `secretWord` on the room allows polling snapshots to be the single source of truth. +- Viewer-specific snapshot fields support “drawer-only visibility” without leaking data to guessers. + +### Name validation (create/join) + +- Update request validation in `backend/src/api/schemas.ts`: + - `playerName` must be a string that trims to length ≥ 1 (reject whitespace-only). +- Update `backend/src/services/roomStore.ts`: + - Normalize participant names by trimming before storing. + +### Start-game behavior (`backend/src/services/roomStore.ts` + `backend/src/api/rooms.ts`) + +On successful `startGame(code, requesterParticipantId)`: + +- Set `room.status = "playing"` (already implemented). +- Set `drawerParticipantId` deterministically: + - primary: `room.hostParticipantId` + - fallback: `room.participants[0]?.id` (should exist if room exists) +- Set `secretWord` deterministically using the algorithm in `speckit.specify`: + - `index = (sum of ASCII codes of room.code characters) % STARTER_WORDS.length` + - `secretWord = STARTER_WORDS[index]` + +Update `toRoomSnapshot(room, viewerParticipantId)`: +- Always include `drawerParticipantId`. +- Derive `viewerRole`: + - `"drawer"` if `viewerParticipantId === drawerParticipantId`, else `"guesser"`. +- Include `secretWord` **only** when viewer is drawer (and viewer id is present and matches). + +### Backend tests (recommended) + +- `backend/src/services/roomStore.test.ts`: + - name trimming behavior + - drawer assignment + - deterministic word selection for a given code + - snapshot visibility rule (drawer gets `secretWord`, guesser does not) + +## Frontend plan (Scenario 2) + +### State model changes (`frontend/src/services/api.ts`) + +- Extend `RoomSnapshot` type to match backend additions: + - `drawerParticipantId: string | null` + - `viewerRole: "drawer" | "guesser"` + - `secretWord?: string` + +### Create/Join validation (frontend forms) + +- `frontend/src/pages/CreateRoomPage.tsx`: + - trim player name on submit; reject whitespace-only with clear error. +- `frontend/src/pages/JoinRoomPage.tsx`: + - apply same name validation (in addition to existing room code validation). + +### Game UI updates (`frontend/src/pages/GamePage.tsx`) + +- Display a clear role indicator based on snapshot: + - If `viewerRole === "drawer"`: show “You are the drawer” and show `secretWord`. + - Else: show “You are guessing” and do not show any word. + +### Polling for in-game state (minimal for Scenario 2) + +- Add game-page polling (about every 2 seconds) to keep drawer/word visibility consistent across tabs after start: + - call `roomStore.fetchRoom()` on an interval while on `/game` + - stop polling when leaving the page + +## Files expected to change (Scenario 2) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` (start route response already exists; may only need snapshot shape updates) +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/pages/CreateRoomPage.tsx` +- `frontend/src/pages/JoinRoomPage.tsx` +- `frontend/src/pages/GamePage.tsx` +- (maybe) `frontend/src/state/roomStore.ts` (if we add helpers; base fetch already exists) + +## Manual verification checklist (Scenario 2) + +- Create with `" "` → blocked with clear message. +- Create with `" Alice "` → saved/displayed as `"Alice"`. +- Join with `" "` → blocked; join with `" Bob "` → saved/displayed as `"Bob"`. +- Host starts with 2 players: + - host sees “drawer” role + secret word + - guesser sees “guesser” role and no secret word +- Refresh/polling keeps roles/visibility consistent. + diff --git a/speckit.specify b/speckit.specify index 8279a0ff..b179a30b 100644 --- a/speckit.specify +++ b/speckit.specify @@ -107,3 +107,92 @@ Implement the Scenario 1 behavior end-to-end: - With 1 player: host cannot start (clear message). - With 2 players: only host can start; non-host cannot. +--- + +# Spec — Iteration 2 (Scenario 2: Game start & drawer flow) + +## Scope (in) + +Implement Scenario 2 behavior (first round start semantics only): + +- Player names are trimmed on create/join; empty/whitespace-only is rejected with a clear message. +- When the first round begins, a drawer is assigned deterministically (host / first player). +- Secret word is selected deterministically from the starter list. +- Secret word visibility is restricted: **drawer-only**. + +## Scope (out) + +- No drawing mechanics (canvas sync) yet. +- No guess submission, scoring, results, or restart (Scenario 3+). +- No websockets; polling only. + +## Definitions + +- **Drawer**: the participant responsible for drawing in the single round. +- **Guesser**: any non-drawer participant in the room. +- **Secret word**: the word that guessers attempt to guess; selected from the starter word list. +- **Deterministic selection**: given the same room code and seed list, selection always yields the same word. + +## Assumptions (to remove ambiguity) + +- **Drawer assignment**: the **host** is the drawer for the single round. (If host is missing, fall back to the first participant.) +- **Word selection algorithm**: + - Use the starter word list in `backend/src/seed/starterData.ts`. + - Compute `index = (sum of ASCII codes of room code characters) % words.length`. + - The secret word is `words[index]`. +- **Visibility rule**: + - Room snapshots may include `secretWord` only when the viewer is the drawer. + - Non-drawers must not receive `secretWord` in snapshots (not even as masked text). + +## Acceptance criteria + +### A. Player name validation (trim + reject empty) + +- Create room: + - leading/trailing whitespace is removed before saving the participant name. + - if the submitted name is empty after trimming, the request is rejected with a clear message. +- Join room: + - same trimming + empty rejection applies. +- Frontend provides immediate feedback (form error message) and backend enforces the same rule (defense in depth). + +### B. Drawer assignment at start + +- When the host starts the game (transition to `playing`): + - a `drawerParticipantId` is set for the room, deterministically based on host/first participant. +- In the Game UI: + - the drawer is clearly identified to all players (e.g., “You are the drawer” vs “You are guessing”). + +### C. Deterministic secret word selection + +- When the room enters `playing` the first time: + - the secret word is chosen deterministically from the starter list using the defined algorithm. +- Determinism checks: + - restarting the backend and repeating the same room code selection method yields the same word for that code. + - different room codes can map to different words (not required, but allowed). + +### D. Drawer-only secret word visibility + +- Drawer sees the secret word in the Game UI. +- Guessers do not see the secret word anywhere in the UI. +- API rule: + - the server only includes the secret word in snapshots for the drawer viewer. + - guessers’ snapshots must omit the field entirely. + +## Edge cases + +- Names like `" Alice "` become `"Alice"`. +- Names like `" "` are rejected with a clear message. +- Two players can use the same display name; uniqueness is by participant id. +- If a viewer refreshes without a valid `participantId`, the backend must not leak the secret word. + +## Manual verification checklist (Scenario 2) + +- **Name validation**: + - Create with `" "` → rejected with clear message. + - Create with `" Alice "` → shows `"Alice"` in lobby/game. + - Join with `" "` → rejected; join with `" Bob "` → saved as `"Bob"`. +- **Drawer + word** (two tabs): + - Host starts → host is the drawer. + - Drawer sees secret word; guesser does not. + - Refresh both tabs → visibility rule remains true after polling. + diff --git a/speckit.tasks b/speckit.tasks index 160ea2f1..46b26250 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -134,3 +134,100 @@ Goal: implement Scenario 1 behavior from `speckit.specify` using the approach in - Rooms are isolated. - Host-only start is enforced in backend and reflected in frontend UI. +--- + +# Tasks — Iteration 2 (Scenario 2: Game start & drawer flow) + +Goal: implement Scenario 2 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend room model for round start state + +- **Depends on**: Scenario 1 code merged +- **Change**: + - `backend/src/models/game.ts` + - add `drawerParticipantId` and `secretWord` to `Room` + - add `drawerParticipantId`, `viewerRole`, and conditional `secretWord` to `RoomSnapshot` +- **Manual check**: `cd backend && npm run build` + +### B2 — Enforce player name trim + non-empty on create/join + +- **Depends on**: none +- **Change**: + - `backend/src/api/schemas.ts` + - require `playerName` as a trimmed non-empty string (clear error message) + - `backend/src/services/roomStore.ts` + - trim names before storing participant +- **Manual check**: + - Create/join with `" "` returns 400 + clear message. + - Create/join with `" Alice "` stores/display `"Alice"`. + +### B3 — Deterministic drawer assignment + word selection on start + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: + - on successful `startGame`, set: + - `drawerParticipantId` (host, else first participant) + - `secretWord` using the defined deterministic algorithm + - update `toRoomSnapshot(room, viewerParticipantId)`: + - include `drawerParticipantId` + - set `viewerRole` + - include `secretWord` only for drawer viewer +- **Manual check**: + - Start game, then `GET /rooms/:code?participantId=` includes `secretWord`. + - Same request for guesser does **not** include `secretWord`. + +### B4 — Backend tests (recommended) + +- **Depends on**: B2, B3 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Update RoomSnapshot types for drawer/word fields + +- **Depends on**: B1, B3 +- **Change**: + - `frontend/src/services/api.ts` + - add `drawerParticipantId`, `viewerRole`, and optional `secretWord` +- **Manual check**: `cd frontend && npm run build` + +### F2 — Enforce player name trim + non-empty in Create/Join UI + +- **Depends on**: B2 (backend enforcement), but can be implemented in parallel +- **Change**: + - `frontend/src/pages/CreateRoomPage.tsx` + - `frontend/src/pages/JoinRoomPage.tsx` +- **Manual check**: + - Both forms show clear error on whitespace-only names. + +### F3 — Game UI: show role and drawer-only secret word + +- **Depends on**: F1, B3 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - show “You are the drawer” + word when `viewerRole === "drawer"` + - show “You are guessing” and no word otherwise +- **Manual check (2 tabs)**: + - Host sees word; guesser does not. + +### F4 — Game polling for snapshot refresh (~2s) + +- **Depends on**: existing `roomStore.fetchRoom()` +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - add ~2s polling to keep snapshot current post-start +- **Manual check (2 tabs)**: + - Refreshing tabs and waiting shows consistent role/word visibility with no manual actions. + +## Scenario 2 completion check (must pass) + +- Names are trimmed and whitespace-only is rejected (frontend + backend). +- Drawer assignment is deterministic and visible to all. +- Secret word selection is deterministic. +- Secret word is visible only to drawer (no leakage via API snapshot). + From 9dadad1cbb9db3198031c1548c9b3548bd6fe280 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:04:21 +0530 Subject: [PATCH 12/27] feat(players): validate and trim player names Require non-empty player names on create and join, trim whitespace before storing, and surface clear form validation feedback in the UI. Co-authored-by: Cursor --- backend/src/api/schemas.ts | 4 ++-- backend/src/services/roomStore.ts | 10 +++++----- frontend/src/pages/CreateRoomPage.tsx | 9 ++++++++- frontend/src/pages/JoinRoomPage.tsx | 9 ++++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index ff56c5db..262a98d3 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -9,11 +9,11 @@ const roomCodeSchema = z .transform((value) => value.toUpperCase()); export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Enter a player name." }) }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Enter a player name." }) }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 13cabf28..7a90d325 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -29,11 +29,11 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +function displayName(name: string) { + return name.trim(); } -function createParticipant(name?: string): Participant { +function createParticipant(name: string): Participant { return { id: randomUUID(), name: displayName(name), @@ -49,7 +49,7 @@ export function listWords() { return [...STARTER_WORDS]; } -export function createRoom(playerName?: string) { +export function createRoom(playerName: string) { const participant = createParticipant(playerName); const room: Room = { code: generateUniqueCode(), @@ -68,7 +68,7 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); if (!room) { diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..211e1114 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -14,7 +14,14 @@ export function CreateRoomPage() { try { setError(null); - await roomStore.createRoom(playerName); + const normalizedName = playerName.trim(); + + if (!normalizedName) { + setError("Enter a player name."); + return; + } + + await roomStore.createRoom(normalizedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index 7e95746d..d39b8fe3 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -15,6 +15,13 @@ export function JoinRoomPage() { try { setError(null); + const normalizedName = playerName.trim(); + + if (!normalizedName) { + setError("Enter a player name."); + return; + } + const normalizedCode = roomCode.trim().toUpperCase(); if (!normalizedCode) { @@ -32,7 +39,7 @@ export function JoinRoomPage() { return; } - await roomStore.joinRoom(normalizedCode, playerName); + await roomStore.joinRoom(normalizedCode, normalizedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); From 3ecb735c2899a3d3a7caed1051c45d65e8511fda Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:05:36 +0530 Subject: [PATCH 13/27] feat(game): assign drawer and reveal secret word to drawer only Assign the host as drawer when the game starts, pick the secret word deterministically from room code, and return/show the word only for the drawer viewer while guessers receive no secret word field. Co-authored-by: Cursor --- backend/src/models/game.ts | 5 +++++ backend/src/services/roomStore.ts | 25 +++++++++++++++++++++++-- frontend/src/pages/GamePage.tsx | 27 +++++++++++++++++++++++++-- frontend/src/services/api.ts | 3 +++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 37cbdf02..c1a9f315 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -11,6 +11,8 @@ export interface Room { code: string; status: RoomStatus; hostParticipantId: string; + drawerParticipantId: string | null; + secretWord: string | null; participants: Participant[]; createdAt: string; updatedAt: string; @@ -20,6 +22,9 @@ export interface RoomSnapshot { code: string; status: RoomStatus; hostParticipantId: string; + drawerParticipantId: string | null; + viewerRole: ParticipantRole; + secretWord?: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 7a90d325..c21c5006 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -45,6 +45,12 @@ function cloneRoom(room: Room) { return structuredClone(room); } +function pickDeterministicWord(roomCode: string) { + const score = Array.from(roomCode).reduce((sum, char) => sum + char.charCodeAt(0), 0); + const index = score % STARTER_WORDS.length; + return STARTER_WORDS[index]; +} + export function listWords() { return [...STARTER_WORDS]; } @@ -55,6 +61,8 @@ export function createRoom(playerName: string) { code: generateUniqueCode(), status: "lobby", hostParticipantId: participant.id, + drawerParticipantId: null, + secretWord: null, participants: [participant], createdAt: now(), updatedAt: now() @@ -106,6 +114,9 @@ export function startGame(code: string, requesterParticipantId: string) { return { ok: false as const, reason: "not_enough_players" as const }; } + const fallbackDrawerId = room.participants[0]?.id ?? null; + room.drawerParticipantId = room.hostParticipantId || fallbackDrawerId; + room.secretWord = pickDeterministicWord(room.code); room.status = "playing"; room.updatedAt = now(); rooms.set(room.code, room); @@ -120,14 +131,24 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawerViewer = Boolean( + viewerParticipantId && room.drawerParticipantId && viewerParticipantId === room.drawerParticipantId + ); - return { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, hostParticipantId: room.hostParticipantId, + drawerParticipantId: room.drawerParticipantId, + viewerRole: isDrawerViewer ? "drawer" : "guesser", participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] }; + + if (isDrawerViewer && room.secretWord) { + snapshot.secretWord = room.secretWord; + } + + return snapshot; } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..7c92fdad 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -5,10 +5,11 @@ 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"; export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); useEffect(() => { @@ -17,11 +18,27 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (!room?.code || room.status !== "playing") { + return; + } + + const intervalId = window.setInterval(() => { + void roomStore.fetchRoom(); + }, 2000); + + return () => { + window.clearInterval(intervalId); + }; + }, [room?.code, room?.status, roomStore]); + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.viewerRole === "drawer"; + const roleLabel = isDrawer ? "You are the drawer" : "You are guessing"; return (
    @@ -56,8 +73,14 @@ export function GamePage() {
    Status
    -
    Playing
    +
    {roleLabel}
    + {isDrawer && room.secretWord ? ( +
    +
    Secret word
    +
    {room.secretWord}
    +
    + ) : null} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 215aee95..629ba128 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,6 +10,9 @@ export interface RoomSnapshot { code: string; status: "lobby" | "playing"; hostParticipantId: string; + drawerParticipantId: string | null; + viewerRole: ParticipantRole; + secretWord?: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; From 1b2d93c8d9e59e7c1a5247df46f496700e71f52b Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:08:17 +0530 Subject: [PATCH 14/27] docs: specify scenario 3 gameplay Define Scenario 3 gameplay interaction acceptance criteria, edge cases, and manual verification steps for drawing, guess validation, history sync, and scoring. Co-authored-by: Cursor --- speckit.specify | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/speckit.specify b/speckit.specify index b179a30b..4cdd1b8a 100644 --- a/speckit.specify +++ b/speckit.specify @@ -196,3 +196,106 @@ Implement Scenario 2 behavior (first round start semantics only): - Drawer sees secret word; guesser does not. - Refresh both tabs → visibility rule remains true after polling. +--- + +# Spec — Iteration 3 (Scenario 3: Gameplay interaction) + +## Scope (in) + +Implement Scenario 3 behavior for one active round: + +- Drawer drawing interaction and clear-canvas action. +- Guess submission validation (trim input, reject empty). +- Case-insensitive comparison against the secret word. +- Guess history synchronized to all players via polling. +- Scoring rules: + - scores start at 0 + - correct guess = +100 + - incorrect guess = +0 + +## Scope (out) + +- Round result screen and restart flow (Scenario 4). +- Timers, countdowns, speed bonuses, drawer bonuses, multiple rounds. + +## Definitions + +- **Stroke**: one drawn segment on the shared canvas state. +- **Clear canvas**: action that resets current stroke/path state to empty. +- **Guess history**: ordered list of guesses with metadata (who guessed, guessed text, correctness, timestamp). +- **Case-insensitive match**: compare normalized strings using lowercase on both sides after trimming. + +## Assumptions (to remove ambiguity) + +- **Canvas authority**: only the drawer can mutate canvas state (draw/clear). Guessers are read-only viewers. +- **Guess normalization**: backend trims guess text before evaluation; empty-after-trim is rejected. +- **Scoring granularity**: each guess event is evaluated independently; a correct guess awards +100 for that submission. +- **History ordering**: guess history is displayed oldest-to-newest by server insertion order. +- **Sync mechanism**: all multiplayer synchronization remains HTTP polling (~2 seconds). + +## Acceptance criteria + +### A. Drawing interaction + clear canvas + +- While room status is `playing`, drawer can: + - draw strokes on canvas + - clear the canvas explicitly +- Guesser behavior: + - cannot draw or clear + - sees synchronized canvas state updates via polling +- After clear: + - canvas state is empty for all players within polling window (~2s) + +### B. Guess validation + +- Guess submission trims whitespace before processing. +- Empty/whitespace-only guesses are rejected with a clear message. +- UI does not crash on rejected guesses. + +### C. Case-insensitive correctness + +- Guess is compared against secret word case-insensitively and trimmed. +- Examples (if word is `pizza`): + - `Pizza`, `PIZZA`, ` pizza ` are correct. + - `pizz` or `pizza!` are incorrect unless explicitly normalized to remove punctuation (not required in this iteration). + +### D. Guess history sync via polling + +- Every accepted guess (correct or incorrect) is appended to shared guess history. +- All players see the same ordered history within ~2 seconds via polling. +- History entries include enough context to render: + - player identity/name + - guess text (normalized or original, consistently defined) + - correctness flag + +### E. Scoring behavior + +- Scoreboard initializes all participants at 0 when game enters `playing`. +- Incorrect guess adds 0 (no change). +- Correct guess adds +100 to the guessing player. +- Score updates are visible to all players via polling. + +## Edge cases + +- Drawer submitting guesses via UI/API should be blocked or ignored consistently (explicitly non-scoring). +- Repeated correct guesses by same player are allowed in this iteration unless blocked by backend rule; if blocked, backend must return clear message and keep score stable. +- Guess submitted by unknown/invalid participant id is rejected safely. +- Clearing an already-empty canvas is a no-op (no crash). + +## Manual verification checklist (Scenario 3) + +- **Canvas controls**: + - Drawer can draw and clear. + - Guesser cannot mutate canvas. +- **Guess validation**: + - Submit `" "` → clear error message. + - Submit `" pizza "` when word is pizza → evaluated correctly. +- **Case-insensitive match**: + - `PIZZA` equals `pizza`. +- **History sync** (two tabs): + - guesses from one tab appear on the other within ~2 seconds. +- **Scoring**: + - initial scores are 0. + - incorrect guess leaves score unchanged. + - correct guess increases that player by exactly +100. + From a0b5da12d8606a58ed171d5dfc620aa59249fbf4 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:08:33 +0530 Subject: [PATCH 15/27] docs: plan scenario 3 Define Scenario 3 backend and frontend implementation plan for canvas interactions, guess processing, shared polling sync, and scoring state. Co-authored-by: Cursor --- speckit.plan | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/speckit.plan b/speckit.plan index 730037f6..656e44e0 100644 --- a/speckit.plan +++ b/speckit.plan @@ -274,3 +274,154 @@ Update `toRoomSnapshot(room, viewerParticipantId)`: - guesser sees “guesser” role and no secret word - Refresh/polling keeps roles/visibility consistent. +--- + +# Plan — Iteration 3 (Scenario 3: Gameplay interaction) + +This plan extends Scenario 2 with real gameplay interactions: canvas draw/clear, guess handling, shared history, and scoring. + +## Backend plan (Scenario 3) + +### Data model updates (`backend/src/models/game.ts`) + +Add gameplay state to `Room`: + +- `canvasStrokes: CanvasStroke[]` (or minimal line/point schema) +- `guesses: GuessEntry[]` +- `scores: Record` keyed by participant id + +Add snapshot fields to `RoomSnapshot`: + +- `canvasStrokes` +- `guesses` +- `scores` + +Add supporting types: + +- `CanvasStroke` with stable shape (id, points, color, width, createdBy, createdAt) +- `GuessEntry` with (id, participantId, participantName, guess, isCorrect, createdAt) + +### Store initialization and invariants (`backend/src/services/roomStore.ts`) + +- On room creation/join: + - ensure scores map is present (join initializes new participant score to 0) +- On `startGame`: + - initialize/reset round state for Scenario 3: + - `canvasStrokes = []` + - `guesses = []` + - `scores` contains all participants with 0 (for first round) + +### New backend operations and endpoints + +Add route handlers in `backend/src/api/rooms.ts` with Zod schemas in `backend/src/api/schemas.ts`: + +- `POST /rooms/:code/canvas/strokes` + - body: `{ participantId, stroke }` + - only drawer can add stroke (403 otherwise) +- `POST /rooms/:code/canvas/clear` + - body: `{ participantId }` + - only drawer can clear (403 otherwise) +- `POST /rooms/:code/guesses` + - body: `{ participantId, guess }` + - trim guess; reject empty (400) + - compare case-insensitively to `secretWord` + - append guess history entry + - score update: +100 if correct, +0 if incorrect + +All endpoints return updated room snapshot. + +### Validation rules (`backend/src/api/schemas.ts`) + +- Guess schema: + - `guess` is string, trimmed, min length 1 +- Canvas stroke schema: + - enforce required fields and reasonable limits (non-empty points list) +- Participant id required for all gameplay mutations. + +### Snapshot and security behavior + +- Keep existing drawer-only secret word visibility rule. +- `guesses`, `scores`, and `canvasStrokes` are visible to all players in room. +- Reject mutations from unknown participant ids. + +### Backend tests (recommended) + +- `backend/src/services/roomStore.test.ts`: + - draw/clear permissions + - guess validation (empty rejected) + - case-insensitive correctness + - score transitions (0 -> +100 on correct, unchanged on incorrect) + - guess history ordering and shared snapshot consistency + +## Frontend plan (Scenario 3) + +### API surface (`frontend/src/services/api.ts`) + +Add methods: + +- `addCanvasStroke(code, participantId, stroke)` +- `clearCanvas(code, participantId)` +- `submitGuess(code, participantId, guess)` + +Extend `RoomSnapshot` type with: + +- `canvasStrokes` +- `guesses` +- `scores` + +### State store updates (`frontend/src/state/roomStore.ts`) + +Add store actions that call API and replace snapshot: + +- `addCanvasStroke(stroke)` +- `clearCanvas()` +- `submitGuess(guess)` + +Keep `fetchRoom()` as the polling sync path for all tabs. + +### Game UI updates (`frontend/src/pages/GamePage.tsx` + components) + +- Canvas area: + - replace placeholder with interactive canvas for drawer + - render existing `canvasStrokes` for all viewers + - show clear button for drawer only +- Guess form: + - enabled for guessers only + - submits through `roomStore.submitGuess` + - shows validation/server errors +- Guess history panel: + - render shared `guesses` from snapshot +- Scoreboard: + - render from snapshot `scores` + participants + +### Polling and sync + +- Reuse GamePage polling (~2s) to keep canvas, guesses, and scores synced across tabs. +- Mutation actions update initiating client immediately via response snapshot; other clients converge via polling. + +## Files expected to change (Scenario 3) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/components/GuessForm.tsx` (if props/state contract changes) +- `frontend/src/components/Scoreboard.tsx` +- `frontend/src/components/ResultPanel.tsx` (may remain placeholder) +- `frontend/src/styles/app.css` (canvas/guess-history styling) + +## Manual verification checklist (Scenario 3) + +- Drawer can draw and clear; guesser cannot mutate canvas. +- Guess form rejects empty/whitespace-only guesses with clear message. +- Case-insensitive correctness works (`PIZZA` matches `pizza`). +- Guess history appears in order and syncs across two tabs within ~2s. +- Scoreboard starts at 0 for all players; correct guess adds +100, incorrect adds +0. + From 902a5e4e0b63ddb9238582b8944a689444092055 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:08:51 +0530 Subject: [PATCH 16/27] docs: tasks scenario 3 Add ordered backend-first Scenario 3 tasks with dependencies and two-tab manual checks for canvas sync, guess handling, and scoring. Co-authored-by: Cursor --- speckit.tasks | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/speckit.tasks b/speckit.tasks index 46b26250..1071c559 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -231,3 +231,133 @@ Goal: implement Scenario 2 behavior from `speckit.specify` using the approach in - Secret word selection is deterministic. - Secret word is visible only to drawer (no leakage via API snapshot). +--- + +# Tasks — Iteration 3 (Scenario 3: Gameplay interaction) + +Goal: implement Scenario 3 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend room/gameplay state types + +- **Depends on**: Scenario 2 complete +- **Change**: + - `backend/src/models/game.ts` + - add `CanvasStroke` and `GuessEntry` types + - add room fields: `canvasStrokes`, `guesses`, `scores` + - expose these in `RoomSnapshot` +- **Manual check**: `cd backend && npm run build` + +### B2 — Initialize/reset gameplay state correctly + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts` + - initialize new room gameplay state + - ensure joining player gets score initialized to 0 + - reset `canvasStrokes` + `guesses` + `scores` when game starts (Scenario 3 single-round start) +- **Manual check**: + - After start, all participants have score 0 and empty history/canvas. + +### B3 — Add canvas mutation endpoints (drawer-only) + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/api/schemas.ts`: canvas stroke/clear schemas + - `backend/src/services/roomStore.ts`: add stroke/clear operations with role checks + - `backend/src/api/rooms.ts`: + - `POST /rooms/:code/canvas/strokes` + - `POST /rooms/:code/canvas/clear` +- **Manual check**: + - Drawer can add stroke/clear; guesser receives 403. + +### B4 — Add guess submission endpoint and scoring + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/api/schemas.ts`: guess schema (trim, min 1) + - `backend/src/services/roomStore.ts`: + - add guess submission operation + - case-insensitive compare to secret word + - append history entries + - apply scoring (+100 correct, +0 incorrect) + - `backend/src/api/rooms.ts`: + - `POST /rooms/:code/guesses` +- **Manual check**: + - whitespace guess rejected (400) + - correct guess increments by 100 + - incorrect guess leaves score unchanged + +### B5 — Backend tests (recommended) + +- **Depends on**: B3, B4 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Extend API + snapshot types for gameplay state + +- **Depends on**: B1-B4 contracts +- **Change**: + - `frontend/src/services/api.ts` + - add `canvasStrokes`, `guesses`, `scores` to snapshot typing + - add API methods for stroke/clear/guess mutations +- **Manual check**: `cd frontend && npm run build` + +### F2 — Add gameplay actions to room store + +- **Depends on**: F1 +- **Change**: + - `frontend/src/state/roomStore.ts` + - `addCanvasStroke`, `clearCanvas`, `submitGuess` + - each action updates local snapshot from API response +- **Manual check**: + - invoking actions updates room snapshot without full-page refresh. + +### F3 — Implement interactive canvas + clear behavior + +- **Depends on**: F1, F2 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - optional helper component(s) if needed + - `frontend/src/styles/app.css` +- **Manual check (two tabs)**: + - drawer draws and clears + - guesser sees updates via polling + - guesser cannot draw/clear + +### F4 — Wire guess submission and validation UI + +- **Depends on**: F1, F2 +- **Change**: + - `frontend/src/components/GuessForm.tsx` and `frontend/src/pages/GamePage.tsx` + - trim input + - reject empty with clear message + - submit valid guesses via store action +- **Manual check**: + - empty guess blocked with message + - valid guesses submit and appear in history + +### F5 — Render synced guess history + scoring + +- **Depends on**: F1, F2, F4 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - `frontend/src/components/Scoreboard.tsx` + - optional history UI section/component +- **Manual check (two tabs)**: + - guesses appear in same order on both tabs within ~2s + - scoreboard starts at 0 for all + - correct guess +100, incorrect +0 + +## Scenario 3 completion check (must pass) + +- Drawer-only draw/clear permissions are enforced. +- Guess input is trimmed and empty rejected with clear feedback. +- Correctness comparison is case-insensitive. +- Guess history syncs across players via polling. +- Scoring rules are deterministic and match +100/+0 with 0 initial scores. + From 70977631d505e4df1a9d475ad1074552d532d802 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:10:39 +0530 Subject: [PATCH 17/27] feat(canvas): implement draw and clear Add drawer-only canvas stroke storage and API endpoints, plus an interactive canvas UI that lets the drawer draw and clear while other players see synced strokes via polling. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 72 ++++++++- backend/src/api/schemas.ts | 23 +++ backend/src/models/game.ts | 16 ++ backend/src/services/roomStore.ts | 61 +++++++- frontend/src/components/DrawingCanvas.tsx | 183 ++++++++++++++++++++++ frontend/src/pages/GamePage.tsx | 14 +- frontend/src/services/api.ts | 27 ++++ frontend/src/state/roomStore.ts | 26 ++- frontend/src/styles/app.css | 20 +++ 9 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 92b3e9ce..c1c87ff5 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,5 +1,7 @@ import { Router } from "express"; import { + addCanvasStrokeSchema, + clearCanvasSchema, createRoomSchema, HttpError, joinRoomSchema, @@ -7,7 +9,15 @@ import { roomViewerQuerySchema, startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; +import { + addCanvasStroke, + clearCanvas, + createRoom, + getRoom, + joinRoom, + startGame, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -89,5 +99,65 @@ export function createRoomsRouter() { } }); + router.post("/:code/canvas/strokes", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, stroke } = addCanvasStrokeSchema.parse(request.body); + const result = addCanvasStroke(code, participantId, stroke); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Drawing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + throw new HttpError(403, "Only the drawer can draw on the canvas."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/canvas/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = clearCanvasSchema.parse(request.body); + const result = clearCanvas(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Drawing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + throw new HttpError(403, "Only the drawer can clear the canvas."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 262a98d3..fc52f21f 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -28,6 +28,29 @@ export const startGameSchema = z.object({ participantId: z.string().min(1, { message: "Missing participant id." }) }); +const canvasPointSchema = z.object({ + x: z.number(), + y: z.number() +}); + +export const canvasStrokeSchema = z.object({ + id: z.string().min(1), + points: z.array(canvasPointSchema).min(1, { message: "Stroke must include at least one point." }), + color: z.string().min(1), + width: z.number().positive(), + createdBy: z.string().min(1), + createdAt: z.string().min(1) +}); + +export const addCanvasStrokeSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }), + stroke: canvasStrokeSchema +}); + +export const clearCanvasSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index c1a9f315..0f1ea7a2 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,6 +1,20 @@ export type ParticipantRole = "drawer" | "guesser"; export type RoomStatus = "lobby" | "playing"; +export interface CanvasPoint { + x: number; + y: number; +} + +export interface CanvasStroke { + id: string; + points: CanvasPoint[]; + color: string; + width: number; + createdBy: string; + createdAt: string; +} + export interface Participant { id: string; name: string; @@ -13,6 +27,7 @@ export interface Room { hostParticipantId: string; drawerParticipantId: string | null; secretWord: string | null; + canvasStrokes: CanvasStroke[]; participants: Participant[]; createdAt: string; updatedAt: string; @@ -25,6 +40,7 @@ export interface RoomSnapshot { drawerParticipantId: string | null; viewerRole: ParticipantRole; secretWord?: string; + canvasStrokes: CanvasStroke[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index c21c5006..2cc520fa 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 { CanvasStroke, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -45,6 +45,10 @@ function cloneRoom(room: Room) { return structuredClone(room); } +function emptyCanvasStrokes(): Room["canvasStrokes"] { + return []; +} + function pickDeterministicWord(roomCode: string) { const score = Array.from(roomCode).reduce((sum, char) => sum + char.charCodeAt(0), 0); const index = score % STARTER_WORDS.length; @@ -63,6 +67,7 @@ export function createRoom(playerName: string) { hostParticipantId: participant.id, drawerParticipantId: null, secretWord: null, + canvasStrokes: emptyCanvasStrokes(), participants: [participant], createdAt: now(), updatedAt: now() @@ -117,6 +122,7 @@ export function startGame(code: string, requesterParticipantId: string) { const fallbackDrawerId = room.participants[0]?.id ?? null; room.drawerParticipantId = room.hostParticipantId || fallbackDrawerId; room.secretWord = pickDeterministicWord(room.code); + room.canvasStrokes = emptyCanvasStrokes(); room.status = "playing"; room.updatedAt = now(); rooms.set(room.code, room); @@ -124,6 +130,58 @@ export function startGame(code: string, requesterParticipantId: string) { return { ok: true as const, room: cloneRoom(room) }; } +function getPlayingRoomForParticipant(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "playing") { + return { ok: false as const, reason: "not_playing" as const }; + } + + const participant = room.participants.find((entry) => entry.id === participantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + if (participantId !== room.drawerParticipantId) { + return { ok: false as const, reason: "not_drawer" as const }; + } + + return { ok: true as const, room, participant }; +} + +export function addCanvasStroke(code: string, participantId: string, stroke: CanvasStroke) { + const access = getPlayingRoomForParticipant(code, participantId); + + if (!access.ok) { + return access; + } + + access.room.canvasStrokes.push(stroke); + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room) }; +} + +export function clearCanvas(code: string, participantId: string) { + const access = getPlayingRoomForParticipant(code, participantId); + + if (!access.ok) { + return access; + } + + access.room.canvasStrokes = emptyCanvasStrokes(); + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room) }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -141,6 +199,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn hostParticipantId: room.hostParticipantId, drawerParticipantId: room.drawerParticipantId, viewerRole: isDrawerViewer ? "drawer" : "guesser", + canvasStrokes: room.canvasStrokes.map((stroke) => ({ ...stroke, points: [...stroke.points] })), participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 00000000..8a24f13a --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import type { CanvasPoint, CanvasStroke } from "../services/api"; + +interface DrawingCanvasProps { + strokes: CanvasStroke[]; + canDraw: boolean; + onStrokeComplete: (stroke: CanvasStroke) => Promise; + onClear: () => Promise; +} + +const STROKE_COLOR = "#111827"; +const STROKE_WIDTH = 3; + +function drawStroke(context: CanvasRenderingContext2D, stroke: CanvasStroke) { + if (stroke.points.length === 0) { + return; + } + + context.strokeStyle = stroke.color; + context.lineWidth = stroke.width; + context.lineCap = "round"; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(stroke.points[0].x, stroke.points[0].y); + + for (let index = 1; index < stroke.points.length; index += 1) { + context.lineTo(stroke.points[index].x, stroke.points[index].y); + } + + context.stroke(); +} + +export function DrawingCanvas({ strokes, canDraw, onStrokeComplete, onClear }: DrawingCanvasProps) { + const canvasRef = useRef(null); + const [activePoints, setActivePoints] = useState([]); + const [isDrawing, setIsDrawing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + strokes.forEach((stroke) => drawStroke(context, stroke)); + + if (activePoints.length > 0) { + drawStroke(context, { + id: "preview", + points: activePoints, + color: STROKE_COLOR, + width: STROKE_WIDTH, + createdBy: "", + createdAt: "" + }); + } + }, [activePoints, strokes]); + + function getCanvasPoint(event: React.PointerEvent): CanvasPoint | null { + const canvas = canvasRef.current; + if (!canvas) { + return null; + } + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY + }; + } + + function handlePointerDown(event: React.PointerEvent) { + if (!canDraw || isSubmitting) { + return; + } + + const point = getCanvasPoint(event); + if (!point) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setIsDrawing(true); + setActivePoints([point]); + } + + function handlePointerMove(event: React.PointerEvent) { + if (!isDrawing || !canDraw) { + return; + } + + const point = getCanvasPoint(event); + if (!point) { + return; + } + + setActivePoints((current) => [...current, point]); + } + + async function handlePointerUp(event: React.PointerEvent) { + if (!isDrawing || !canDraw) { + return; + } + + event.currentTarget.releasePointerCapture(event.pointerId); + setIsDrawing(false); + + const completedPoints = activePoints; + setActivePoints([]); + + if (completedPoints.length === 0) { + return; + } + + const stroke: CanvasStroke = { + id: crypto.randomUUID(), + points: completedPoints, + color: STROKE_COLOR, + width: STROKE_WIDTH, + createdBy: "", + createdAt: new Date().toISOString() + }; + + try { + setIsSubmitting(true); + await onStrokeComplete(stroke); + } finally { + setIsSubmitting(false); + } + } + + async function handleClear() { + if (!canDraw || isSubmitting) { + return; + } + + try { + setIsSubmitting(true); + await onClear(); + } finally { + setIsSubmitting(false); + } + } + + return ( +
    + + {canDraw ? ( +
    + +
    + ) : ( +

    Watch the drawer's sketch update here.

    + )} +
    + ); +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 7c92fdad..6f165c30 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,6 +1,7 @@ import { useEffect } 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"; @@ -58,9 +59,16 @@ export function GamePage() {
    -
    - Waiting for drawer... -
    + { + await roomStore.addCanvasStroke(stroke); + }} + onClear={async () => { + await roomStore.clearCanvas(); + }} + />
    diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 629ba128..801c0f25 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,19 @@ export type ParticipantRole = "drawer" | "guesser"; +export interface CanvasPoint { + x: number; + y: number; +} + +export interface CanvasStroke { + id: string; + points: CanvasPoint[]; + color: string; + width: number; + createdBy: string; + createdAt: string; +} + export interface Participant { id: string; name: string; @@ -13,6 +27,7 @@ export interface RoomSnapshot { drawerParticipantId: string | null; viewerRole: ParticipantRole; secretWord?: string; + canvasStrokes: CanvasStroke[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -67,5 +82,17 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + addCanvasStroke(code: string, participantId: string, stroke: CanvasStroke) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/canvas/strokes`, { + method: "POST", + body: JSON.stringify({ participantId, stroke }) + }); + }, + clearCanvas(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/canvas/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index cfa5427a..daad16a8 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 CanvasStroke, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -110,6 +110,30 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async addCanvasStroke(stroke: CanvasStroke) { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing room session"); + } + + const response = await api.addCanvasStroke( + this.state.room.code, + this.state.participantId, + { ...stroke, createdBy: this.state.participantId } + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async clearCanvas() { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing room session"); + } + + const response = await api.clearCanvas(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..46f71f11 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -451,6 +451,26 @@ input { font-weight: 500; } +.drawing-canvas { + display: grid; + gap: 12px; +} + +.drawing-canvas__surface { + width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + display: block; +} + +.drawing-canvas__hint { + margin: 0; + color: var(--ink-soft); + font-size: 0.95rem; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { From c131f5ca5039f03af5c138f814e4ea37a167051f Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:11:21 +0530 Subject: [PATCH 18/27] feat(guesses): submit and validate guesses Add guess submission with trimmed non-empty validation, case-insensitive matching, drawer-only restrictions, and wire the game guess form to the backend endpoint. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 42 ++++++++++++++++- backend/src/api/schemas.ts | 5 ++ backend/src/models/game.ts | 11 +++++ backend/src/services/roomStore.ts | 68 ++++++++++++++++++++++++++- frontend/src/components/GuessForm.tsx | 30 ++++++++++-- frontend/src/pages/GamePage.tsx | 7 ++- frontend/src/services/api.ts | 16 +++++++ frontend/src/state/roomStore.ts | 10 ++++ 8 files changed, 182 insertions(+), 7 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index c1c87ff5..42828818 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -7,7 +7,8 @@ import { joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, - startGameSchema + startGameSchema, + submitGuessSchema } from "./schemas.js"; import { addCanvasStroke, @@ -16,6 +17,7 @@ import { getRoom, joinRoom, startGame, + submitGuess, toRoomSnapshot } from "../services/roomStore.js"; @@ -159,5 +161,43 @@ export function createRoomsRouter() { } }); + router.post("/:code/guesses", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const result = submitGuess(code, participantId, guess); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Guessing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + if (result.reason === "drawer_cannot_guess") { + throw new HttpError(403, "The drawer cannot submit guesses."); + } + + if (result.reason === "empty_guess") { + throw new HttpError(400, "Enter a guess."); + } + + throw new HttpError(400, "Unable to submit guess."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index fc52f21f..bbbc8cca 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -51,6 +51,11 @@ export const clearCanvasSchema = z.object({ participantId: z.string().min(1, { message: "Missing participant id." }) }); +export const submitGuessSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }), + guess: z.string().trim().min(1, { message: "Enter a guess." }) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 0f1ea7a2..fa1d4514 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -15,6 +15,15 @@ export interface CanvasStroke { createdAt: string; } +export interface GuessEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + isCorrect: boolean; + createdAt: string; +} + export interface Participant { id: string; name: string; @@ -28,6 +37,7 @@ export interface Room { drawerParticipantId: string | null; secretWord: string | null; canvasStrokes: CanvasStroke[]; + guesses: GuessEntry[]; participants: Participant[]; createdAt: string; updatedAt: string; @@ -41,6 +51,7 @@ export interface RoomSnapshot { viewerRole: ParticipantRole; secretWord?: string; canvasStrokes: CanvasStroke[]; + guesses: GuessEntry[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 2cc520fa..54cc3d50 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { CanvasStroke, Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { CanvasStroke, GuessEntry, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -49,6 +49,10 @@ function emptyCanvasStrokes(): Room["canvasStrokes"] { return []; } +function emptyGuesses(): Room["guesses"] { + return []; +} + function pickDeterministicWord(roomCode: string) { const score = Array.from(roomCode).reduce((sum, char) => sum + char.charCodeAt(0), 0); const index = score % STARTER_WORDS.length; @@ -68,6 +72,7 @@ export function createRoom(playerName: string) { drawerParticipantId: null, secretWord: null, canvasStrokes: emptyCanvasStrokes(), + guesses: emptyGuesses(), participants: [participant], createdAt: now(), updatedAt: now() @@ -123,6 +128,7 @@ export function startGame(code: string, requesterParticipantId: string) { room.drawerParticipantId = room.hostParticipantId || fallbackDrawerId; room.secretWord = pickDeterministicWord(room.code); room.canvasStrokes = emptyCanvasStrokes(); + room.guesses = emptyGuesses(); room.status = "playing"; room.updatedAt = now(); rooms.set(room.code, room); @@ -182,6 +188,65 @@ export function clearCanvas(code: string, participantId: string) { return { ok: true as const, room: cloneRoom(access.room) }; } +function getGuesserRoom(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "playing") { + return { ok: false as const, reason: "not_playing" as const }; + } + + const participant = room.participants.find((entry) => entry.id === participantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + if (participantId === room.drawerParticipantId) { + return { ok: false as const, reason: "drawer_cannot_guess" as const }; + } + + return { ok: true as const, room, participant }; +} + +export function submitGuess(code: string, participantId: string, guess: string) { + const access = getGuesserRoom(code, participantId); + + if (!access.ok) { + return access; + } + + const normalizedGuess = guess.trim(); + + if (!normalizedGuess) { + return { ok: false as const, reason: "empty_guess" as const }; + } + + if (!access.room.secretWord) { + return { ok: false as const, reason: "not_playing" as const }; + } + + const isCorrect = normalizedGuess.toLowerCase() === access.room.secretWord.toLowerCase(); + + const entry: GuessEntry = { + id: randomUUID(), + participantId: access.participant.id, + participantName: access.participant.name, + guess: normalizedGuess, + isCorrect, + createdAt: now() + }; + + access.room.guesses.push(entry); + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room), entry }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -200,6 +265,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn drawerParticipantId: room.drawerParticipantId, viewerRole: isDrawerViewer ? "drawer" : "guesser", canvasStrokes: room.canvasStrokes.map((stroke) => ({ ...stroke, points: [...stroke.points] })), + guesses: room.guesses.map((guess) => ({ ...guess })), participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..9ae83426 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -2,13 +2,34 @@ import { useState } from "react"; interface GuessFormProps { disabled?: boolean; + onSubmitGuess: (guess: 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(); + + const normalizedGuess = guessText.trim(); + + if (!normalizedGuess) { + setError("Enter a guess."); + return; + } + + try { + setError(null); + setIsSubmitting(true); + await onSubmitGuess(normalizedGuess); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } finally { + setIsSubmitting(false); + } } return ( @@ -19,11 +40,12 @@ 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/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 6f165c30..e629f734 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -93,7 +93,12 @@ export function GamePage() { - + { + await roomStore.submitGuess(guess); + }} + /> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 801c0f25..81cfac44 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -14,6 +14,15 @@ export interface CanvasStroke { createdAt: string; } +export interface GuessEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + isCorrect: boolean; + createdAt: string; +} + export interface Participant { id: string; name: string; @@ -28,6 +37,7 @@ export interface RoomSnapshot { viewerRole: ParticipantRole; secretWord?: string; canvasStrokes: CanvasStroke[]; + guesses: GuessEntry[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -94,5 +104,11 @@ export const api = { method: "POST", body: JSON.stringify({ participantId }) }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guesses`, { + method: "POST", + body: JSON.stringify({ participantId, guess }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index daad16a8..1d7bc2f5 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -134,6 +134,16 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async submitGuess(guess: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing room session"); + } + + const response = await api.submitGuess(this.state.room.code, this.state.participantId, guess); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); From c9201862ad07448e69de8fdb7bd0fb5ff751904f Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:11:36 +0530 Subject: [PATCH 19/27] feat(sync): poll and display guess history Render shared guess history from room snapshots on the game page so all players see submitted guesses update through existing polling sync. Co-authored-by: Cursor --- frontend/src/components/GuessHistory.tsx | 28 +++++++++++++++ frontend/src/pages/GamePage.tsx | 2 ++ frontend/src/styles/app.css | 44 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 frontend/src/components/GuessHistory.tsx diff --git a/frontend/src/components/GuessHistory.tsx b/frontend/src/components/GuessHistory.tsx new file mode 100644 index 00000000..c0a78e24 --- /dev/null +++ b/frontend/src/components/GuessHistory.tsx @@ -0,0 +1,28 @@ +import { Card } from "./Card"; +import type { GuessEntry } from "../services/api"; + +interface GuessHistoryProps { + guesses: GuessEntry[]; +} + +export function GuessHistory({ guesses }: GuessHistoryProps) { + return ( + + {guesses.length === 0 ? ( +

    No guesses yet.

    + ) : ( +
      + {guesses.map((entry) => ( +
    • + {entry.participantName} + {entry.guess} + + {entry.isCorrect ? "Correct" : "Incorrect"} + +
    • + ))} +
    + )} +
    + ); +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index e629f734..1d29b18b 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { DrawingCanvas } from "../components/DrawingCanvas"; import { GuessForm } from "../components/GuessForm"; +import { GuessHistory } from "../components/GuessHistory"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; @@ -54,6 +55,7 @@ export function GamePage() {
    diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 46f71f11..fb09c9bf 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -471,6 +471,50 @@ input { font-size: 0.95rem; } +.guess-history { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.guess-history__empty { + margin: 0; + color: var(--ink-soft); +} + +.guess-history__item { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-strong); + font-size: 0.95rem; +} + +.guess-history__player { + font-weight: 600; + color: var(--ink); +} + +.guess-history__guess { + color: var(--ink-soft); +} + +.guess-history__result { + font-size: 0.85rem; + font-weight: 600; + color: var(--ink-soft); +} + +.guess-history__result--correct { + color: #047857; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { From da1a7f31b5eac934655cd496a945d10299165120 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 00:12:05 +0530 Subject: [PATCH 20/27] feat(scoring): award points for correct guesses Track per-participant scores starting at zero, award 100 points on a player's first correct guess only, and render live scoreboard values from room snapshots. Co-authored-by: Cursor --- backend/src/models/game.ts | 2 ++ backend/src/services/roomStore.ts | 16 ++++++++++++++ frontend/src/components/Scoreboard.tsx | 30 ++++++++++++++++++++------ frontend/src/pages/GamePage.tsx | 2 +- frontend/src/services/api.ts | 1 + frontend/src/styles/app.css | 5 +++-- 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index fa1d4514..c7379a71 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -38,6 +38,7 @@ export interface Room { secretWord: string | null; canvasStrokes: CanvasStroke[]; guesses: GuessEntry[]; + scores: Record; participants: Participant[]; createdAt: string; updatedAt: string; @@ -52,6 +53,7 @@ export interface RoomSnapshot { secretWord?: string; canvasStrokes: CanvasStroke[]; guesses: GuessEntry[]; + scores: Record; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 54cc3d50..e6942e56 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -53,6 +53,10 @@ function emptyGuesses(): Room["guesses"] { return []; } +function initialScores(participants: Participant[]): Record { + return Object.fromEntries(participants.map((participant) => [participant.id, 0])); +} + function pickDeterministicWord(roomCode: string) { const score = Array.from(roomCode).reduce((sum, char) => sum + char.charCodeAt(0), 0); const index = score % STARTER_WORDS.length; @@ -73,6 +77,7 @@ export function createRoom(playerName: string) { secretWord: null, canvasStrokes: emptyCanvasStrokes(), guesses: emptyGuesses(), + scores: initialScores([participant]), participants: [participant], createdAt: now(), updatedAt: now() @@ -95,6 +100,7 @@ export function joinRoom(code: string, playerName: string) { const participant = createParticipant(playerName); room.participants.push(participant); + room.scores[participant.id] = 0; room.updatedAt = now(); rooms.set(room.code, room); @@ -129,6 +135,7 @@ export function startGame(code: string, requesterParticipantId: string) { room.secretWord = pickDeterministicWord(room.code); room.canvasStrokes = emptyCanvasStrokes(); room.guesses = emptyGuesses(); + room.scores = initialScores(room.participants); room.status = "playing"; room.updatedAt = now(); rooms.set(room.code, room); @@ -230,6 +237,9 @@ export function submitGuess(code: string, participantId: string, guess: string) } const isCorrect = normalizedGuess.toLowerCase() === access.room.secretWord.toLowerCase(); + const alreadyScoredCorrectly = access.room.guesses.some( + (existingGuess) => existingGuess.participantId === participantId && existingGuess.isCorrect + ); const entry: GuessEntry = { id: randomUUID(), @@ -241,6 +251,11 @@ export function submitGuess(code: string, participantId: string, guess: string) }; access.room.guesses.push(entry); + + if (isCorrect && !alreadyScoredCorrectly) { + access.room.scores[access.participant.id] = (access.room.scores[access.participant.id] ?? 0) + 100; + } + access.room.updatedAt = now(); rooms.set(access.room.code, access.room); @@ -266,6 +281,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn viewerRole: isDrawerViewer ? "drawer" : "guesser", canvasStrokes: room.canvasStrokes.map((stroke) => ({ ...stroke, points: [...stroke.points] })), guesses: room.guesses.map((guess) => ({ ...guess })), + scores: { ...room.scores }, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..1b45f3d0 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,32 @@ import { Card } from "./Card"; +import type { Participant } from "../services/api"; + +interface ScoreboardProps { + participants: Participant[]; + scores: Record; +} + +export function Scoreboard({ participants, scores }: ScoreboardProps) { + const rows = participants.map((participant) => ({ + id: participant.id, + name: participant.name, + score: scores[participant.id] ?? 0 + })); -export function Scoreboard() { return ( -
    -
    - Waiting for players... - 0 + {rows.length === 0 ? ( +

    Waiting for players...

    + ) : ( +
    + {rows.map((row) => ( +
    + {row.name} + {row.score} +
    + ))}
    -
    + )} ); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 1d29b18b..9ae95c6f 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -54,7 +54,7 @@ export function GamePage() {
    diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 81cfac44..b723e791 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -38,6 +38,7 @@ export interface RoomSnapshot { secretWord?: string; canvasStrokes: CanvasStroke[]; guesses: GuessEntry[]; + scores: Record; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index fb09c9bf..54b228e2 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -511,8 +511,9 @@ input { color: var(--ink-soft); } -.guess-history__result--correct { - color: #047857; +.scoreboard__empty { + margin: 0; + color: var(--ink-soft); } /* --- GAME PAGE LAYOUT --- */ From 61d3d457c2efeeba3544850847ac377947cfb5b3 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:51:07 +0530 Subject: [PATCH 21/27] docs: specify scenario 4 results and restart Define Scenario 4 acceptance criteria for shared results state, round-end behavior, host-only restart, and final two-tab validation checks. Co-authored-by: Cursor --- speckit.specify | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/speckit.specify b/speckit.specify index 4cdd1b8a..3dfdedfc 100644 --- a/speckit.specify +++ b/speckit.specify @@ -299,3 +299,95 @@ Implement Scenario 3 behavior for one active round: - incorrect guess leaves score unchanged. - correct guess increases that player by exactly +100. +--- + +# Spec — Iteration 4 (Scenario 4: Result, restart & final validation) + +## Scope (in) + +Implement Scenario 4 behavior for the single-round flow: + +- Transition room to a shared **results** state when the round ends. +- Show results to all players: correct word, final scores, full guess history. +- Allow **host-only restart** that returns all players to lobby with participants preserved and round state cleared. + +## Scope (out) + +- Multiple rounds with drawer rotation. +- Timers, countdowns, bonuses. +- Persistent storage or auth. + +## Definitions + +- **Round end**: the room transitions from `playing` to `results` when any guesser submits a **correct** guess. +- **Results state**: room status is `"results"`; all players can see the revealed word and final round summary. +- **Restart**: host action that resets round-specific state and sets room status back to `"lobby"`. + +## Assumptions (to remove ambiguity) + +- **Round-end trigger**: first correct guess ends the round immediately (no further guesses accepted after transition). +- **Word reveal in results**: during `results`, the secret word is visible to **all** players in snapshots (not drawer-only). +- **Restart permissions**: only the host can restart; non-host attempts are rejected with clear feedback. +- **Restart navigation**: all tabs observe `status === "lobby"` via polling and navigate back to `/lobby`. + +## Acceptance criteria + +### A. Result state shown to all + +- When the round ends, room status becomes `"results"`. +- All players see, via polling/UI: + - the correct secret word + - final scores for all participants + - full guess history from the round (ordered oldest → newest) +- Result UI is consistent across tabs within ~2 seconds. + +### B. Round-end behavior + +- After transition to `results`: + - no additional guesses are accepted (400/409 with clear message) + - drawer cannot draw/clear (403/409 with clear message) +- Existing guesses/scores/canvas/history remain visible for results display. + +### C. Host-only restart + +- Host can restart from results state. +- Non-host restart attempts are rejected (403) with clear message. +- On successful restart: + - room status returns to `"lobby"` + - participants and host identity are preserved + - round state is cleared: + - `drawerParticipantId = null` + - `secretWord = null` + - `canvasStrokes = []` + - `guesses = []` + - `scores` reset for all current participants to `0` + +### D. Post-restart lobby sync + +- All players automatically return to lobby UI via polling (~2s) after restart. +- Lobby shows preserved participants and host indicator. +- Host can start a new round once ≥2 players are present (reusing existing start rules). + +## Edge cases + +- If multiple tabs submit a correct guess nearly simultaneously, room ends once and remains in stable `results` state. +- Restart while not in `results` is rejected with clear message. +- Restart with missing/invalid host participant id is rejected safely. +- Unknown participant id on restart returns clear error without mutating room state. + +## Manual verification checklist (Scenario 4) + +- **End-to-end round** (two tabs): + - create → join → start → play → submit correct guess. +- **Results visibility**: + - both tabs show correct word, final scores, and full guess history. +- **Post-end restrictions**: + - further guess/canvas actions are blocked with clear feedback. +- **Restart**: + - host restart returns both tabs to lobby; players remain. + - round fields are cleared (no old word/canvas/guesses/scores leakage). +- **Final validation**: + - `cd backend && npm run build` + - `cd frontend && npm run build` + - repeat full flow including restart back to lobby. + From 151d68cca06a601c08de0a24e8a343ec8e90a54e Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:51:14 +0530 Subject: [PATCH 22/27] docs: plan scenario 4 Plan Scenario 4 backend status transitions, results snapshot visibility, restart endpoint, and frontend results/restart UI behavior. Co-authored-by: Cursor --- speckit.plan | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/speckit.plan b/speckit.plan index 656e44e0..14a05dfa 100644 --- a/speckit.plan +++ b/speckit.plan @@ -425,3 +425,114 @@ Keep `fetchRoom()` as the polling sync path for all tabs. - Guess history appears in order and syncs across two tabs within ~2s. - Scoreboard starts at 0 for all players; correct guess adds +100, incorrect adds +0. +--- + +# Plan — Iteration 4 (Scenario 4: Result, restart & final validation) + +This plan extends Scenario 3 to add round completion (`results`) and host restart back to lobby. + +## Backend plan (Scenario 4) + +### Data model updates (`backend/src/models/game.ts`) + +- Extend `RoomStatus`: + - `"lobby" | "playing" | "results"` +- No new persistent entities required beyond status transition semantics. +- Snapshot behavior changes: + - when `status === "results"`, include `secretWord` for all viewers (not drawer-only). + +### Round-end transition (`backend/src/services/roomStore.ts`) + +- In `submitGuess`: + - after recording a correct guess and applying score (+100 first time only, existing rule), + - set `room.status = "results"`. +- Guard gameplay mutations: + - reject draw/clear/guess when status is not `playing`. + +### Restart operation + route + +Add `restartGame(code, requesterParticipantId)` in `roomStore`: + +- validate room exists +- validate status is `"results"` +- validate requester is host +- reset round fields: + - `status = "lobby"` + - `drawerParticipantId = null` + - `secretWord = null` + - `canvasStrokes = []` + - `guesses = []` + - `scores = initialScores(participants)` (all zeros) +- preserve `participants` and `hostParticipantId` + +Add route: + +- `POST /rooms/:code/restart` + - body: `{ participantId }` + - errors: + - 404 room/participant not found + - 403 non-host + - 409 if not in results + - success: updated snapshot + +Add schema in `backend/src/api/schemas.ts`: + +- `restartGameSchema` (same shape as start: `{ participantId }`) + +### Snapshot updates (`toRoomSnapshot`) + +- If `room.status === "results"` and `room.secretWord` exists: + - include `secretWord` for every viewer. +- Keep existing drawer-only visibility rule for `playing`. + +## Frontend plan (Scenario 4) + +### API + types (`frontend/src/services/api.ts`) + +- Extend status union to include `"results"`. +- Add `api.restartGame(code, participantId)`. + +### Store (`frontend/src/state/roomStore.ts`) + +- Add `restartGame()` action updating snapshot from API response. + +### UI behavior + +- `ResultPanel` (`frontend/src/components/ResultPanel.tsx`): + - when `status === "results"`, show: + - correct word + - final scores + - full guess history (or reuse `GuessHistory`) +- `GamePage`: + - disable guess/canvas controls when not `playing` + - show host-only **Restart Game** button in results + - non-host sees waiting message +- Navigation sync: + - `GamePage`: if status becomes `results`, stay on game page but show results panel + - `LobbyPage`/`GamePage`: navigate to lobby when snapshot status becomes `"lobby"` while on game route + - after host restart, all tabs return to lobby via polling + +## Files expected to change (Scenario 4) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/pages/LobbyPage.tsx` (optional redirect safety) +- `frontend/src/components/ResultPanel.tsx` +- `frontend/src/styles/app.css` (results/restart styling) + +## Manual verification checklist (Scenario 4) + +- Correct guess transitions both tabs to results view with shared word/scores/history. +- Post-results gameplay actions are blocked. +- Host restart returns all tabs to lobby; players preserved; round state cleared. +- Backend/frontend builds pass. + From b7c7f943bbee2ca6e4f4abdebfb98e9ba2beab3c Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:51:14 +0530 Subject: [PATCH 23/27] docs: tasks scenario 4 Add ordered Scenario 4 backend-first tasks with dependencies and manual checks for results display, restart flow, and final validation. Co-authored-by: Cursor --- speckit.tasks | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/speckit.tasks b/speckit.tasks index 1071c559..66707237 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -361,3 +361,109 @@ Goal: implement Scenario 3 behavior from `speckit.specify` using the approach in - Guess history syncs across players via polling. - Scoring rules are deterministic and match +100/+0 with 0 initial scores. +--- + +# Tasks — Iteration 4 (Scenario 4: Result, restart & final validation) + +Goal: implement Scenario 4 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend status model to include results + +- **Depends on**: Scenario 3 complete +- **Change**: + - `backend/src/models/game.ts`: add `"results"` to `RoomStatus` +- **Manual check**: `cd backend && npm run build` + +### B2 — End round on first correct guess + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: + - in `submitGuess`, when guess is correct set `status = "results"` + - block draw/clear/guess when status !== `"playing"` +- **Manual check**: + - correct guess moves room to `results` + - further guess/draw attempts rejected with clear message + +### B3 — Reveal secret word to all viewers in results snapshots + +- **Depends on**: B1, B2 +- **Change**: + - `toRoomSnapshot`: include `secretWord` for all viewers when status is `"results"` +- **Manual check**: + - drawer and guesser snapshots both include word in results state + +### B4 — Add host-only restart operation + route + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: add `restartGame(code, requesterParticipantId)` + - `backend/src/api/schemas.ts`: add restart schema + - `backend/src/api/rooms.ts`: add `POST /rooms/:code/restart` +- **Manual check**: + - host restart from results succeeds and clears round state + - non-host restart returns 403 + - restart outside results returns 409 + +### B5 — Backend tests (recommended) + +- **Depends on**: B2, B4 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Extend API/types + restart store action + +- **Depends on**: B4 +- **Change**: + - `frontend/src/services/api.ts`: `"results"` status + `restartGame` API + - `frontend/src/state/roomStore.ts`: `restartGame()` action +- **Manual check**: `cd frontend && npm run build` + +### F2 — Results UI (word, scores, history) + +- **Depends on**: B2, B3, F1 +- **Change**: + - `frontend/src/components/ResultPanel.tsx` + - `frontend/src/pages/GamePage.tsx` wiring +- **Manual check (two tabs)**: + - after correct guess both tabs show same word/scores/history in results + +### F3 — Disable gameplay controls after round end + +- **Depends on**: F2 +- **Change**: + - `GamePage`, `GuessForm`, `DrawingCanvas` usage + - disable guess/canvas interactions when status is `"results"` +- **Manual check**: + - no further draw/guess actions possible in results state + +### F4 — Host restart + auto return to lobby via polling + +- **Depends on**: F1, B4 +- **Change**: + - `GamePage`: host-only restart button + waiting text for guests + - `LobbyPage`/`GamePage`: navigate to lobby when snapshot status becomes `"lobby"` +- **Manual check (two tabs)**: + - host restart returns both tabs to lobby within ~2s + - participants preserved, round data cleared + +### F5 — Final validation pass + +- **Depends on**: all above +- **Manual check**: + - full two-tab flow: create → join → start → play → correct guess → results → restart → lobby + - `cd backend && npm run build` + - `cd frontend && npm run build` + +## Scenario 4 completion check (must pass) + +- Results state is shared and complete (word, scores, history). +- Round-end locks gameplay mutations. +- Host-only restart clears round state and preserves players. +- All clients converge via polling and can replay start flow. + From 235d11f29af1a544814e9e6430d8e3fab06d3401 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:53:54 +0530 Subject: [PATCH 24/27] feat(results): show round results to all players Transition to a shared results state on the first correct guess, reveal the secret word to all viewers in snapshots, and render final scores and guess history in the results panel. Co-authored-by: Cursor --- backend/src/models/game.ts | 2 +- backend/src/services/roomStore.ts | 6 ++- frontend/src/components/ResultPanel.tsx | 57 ++++++++++++++++++++++--- frontend/src/pages/GamePage.tsx | 24 ++++++++--- frontend/src/services/api.ts | 2 +- frontend/src/styles/app.css | 38 +++++++++++++++++ 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index c7379a71..03e05b9c 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" | "playing"; +export type RoomStatus = "lobby" | "playing" | "results"; export interface CanvasPoint { x: number; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e6942e56..742b2ec0 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -256,6 +256,10 @@ export function submitGuess(code: string, participantId: string, guess: string) access.room.scores[access.participant.id] = (access.room.scores[access.participant.id] ?? 0) + 100; } + if (isCorrect) { + access.room.status = "results"; + } + access.room.updatedAt = now(); rooms.set(access.room.code, access.room); @@ -287,7 +291,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn roles: [...STARTER_ROLES] }; - if (isDrawerViewer && room.secretWord) { + if (room.secretWord && (room.status === "results" || isDrawerViewer)) { snapshot.secretWord = room.secretWord; } diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..b5d0b5c7 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,58 @@ import { Card } from "./Card"; +import type { GuessEntry, Participant } from "../services/api"; + +interface ResultPanelProps { + status: "lobby" | "playing" | "results"; + secretWord?: string; + participants: Participant[]; + scores: Record; + guesses: GuessEntry[]; +} + +export function ResultPanel({ status, secretWord, participants, scores, guesses }: ResultPanelProps) { + if (status !== "results") { + return ( + +

    Results will appear here when the round ends.

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

    Game activity and guesses will appear here.

    -
    + +
    +
    +
    Correct word
    +
    {secretWord ?? "Unknown"}
    +
    +
    + +

    Final scores

    +
      + {participants.map((participant) => ( +
    • + {participant.name} + {scores[participant.id] ?? 0} +
    • + ))} +
    + +

    Guess history

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

    No guesses were submitted.

    + ) : ( +
      + {guesses.map((entry) => ( +
    • + {entry.participantName} + {entry.guess} + + {entry.isCorrect ? "Correct" : "Incorrect"} + +
    • + ))} +
    + )}
    ); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 9ae95c6f..2e5c1a18 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -21,7 +21,7 @@ export function GamePage() { }, [navigate, room]); useEffect(() => { - if (!room?.code || room.status !== "playing") { + if (!room?.code || (room.status !== "playing" && room.status !== "results")) { return; } @@ -40,7 +40,13 @@ export function GamePage() { const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; const isDrawer = room.viewerRole === "drawer"; - const roleLabel = isDrawer ? "You are the drawer" : "You are guessing"; + const isPlaying = room.status === "playing"; + const roleLabel = + room.status === "results" + ? "Round complete" + : isDrawer + ? "You are the drawer" + : "You are guessing"; return (
    @@ -56,14 +62,20 @@ export function GamePage() {
    { await roomStore.addCanvasStroke(stroke); }} @@ -85,7 +97,7 @@ export function GamePage() {
    Status
    {roleLabel}
    - {isDrawer && room.secretWord ? ( + {isPlaying && isDrawer && room.secretWord ? (
    Secret word
    {room.secretWord}
    @@ -96,7 +108,7 @@ export function GamePage() { { await roomStore.submitGuess(guess); }} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b723e791..0050ff8e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -31,7 +31,7 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby" | "playing"; + status: "lobby" | "playing" | "results"; hostParticipantId: string; drawerParticipantId: string | null; viewerRole: ParticipantRole; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 54b228e2..731b81c2 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -516,6 +516,44 @@ input { color: var(--ink-soft); } +.result-panel { + margin-bottom: 16px; +} + +.result-panel__pending { + margin: 0; + color: var(--ink-soft); + font-size: 0.95rem; +} + +.result-panel__heading { + margin: 16px 0 8px; + font-size: 0.95rem; + color: var(--ink); +} + +.result-panel__scores { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.result-panel__scores li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-strong); +} + +.guess-history__result--correct { + color: #047857; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { From 88cd2093f454c0b42c258d0bdcf0f2110136ce45 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:55:59 +0530 Subject: [PATCH 25/27] feat(restart): host restart clears round state and returns to lobby Add host-only restart from results that resets round data while preserving participants, and navigate all clients back to the lobby when polling observes lobby status. Co-authored-by: Cursor --- backend/src/api/rooms.ts | 36 ++++++++++++++++++++++++++++++ backend/src/api/schemas.ts | 4 ++++ backend/src/services/roomStore.ts | 37 +++++++++++++++++++++++++++++++ frontend/src/pages/GamePage.tsx | 21 ++++++++++++++++++ frontend/src/services/api.ts | 6 +++++ frontend/src/state/roomStore.ts | 12 ++++++++++ frontend/src/styles/app.css | 5 +++++ 7 files changed, 121 insertions(+) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 42828818..d5891010 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -7,6 +7,7 @@ import { joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, + restartGameSchema, startGameSchema, submitGuessSchema } from "./schemas.js"; @@ -16,6 +17,7 @@ import { createRoom, getRoom, joinRoom, + restartGame, startGame, submitGuess, toRoomSnapshot @@ -90,6 +92,10 @@ export function createRoomsRouter() { throw new HttpError(403, "Only the host can start the game."); } + if (result.reason === "not_in_lobby") { + throw new HttpError(409, "The game can only be started from the lobby."); + } + throw new HttpError(409, "At least 2 players are required to start the game."); } @@ -199,5 +205,35 @@ export function createRoomsRouter() { } }); + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartGameSchema.parse(request.body); + const result = restartGame(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + if (result.reason === "not_host") { + throw new HttpError(403, "Only the host can restart the game."); + } + + throw new HttpError(409, "The game can only be restarted from results."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bbbc8cca..f4c3d4dd 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -28,6 +28,10 @@ export const startGameSchema = z.object({ participantId: z.string().min(1, { message: "Missing participant id." }) }); +export const restartGameSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + const canvasPointSchema = z.object({ x: z.number(), y: z.number() diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 742b2ec0..14cc2cd8 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -130,6 +130,10 @@ export function startGame(code: string, requesterParticipantId: string) { return { ok: false as const, reason: "not_enough_players" as const }; } + if (room.status !== "lobby") { + return { ok: false as const, reason: "not_in_lobby" as const }; + } + const fallbackDrawerId = room.participants[0]?.id ?? null; room.drawerParticipantId = room.hostParticipantId || fallbackDrawerId; room.secretWord = pickDeterministicWord(room.code); @@ -266,6 +270,39 @@ export function submitGuess(code: string, participantId: string, guess: string) return { ok: true as const, room: cloneRoom(access.room), entry }; } +export function restartGame(code: string, requesterParticipantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "results") { + return { ok: false as const, reason: "not_in_results" as const }; + } + + if (requesterParticipantId !== room.hostParticipantId) { + return { ok: false as const, reason: "not_host" as const }; + } + + const participant = room.participants.find((entry) => entry.id === requesterParticipantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + room.status = "lobby"; + room.drawerParticipantId = null; + room.secretWord = null; + room.canvasStrokes = emptyCanvasStrokes(); + room.guesses = emptyGuesses(); + room.scores = initialScores(room.participants); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { ok: true as const, room: cloneRoom(room) }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 2e5c1a18..b6ad49a2 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -20,6 +20,12 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } + }, [navigate, room?.status]); + useEffect(() => { if (!room?.code || (room.status !== "playing" && room.status !== "results")) { return; @@ -41,6 +47,8 @@ export function GamePage() { const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; const isDrawer = room.viewerRole === "drawer"; const isPlaying = room.status === "playing"; + const isResults = room.status === "results"; + const isHost = participantId === room.hostParticipantId; const roleLabel = room.status === "results" ? "Round complete" @@ -48,6 +56,10 @@ export function GamePage() { ? "You are the drawer" : "You are guessing"; + async function handleRestart() { + await roomStore.restartGame(); + } + return (
    @@ -118,6 +130,15 @@ export function GamePage() {
    + {isResults ? ( + isHost ? ( + + ) : ( +

    Waiting for the host to restart...

    + ) + ) : null} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0050ff8e..04f8f3fe 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -111,5 +111,11 @@ export const api = { 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 }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 1d7bc2f5..3e3236ca 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -144,6 +144,18 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing 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 731b81c2..7b34121e 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -550,6 +550,11 @@ input { background: var(--surface-strong); } +.game-page__waiting { + margin: 0; + color: var(--ink-soft); +} + .guess-history__result--correct { color: #047857; } From a2140d9939095cd268b996f458b90be57e599a0e Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 01:59:10 +0530 Subject: [PATCH 26/27] docs: add reflection report Document what the starter provided, what was added across all four scenarios, key tradeoffs, and how AI-assisted work was reviewed against the spec. Co-authored-by: Cursor --- REFLECTION.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 REFLECTION.md diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 00000000..62146416 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,79 @@ +# Reflection Report — Scribble Lab + +## What the starter app already had + +The starter was a runnable scaffold with: + +- React routes for Start, Create Room, Join Room, Lobby, and Game +- A minimal Express API: `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code`, `GET /health` +- In-memory room storage with participant lists +- Placeholder game UI (canvas, guess form, scoreboard, results) without real gameplay logic +- Manual lobby refresh only (no polling) +- No host permissions, drawer assignment, secret word rules, scoring, or restart flow + +## What I added + +Work followed the Spec Kit loop: discovery → constitution → specify/plan/tasks per scenario → incremental implementation → validation. + +### Artifacts + +- `speckit.discovery.md` — gaps, assumptions, relevant files +- `speckit.constitution` — scope boundaries, deterministic rules, AI/review discipline +- `speckit.specify`, `speckit.plan`, `speckit.tasks` — four iterations aligned to Scenarios 1–4 + +### Scenario 1 — Room setup & lobby + +- Host tracking on room creation +- Join validation with clear 400/404 errors +- Lobby polling (~2s) +- Host-only start with 2-player minimum +- Commits: `feat(rooms)`, `feat(join)`, `feat(lobby)`, `feat(game)` + +### Scenario 2 — Game start & drawer flow + +- Player name trim + non-empty validation +- Deterministic drawer assignment (host) and secret word selection (from room code) +- Drawer-only word visibility during `playing` +- Commits: `feat(players)`, `feat(game)` + +### Scenario 3 — Gameplay interaction + +- Drawer-only canvas draw/clear with synced strokes via polling +- Guess submission with trim, case-insensitive match, shared history, and scoring (+100 first correct, +0 incorrect) +- Commits: `feat(canvas)`, `feat(guesses)`, `feat(sync)`, `feat(scoring)` + +### Scenario 4 — Result & restart + +- Transition to `results` on first correct guess; word/scores/history visible to all +- Host-only restart clears round state and returns everyone to lobby via polling +- Commits: `feat(results)`, `feat(restart)` + +## Decisions and tradeoffs + +- **Polling over push**: kept HTTP polling (~2s) everywhere to match lab constraints; simple and sufficient for two-tab validation, but not real-time. +- **Deterministic word pick**: used room-code checksum modulo starter word list for repeatable behavior and easier manual testing. +- **First correct guess ends round**: single-round flow; avoids timers/multi-round complexity called out as out of scope. +- **Score once per player**: first correct guess awards 100; later correct guesses do not stack, keeping scoring predictable. +- **Minimal canvas model**: stored stroke paths rather than pixel buffers; easier to sync over REST but not optimized for high-frequency drawing. +- **Incremental commits**: each scenario split into small backend/frontend slices so changes stay traceable to spec acceptance criteria. + +## AI usage + +AI assisted with: + +- Drafting and refining Spec Kit artifacts from README scenarios +- Exploring the starter codebase and proposing file-level plans +- Implementing slices incrementally with typed backend/frontend changes +- Smoke-testing API flows and build checks between commits + +I reviewed AI output against `speckit.specify` acceptance criteria before committing, especially for permission rules (host/drawer/guesser), validation messages, and snapshot visibility (drawer-only word during play, all players in results). + +## Traceability + +Each implementation commit maps to a scenario slice in `speckit.tasks` and acceptance criteria in `speckit.specify`. Out-of-scope items from the README (WebSockets, databases, auth, multi-round rotation) were intentionally not implemented. + +## Manual validation performed + +- Two-tab flows for lobby sync, game start, drawing, guessing, results, and restart +- Backend and frontend `npm run build` after major slices +- API smoke checks for validation codes, role gating, and scoring/restart behavior From aeecc60023487976271e72256a0522afd4fdb466 Mon Sep 17 00:00:00 2001 From: Radha Date: Wed, 10 Jun 2026 02:13:11 +0530 Subject: [PATCH 27/27] docs: add Spec Kit artifact layout for pre-evaluation checks Create .specify/memory/constitution.md and four specs/NNN-* feature folders extracted from existing speckit artifacts so CI precheck passes. Co-authored-by: Cursor --- .specify/memory/constitution.md | 154 +++++++++++++++++++++++++++ specs/001-room-lobby/plan.md | 165 +++++++++++++++++++++++++++++ specs/001-room-lobby/spec.md | 108 +++++++++++++++++++ specs/001-room-lobby/tasks.md | 135 +++++++++++++++++++++++ specs/002-drawer-flow/plan.md | 107 +++++++++++++++++++ specs/002-drawer-flow/spec.md | 86 +++++++++++++++ specs/002-drawer-flow/tasks.md | 94 ++++++++++++++++ specs/003-gameplay/plan.md | 148 ++++++++++++++++++++++++++ specs/003-gameplay/spec.md | 100 +++++++++++++++++ specs/003-gameplay/tasks.md | 127 ++++++++++++++++++++++ specs/004-results-restart/plan.md | 108 +++++++++++++++++++ specs/004-results-restart/spec.md | 89 ++++++++++++++++ specs/004-results-restart/tasks.md | 103 ++++++++++++++++++ 13 files changed, 1524 insertions(+) create mode 100644 .specify/memory/constitution.md 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-drawer-flow/plan.md create mode 100644 specs/002-drawer-flow/spec.md create mode 100644 specs/002-drawer-flow/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-results-restart/plan.md create mode 100644 specs/004-results-restart/spec.md create mode 100644 specs/004-results-restart/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..4001df4c --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,154 @@ +# Spec Kit Constitution — Scribble Lab + +## Purpose + +This constitution constrains how we specify, plan, implement, and validate changes in this repo so the delivered behavior matches the README business scenarios and remains easy to review. + +## Non-negotiable boundaries (must match README) + +- **No WebSockets / real-time push**: all synchronization is HTTP + polling only. +- **No database / persistence**: backend state is in-memory only; restarts wipe rooms. +- **No authentication/accounts/sessions**. +- **No scope creep**: do not add multi-rounds, timers, bonuses, spectator mode, moderation, passwords/invites, custom word packs, new routing/state libraries, or unrelated refactors. +- **No “rewrite the starter”**: enhance incrementally; prefer minimal, local changes. + +## Engineering principles + +- **TypeScript-first**: avoid `any`; use `unknown` and narrow when needed. +- **Deterministic game rules**: outcomes must be repeatable across runs and tabs (no random word selection or random scoring behavior). +- **Backwards-compatible increments**: each commit should keep the app runnable. +- **Keep state minimal**: room state should store only what is required for the scenarios; remove/clear round state on restart. +- **Fail fast with clear errors**: + - Backend returns consistent JSON errors with useful messages. + - Frontend shows clear, user-facing validation feedback (not stack traces). + +## AI usage rules + +- **AI is allowed for**: exploration help, drafting specs/plans, proposing code changes, writing tests, and suggesting refactors that are directly justified by scenarios. +- **AI is not allowed to decide** ambiguous product rules silently: + - If a rule is unclear, record an assumption explicitly in `/speckit.specify` (and/or discovery notes) before implementing. +- **No blind copy/paste**: every AI-proposed change must be read, understood, and matched against acceptance criteria before committing. + +## Spec → Plan → Tasks → Code traceability + +- Every scenario must have: + - **Specification**: acceptance criteria + edge cases. + - **Plan**: state model + endpoints + file-level change list. + - **Tasks**: ordered, testable slices with dependencies. +- Implementation must remain consistent with artifacts; if implementation deviates, update the spec/plan/tasks in the same feature group. +- Complete **at least 4 specify iterations** (one per scenario group minimum). + +## Validation expectations (manual) + +All scenarios must be validated with **two browser tabs** (or two browsers) to simulate multiplayer. + +- **Polling**: observable refresh roughly every ~2 seconds where required (Lobby and Game sync). +- **Input validation**: + - Names and guesses are **trimmed**; empty/whitespace-only is rejected with a clear message. + - Guess matching is **case-insensitive**. + - Room code invalid/empty is rejected with clear feedback. +- **Isolation**: actions in one room must not affect another room. +- **Determinism**: word selection and scoring behave predictably and repeatably. + +## Commit discipline + +- Keep commits **granular and meaningful**: + - Prefer one behavioral slice per commit (backend + frontend together if needed for a slice). + - Include artifact updates in their own commits (docs-only) when possible. +- Before committing: + - Verify the slice manually against its acceptance criteria. + - Ensure no unrelated files (like lockfiles) are included unless intentionally changed. + +## Build verification (required before final handoff) + +- `cd backend && npm run build` +- `cd frontend && npm run build` + + + +## Discovery summary + +## What the starter already provides + +- Frontend routes/screens for Start, Create Room, Join Room, Lobby, and Game. +- Minimal REST backend with in-memory room store: + - `GET /health` + - `POST /rooms` + - `POST /rooms/:code/join` + - `GET /rooms/:code` +- Room snapshot includes participant list plus starter seed lists: + - words: `rocket`, `pizza`, `castle`, `guitar`, `sunflower` + - roles: `drawer`, `guesser` + +## Key incomplete behaviors observed (gaps vs README scenarios) + +- **No host concept / permissions** + - Room creation does not record a host participant id. + - Lobby “Start Game” is just a navigation button; there is no backend “start game” action, no host-only gating, and no 2-player minimum enforcement. + +- **No automatic polling** + - Lobby state updates only via a manual “Refresh Room” button; no ~2s polling loop. + - There is no game-state polling at all (guesses, results, etc.). + +- **Gameplay state model is missing** + - `RoomStatus` is only `"lobby"`; there is no `"playing"` or `"results"` state. + - No drawer assignment, no secret word selection/storage, and no “drawer-only visibility”. + - No guess submission endpoint/state, no guess history, no scoring, no result state, no restart flow. + +- **Validation is permissive / not aligned to scenarios** + - `playerName` is optional on create/join; backend coerces missing to `"Player"` and does not trim or reject whitespace-only names. + - `roomCode` param schema is `z.string()` (no length/format validation), so “invalid/empty codes rejected with clear feedback” is not implemented. + +- **Viewer-specific snapshot logic is not implemented** + - Backend `toRoomSnapshot(room, viewerParticipantId)` currently ignores `viewerParticipantId`. + - This will matter for “secret word visible only to drawer”. + +- **Potential API base URL bug in frontend** + - `frontend/src/services/api.ts` defaults `VITE_API_URL` to `http://localhost:3001/bug` (appending `/bug`). + - Without a proxy, this would call `/bug/rooms` which the backend does not serve. The README “Quick Verification” implies the starter works, so this may rely on an environment override or be an intentional scaffold flaw. + +## Assumptions (to resolve ambiguity while staying deterministic) + +- **A1 — Host definition** + - The creator of the room is the host. + - Store `hostParticipantId` on the room and expose it in snapshots so the frontend can gate host-only actions. + +- **A2 — Polling strategy** + - Implement client polling with `setInterval` (about every 2000ms) in screens that must stay fresh (Lobby and Game). + - Polling will call `GET /rooms/:code?participantId=...` and replace the room snapshot in the store. + +- **A3 — Deterministic word selection** + - Select the secret word deterministically from the starter word list using a stable input (e.g., room code) so it is repeatable and testable without randomness. + +- **A4 — Drawer assignment** + - Drawer for the single-round game is the host (or, if host is missing for any reason, the first participant in the room). + +## Relevant files inspected (likely to be touched later) + +### Backend +- Routes and validation: + - `backend/src/api/rooms.ts` + - `backend/src/api/schemas.ts` + - `backend/src/api/router.ts` +- In-memory room store + snapshot: + - `backend/src/services/roomStore.ts` +- Types + state model: + - `backend/src/models/game.ts` +- Seed data: + - `backend/src/seed/starterData.ts` + +### Frontend +- Room client + types: + - `frontend/src/services/api.ts` + - `frontend/src/state/roomStore.ts` +- Screens: + - `frontend/src/pages/CreateRoomPage.tsx` + - `frontend/src/pages/JoinRoomPage.tsx` + - `frontend/src/pages/LobbyPage.tsx` + - `frontend/src/pages/GamePage.tsx` + +## Notes / risks to track + +- Ensure all “sync” remains HTTP polling (explicitly no websockets). +- Backend is in-memory only; restarting backend wipes all rooms (expected). +- If the API base URL default really is wrong, we will need to fix it early (otherwise later work can’t be verified in two tabs). diff --git a/specs/001-room-lobby/plan.md b/specs/001-room-lobby/plan.md new file mode 100644 index 00000000..d5205b2e --- /dev/null +++ b/specs/001-room-lobby/plan.md @@ -0,0 +1,165 @@ +# Plan — Iteration 1 (Scenario 1: Room setup & lobby) + +This plan implements Scenario 1 from `speckit.specify` and stays within `speckit.constitution` constraints (HTTP polling only, in-memory backend, no auth). + +## Current state (starter) + +- Backend supports: + - `POST /rooms` → creates a room with 1 participant + - `POST /rooms/:code/join` → adds a participant + - `GET /rooms/:code?participantId=...` → returns a snapshot (viewer id currently ignored) +- Room model: + - `RoomStatus` is `"lobby"` only + - no host tracking +- Frontend: + - Lobby updates via manual “Refresh Room” + - “Start Game” button just navigates to `/game` (no backend action, no gating) + +## Target end state (Scenario 1) + +- Creating a room assigns a **host participant** and exposes host identity in the snapshot. +- Joining validates room codes; invalid input yields clear **400** errors; well-formed but missing room yields **404**. +- Lobby **polls every ~2 seconds** and updates participants automatically. +- Only the **host** can start the game, and only when **≥2 participants** are present. +- Start game is a backend-controlled state transition; all tabs observe it via polling and transition UI accordingly. + +## Backend plan + +### Data model changes (`backend/src/models/game.ts`) + +- Extend `RoomStatus` to support leaving the lobby: + - `type RoomStatus = "lobby" | "playing";` + - (Further statuses like `"results"` are deferred to later scenarios.) +- Add host tracking: + - `Room.hostParticipantId: string` +- Expose host identity to clients: + - `RoomSnapshot.hostParticipantId: string` + +### Room store changes (`backend/src/services/roomStore.ts`) + +- On `createRoom`: + - create participant as today + - set `hostParticipantId` to created participant’s id +- On `joinRoom`: + - no host changes +- Update `toRoomSnapshot`: + - include `hostParticipantId` + - keep `viewerParticipantId` parameter (will be used in later scenarios) +- Add a “start game” operation (new function, e.g. `startGame(code, requesterParticipantId)`): + - validate room exists + - validate requester is host (`requesterParticipantId === room.hostParticipantId`) + - validate `room.participants.length >= 2` + - set `room.status = "playing"` and `saveRoom(room)` + +### API changes + +#### Validation (`backend/src/api/schemas.ts`) + +- Strengthen room code validation: + - `code` must be a **trimmed** 4-character string + - reject empty/whitespace-only and wrong length as **400** + - optionally restrict characters to `[A-Z0-9]` (aligning with generated alphabet) +- Add schema for start request: + - body contains `participantId: string` + +#### Routes (`backend/src/api/rooms.ts`) + +- Add endpoint: + - `POST /rooms/:code/start` + - body: `{ participantId }` + - errors: + - 400 for invalid payload + - 404 if room not found + - 403 if requester is not host + - 409 (or 400) if fewer than 2 participants + - success: returns updated snapshot (or `{ room: snapshot }`) for immediate UI update + +### Backend tests (optional but recommended) + +- Update / add tests in: + - `backend/src/services/roomStore.test.ts` (host assignment, start gating) + - `backend/src/api/schemas.test.ts` (room code validation rules) + +## Frontend plan + +### State model (`frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`) + +- Extend `RoomSnapshot` type to include: + - `hostParticipantId: string` + - updated `status` union to include `"playing"` +- Add API method: + - `api.startGame(code: string, participantId: string)` +- Add store method: + - `roomStore.startGame()` that calls `api.startGame(room.code, participantId)` + - on success, updates the stored snapshot + +### Lobby polling (`frontend/src/pages/LobbyPage.tsx`) + +- Add a `useEffect` interval that calls `roomStore.fetchRoom()` every ~2000ms while: + - the user is on the Lobby route + - and `room` + `participantId` exist +- Ensure cleanup on unmount (clear interval). +- UI updates: + - Identify host in participant list and/or show “You are the host” if `participantId === room.hostParticipantId`. + - “Start Game” button: + - enabled only if viewer is host and `participants.length >= 2` + - otherwise disabled with clear “waiting” messaging + - Replace navigation-only start with calling `roomStore.startGame()`. + +### Transition on start (`frontend/src/pages/LobbyPage.tsx` and/or routing) + +- When polling detects `room.status === "playing"`: + - automatically navigate to `/game` in all tabs. +- If Lobby polling receives 404 (room not found): + - navigate to `/` with a clear message (or show a non-crashing error and stop polling). + +### Known risk: API base URL default (`frontend/src/services/api.ts`) + +- The current default `VITE_API_URL` includes `/bug`. +- If this prevents the starter from working in this repo, fix early to: + - default to `http://localhost:3001` + - keep `VITE_API_URL` override behavior intact + +## Files expected to change (Scenario 1) + +### Backend + +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- (maybe) `backend/src/services/roomStore.test.ts` +- (maybe) `backend/src/api/schemas.test.ts` + +### Frontend + +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/LobbyPage.tsx` +- (maybe) `frontend/src/pages/GamePage.tsx` (only to react to status/display; gameplay is later) + +## Data flow (Scenario 1) + +- Create/Join: + - frontend calls `POST /rooms` or `POST /rooms/:code/join` + - store saves `{ participantId, room }` +- Lobby refresh: + - polling calls `GET /rooms/:code?participantId=...` + - store replaces `room` snapshot +- Start: + - host calls `POST /rooms/:code/start` with `participantId` + - backend transitions `status` to `"playing"` + - all clients observe `"playing"` via polling and navigate to `/game` + +## Manual verification checklist (Scenario 1) + +- **Host tracking**: creator is host; non-host is not host; consistent across polls. +- **Join validation**: + - empty/whitespace/invalid length/invalid chars rejected with clear message (400) + - well-formed but missing room returns not found (404) +- **Polling**: Lobby participant list updates within ~2 seconds after a second tab joins. +- **Isolation**: two rooms remain isolated across joins and polls. +- **Start gating**: + - host cannot start with <2 players + - non-host cannot start (403) + - with 2 players, host start transitions all tabs to `/game` via polling diff --git a/specs/001-room-lobby/spec.md b/specs/001-room-lobby/spec.md new file mode 100644 index 00000000..8f883263 --- /dev/null +++ b/specs/001-room-lobby/spec.md @@ -0,0 +1,108 @@ +# Spec — Iteration 1 (Scenario 1: Room setup & lobby) + +## Scope (in) + +Implement the Scenario 1 behavior end-to-end: + +- Create room + join room by code +- Host tracking (creator is host) +- Invalid/empty code rejection with clear feedback +- Room isolation +- Lobby automatic polling (about every 2 seconds) +- Host-only start game, only allowed when at least 2 players are present + +## Scope (out) + +- No websockets (polling only) +- No persistence/database +- No auth/accounts +- No gameplay (drawer/word/guesses/scoring/results/restart) beyond transitioning out of lobby + +## Definitions + +- **Room**: identified by a 4-character code (case-insensitive input; normalized to uppercase). +- **Participant**: a player instance associated with a room; has an id and display name. +- **Host**: the participant who created the room. There is exactly one host per room. +- **Lobby polling**: the Lobby screen refreshes its room snapshot automatically every ~2 seconds. +- **Start game**: a state transition initiated by host that moves the room from lobby into the next state (details of gameplay are handled in later scenarios). + +## Assumptions (to remove ambiguity) + +- **Host selection**: the room creator is host; host identity is stable for the lifetime of the room. +- **Polling cadence**: target interval is **2000ms**; minor drift is acceptable; polling stops when leaving the Lobby route. +- **Start game semantics (Scenario 1 only)**: starting the game changes room status away from `"lobby"` (exact downstream game state is specified in Scenario 2+). + +## Acceptance criteria + +### A. Host tracking on create + +- When a player creates a room, the returned room snapshot includes enough information to identify the host. +- In the Lobby UI, the host is determinable (e.g., “You are the host” and/or host badge on participant list). +- Host identity is consistent across refreshes/polls. + +### B. Invalid/empty room codes rejected with clear feedback + +- Join flow rejects: + - empty string + - whitespace-only + - wrong length (not 4 chars after trimming) + - invalid characters (non-alphanumeric; optionally restrict to the backend’s generation alphabet) +- The user sees a clear, actionable message (examples): + - “Enter a 4‑letter room code.” + - “Room not found. Check the code and try again.” +- Backend returns an appropriate status code: + - **400** for invalid input format + - **404** when the room code is well-formed but not found + +### C. Room isolation + +- Two different room codes represent isolated rooms: + - participants in room A never appear in room B + - host-only actions in room A have no effect on room B +- Polling for room A cannot leak room B state. + +### D. Lobby polling (~2s) + +- After joining/creating a room, the Lobby automatically refreshes the participant list without requiring a manual “Refresh” button. +- With two tabs in the same room: + - when tab 2 joins, tab 1’s participant list updates within ~2 seconds + - the UI does not flicker or duplicate participants during repeated polls +- Polling error behavior: + - transient network errors show a non-crashing message and continue attempting subsequent polls + - if the room is not found (404), the user is redirected out of the Lobby (or shown a clear “room no longer exists” message) and polling stops + +### E. Host-only start game, minimum 2 players + +- Start is only enabled/allowed when: + - viewer is the host + - room has **at least 2** participants +- Non-host behavior: + - the “Start game” control is disabled or hidden, and the UI explains “Waiting for host…” + - if a non-host attempts to start (e.g., via a direct API call), the backend rejects it with a clear error (e.g., **403**) +- Minimum players enforcement: + - if host attempts to start with <2 players, backend rejects with a clear error (e.g., **409** or **400**) and the UI displays it +- Once start succeeds: + - the room status updates away from lobby + - all tabs observe the status change via polling and navigate/transition accordingly + +## Edge cases + +- **Code normalization**: `abcd`, `ABCD`, and ` a b c d ` (after trimming) should behave consistently (normalize to `ABCD`). +- **Refreshing as unknown viewer**: if `participantId` is missing/invalid, backend must still return a safe room snapshot (no crash); UI should prompt re-join if needed. +- **Duplicate names**: allowed in Scenario 1; participants are uniquely identified by id. +- **Rapid joins**: multiple join requests in quick succession still result in a stable participant list with no duplicates. + +## Manual verification checklist (Scenario 1) + +- **Create room**: + - You land in Lobby and can identify “host” in UI. +- **Join room (2nd tab)**: + - Join succeeds with a valid code; Lobby in tab 1 updates within ~2 seconds without clicking refresh. +- **Invalid joins**: + - Try empty / whitespace / short / long / special characters → see clear validation. + - Try well-formed but nonexistent code → see “room not found” style error. +- **Isolation**: + - Create room A and room B; joining A does not change B. +- **Start game gating**: + - With 1 player: host cannot start (clear message). + - With 2 players: only host can start; non-host cannot. diff --git a/specs/001-room-lobby/tasks.md b/specs/001-room-lobby/tasks.md new file mode 100644 index 00000000..c2f0df0d --- /dev/null +++ b/specs/001-room-lobby/tasks.md @@ -0,0 +1,135 @@ +# Tasks — Iteration 1 (Scenario 1: Room setup & lobby) + +Goal: implement Scenario 1 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Discovery + alignment (quick) + +- [ ] Confirm current starter endpoints still match the plan: + - `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code` +- [ ] Confirm we remain within constitution boundaries (HTTP polling only, in-memory, no auth). + +## Backend tasks (do first) + +### B1 — Update backend types for Scenario 1 + +- **Depends on**: none +- **Change**: + - `backend/src/models/game.ts` + - extend `RoomStatus` to include `"playing"` + - add `Room.hostParticipantId: string` + - add `RoomSnapshot.hostParticipantId: string` +- **Manual check**: `npm run build` (backend) still compiles after type changes. + +### B2 — Host tracking on room creation + snapshot field + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts` + - set `hostParticipantId` when creating a room + - include `hostParticipantId` in `toRoomSnapshot` +- **Manual check**: + - Create a room → response includes a host field (via snapshot) and it remains stable on `GET /rooms/:code`. + +### B3 — Strengthen room code validation + +- **Depends on**: none (can be done before/after B1/B2) +- **Change**: + - `backend/src/api/schemas.ts`: + - enforce 4-char trimmed room code + - optionally enforce allowed characters + - `backend/src/api/rooms.ts`: + - ensure invalid format yields 400 + - ensure missing room yields 404 +- **Manual check**: + - Join with empty/whitespace/invalid length/invalid chars → 400 with clear message + - Join with well-formed but nonexistent code → 404 with clear message + +### B4 — Add start game backend operation + route + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/services/roomStore.ts`: + - add `startGame(code, requesterParticipantId)` with: + - 404 if room missing + - 403 if requester not host + - 409 (or 400) if participants < 2 + - set `status = "playing"` on success + - `backend/src/api/schemas.ts`: + - add request schema for start game body `{ participantId }` + - `backend/src/api/rooms.ts`: + - add `POST /rooms/:code/start` + - return updated snapshot +- **Manual check** (use REST client or browser devtools): + - With 1 participant, host start → rejected with clear message + - Non-host start → rejected with clear message + - With 2 participants, host start → success; `GET /rooms/:code` shows `status: "playing"` + +### B5 — Backend tests (optional but recommended) + +- **Depends on**: B2, B3, B4 +- **Change**: + - Add/update tests in `backend/src/services/roomStore.test.ts` and `backend/src/api/schemas.test.ts` +- **Manual check**: `npm test` (backend) passes if tests exist in this repo. + +## Frontend tasks (after backend) + +### F1 — Update frontend RoomSnapshot types and API base URL if needed + +- **Depends on**: B1, B2 (for new snapshot fields), plus “starter runs locally” +- **Change**: + - `frontend/src/services/api.ts`: + - extend `RoomSnapshot` to include `hostParticipantId` and `"playing"` status + - fix `VITE_API_URL` default if it prevents local usage (remove `/bug`) +- **Manual check**: + - Create room works in browser without console errors; network calls hit the backend correctly. + +### F2 — Add start game client API + store method + +- **Depends on**: B4, F1 +- **Change**: + - `frontend/src/services/api.ts`: add `startGame(code, participantId)` + - `frontend/src/state/roomStore.ts`: add `startGame()` which updates stored snapshot +- **Manual check**: + - Clicking “Start Game” (once wired) results in `status` changing in store snapshot. + +### F3 — Implement Lobby polling (~2s) + cleanup + +- **Depends on**: F1 (for typing), existing `fetchRoom()` +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - add `useEffect` interval calling `roomStore.fetchRoom()` every ~2000ms + - clear interval on unmount + - handle errors without crashing (surface message; stop on 404) +- **Manual check (two tabs)**: + - Tab A creates room and waits in Lobby + - Tab B joins → Tab A participant list updates within ~2 seconds without clicking refresh + +### F4 — Host UI + host-only start gating + +- **Depends on**: B2, F1, F2 +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - display host indicator and/or “You are the host” + - disable/hide start button for non-host + - disable start when participants < 2 and show reason + - call `roomStore.startGame()` instead of navigation-only +- **Manual check (two tabs)**: + - With 1 player, host cannot start (clear message) + - With 2 players, only host can start; non-host sees “waiting for host” + +### F5 — Transition all players to Game when status becomes playing + +- **Depends on**: B4, F3 +- **Change**: + - `frontend/src/pages/LobbyPage.tsx`: + - if snapshot becomes `status === "playing"`, navigate to `/game` +- **Manual check (two tabs)**: + - Host starts game → both tabs navigate to `/game` via polling within ~2 seconds + +## Scenario 1 completion check (must pass) + +- Host tracking works and is stable across polls. +- Join validation has clear 400 vs 404 behavior. +- Lobby polls automatically ~2 seconds and updates participants. +- Rooms are isolated. +- Host-only start is enforced in backend and reflected in frontend UI. diff --git a/specs/002-drawer-flow/plan.md b/specs/002-drawer-flow/plan.md new file mode 100644 index 00000000..52b445ec --- /dev/null +++ b/specs/002-drawer-flow/plan.md @@ -0,0 +1,107 @@ +# Plan — Iteration 2 (Scenario 2: Game start & drawer flow) + +This plan extends the existing Scenario 1 implementation to satisfy Scenario 2 requirements: name validation, drawer assignment, deterministic word selection, and drawer-only word visibility. + +## Backend plan (Scenario 2) + +### Data model changes (`backend/src/models/game.ts`) + +Add minimal fields required for a single round start: + +- `Room.drawerParticipantId: string | null` +- `Room.secretWord: string | null` +- Extend `RoomSnapshot` to include: + - `drawerParticipantId: string | null` + - `viewerRole: "drawer" | "guesser"` (derived from viewer id) + - `secretWord?: string` (present only for drawer viewer) + +Rationale: +- Storing `drawerParticipantId` and `secretWord` on the room allows polling snapshots to be the single source of truth. +- Viewer-specific snapshot fields support “drawer-only visibility” without leaking data to guessers. + +### Name validation (create/join) + +- Update request validation in `backend/src/api/schemas.ts`: + - `playerName` must be a string that trims to length ≥ 1 (reject whitespace-only). +- Update `backend/src/services/roomStore.ts`: + - Normalize participant names by trimming before storing. + +### Start-game behavior (`backend/src/services/roomStore.ts` + `backend/src/api/rooms.ts`) + +On successful `startGame(code, requesterParticipantId)`: + +- Set `room.status = "playing"` (already implemented). +- Set `drawerParticipantId` deterministically: + - primary: `room.hostParticipantId` + - fallback: `room.participants[0]?.id` (should exist if room exists) +- Set `secretWord` deterministically using the algorithm in `speckit.specify`: + - `index = (sum of ASCII codes of room.code characters) % STARTER_WORDS.length` + - `secretWord = STARTER_WORDS[index]` + +Update `toRoomSnapshot(room, viewerParticipantId)`: +- Always include `drawerParticipantId`. +- Derive `viewerRole`: + - `"drawer"` if `viewerParticipantId === drawerParticipantId`, else `"guesser"`. +- Include `secretWord` **only** when viewer is drawer (and viewer id is present and matches). + +### Backend tests (recommended) + +- `backend/src/services/roomStore.test.ts`: + - name trimming behavior + - drawer assignment + - deterministic word selection for a given code + - snapshot visibility rule (drawer gets `secretWord`, guesser does not) + +## Frontend plan (Scenario 2) + +### State model changes (`frontend/src/services/api.ts`) + +- Extend `RoomSnapshot` type to match backend additions: + - `drawerParticipantId: string | null` + - `viewerRole: "drawer" | "guesser"` + - `secretWord?: string` + +### Create/Join validation (frontend forms) + +- `frontend/src/pages/CreateRoomPage.tsx`: + - trim player name on submit; reject whitespace-only with clear error. +- `frontend/src/pages/JoinRoomPage.tsx`: + - apply same name validation (in addition to existing room code validation). + +### Game UI updates (`frontend/src/pages/GamePage.tsx`) + +- Display a clear role indicator based on snapshot: + - If `viewerRole === "drawer"`: show “You are the drawer” and show `secretWord`. + - Else: show “You are guessing” and do not show any word. + +### Polling for in-game state (minimal for Scenario 2) + +- Add game-page polling (about every 2 seconds) to keep drawer/word visibility consistent across tabs after start: + - call `roomStore.fetchRoom()` on an interval while on `/game` + - stop polling when leaving the page + +## Files expected to change (Scenario 2) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` (start route response already exists; may only need snapshot shape updates) +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/pages/CreateRoomPage.tsx` +- `frontend/src/pages/JoinRoomPage.tsx` +- `frontend/src/pages/GamePage.tsx` +- (maybe) `frontend/src/state/roomStore.ts` (if we add helpers; base fetch already exists) + +## Manual verification checklist (Scenario 2) + +- Create with `" "` → blocked with clear message. +- Create with `" Alice "` → saved/displayed as `"Alice"`. +- Join with `" "` → blocked; join with `" Bob "` → saved/displayed as `"Bob"`. +- Host starts with 2 players: + - host sees “drawer” role + secret word + - guesser sees “guesser” role and no secret word +- Refresh/polling keeps roles/visibility consistent. diff --git a/specs/002-drawer-flow/spec.md b/specs/002-drawer-flow/spec.md new file mode 100644 index 00000000..93e902b0 --- /dev/null +++ b/specs/002-drawer-flow/spec.md @@ -0,0 +1,86 @@ +# Spec — Iteration 2 (Scenario 2: Game start & drawer flow) + +## Scope (in) + +Implement Scenario 2 behavior (first round start semantics only): + +- Player names are trimmed on create/join; empty/whitespace-only is rejected with a clear message. +- When the first round begins, a drawer is assigned deterministically (host / first player). +- Secret word is selected deterministically from the starter list. +- Secret word visibility is restricted: **drawer-only**. + +## Scope (out) + +- No drawing mechanics (canvas sync) yet. +- No guess submission, scoring, results, or restart (Scenario 3+). +- No websockets; polling only. + +## Definitions + +- **Drawer**: the participant responsible for drawing in the single round. +- **Guesser**: any non-drawer participant in the room. +- **Secret word**: the word that guessers attempt to guess; selected from the starter word list. +- **Deterministic selection**: given the same room code and seed list, selection always yields the same word. + +## Assumptions (to remove ambiguity) + +- **Drawer assignment**: the **host** is the drawer for the single round. (If host is missing, fall back to the first participant.) +- **Word selection algorithm**: + - Use the starter word list in `backend/src/seed/starterData.ts`. + - Compute `index = (sum of ASCII codes of room code characters) % words.length`. + - The secret word is `words[index]`. +- **Visibility rule**: + - Room snapshots may include `secretWord` only when the viewer is the drawer. + - Non-drawers must not receive `secretWord` in snapshots (not even as masked text). + +## Acceptance criteria + +### A. Player name validation (trim + reject empty) + +- Create room: + - leading/trailing whitespace is removed before saving the participant name. + - if the submitted name is empty after trimming, the request is rejected with a clear message. +- Join room: + - same trimming + empty rejection applies. +- Frontend provides immediate feedback (form error message) and backend enforces the same rule (defense in depth). + +### B. Drawer assignment at start + +- When the host starts the game (transition to `playing`): + - a `drawerParticipantId` is set for the room, deterministically based on host/first participant. +- In the Game UI: + - the drawer is clearly identified to all players (e.g., “You are the drawer” vs “You are guessing”). + +### C. Deterministic secret word selection + +- When the room enters `playing` the first time: + - the secret word is chosen deterministically from the starter list using the defined algorithm. +- Determinism checks: + - restarting the backend and repeating the same room code selection method yields the same word for that code. + - different room codes can map to different words (not required, but allowed). + +### D. Drawer-only secret word visibility + +- Drawer sees the secret word in the Game UI. +- Guessers do not see the secret word anywhere in the UI. +- API rule: + - the server only includes the secret word in snapshots for the drawer viewer. + - guessers’ snapshots must omit the field entirely. + +## Edge cases + +- Names like `" Alice "` become `"Alice"`. +- Names like `" "` are rejected with a clear message. +- Two players can use the same display name; uniqueness is by participant id. +- If a viewer refreshes without a valid `participantId`, the backend must not leak the secret word. + +## Manual verification checklist (Scenario 2) + +- **Name validation**: + - Create with `" "` → rejected with clear message. + - Create with `" Alice "` → shows `"Alice"` in lobby/game. + - Join with `" "` → rejected; join with `" Bob "` → saved as `"Bob"`. +- **Drawer + word** (two tabs): + - Host starts → host is the drawer. + - Drawer sees secret word; guesser does not. + - Refresh both tabs → visibility rule remains true after polling. diff --git a/specs/002-drawer-flow/tasks.md b/specs/002-drawer-flow/tasks.md new file mode 100644 index 00000000..ec4fad5f --- /dev/null +++ b/specs/002-drawer-flow/tasks.md @@ -0,0 +1,94 @@ +# Tasks — Iteration 2 (Scenario 2: Game start & drawer flow) + +Goal: implement Scenario 2 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend room model for round start state + +- **Depends on**: Scenario 1 code merged +- **Change**: + - `backend/src/models/game.ts` + - add `drawerParticipantId` and `secretWord` to `Room` + - add `drawerParticipantId`, `viewerRole`, and conditional `secretWord` to `RoomSnapshot` +- **Manual check**: `cd backend && npm run build` + +### B2 — Enforce player name trim + non-empty on create/join + +- **Depends on**: none +- **Change**: + - `backend/src/api/schemas.ts` + - require `playerName` as a trimmed non-empty string (clear error message) + - `backend/src/services/roomStore.ts` + - trim names before storing participant +- **Manual check**: + - Create/join with `" "` returns 400 + clear message. + - Create/join with `" Alice "` stores/display `"Alice"`. + +### B3 — Deterministic drawer assignment + word selection on start + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: + - on successful `startGame`, set: + - `drawerParticipantId` (host, else first participant) + - `secretWord` using the defined deterministic algorithm + - update `toRoomSnapshot(room, viewerParticipantId)`: + - include `drawerParticipantId` + - set `viewerRole` + - include `secretWord` only for drawer viewer +- **Manual check**: + - Start game, then `GET /rooms/:code?participantId=` includes `secretWord`. + - Same request for guesser does **not** include `secretWord`. + +### B4 — Backend tests (recommended) + +- **Depends on**: B2, B3 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Update RoomSnapshot types for drawer/word fields + +- **Depends on**: B1, B3 +- **Change**: + - `frontend/src/services/api.ts` + - add `drawerParticipantId`, `viewerRole`, and optional `secretWord` +- **Manual check**: `cd frontend && npm run build` + +### F2 — Enforce player name trim + non-empty in Create/Join UI + +- **Depends on**: B2 (backend enforcement), but can be implemented in parallel +- **Change**: + - `frontend/src/pages/CreateRoomPage.tsx` + - `frontend/src/pages/JoinRoomPage.tsx` +- **Manual check**: + - Both forms show clear error on whitespace-only names. + +### F3 — Game UI: show role and drawer-only secret word + +- **Depends on**: F1, B3 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - show “You are the drawer” + word when `viewerRole === "drawer"` + - show “You are guessing” and no word otherwise +- **Manual check (2 tabs)**: + - Host sees word; guesser does not. + +### F4 — Game polling for snapshot refresh (~2s) + +- **Depends on**: existing `roomStore.fetchRoom()` +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - add ~2s polling to keep snapshot current post-start +- **Manual check (2 tabs)**: + - Refreshing tabs and waiting shows consistent role/word visibility with no manual actions. + +## Scenario 2 completion check (must pass) + +- Names are trimmed and whitespace-only is rejected (frontend + backend). +- Drawer assignment is deterministic and visible to all. +- Secret word selection is deterministic. +- Secret word is visible only to drawer (no leakage via API snapshot). diff --git a/specs/003-gameplay/plan.md b/specs/003-gameplay/plan.md new file mode 100644 index 00000000..919195ae --- /dev/null +++ b/specs/003-gameplay/plan.md @@ -0,0 +1,148 @@ +# Plan — Iteration 3 (Scenario 3: Gameplay interaction) + +This plan extends Scenario 2 with real gameplay interactions: canvas draw/clear, guess handling, shared history, and scoring. + +## Backend plan (Scenario 3) + +### Data model updates (`backend/src/models/game.ts`) + +Add gameplay state to `Room`: + +- `canvasStrokes: CanvasStroke[]` (or minimal line/point schema) +- `guesses: GuessEntry[]` +- `scores: Record` keyed by participant id + +Add snapshot fields to `RoomSnapshot`: + +- `canvasStrokes` +- `guesses` +- `scores` + +Add supporting types: + +- `CanvasStroke` with stable shape (id, points, color, width, createdBy, createdAt) +- `GuessEntry` with (id, participantId, participantName, guess, isCorrect, createdAt) + +### Store initialization and invariants (`backend/src/services/roomStore.ts`) + +- On room creation/join: + - ensure scores map is present (join initializes new participant score to 0) +- On `startGame`: + - initialize/reset round state for Scenario 3: + - `canvasStrokes = []` + - `guesses = []` + - `scores` contains all participants with 0 (for first round) + +### New backend operations and endpoints + +Add route handlers in `backend/src/api/rooms.ts` with Zod schemas in `backend/src/api/schemas.ts`: + +- `POST /rooms/:code/canvas/strokes` + - body: `{ participantId, stroke }` + - only drawer can add stroke (403 otherwise) +- `POST /rooms/:code/canvas/clear` + - body: `{ participantId }` + - only drawer can clear (403 otherwise) +- `POST /rooms/:code/guesses` + - body: `{ participantId, guess }` + - trim guess; reject empty (400) + - compare case-insensitively to `secretWord` + - append guess history entry + - score update: +100 if correct, +0 if incorrect + +All endpoints return updated room snapshot. + +### Validation rules (`backend/src/api/schemas.ts`) + +- Guess schema: + - `guess` is string, trimmed, min length 1 +- Canvas stroke schema: + - enforce required fields and reasonable limits (non-empty points list) +- Participant id required for all gameplay mutations. + +### Snapshot and security behavior + +- Keep existing drawer-only secret word visibility rule. +- `guesses`, `scores`, and `canvasStrokes` are visible to all players in room. +- Reject mutations from unknown participant ids. + +### Backend tests (recommended) + +- `backend/src/services/roomStore.test.ts`: + - draw/clear permissions + - guess validation (empty rejected) + - case-insensitive correctness + - score transitions (0 -> +100 on correct, unchanged on incorrect) + - guess history ordering and shared snapshot consistency + +## Frontend plan (Scenario 3) + +### API surface (`frontend/src/services/api.ts`) + +Add methods: + +- `addCanvasStroke(code, participantId, stroke)` +- `clearCanvas(code, participantId)` +- `submitGuess(code, participantId, guess)` + +Extend `RoomSnapshot` type with: + +- `canvasStrokes` +- `guesses` +- `scores` + +### State store updates (`frontend/src/state/roomStore.ts`) + +Add store actions that call API and replace snapshot: + +- `addCanvasStroke(stroke)` +- `clearCanvas()` +- `submitGuess(guess)` + +Keep `fetchRoom()` as the polling sync path for all tabs. + +### Game UI updates (`frontend/src/pages/GamePage.tsx` + components) + +- Canvas area: + - replace placeholder with interactive canvas for drawer + - render existing `canvasStrokes` for all viewers + - show clear button for drawer only +- Guess form: + - enabled for guessers only + - submits through `roomStore.submitGuess` + - shows validation/server errors +- Guess history panel: + - render shared `guesses` from snapshot +- Scoreboard: + - render from snapshot `scores` + participants + +### Polling and sync + +- Reuse GamePage polling (~2s) to keep canvas, guesses, and scores synced across tabs. +- Mutation actions update initiating client immediately via response snapshot; other clients converge via polling. + +## Files expected to change (Scenario 3) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/components/GuessForm.tsx` (if props/state contract changes) +- `frontend/src/components/Scoreboard.tsx` +- `frontend/src/components/ResultPanel.tsx` (may remain placeholder) +- `frontend/src/styles/app.css` (canvas/guess-history styling) + +## Manual verification checklist (Scenario 3) + +- Drawer can draw and clear; guesser cannot mutate canvas. +- Guess form rejects empty/whitespace-only guesses with clear message. +- Case-insensitive correctness works (`PIZZA` matches `pizza`). +- Guess history appears in order and syncs across two tabs within ~2s. +- Scoreboard starts at 0 for all players; correct guess adds +100, incorrect adds +0. diff --git a/specs/003-gameplay/spec.md b/specs/003-gameplay/spec.md new file mode 100644 index 00000000..1f56eb05 --- /dev/null +++ b/specs/003-gameplay/spec.md @@ -0,0 +1,100 @@ +# Spec — Iteration 3 (Scenario 3: Gameplay interaction) + +## Scope (in) + +Implement Scenario 3 behavior for one active round: + +- Drawer drawing interaction and clear-canvas action. +- Guess submission validation (trim input, reject empty). +- Case-insensitive comparison against the secret word. +- Guess history synchronized to all players via polling. +- Scoring rules: + - scores start at 0 + - correct guess = +100 + - incorrect guess = +0 + +## Scope (out) + +- Round result screen and restart flow (Scenario 4). +- Timers, countdowns, speed bonuses, drawer bonuses, multiple rounds. + +## Definitions + +- **Stroke**: one drawn segment on the shared canvas state. +- **Clear canvas**: action that resets current stroke/path state to empty. +- **Guess history**: ordered list of guesses with metadata (who guessed, guessed text, correctness, timestamp). +- **Case-insensitive match**: compare normalized strings using lowercase on both sides after trimming. + +## Assumptions (to remove ambiguity) + +- **Canvas authority**: only the drawer can mutate canvas state (draw/clear). Guessers are read-only viewers. +- **Guess normalization**: backend trims guess text before evaluation; empty-after-trim is rejected. +- **Scoring granularity**: each guess event is evaluated independently; a correct guess awards +100 for that submission. +- **History ordering**: guess history is displayed oldest-to-newest by server insertion order. +- **Sync mechanism**: all multiplayer synchronization remains HTTP polling (~2 seconds). + +## Acceptance criteria + +### A. Drawing interaction + clear canvas + +- While room status is `playing`, drawer can: + - draw strokes on canvas + - clear the canvas explicitly +- Guesser behavior: + - cannot draw or clear + - sees synchronized canvas state updates via polling +- After clear: + - canvas state is empty for all players within polling window (~2s) + +### B. Guess validation + +- Guess submission trims whitespace before processing. +- Empty/whitespace-only guesses are rejected with a clear message. +- UI does not crash on rejected guesses. + +### C. Case-insensitive correctness + +- Guess is compared against secret word case-insensitively and trimmed. +- Examples (if word is `pizza`): + - `Pizza`, `PIZZA`, ` pizza ` are correct. + - `pizz` or `pizza!` are incorrect unless explicitly normalized to remove punctuation (not required in this iteration). + +### D. Guess history sync via polling + +- Every accepted guess (correct or incorrect) is appended to shared guess history. +- All players see the same ordered history within ~2 seconds via polling. +- History entries include enough context to render: + - player identity/name + - guess text (normalized or original, consistently defined) + - correctness flag + +### E. Scoring behavior + +- Scoreboard initializes all participants at 0 when game enters `playing`. +- Incorrect guess adds 0 (no change). +- Correct guess adds +100 to the guessing player. +- Score updates are visible to all players via polling. + +## Edge cases + +- Drawer submitting guesses via UI/API should be blocked or ignored consistently (explicitly non-scoring). +- Repeated correct guesses by same player are allowed in this iteration unless blocked by backend rule; if blocked, backend must return clear message and keep score stable. +- Guess submitted by unknown/invalid participant id is rejected safely. +- Clearing an already-empty canvas is a no-op (no crash). + +## Manual verification checklist (Scenario 3) + +- **Canvas controls**: + - Drawer can draw and clear. + - Guesser cannot mutate canvas. +- **Guess validation**: + - Submit `" "` → clear error message. + - Submit `" pizza "` when word is pizza → evaluated correctly. +- **Case-insensitive match**: + - `PIZZA` equals `pizza`. +- **History sync** (two tabs): + - guesses from one tab appear on the other within ~2 seconds. +- **Scoring**: + - initial scores are 0. + - incorrect guess leaves score unchanged. + - correct guess increases that player by exactly +100. diff --git a/specs/003-gameplay/tasks.md b/specs/003-gameplay/tasks.md new file mode 100644 index 00000000..0a28316f --- /dev/null +++ b/specs/003-gameplay/tasks.md @@ -0,0 +1,127 @@ +# Tasks — Iteration 3 (Scenario 3: Gameplay interaction) + +Goal: implement Scenario 3 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend room/gameplay state types + +- **Depends on**: Scenario 2 complete +- **Change**: + - `backend/src/models/game.ts` + - add `CanvasStroke` and `GuessEntry` types + - add room fields: `canvasStrokes`, `guesses`, `scores` + - expose these in `RoomSnapshot` +- **Manual check**: `cd backend && npm run build` + +### B2 — Initialize/reset gameplay state correctly + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts` + - initialize new room gameplay state + - ensure joining player gets score initialized to 0 + - reset `canvasStrokes` + `guesses` + `scores` when game starts (Scenario 3 single-round start) +- **Manual check**: + - After start, all participants have score 0 and empty history/canvas. + +### B3 — Add canvas mutation endpoints (drawer-only) + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/api/schemas.ts`: canvas stroke/clear schemas + - `backend/src/services/roomStore.ts`: add stroke/clear operations with role checks + - `backend/src/api/rooms.ts`: + - `POST /rooms/:code/canvas/strokes` + - `POST /rooms/:code/canvas/clear` +- **Manual check**: + - Drawer can add stroke/clear; guesser receives 403. + +### B4 — Add guess submission endpoint and scoring + +- **Depends on**: B1, B2 +- **Change**: + - `backend/src/api/schemas.ts`: guess schema (trim, min 1) + - `backend/src/services/roomStore.ts`: + - add guess submission operation + - case-insensitive compare to secret word + - append history entries + - apply scoring (+100 correct, +0 incorrect) + - `backend/src/api/rooms.ts`: + - `POST /rooms/:code/guesses` +- **Manual check**: + - whitespace guess rejected (400) + - correct guess increments by 100 + - incorrect guess leaves score unchanged + +### B5 — Backend tests (recommended) + +- **Depends on**: B3, B4 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Extend API + snapshot types for gameplay state + +- **Depends on**: B1-B4 contracts +- **Change**: + - `frontend/src/services/api.ts` + - add `canvasStrokes`, `guesses`, `scores` to snapshot typing + - add API methods for stroke/clear/guess mutations +- **Manual check**: `cd frontend && npm run build` + +### F2 — Add gameplay actions to room store + +- **Depends on**: F1 +- **Change**: + - `frontend/src/state/roomStore.ts` + - `addCanvasStroke`, `clearCanvas`, `submitGuess` + - each action updates local snapshot from API response +- **Manual check**: + - invoking actions updates room snapshot without full-page refresh. + +### F3 — Implement interactive canvas + clear behavior + +- **Depends on**: F1, F2 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - optional helper component(s) if needed + - `frontend/src/styles/app.css` +- **Manual check (two tabs)**: + - drawer draws and clears + - guesser sees updates via polling + - guesser cannot draw/clear + +### F4 — Wire guess submission and validation UI + +- **Depends on**: F1, F2 +- **Change**: + - `frontend/src/components/GuessForm.tsx` and `frontend/src/pages/GamePage.tsx` + - trim input + - reject empty with clear message + - submit valid guesses via store action +- **Manual check**: + - empty guess blocked with message + - valid guesses submit and appear in history + +### F5 — Render synced guess history + scoring + +- **Depends on**: F1, F2, F4 +- **Change**: + - `frontend/src/pages/GamePage.tsx` + - `frontend/src/components/Scoreboard.tsx` + - optional history UI section/component +- **Manual check (two tabs)**: + - guesses appear in same order on both tabs within ~2s + - scoreboard starts at 0 for all + - correct guess +100, incorrect +0 + +## Scenario 3 completion check (must pass) + +- Drawer-only draw/clear permissions are enforced. +- Guess input is trimmed and empty rejected with clear feedback. +- Correctness comparison is case-insensitive. +- Guess history syncs across players via polling. +- Scoring rules are deterministic and match +100/+0 with 0 initial scores. diff --git a/specs/004-results-restart/plan.md b/specs/004-results-restart/plan.md new file mode 100644 index 00000000..586b6fe2 --- /dev/null +++ b/specs/004-results-restart/plan.md @@ -0,0 +1,108 @@ +# Plan — Iteration 4 (Scenario 4: Result, restart & final validation) + +This plan extends Scenario 3 to add round completion (`results`) and host restart back to lobby. + +## Backend plan (Scenario 4) + +### Data model updates (`backend/src/models/game.ts`) + +- Extend `RoomStatus`: + - `"lobby" | "playing" | "results"` +- No new persistent entities required beyond status transition semantics. +- Snapshot behavior changes: + - when `status === "results"`, include `secretWord` for all viewers (not drawer-only). + +### Round-end transition (`backend/src/services/roomStore.ts`) + +- In `submitGuess`: + - after recording a correct guess and applying score (+100 first time only, existing rule), + - set `room.status = "results"`. +- Guard gameplay mutations: + - reject draw/clear/guess when status is not `playing`. + +### Restart operation + route + +Add `restartGame(code, requesterParticipantId)` in `roomStore`: + +- validate room exists +- validate status is `"results"` +- validate requester is host +- reset round fields: + - `status = "lobby"` + - `drawerParticipantId = null` + - `secretWord = null` + - `canvasStrokes = []` + - `guesses = []` + - `scores = initialScores(participants)` (all zeros) +- preserve `participants` and `hostParticipantId` + +Add route: + +- `POST /rooms/:code/restart` + - body: `{ participantId }` + - errors: + - 404 room/participant not found + - 403 non-host + - 409 if not in results + - success: updated snapshot + +Add schema in `backend/src/api/schemas.ts`: + +- `restartGameSchema` (same shape as start: `{ participantId }`) + +### Snapshot updates (`toRoomSnapshot`) + +- If `room.status === "results"` and `room.secretWord` exists: + - include `secretWord` for every viewer. +- Keep existing drawer-only visibility rule for `playing`. + +## Frontend plan (Scenario 4) + +### API + types (`frontend/src/services/api.ts`) + +- Extend status union to include `"results"`. +- Add `api.restartGame(code, participantId)`. + +### Store (`frontend/src/state/roomStore.ts`) + +- Add `restartGame()` action updating snapshot from API response. + +### UI behavior + +- `ResultPanel` (`frontend/src/components/ResultPanel.tsx`): + - when `status === "results"`, show: + - correct word + - final scores + - full guess history (or reuse `GuessHistory`) +- `GamePage`: + - disable guess/canvas controls when not `playing` + - show host-only **Restart Game** button in results + - non-host sees waiting message +- Navigation sync: + - `GamePage`: if status becomes `results`, stay on game page but show results panel + - `LobbyPage`/`GamePage`: navigate to lobby when snapshot status becomes `"lobby"` while on game route + - after host restart, all tabs return to lobby via polling + +## Files expected to change (Scenario 4) + +### Backend +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- `backend/src/services/roomStore.test.ts` (recommended) + +### Frontend +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/pages/LobbyPage.tsx` (optional redirect safety) +- `frontend/src/components/ResultPanel.tsx` +- `frontend/src/styles/app.css` (results/restart styling) + +## Manual verification checklist (Scenario 4) + +- Correct guess transitions both tabs to results view with shared word/scores/history. +- Post-results gameplay actions are blocked. +- Host restart returns all tabs to lobby; players preserved; round state cleared. +- Backend/frontend builds pass. diff --git a/specs/004-results-restart/spec.md b/specs/004-results-restart/spec.md new file mode 100644 index 00000000..24a9e0c7 --- /dev/null +++ b/specs/004-results-restart/spec.md @@ -0,0 +1,89 @@ +# Spec — Iteration 4 (Scenario 4: Result, restart & final validation) + +## Scope (in) + +Implement Scenario 4 behavior for the single-round flow: + +- Transition room to a shared **results** state when the round ends. +- Show results to all players: correct word, final scores, full guess history. +- Allow **host-only restart** that returns all players to lobby with participants preserved and round state cleared. + +## Scope (out) + +- Multiple rounds with drawer rotation. +- Timers, countdowns, bonuses. +- Persistent storage or auth. + +## Definitions + +- **Round end**: the room transitions from `playing` to `results` when any guesser submits a **correct** guess. +- **Results state**: room status is `"results"`; all players can see the revealed word and final round summary. +- **Restart**: host action that resets round-specific state and sets room status back to `"lobby"`. + +## Assumptions (to remove ambiguity) + +- **Round-end trigger**: first correct guess ends the round immediately (no further guesses accepted after transition). +- **Word reveal in results**: during `results`, the secret word is visible to **all** players in snapshots (not drawer-only). +- **Restart permissions**: only the host can restart; non-host attempts are rejected with clear feedback. +- **Restart navigation**: all tabs observe `status === "lobby"` via polling and navigate back to `/lobby`. + +## Acceptance criteria + +### A. Result state shown to all + +- When the round ends, room status becomes `"results"`. +- All players see, via polling/UI: + - the correct secret word + - final scores for all participants + - full guess history from the round (ordered oldest → newest) +- Result UI is consistent across tabs within ~2 seconds. + +### B. Round-end behavior + +- After transition to `results`: + - no additional guesses are accepted (400/409 with clear message) + - drawer cannot draw/clear (403/409 with clear message) +- Existing guesses/scores/canvas/history remain visible for results display. + +### C. Host-only restart + +- Host can restart from results state. +- Non-host restart attempts are rejected (403) with clear message. +- On successful restart: + - room status returns to `"lobby"` + - participants and host identity are preserved + - round state is cleared: + - `drawerParticipantId = null` + - `secretWord = null` + - `canvasStrokes = []` + - `guesses = []` + - `scores` reset for all current participants to `0` + +### D. Post-restart lobby sync + +- All players automatically return to lobby UI via polling (~2s) after restart. +- Lobby shows preserved participants and host indicator. +- Host can start a new round once ≥2 players are present (reusing existing start rules). + +## Edge cases + +- If multiple tabs submit a correct guess nearly simultaneously, room ends once and remains in stable `results` state. +- Restart while not in `results` is rejected with clear message. +- Restart with missing/invalid host participant id is rejected safely. +- Unknown participant id on restart returns clear error without mutating room state. + +## Manual verification checklist (Scenario 4) + +- **End-to-end round** (two tabs): + - create → join → start → play → submit correct guess. +- **Results visibility**: + - both tabs show correct word, final scores, and full guess history. +- **Post-end restrictions**: + - further guess/canvas actions are blocked with clear feedback. +- **Restart**: + - host restart returns both tabs to lobby; players remain. + - round fields are cleared (no old word/canvas/guesses/scores leakage). +- **Final validation**: + - `cd backend && npm run build` + - `cd frontend && npm run build` + - repeat full flow including restart back to lobby. diff --git a/specs/004-results-restart/tasks.md b/specs/004-results-restart/tasks.md new file mode 100644 index 00000000..b74e6bd9 --- /dev/null +++ b/specs/004-results-restart/tasks.md @@ -0,0 +1,103 @@ +# Tasks — Iteration 4 (Scenario 4: Result, restart & final validation) + +Goal: implement Scenario 4 behavior from `speckit.specify` using the approach in `speckit.plan`. + +## Backend tasks (do first) + +### B1 — Extend status model to include results + +- **Depends on**: Scenario 3 complete +- **Change**: + - `backend/src/models/game.ts`: add `"results"` to `RoomStatus` +- **Manual check**: `cd backend && npm run build` + +### B2 — End round on first correct guess + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: + - in `submitGuess`, when guess is correct set `status = "results"` + - block draw/clear/guess when status !== `"playing"` +- **Manual check**: + - correct guess moves room to `results` + - further guess/draw attempts rejected with clear message + +### B3 — Reveal secret word to all viewers in results snapshots + +- **Depends on**: B1, B2 +- **Change**: + - `toRoomSnapshot`: include `secretWord` for all viewers when status is `"results"` +- **Manual check**: + - drawer and guesser snapshots both include word in results state + +### B4 — Add host-only restart operation + route + +- **Depends on**: B1 +- **Change**: + - `backend/src/services/roomStore.ts`: add `restartGame(code, requesterParticipantId)` + - `backend/src/api/schemas.ts`: add restart schema + - `backend/src/api/rooms.ts`: add `POST /rooms/:code/restart` +- **Manual check**: + - host restart from results succeeds and clears round state + - non-host restart returns 403 + - restart outside results returns 409 + +### B5 — Backend tests (recommended) + +- **Depends on**: B2, B4 +- **Change**: + - `backend/src/services/roomStore.test.ts` +- **Manual check**: `cd backend && npm test` (if configured) + +## Frontend tasks + +### F1 — Extend API/types + restart store action + +- **Depends on**: B4 +- **Change**: + - `frontend/src/services/api.ts`: `"results"` status + `restartGame` API + - `frontend/src/state/roomStore.ts`: `restartGame()` action +- **Manual check**: `cd frontend && npm run build` + +### F2 — Results UI (word, scores, history) + +- **Depends on**: B2, B3, F1 +- **Change**: + - `frontend/src/components/ResultPanel.tsx` + - `frontend/src/pages/GamePage.tsx` wiring +- **Manual check (two tabs)**: + - after correct guess both tabs show same word/scores/history in results + +### F3 — Disable gameplay controls after round end + +- **Depends on**: F2 +- **Change**: + - `GamePage`, `GuessForm`, `DrawingCanvas` usage + - disable guess/canvas interactions when status is `"results"` +- **Manual check**: + - no further draw/guess actions possible in results state + +### F4 — Host restart + auto return to lobby via polling + +- **Depends on**: F1, B4 +- **Change**: + - `GamePage`: host-only restart button + waiting text for guests + - `LobbyPage`/`GamePage`: navigate to lobby when snapshot status becomes `"lobby"` +- **Manual check (two tabs)**: + - host restart returns both tabs to lobby within ~2s + - participants preserved, round data cleared + +### F5 — Final validation pass + +- **Depends on**: all above +- **Manual check**: + - full two-tab flow: create → join → start → play → correct guess → results → restart → lobby + - `cd backend && npm run build` + - `cd frontend && npm run build` + +## Scenario 4 completion check (must pass) + +- Results state is shared and complete (word, scores, history). +- Round-end locks gameplay mutations. +- Host-only restart clears round state and preserves players. +- All clients converge via polling and can replay start flow.