From 7c346f9f910eeb35148499caaefc1c94b60e66ef Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Fri, 29 May 2026 16:31:57 +0530 Subject: [PATCH 01/25] fix API base URL for room endpoints --- frontend/src/services/api.ts | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..cfbe401 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -19,19 +19,21 @@ 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}`, { headers: { "Content-Type": "application/json", - ...(init?.headers ?? {}) + ...(init?.headers ?? {}), }, - ...init + ...init, }); if (!response.ok) { - const errorBody = (await response.json().catch(() => ({ message: "Request failed" }))) as { + const errorBody = (await response + .json() + .catch(() => ({ message: "Request failed" }))) as { message?: string; }; @@ -45,17 +47,24 @@ export const api = { createRoom(playerName: string) { return request("/rooms", { method: "POST", - body: JSON.stringify({ playerName }) + body: JSON.stringify({ playerName }), }); }, joinRoom(code: string, playerName: string) { - return request(`/rooms/${encodeURIComponent(code)}/join`, { - method: "POST", - body: JSON.stringify({ playerName }) - }); + return request( + `/rooms/${encodeURIComponent(code)}/join`, + { + method: "POST", + body: JSON.stringify({ playerName }), + }, + ); }, fetchRoom(code: string, participantId?: string) { - const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; - return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); - } + const query = participantId + ? `?participantId=${encodeURIComponent(participantId)}` + : ""; + return request<{ room: RoomSnapshot }>( + `/rooms/${encodeURIComponent(code)}${query}`, + ); + }, }; From 85a818fbd2e9b5c837b89d533fb3b1ea1ae054a6 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Fri, 29 May 2026 17:34:55 +0530 Subject: [PATCH 02/25] add discovery notes --- docs/discovery.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/discovery.md diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 0000000..672f3f7 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,129 @@ +# Discovery Notes + +## What Works + +### Room Lifecycle +- Creating a room generates a unique 4-character code and returns a `participantId` +- Joining a room by code adds the player and returns the same session shape +- Fetching a room (`GET /rooms/:code`) returns a snapshot with participants, available words, and roles +- All room data is stored in memory on the backend; restarting clears all rooms + +### Frontend Shell +- Full page routing: Start → Create Room / Join Room → Lobby → Game +- `RoomStoreProvider` wraps the app and holds `room`, `participantId`, `isLoading`, and `error` in a React context store +- `CreateRoomPage` and `JoinRoomPage` call the API and write the session into the store on success +- `LobbyPage` shows the current participant list with a manual refresh button +- `GamePage` renders a layout with sidebar, canvas area, guess form, and scoreboard — all placeholders + +### Validation & Error Handling +- Zod schemas on the backend validate request bodies; invalid payloads return 400 +- Missing or unknown room codes return 404 +- The frontend store catches errors and surfaces them via the `error` field + +### Seed Data +- 5 words available: `rocket`, `pizza`, `castle`, `guitar`, `sunflower` +- 2 roles defined: `drawer`, `guesser` +- Both are returned on every room snapshot but are not yet used by any game logic + +--- + +## What Is Missing + +### Host Behavior +- No field on `Room` or `Participant` to mark who created the room +- All players are treated equally; there is no host-only permission check anywhere +- The "Start Game" button does not exist + +### Lobby Polling +- The lobby only refreshes on manual button click — no automatic polling +- No interval-based fetch is wired in `LobbyPage` or the store + +### Game Start Flow +- `RoomStatus` is typed as `"lobby"` only — no `"playing"` or `"finished"` states exist +- There is no API endpoint to transition room status +- The frontend has no logic to detect that a game has started and navigate to the game screen + +### Drawer Assignment +- The `roles` array is returned in the snapshot but is never mapped to specific participants +- No field on `Room` or `RoomSnapshot` associates a participant with the `drawer` role +- The drawer's secret word is not tracked anywhere + +### Secret Word Visibility +- `availableWords` is returned to every player on every snapshot — there is no per-viewer filtering +- `toRoomSnapshot` receives `viewerParticipantId` but ignores it (`void viewerParticipantId`) + +### Drawing Canvas +- The canvas area in `GamePage` is a plain `
` with static text "Waiting for drawer..." +- No drawing library, canvas element, or pointer event handling exists +- No clear canvas action exists + +### Guess Submission +- `GuessForm` component exists but has no form submit handler and makes no API call +- No backend endpoint exists for submitting or storing guesses +- No guess history is tracked on the `Room` model + +### Guess Sync +- No polling in `GamePage` to fetch updated room state during a round +- Other players cannot see guesses in real time or near-real time + +### Scoring +- No `score` field exists on `Participant` +- No scoring logic exists anywhere — correct guess awarding 100 points is fully absent + +### Result State +- No `"finished"` room status +- No endpoint or model field for storing the correct word, final scores, or full guess history after a round ends +- `ResultPanel` component is a placeholder with no data + +### Restart Flow +- No endpoint to reset a room back to lobby state +- No mechanism to clear round-specific data (drawer, word, guesses, scores) while keeping participants + +--- + +## Assumptions + +1. **Host = first participant.** The player who calls `POST /rooms` is the host. This can be tracked by storing the `participantId` of the first participant on the `Room` model, without adding a separate role field. + +2. **Deterministic word selection means index-based, not random.** "Deterministically selected" in the spec means the word is chosen by a fixed rule (e.g. round count mod word list length), so all clients agree on the word without coordination. + +3. **Polling is the only sync mechanism.** WebSockets are explicitly out of scope. Both lobby and in-game state updates will use `setInterval`-based polling (~2s cadence) calling the existing `GET /rooms/:code` endpoint. + +4. **One round only.** The spec describes a single round with no drawer rotation or round counter. Restart returns everyone to the lobby, preserving participants, and clears all round state. + +5. **The backend filters the secret word per viewer.** Since `toRoomSnapshot` already receives `viewerParticipantId` (currently unused), the intended pattern is to populate `currentWord` only when the viewer is the drawer — no separate endpoint needed. + +6. **Guess comparison is case-insensitive and trimmed server-side.** The spec says "trimmed, case-insensitively compared" — this logic belongs in the backend endpoint, not the client. + +7. **Empty or whitespace-only player names are rejected.** The spec states names must be trimmed and non-empty. This validation needs to be added to both the Zod schemas and the frontend forms. + +--- + +## Files Involved + +### Backend +| File | Relevance | +|---|---| +| `backend/src/models/game.ts` | Core types — needs `hostId`, `currentWord`, `guesses`, `scores`, status values added | +| `backend/src/services/roomStore.ts` | Room CRUD — needs `startGame`, `submitGuess`, `restartRoom` operations; `toRoomSnapshot` needs viewer filtering | +| `backend/src/api/rooms.ts` | Route handlers — needs new endpoints for start, guess, and restart | +| `backend/src/api/schemas.ts` | Zod schemas — needs schemas for new request bodies and name validation | +| `backend/src/api/router.ts` | Route registration — needs new routes mounted | +| `backend/src/seed/starterData.ts` | Seed words and roles — read-only reference, no changes needed | + +### Frontend +| File | Relevance | +|---|---| +| `frontend/src/pages/LobbyPage.tsx` | Needs polling, host badge, and conditional "Start Game" button | +| `frontend/src/pages/GamePage.tsx` | Needs real canvas, guess submission wiring, polling, role-aware layout | +| `frontend/src/components/GuessForm.tsx` | Needs submit handler calling the guess API | +| `frontend/src/components/Scoreboard.tsx` | Needs real score data from room snapshot | +| `frontend/src/components/ResultPanel.tsx` | Needs correct word, scores, and guess history from result state | +| `frontend/src/state/roomStore.ts` | Needs actions for `startGame`, `submitGuess`, `restartRoom`; polling helpers | +| `frontend/src/services/api.ts` | Needs client methods for all new endpoints | + +### Config / CI +| File | Relevance | +|---|---| +| `.github/workflows/ci.yml` | Runs build and tests — changes must keep this green | +| `.github/pull_request_template.md` | PR submission format to follow | From 1a581c750df63af006287ddfe517ae02bf8eae66 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Fri, 29 May 2026 17:38:29 +0530 Subject: [PATCH 03/25] add project constitution --- speckit.constitution | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 speckit.constitution diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 0000000..b519f8b --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,35 @@ +# Constitution + +Rules that govern every decision made during this assignment. +Deviating from these rules requires an explicit note in the commit message. + +--- + +## 1. No Extra Features + +Build only what the current scenario requires. +Do not add fields, endpoints, components, or behavior that are not demanded by the acceptance criteria being worked on. +Out-of-scope items listed in the README (WebSockets, timers, multiple rounds, auth, etc.) are permanently off the table. +If something seems useful but is not in the spec, write it down as a future note — do not build it. + +## 2. Test Before Commit + +Every commit must leave the test suite passing. +Run `npm test` in both `backend/` and `frontend/` before staging. +Run `npm run build` in both directories to confirm there are no type errors. +If a change breaks an existing test, fix the test or fix the code — do not skip or delete the test. +New logic that can be unit tested (validation, scoring, snapshot filtering) must have a test before the code is committed. + +## 3. Small Commits + +Each commit should cover exactly one logical change: a model update, a single endpoint, a single component, a state action, or a test. +A commit that touches both backend and frontend is a signal to split it. +Commit messages must state what changed and why in plain language. +Large "everything works now" commits are not acceptable. + +## 4. Review AI-Generated Code + +Never commit AI-generated code without reading it line by line first. +Verify that the code matches the spec, not just that it looks reasonable. +Check for: invented fields or endpoints not in the plan, silent error suppression, logic that differs from acceptance criteria, and unnecessary abstractions. +If the AI output requires more than minor edits to be correct, treat the output as a draft and rewrite the relevant parts before committing. From b9e3cda6954e7562c624a38842e805ea91f55cfc Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 11:00:58 +0530 Subject: [PATCH 04/25] add spec scenario 1 requirements --- speckit.specify | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 speckit.specify diff --git a/speckit.specify b/speckit.specify new file mode 100644 index 0000000..5af2a3b --- /dev/null +++ b/speckit.specify @@ -0,0 +1,77 @@ +# Specification + +--- + +## Scenario 1 — Room Setup & Lobby + +### Problem + +Players need a reliable way to create and join a game room before play begins. +The current scaffold lets players create and join rooms but treats every player identically — there is no host, no start action, and the lobby only updates on manual refresh. +A player has no way to know who else is waiting, and nobody can actually begin the game. + +--- + +### Requirements + +#### Host Tracking +- The player who creates a room is automatically the host. +- Host status must be stored on the backend and included in every room snapshot. +- The frontend must visually distinguish the host from other participants in the lobby. +- Host status does not transfer if the host leaves (out of scope for this scenario). + +#### Room Validation +- A player name must be non-empty after trimming whitespace. Empty or whitespace-only names are rejected with a clear error message before any API call is made. +- Joining with an unknown or missing room code returns a clear error message ("Room not found" or equivalent). The player stays on the join screen. +- Joining with an empty room code is rejected on the frontend before any API call is made. +- Each room is isolated: participants, state, and game data from one room never appear in another. + +#### Lobby Polling +- While a player is on the lobby screen, the room snapshot is fetched automatically at approximately 2-second intervals. +- Polling starts when the lobby mounts and stops when the player navigates away. +- The participant list updates without any manual user action. +- Polling uses the existing `GET /rooms/:code` endpoint — no new endpoint is needed. + +#### Host-Only Start +- Only the host sees the "Start Game" button in the lobby. +- The button is disabled (and shows why) when fewer than 2 players are present. +- The button is enabled when at least 2 players have joined. +- Non-host players see a waiting message instead of the button. + +#### Minimum 2 Players +- The game cannot be started with fewer than 2 participants. +- This rule is enforced on the backend: a start request with only 1 participant returns an error. +- The frontend reflects this state by disabling the start button until the count is met. + +--- + +### Acceptance Criteria + +**Host tracking** +- [ ] Creating a room returns a snapshot where one participant is marked as host. +- [ ] Joining a room returns a snapshot that still correctly identifies the original creator as host. +- [ ] The host participant is visually marked in the lobby (e.g. a "Host" badge). +- [ ] A non-host participant does not have the host marker. + +**Room validation** +- [ ] Submitting an empty player name on Create Room shows an inline error and does not call the API. +- [ ] Submitting an empty player name on Join Room shows an inline error and does not call the API. +- [ ] Submitting a whitespace-only player name is treated as empty and rejected with the same message. +- [ ] Joining with a code that does not match any room shows "Room not found" (or equivalent) and keeps the player on the join screen. +- [ ] Joining with an empty code shows an inline error and does not call the API. +- [ ] Two rooms created in the same session have independent participant lists — joining room A does not affect room B. + +**Lobby polling** +- [ ] Opening the lobby screen starts automatic polling; the participant list updates within ~2 seconds when a second browser tab joins the same room. +- [ ] Navigating away from the lobby stops polling (no further requests fired after leaving). +- [ ] Polling does not require any manual user interaction. + +**Host-only start** +- [ ] The host sees a "Start Game" button; a non-host player does not. +- [ ] The button is disabled when only 1 player is in the room. +- [ ] The button is enabled when 2 or more players are in the room. +- [ ] Clicking the enabled button by the host triggers the start game action (transitions to game screen — full game start behavior is specified in Scenario 2). + +**Minimum 2 players** +- [ ] A start request sent with only 1 participant in the room returns a 4xx error from the backend. +- [ ] The frontend does not allow the host to trigger the start request when fewer than 2 players are present. From 59d5381621ee3765c1c6b5a637eb7077429d7672 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 11:12:57 +0530 Subject: [PATCH 05/25] add scenario 1 implementation plan --- speckit.plan | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 speckit.plan diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 0000000..1f7b79f --- /dev/null +++ b/speckit.plan @@ -0,0 +1,242 @@ +# Plan — Scenario 1: Room Setup & Lobby + +--- + +## Findings + +### What exists and is relevant + +| Area | File | Observation | +|---|---|---| +| Room model | `backend/src/models/game.ts` | `Room` has `code`, `status`, `participants`, `createdAt`, `updatedAt`. No `hostId`. `RoomStatus` is the literal `"lobby"` only. | +| Participant model | `backend/src/models/game.ts` | `Participant` has `id`, `name`, `joinedAt`. No role or host marker. | +| Room snapshot | `backend/src/models/game.ts` | `RoomSnapshot` has `participants`, `availableWords`, `roles`. No `hostId`. | +| Room store | `backend/src/services/roomStore.ts` | `createRoom` creates the first participant but never records them as host. `toRoomSnapshot` receives `viewerParticipantId` but immediately voids it — no filtering applied. | +| Schemas | `backend/src/api/schemas.ts` | `createRoomSchema` and `joinRoomSchema` both mark `playerName` as `z.string().optional()` — empty and whitespace-only names pass validation. `roomCodeParamsSchema` accepts any string with no minimum length. | +| Rooms router | `backend/src/api/rooms.ts` | Three endpoints exist: `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code`. No start endpoint. | +| API router | `backend/src/api/router.ts` | Mounts `/rooms` router. Global error handler handles `ZodError` (400), `HttpError` (statusCode), and generic (500). Pattern is reusable. | +| Frontend types | `frontend/src/services/api.ts` | `RoomSnapshot` interface mirrors backend but has no `hostId`. No `startGame` client method. | +| Create Room | `frontend/src/pages/CreateRoomPage.tsx` | Calls `roomStore.createRoom(playerName)` directly — no client-side validation. Empty name is sent to the API. | +| Join Room | `frontend/src/pages/JoinRoomPage.tsx` | Calls `roomStore.joinRoom(roomCode, playerName)` directly — no client-side validation. Empty name and empty code are sent to the API. | +| Lobby | `frontend/src/pages/LobbyPage.tsx` | Only manual refresh via `handleRefresh`. No polling interval. "Start Game" button is visible to all players and navigates to `/game` without any API call or host/player-count check. | +| Room store (frontend) | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom` actions. No `startGame` action. No polling helper. | + +### What is missing + +1. `Room.hostId` — no field records which participant created the room. +2. `RoomSnapshot.hostId` — not surfaced to the frontend. +3. Name validation — empty and whitespace-only names are accepted by schemas and forms. +4. Code validation — empty room code is accepted by the join form. +5. Lobby polling — no `setInterval` anywhere in the frontend lobby flow. +6. Host-only start button — button is shown to everyone and is always enabled. +7. Start game endpoint — no `POST /rooms/:code/start` exists. +8. Start game minimum player guard — no check that at least 2 participants are present before starting. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Room (before) Room (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +createdAt: string createdAt: string +updatedAt: string updatedAt: string + hostId: string ← NEW +``` + +``` +RoomSnapshot (before) RoomSnapshot (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + hostId: string ← NEW +``` + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + hostId: string ← NEW +``` + +--- + +## Required API Changes + +### Modified: `POST /rooms` +- Schema change: `playerName` becomes required, trimmed, minimum 1 character after trim. +- Service change: `createRoom` writes `hostId: participant.id` on the new `Room`. +- Snapshot change: `toRoomSnapshot` includes `hostId` in the returned object. +- No change to route handler or response shape beyond the snapshot addition. + +### Modified: `POST /rooms/:code/join` +- Schema change: `playerName` becomes required, trimmed, minimum 1 character after trim. +- No other changes to handler or service. + +### Modified: Zod schemas — `backend/src/api/schemas.ts` +``` +createRoomSchema.playerName: z.string().optional() + → z.string().min(1, "Player name is required").transform(s => s.trim()).refine(s => s.length > 0, "Player name cannot be blank") + +joinRoomSchema.playerName: z.string().optional() + → same as above +``` + +### New endpoint: `POST /rooms/:code/start` +- **Purpose:** Transition a room from `"lobby"` to `"playing"` with host and player-count guards. +- **Request:** Body `{ participantId: string }` — identifies the caller. +- **Validations (in order):** + 1. Room must exist → 404 if not. + 2. `participantId` must match `room.hostId` → 403 "Only the host can start the game" if not. + 3. Room must have at least 2 participants → 422 "Need at least 2 players to start" if not. +- **Success:** Sets `room.status = "playing"`, saves, returns `{ room: RoomSnapshot }` with status 200. +- **Schema needed:** `startGameSchema = z.object({ participantId: z.string().min(1) })` + +--- + +## Data Flow + +### Create Room +``` +CreateRoomPage + → [validate name client-side, reject if blank] + → POST /rooms { playerName } + → backend validates name (Zod, trimmed, non-empty) + → createRoom() sets hostId = participant.id + → toRoomSnapshot() includes hostId + → response { participantId, room: { ...hostId } } + → roomStore.setRoomSession() + → navigate("/lobby") +``` + +### Join Room +``` +JoinRoomPage + → [validate name client-side, reject if blank] + → [validate code client-side, reject if blank] + → POST /rooms/:code/join { playerName } + → backend validates name (Zod, trimmed, non-empty) + → joinRoom() adds participant, hostId unchanged + → response { participantId, room: { ...hostId } } + → roomStore.setRoomSession() + → navigate("/lobby") +``` + +### Lobby Polling +``` +LobbyPage mounts + → useEffect starts setInterval(~2000ms) + → GET /rooms/:code?participantId=... + → roomStore.setRoomSnapshot(room) + → participant list re-renders + → useEffect cleanup clears interval on unmount +``` + +### Start Game +``` +LobbyPage (host only, ≥2 players) + → click "Start Game" + → POST /rooms/:code/start { participantId } + → backend checks hostId, checks participant count + → sets status = "playing" + → response { room } + → roomStore.setRoomSnapshot(room) + → navigate("/game") +``` + +--- + +## Implementation Sequence + +Steps are ordered so each one is independently testable before moving to the next. + +### Step 1 — Backend: extend the Room model +- **File:** `backend/src/models/game.ts` +- Add `hostId: string` to the `Room` interface. +- Expand `RoomStatus` to `"lobby" | "playing"`. +- Add `hostId: string` to the `RoomSnapshot` interface. + +### Step 2 — Backend: tighten validation schemas +- **File:** `backend/src/api/schemas.ts` +- Update `createRoomSchema.playerName` to required, trimmed, non-empty. +- Update `joinRoomSchema.playerName` to required, trimmed, non-empty. +- Add `startGameSchema` for the new endpoint. + +### Step 3 — Backend: set hostId in the room service +- **File:** `backend/src/services/roomStore.ts` +- In `createRoom`: assign `room.hostId = participant.id`. +- In `toRoomSnapshot`: include `hostId: room.hostId` in the returned object. + +### Step 4 — Backend: add the start game endpoint +- **File:** `backend/src/api/rooms.ts` +- Add `POST /:code/start` handler using `startGameSchema`. +- Add a `startGame(code, participantId)` function to `roomStore.ts` that enforces host check, player count, and status transition. + +### Step 5 — Frontend: update RoomSnapshot type +- **File:** `frontend/src/services/api.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- Add `status: "lobby" | "playing"` to the `RoomSnapshot` interface. +- Add `startGame(code: string, participantId: string)` client method calling `POST /rooms/:code/start`. + +### Step 6 — Frontend: add name and code validation to forms +- **File:** `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the name and reject if empty with an inline error message. +- **File:** `frontend/src/pages/JoinRoomPage.tsx` +- Trim name and reject if empty; trim code and reject if empty. Both show distinct inline errors. + +### Step 7 — Frontend: add startGame action to the store +- **File:** `frontend/src/state/roomStore.ts` +- Add `startGame()` method that calls `api.startGame` and updates the snapshot on success. + +### Step 8 — Frontend: replace manual refresh with polling and add host UI +- **File:** `frontend/src/pages/LobbyPage.tsx` +- Replace the manual refresh `useEffect`/button with a `setInterval` polling loop (~2000ms) that calls `roomStore.fetchRoom()`. +- Derive `isHost = room.hostId === participantId`. +- Show "Host" badge next to the host's name in the participant list. +- Show "Start Game" button only when `isHost` is true. +- Disable the button and show a reason when `room.participants.length < 2`. +- On success, navigate to `/game`. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `hostId`, expand `RoomStatus` | +| `backend/src/api/schemas.ts` | Modify — tighten name schemas, add `startGameSchema` | +| `backend/src/services/roomStore.ts` | Modify — set `hostId` in `createRoom`, include in `toRoomSnapshot`, add `startGame` function | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/start` handler | +| `frontend/src/services/api.ts` | Modify — add `hostId` + `status` to `RoomSnapshot`, add `startGame` client method | +| `frontend/src/pages/CreateRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/JoinRoomPage.tsx` | Modify — add client-side name and code validation | +| `frontend/src/state/roomStore.ts` | Modify — add `startGame` action | +| `frontend/src/pages/LobbyPage.tsx` | Modify — replace manual refresh with polling, add host UI, add conditional start button | + +No new files need to be created. No new libraries are required. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Polling fires after component unmounts (memory leak / stale state update) | Clear the interval in the `useEffect` cleanup function | +| Backend name validation rejects names that were previously accepted | Acceptable — the spec requires it; existing in-memory rooms are cleared on restart anyway | +| `hostId` missing from old rooms if the backend restarts during testing | Not a concern — in-memory store is wiped on restart; all rooms are fresh | +| Start game navigates to `/game` before game state (drawer, word) is ready | Acceptable for Scenario 1; game screen displays placeholder until Scenario 2 is implemented | +| Two players simultaneously click "Start Game" | Only one will win the host check; second gets 403 — handled by the error path in the store | From a86aa33757181eccf6fa901ed52721af473aa05d Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 11:24:54 +0530 Subject: [PATCH 06/25] add scenario 1 tasks breakdown --- speckit.tasks | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 speckit.tasks diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 0000000..b39d033 --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,282 @@ +# Tasks — Scenario 1: Room Setup & Lobby + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group A — Backend Model + +### A1 — Expand RoomStatus type +- File: `backend/src/models/game.ts` +- Change `RoomStatus` from the literal `"lobby"` to `"lobby" | "playing"`. +- No other changes. +- Verify: `npm run build` in `backend/` passes with no type errors. + +### A2 — Add hostId to Room interface +- File: `backend/src/models/game.ts` +- Add `hostId: string` to the `Room` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Existing `RoomSnapshot` and service files will show type errors — those are fixed in A3 and A4. + +### A3 — Add hostId to RoomSnapshot interface +- File: `backend/src/models/game.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group B — Backend Validation + +### B1 — Tighten playerName in createRoomSchema +- File: `backend/src/api/schemas.ts` +- Change `playerName` from `z.string().optional()` to required, trimmed, and non-empty (min 1 character after trim). +- No other schema changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### B2 — Tighten playerName in joinRoomSchema +- File: `backend/src/api/schemas.ts` +- Apply the same validation as B1 to `joinRoomSchema.playerName`. +- No other schema changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### B3 — Add startGameSchema +- File: `backend/src/api/schemas.ts` +- Add `startGameSchema = z.object({ participantId: z.string().min(1) })`. +- Export it alongside the existing schemas. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group C — Backend Service + +### C1 — Set hostId when creating a room +- File: `backend/src/services/roomStore.ts` +- In `createRoom`, assign `room.hostId = participant.id` before storing. +- Verify: `npm run build` in `backend/` passes. + +### C2 — Include hostId in toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- In `toRoomSnapshot`, add `hostId: room.hostId` to the returned snapshot object. +- Verify: `npm run build` in `backend/` passes. + +### C3 — Add startGame function to room service +- File: `backend/src/services/roomStore.ts` +- Add an exported `startGame(code: string, participantId: string)` function. +- The function must: + 1. Return `null` if the room does not exist. + 2. Return `"not-host"` if `participantId` does not match `room.hostId`. + 3. Return `"not-enough-players"` if `room.participants.length < 2`. + 4. Set `room.status = "playing"`, save, and return the updated room snapshot. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group D — Backend Endpoint + +### D1 — Add POST /rooms/:code/start route handler +- File: `backend/src/api/rooms.ts` +- Import `startGameSchema` and `startGame` from their respective modules. +- Add `router.post("/:code/start", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `startGameSchema`. + 3. Calls `startGame(code, participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with message "Only the host can start the game" if result is `"not-host"`. + 6. Returns 422 with message "Need at least 2 players to start" if result is `"not-enough-players"`. + 7. Returns 200 with `{ room: snapshot }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group E — Backend Tests + +### E1 — Test createRoomSchema rejects empty and whitespace-only names +- File: `backend/src/api/schemas.test.ts` +- Add test cases: empty string, whitespace-only string, valid name. +- Run: `npm test` in `backend/` — all tests pass. + +### E2 — Test joinRoomSchema rejects empty and whitespace-only names +- File: `backend/src/api/schemas.test.ts` +- Add test cases matching E1 for `joinRoomSchema`. +- Run: `npm test` in `backend/` — all tests pass. + +### E3 — Test createRoom sets hostId to first participant +- File: `backend/src/services/roomStore.test.ts` +- Add a test that calls `createRoom` and asserts `result.room.hostId === result.participantId`. +- Run: `npm test` in `backend/` — all passes. + +### E4 — Test toRoomSnapshot includes hostId +- File: `backend/src/services/roomStore.test.ts` +- Add a test that creates a room, calls `toRoomSnapshot`, and asserts `snapshot.hostId` matches the creator's id. +- Run: `npm test` in `backend/` — all passes. + +### E5 — Test startGame: room not found returns null +- File: `backend/src/services/roomStore.test.ts` +- Add a test calling `startGame("XXXX", "any-id")` and asserting the result is `null`. +- Run: `npm test` in `backend/` — all passes. + +### E6 — Test startGame: non-host participant is rejected +- File: `backend/src/services/roomStore.test.ts` +- Create a room, call `startGame` with a different `participantId`, assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all passes. + +### E7 — Test startGame: fewer than 2 participants is rejected +- File: `backend/src/services/roomStore.test.ts` +- Create a room (1 participant), call `startGame` with the host id, assert result is `"not-enough-players"`. +- Run: `npm test` in `backend/` — all passes. + +### E8 — Test startGame: succeeds with host and 2+ participants +- File: `backend/src/services/roomStore.test.ts` +- Create a room, join a second player, call `startGame` with the host id, assert returned snapshot has `status: "playing"`. +- Run: `npm test` in `backend/` — all passes. + +--- + +## Group F — Frontend Types + +### F1 — Add hostId and expand status in RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- Change `status: "lobby"` to `status: "lobby" | "playing"`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes (type errors in pages are expected until F2–F5 are done). + +### F2 — Add startGame client method +- File: `frontend/src/services/api.ts` +- Add `startGame(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/start` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group G — Frontend Validation + +### G1 — Add name validation to CreateRoomPage +- File: `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the player name. +- If the trimmed value is empty, set an inline error ("Player name is required") and return without calling the API. +- Verify: `npm run build` in `frontend/` passes. + +### G2 — Add name validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before calling the store, trim the player name. +- If trimmed name is empty, set an inline error ("Player name is required") and return. +- Verify: `npm run build` in `frontend/` passes. + +### G3 — Add room code validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before calling the store, trim the room code. +- If trimmed code is empty, set an inline error ("Room code is required") and return. +- Both name and code are checked; both errors can show independently. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group H — Frontend Store + +### H1 — Add startGame action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add a `startGame()` method to the `RoomStore` class. +- It reads `this.state.room.code` and `this.state.participantId` from current state. +- It calls `api.startGame(code, participantId)` inside `withLoading`. +- On success, calls `this.setRoomSnapshot(response.room)`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group I — Frontend Lobby + +### I1 — Replace manual refresh with polling +- File: `frontend/src/pages/LobbyPage.tsx` +- Remove the `handleRefresh` function and the manual "Refresh Room" button. +- Add a `useEffect` that starts a `setInterval` calling `roomStore.fetchRoom()` every 2000ms. +- The cleanup function must call `clearInterval` to stop polling on unmount. +- Verify: `npm run build` in `frontend/` passes. + +### I2 — Derive isHost and add host badge to participant list +- File: `frontend/src/pages/LobbyPage.tsx` +- Derive `isHost = room.hostId === participantId` using state from the store. +- In the participant list, render a "Host" badge next to the participant whose id matches `room.hostId`. +- Verify: `npm run build` in `frontend/` passes. + +### I3 — Show Start Game button only to host +- File: `frontend/src/pages/LobbyPage.tsx` +- Render the "Start Game" button only when `isHost` is true. +- Non-host players see a static "Waiting for the host to start..." message instead. +- Verify: `npm run build` in `frontend/` passes. + +### I4 — Disable Start Game when fewer than 2 players +- File: `frontend/src/pages/LobbyPage.tsx` +- Compute `canStart = room.participants.length >= 2`. +- Disable the button when `!canStart`. +- Show a reason below the button ("Need at least 2 players") when the button is disabled. +- Verify: `npm run build` in `frontend/` passes. + +### I5 — Wire Start Game button to store action and navigate +- File: `frontend/src/pages/LobbyPage.tsx` +- On click, call `roomStore.startGame()`. +- On success, navigate to `/game`. +- On error, display the error message from the store (403 or 422 surface naturally via the existing error field). +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group J — Frontend Tests + +### J1 — Test api service: startGame sends correct request +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.startGame` makes a `POST` to `/rooms/:code/start` with the correct body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group K — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### K1 — Validate: empty name is rejected on Create Room +- Open Create Room, submit with empty name. +- Expected: inline error appears, no network request made. + +### K2 — Validate: whitespace-only name is rejected on Create Room +- Open Create Room, submit with spaces only. +- Expected: inline error appears, no network request made. + +### K3 — Validate: empty name is rejected on Join Room +- Open Join Room, submit with empty name and a valid code. +- Expected: inline error appears, no network request made. + +### K4 — Validate: empty code is rejected on Join Room +- Open Join Room, submit with a valid name and empty code. +- Expected: inline error appears, no network request made. + +### K5 — Validate: unknown room code shows error +- Open Join Room, submit with a valid name and a code that does not exist ("ZZZZ"). +- Expected: error message shown, player stays on join screen. + +### K6 — Validate: lobby polls automatically +- Tab 1: create a room, land in lobby. +- Tab 2: join the same room. +- Expected: Tab 1 participant list updates within ~2 seconds without any manual action. + +### K7 — Validate: non-host does not see Start Game +- Tab 1: create a room (host). Tab 2: join the room. +- In Tab 2, confirm "Start Game" button is not visible. +- Expected: Tab 2 shows "Waiting for the host to start..." message. + +### K8 — Validate: host cannot start with 1 player +- Tab 1: create a room, stay in lobby alone. +- Expected: "Start Game" button is disabled and shows the minimum player message. + +### K9 — Validate: host can start with 2 players and navigates to game +- Tab 1: create a room. Tab 2: join the room. +- In Tab 1, click "Start Game". +- Expected: Tab 1 navigates to `/game`. No errors shown. + +### K10 — Validate: rooms are isolated +- Create Room A in Tab 1. Create Room B in Tab 2. +- Join Room A in a third tab. +- Expected: Room B participant list is unaffected. From b4109614918203a3b7cd7623a77143a50fe11e2d Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 11:47:40 +0530 Subject: [PATCH 07/25] add room host tracking --- backend/src/models/game.ts | 4 +++- backend/src/services/roomStore.ts | 2 ++ frontend/src/services/api.ts | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..b3af2f0 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; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a..c77bf3d 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", + hostId: 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, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cfbe401..5c22bdb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,8 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing"; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; From 884faf58656528da58de71d4346f44d342bb38b8 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 12:07:27 +0530 Subject: [PATCH 08/25] add room code validation --- backend/src/api/rooms.ts | 2 +- frontend/src/pages/JoinRoomPage.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..a96a3a8 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -32,7 +32,7 @@ export function createRoomsRouter() { const result = joinRoom(code.toUpperCase(), playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found"); } response.json({ diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f530..02d7f68 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,14 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (roomCode.trim() === "") { + setError("Room code is required"); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); From c77aef404d98e89386895754f6a1b9f66f76cb29 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 13:21:36 +0530 Subject: [PATCH 09/25] add automatic lobby polling --- frontend/src/pages/LobbyPage.tsx | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd2..25713e0 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; @@ -9,7 +9,6 @@ export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); useEffect(() => { if (!room) { @@ -17,14 +16,15 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { - try { - setRefreshError(null); - await roomStore.fetchRoom(); - } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); - } - } + useEffect(() => { + if (!room) return; + + const interval = setInterval(() => { + roomStore.fetchRoom().catch(() => {}); + }, 2000); + + return () => clearInterval(interval); + }, [room?.code, roomStore]); if (!room) { return null; @@ -61,14 +61,11 @@ export function LobbyPage() {

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

-

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

+

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

- From e0558adfcbd2530d90bccbf81c9defd2cbde86df Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 13:29:17 +0530 Subject: [PATCH 10/25] enforce host-only game start --- backend/src/api/rooms.ts | 27 ++++++++++++++++++++-- backend/src/api/schemas.ts | 4 ++++ backend/src/services/roomStore.ts | 14 ++++++++++++ frontend/src/pages/LobbyPage.tsx | 37 ++++++++++++++++++++++++++----- frontend/src/services/api.ts | 6 +++++ frontend/src/state/roomStore.ts | 10 +++++++++ 6 files changed, 91 insertions(+), 7 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index a96a3a8..92800cb 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(); @@ -44,6 +45,28 @@ 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.toUpperCase(), participantId); + + if (result === null) { + throw new HttpError(404, "Room not found"); + } + if (result === "not-host") { + throw new HttpError(403, "Only the host can start the game"); + } + if (result === "not-enough-players") { + throw new HttpError(422, "Need at least 2 players to start"); + } + + response.json({ room: result }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba0..c309e8e 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -16,6 +16,10 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameSchema = z.object({ + participantId: z.string().min(1) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index c77bf3d..85a115c 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -97,6 +97,20 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) return null; + if (room.hostId !== participantId) return "not-host" as const; + if (room.participants.length < 2) return "not-enough-players" as const; + + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room), participantId); +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { void viewerParticipantId; diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 25713e0..9c64d24 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(); useEffect(() => { if (!room) { @@ -30,6 +30,18 @@ export function LobbyPage() { return null; } + const isHost = room.hostId === participantId; + const canStart = room.participants.length >= 2; + + async function handleStart() { + try { + await roomStore.startGame(); + navigate("/game"); + } catch { + // error is written to store state and shown in the Status card + } + } + return (
@@ -50,7 +62,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostId ? "Host" : "joined"} +
  • ))} @@ -66,9 +80,22 @@ export function LobbyPage() {
    - + {isHost ? ( +
    + + {!canStart && ( +

    Need at least 2 players to start.

    + )} +
    + ) : ( +

    Waiting for the host to start the game...

    + )}
    ); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5c22bdb..5ead7c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -68,4 +68,10 @@ export const api = { `/rooms/${encodeURIComponent(code)}${query}`, ); }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }), + }); + }, }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd373..2b65935 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,16 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame() { + if (!this.state.room || !this.state.participantId) return; + + const response = await this.withLoading(() => + api.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response; + } } const RoomStoreContext = createContext(null); From 08bc6f3f0e162716d6b4c3c7d1eaa94f9b8a8c7a Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 13:35:27 +0530 Subject: [PATCH 11/25] validate scenario 1 acceptance criteria From 6c3fe7e9aa934764a29edab140f181fc201070bd Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:09:25 +0530 Subject: [PATCH 12/25] add scenario 2 game start requirements --- speckit.specify | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/speckit.specify b/speckit.specify index 5af2a3b..df2012f 100644 --- a/speckit.specify +++ b/speckit.specify @@ -75,3 +75,95 @@ A player has no way to know who else is waiting, and nobody can actually begin t **Minimum 2 players** - [ ] A start request sent with only 1 participant in the room returns a 4xx error from the backend. - [ ] The frontend does not allow the host to trigger the start request when fewer than 2 players are present. + +--- + +## Scenario 2 — Game Start & Drawer Flow + +### Problem + +When the host starts the game, two things must happen that currently do not: a drawer must be assigned, and a secret word must be chosen. The game screen is a placeholder — it shows the same static content to every player regardless of their role. Players have no way to know who is drawing, and the drawer has no word to draw. The backend transitions the room to `"playing"` but assigns no drawer and selects no word. The `toRoomSnapshot` function returns the same data to everyone, meaning sensitive information (the secret word) cannot be hidden from guessers even once it exists. Additionally, player names are accepted without trimming or emptiness checks, so whitespace-only names can enter the game. + +--- + +### Requirements + +#### Player Name Validation +- Player names must be trimmed of leading and trailing whitespace before being stored. +- A name that is empty or whitespace-only after trimming must be rejected with a clear inline error message. +- Rejection happens on the frontend before any API call is made. +- The backend also enforces the rule: a request with an empty or whitespace-only name returns a 400 error. +- Both Create Room and Join Room enforce this rule identically. + +#### Drawer Assignment +- When a game starts, the host is assigned as the drawer for the first round. +- Drawer assignment is recorded on the backend in the room's state. +- The drawer identity (`drawerId`) is included in every room snapshot and is visible to all players — everyone needs to know who is drawing. +- Only one player can be the drawer at a time. + +#### Secret Word Selection +- A secret word is selected when the game starts, at the same moment the drawer is assigned. +- The word is chosen deterministically from the starter word list using the room code as the seed — the same room code always produces the same word. +- The word is stored on the backend in the room's state. +- The word is never randomly selected; the selection rule must be reproducible. + +#### Secret Word Visibility +- The secret word is visible only to the drawer. +- All other players receive `null` (or no value) for the current word in their snapshot. +- The backend enforces this rule in `toRoomSnapshot` by comparing the requesting participant's id to the drawer's id. +- The frontend renders the word only when the viewer is the drawer; guessers never see it. + +#### Drawer Identification in the UI +- The game screen clearly identifies who the drawer is by name. +- The drawer's own view shows their role ("You are drawing") and their secret word. +- A guesser's view shows who is drawing ("Waiting for [name] to draw...") and does not show the word. +- The Player Info panel on the game screen shows the viewer's role as "Drawer" or "Guesser". + +--- + +### Edge Cases + +- A whitespace-only name (e.g. `" "`) must be rejected the same way as a fully empty name — after trimming, the result is empty. +- A name that is only spaces on one side (e.g. `" Alice"`) must be accepted after trimming and stored as `"Alice"`. +- If a player joins with a valid name that has surrounding spaces, the stored name must be trimmed; the raw input must not be persisted. +- The drawer is assigned at the moment of game start — joining the room after the game has started does not change the drawer. +- The secret word must not change between polls. Once assigned, `currentWord` is fixed until the game is restarted. +- The same viewer id used in the polling query (`GET /rooms/:code?participantId=...`) is what determines word visibility — if `participantId` is missing or does not match the drawer, the word is hidden. +- If a player opens the game URL directly without a room in state, they are redirected to the start screen (existing behavior, unchanged). + +--- + +### Acceptance Criteria + +**Player name validation** +- [ ] Submitting an empty name on Create Room shows "Player name is required" and does not call the API. +- [ ] Submitting a whitespace-only name on Create Room shows the same error and does not call the API. +- [ ] Submitting an empty name on Join Room shows "Player name is required" and does not call the API. +- [ ] Submitting a whitespace-only name on Join Room shows the same error and does not call the API. +- [ ] A name with surrounding spaces (e.g. `" Alice "`) is accepted and stored as `"Alice"` — the trimmed value is displayed in the lobby and game screen. +- [ ] Sending an empty or whitespace-only name directly to the backend returns a 400 error. + +**Drawer assignment** +- [ ] After the host starts the game, the room snapshot includes a `drawerId` matching the host's participant id. +- [ ] Every player's snapshot (fetched via polling) includes the same `drawerId`. +- [ ] The drawer is the host — no other participant is ever assigned as drawer in this scenario. + +**Secret word selection** +- [ ] After the host starts the game, a `currentWord` is set on the backend. +- [ ] The selected word is one of the five starter words (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`). +- [ ] Starting a game with the same room code always selects the same word (deterministic). +- [ ] Different room codes produce different words (at least across the range of the starter list). + +**Secret word visibility** +- [ ] The drawer's snapshot includes the secret word as a non-null string. +- [ ] A guesser's snapshot returns `null` (or no value) for `currentWord`. +- [ ] Calling `GET /rooms/:code` without a `participantId` does not expose the word. +- [ ] Calling `GET /rooms/:code?participantId=` does not expose the word. +- [ ] Calling `GET /rooms/:code?participantId=` returns the word. + +**Drawer identification in the UI** +- [ ] The drawer's game screen shows their role as "Drawer" (or equivalent) in the Player Info panel. +- [ ] The drawer's game screen shows the secret word clearly. +- [ ] A guesser's game screen shows their role as "Guesser" in the Player Info panel. +- [ ] A guesser's game screen shows the drawer's name in the canvas area (e.g. "Waiting for Alice to draw..."). +- [ ] The guesser's game screen does not show the secret word anywhere. From 12720a2f44b043cf00fca0732074b3a6d169b53f Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:12:53 +0530 Subject: [PATCH 13/25] add scenario 2 implementation plan --- speckit.plan | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/speckit.plan b/speckit.plan index 1f7b79f..acd0622 100644 --- a/speckit.plan +++ b/speckit.plan @@ -240,3 +240,283 @@ No new files need to be created. No new libraries are required. | `hostId` missing from old rooms if the backend restarts during testing | Not a concern — in-memory store is wiped on restart; all rooms are fresh | | Start game navigates to `/game` before game state (drawer, word) is ready | Acceptable for Scenario 1; game screen displays placeholder until Scenario 2 is implemented | | Two players simultaneously click "Start Game" | Only one will win the host check; second gets 403 — handled by the error path in the store | + +--- + +--- + +# Plan — Scenario 2: Game Start & Drawer Flow + +--- + +## Findings + +### What exists and is relevant (post Scenario 1) + +| Area | File | Current behavior | +|---|---|---| +| Room model | `backend/src/models/game.ts` | `Room` has `code`, `status` (`"lobby" \| "playing"`), `hostId`, `participants`, `createdAt`, `updatedAt`. No `drawerId`. No `currentWord`. | +| RoomSnapshot model | `backend/src/models/game.ts` | `RoomSnapshot` has `code`, `status`, `hostId`, `participants`, `availableWords`, `roles`. No `drawerId`. No `currentWord`. | +| Name schemas | `backend/src/api/schemas.ts` | `createRoomSchema.playerName` is `z.string().optional()`. `joinRoomSchema.playerName` is `z.string().optional()`. Neither trims nor enforces a minimum length. | +| displayName helper | `backend/src/services/roomStore.ts` | `displayName(name?)` returns `name \|\| "Player"`. Falsy check passes empty strings to the `"Player"` fallback, but `" "` (spaces only) is truthy and would be stored as-is. | +| startGame service | `backend/src/services/roomStore.ts` | Sets `room.status = "playing"` and saves. Returns the snapshot. Does not assign a drawer. Does not select a word. | +| toRoomSnapshot | `backend/src/services/roomStore.ts` | Receives `viewerParticipantId` as a parameter but immediately voids it (`void viewerParticipantId`). Returns identical data to every caller — no per-viewer filtering of any kind. | +| Rooms router | `backend/src/api/rooms.ts` | Four endpoints: `POST /rooms`, `POST /rooms/:code/join`, `POST /rooms/:code/start`, `GET /rooms/:code`. The `GET` endpoint already passes `participantId` from the query string into `toRoomSnapshot` — the wiring is there, the filtering logic is not. | +| Frontend snapshot type | `frontend/src/services/api.ts` | `RoomSnapshot` interface has `code`, `status`, `hostId`, `participants`, `availableWords`, `roles`. No `drawerId`. No `currentWord`. | +| CreateRoomPage | `frontend/src/pages/CreateRoomPage.tsx` | Calls `roomStore.createRoom(playerName)` with no prior validation. An empty or whitespace-only name is sent directly to the API. Backend accepts it and stores `"Player"` (empty) or the raw whitespace (spaces-only). | +| JoinRoomPage | `frontend/src/pages/JoinRoomPage.tsx` | Has client-side code validation (empty code rejected). No name validation. Empty or whitespace-only names are sent to the API without error. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Canvas area contains a static `
    ` with `"Waiting for drawer..."`. Player Info card shows hardcoded `"Playing"` status. No drawer is identified. No word is shown. No role-aware branching exists anywhere in the component. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame` actions. No game-screen polling. | + +### What is missing + +1. **Name trimming and rejection (schemas)** — `playerName` in both create and join schemas accepts any string including empty and whitespace-only. The `displayName` fallback masks empty strings with `"Player"` but cannot reject them or trim them. +2. **Name validation (frontend forms)** — `CreateRoomPage` has no client-side name check. `JoinRoomPage` has code validation but no name check. Both must reject before making an API call. +3. **Drawer field on Room** — `Room` has no `drawerId` field. `startGame` transitions status but assigns no drawer. +4. **Current word field on Room** — `Room` has no `currentWord` field. `startGame` selects no word. +5. **Drawer and word in RoomSnapshot** — `RoomSnapshot` has no `drawerId` or `currentWord`. Even if the fields existed on `Room`, they would not be returned to the frontend. +6. **Per-viewer filtering in toRoomSnapshot** — `viewerParticipantId` is received but voided. The secret word must be returned only when the viewer is the drawer. The infrastructure (the parameter, the query-string wiring in `GET /rooms/:code`) already exists; only the conditional logic is missing. +7. **Role-aware GamePage** — the game screen renders identically for every player. It must branch on whether the viewer is the drawer or a guesser and display accordingly. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Room (before) Room (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: RoomStatus status: RoomStatus +hostId: string hostId: string +participants: Participant[] participants: Participant[] +createdAt: string createdAt: string +updatedAt: string updatedAt: string + drawerId: string | null ← NEW + currentWord: string | null ← NEW +``` + +Both fields are `null` when the room is in `"lobby"` status and are set at the moment `startGame` transitions the room to `"playing"`. + +``` +RoomSnapshot (before) RoomSnapshot (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: RoomStatus status: RoomStatus +hostId: string hostId: string +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + drawerId: string | null ← NEW (visible to all) + currentWord: string | null ← NEW (visible only to drawer) +``` + +`drawerId` is always included — every player needs to know who is drawing. +`currentWord` is conditionally populated: non-null only when `viewerParticipantId === drawerId`. + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: "lobby" | "playing" status: "lobby" | "playing" +hostId: string hostId: string +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + drawerId: string | null ← NEW + currentWord: string | null ← NEW +``` + +--- + +## Required API Changes + +### Modified: Zod schemas — `backend/src/api/schemas.ts` + +``` +createRoomSchema.playerName: z.string().optional() + → z.string().trim().min(1, "Player name is required") + +joinRoomSchema.playerName: z.string().optional() + → z.string().trim().min(1, "Player name is required") +``` + +`z.string().trim()` runs before `.min(1)` is evaluated. A whitespace-only string trims to `""` and fails the length check, returning a 400. No `.refine()` is needed. + +### Modified: `createRoom` service — `backend/src/services/roomStore.ts` + +Initialise the two new fields on every new room: +``` +drawerId: null +currentWord: null +``` +No other logic change. The schema now guarantees the name is trimmed before reaching the service. + +### Modified: `startGame` service — `backend/src/services/roomStore.ts` + +Add two assignments before saving: +``` +room.drawerId = room.hostId +room.currentWord = selectWord(room.code) +``` + +`selectWord` is a private pure function that derives a stable index from the room code: +``` +index = sum of char codes of room.code % STARTER_WORDS.length +``` +The same room code always yields the same index. The function has no side effects and requires no new dependencies. + +### Modified: `toRoomSnapshot` — `backend/src/services/roomStore.ts` + +Remove `void viewerParticipantId`. Add two fields to the returned object: +``` +drawerId: room.drawerId +currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null +``` + +The word is `null` for any caller whose id does not match the drawer, including callers who omit `participantId` entirely. + +### No new endpoints + +The existing four endpoints are sufficient. +- `POST /rooms` — benefits automatically from schema change (name trimmed + validated). +- `POST /rooms/:code/join` — same. +- `POST /rooms/:code/start` — calls `startGame` which will gain drawer + word assignment. +- `GET /rooms/:code` — already passes `participantId` to `toRoomSnapshot`; filtering logic slots in there with no handler change. + +--- + +## Data Flow + +### Create Room (name validation enforced) +``` +CreateRoomPage + → [trim name client-side; if empty → "Player name is required", stop] + → POST /rooms { playerName } + → Zod: z.string().trim().min(1) — rejects whitespace-only with 400 + → createRoom(trimmedName): room.drawerId = null, room.currentWord = null + → toRoomSnapshot(room, participantId): drawerId null, currentWord null + → response unchanged shape +``` + +### Join Room (name validation added) +``` +JoinRoomPage + → [trim name client-side; if empty → "Player name is required", stop] ← NEW + → [trim code client-side; if empty → "Room code is required"] (exists) + → POST /rooms/:code/join { playerName } + → Zod: z.string().trim().min(1) — rejects whitespace-only with 400 + → joinRoom(code, trimmedName) + → response unchanged shape +``` + +### Start Game (drawer + word assigned) +``` +LobbyPage (host, ≥2 players) + → POST /rooms/:code/start { participantId: hostId } + → startGame(code, hostId): + room.drawerId = room.hostId + room.currentWord = selectWord(room.code) + room.status = "playing" + → toRoomSnapshot(room, hostId): + drawerId: hostId ← non-null + currentWord: room.currentWord ← visible: viewer IS the drawer + → response { room } — host's snapshot includes the word + → roomStore.setRoomSnapshot(room) + → navigate("/game") +``` + +### Polling from game screen (per-viewer word filtering) +``` +GamePage (every ~2s) + → GET /rooms/:code?participantId= + → toRoomSnapshot(room, viewerId): + drawerId: room.drawerId (always included) + currentWord: room.currentWord if viewerId === room.drawerId + currentWord: null otherwise + → roomStore.setRoomSnapshot(room) + → GamePage re-renders: drawer sees word, guessers see null +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: tighten name schemas +- **File:** `backend/src/api/schemas.ts` +- Change `createRoomSchema.playerName` to `z.string().trim().min(1, "Player name is required")`. +- Change `joinRoomSchema.playerName` to the same. +- Verify: `npm run build` in `backend/` passes. + +### Step 2 — Backend: extend Room and RoomSnapshot models +- **File:** `backend/src/models/game.ts` +- Add `drawerId: string | null` and `currentWord: string | null` to `Room`. +- Add `drawerId: string | null` and `currentWord: string | null` to `RoomSnapshot`. +- Verify: `npm run build` in `backend/` passes (type errors in the service are expected — fixed in Step 3). + +### Step 3 — Backend: update createRoom and startGame in the room service +- **File:** `backend/src/services/roomStore.ts` +- In `createRoom`: add `drawerId: null` and `currentWord: null` to the new room literal. +- Add private `selectWord(code: string)` function using char-code-sum mod word-list-length. +- In `startGame`: assign `room.drawerId = room.hostId` and `room.currentWord = selectWord(room.code)` before saving. +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add per-viewer filtering to toRoomSnapshot +- **File:** `backend/src/services/roomStore.ts` +- Remove `void viewerParticipantId`. +- Add `drawerId: room.drawerId` to the returned object. +- Add `currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null` to the returned object. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Frontend: update RoomSnapshot interface +- **File:** `frontend/src/services/api.ts` +- Add `drawerId: string | null` and `currentWord: string | null` to the `RoomSnapshot` interface. +- Verify: `npm run build` in `frontend/` passes (type errors in pages expected — fixed in Step 7). + +### Step 6 — Frontend: add name validation to forms +- **File:** `frontend/src/pages/CreateRoomPage.tsx` + - Trim name before submitting. If empty after trim, show "Player name is required" inline and return without calling the API. +- **File:** `frontend/src/pages/JoinRoomPage.tsx` + - Apply the same name check before the existing code check. Show "Player name is required" inline and return. +- Verify: `npm run build` in `frontend/` passes. + +### Step 7 — Frontend: update GamePage for role-aware rendering +- **File:** `frontend/src/pages/GamePage.tsx` +- Derive `isDrawer = room.drawerId === participantId`. +- Derive `drawerName` by finding the participant whose id matches `room.drawerId`. +- Player Info card: show role as `"Drawer"` or `"Guesser"` based on `isDrawer`. +- Canvas area: + - Drawer: show their secret word (`room.currentWord`) and a label indicating they are drawing. + - Guesser: show `"Waiting for [drawerName] to draw..."`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/api/schemas.ts` | Modify — tighten `playerName` in create and join schemas | +| `backend/src/models/game.ts` | Modify — add `drawerId` and `currentWord` to `Room` and `RoomSnapshot` | +| `backend/src/services/roomStore.ts` | Modify — initialise fields in `createRoom`, add `selectWord`, assign in `startGame`, filter in `toRoomSnapshot` | +| `frontend/src/services/api.ts` | Modify — add `drawerId` and `currentWord` to `RoomSnapshot` interface | +| `frontend/src/pages/CreateRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/JoinRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/GamePage.tsx` | Modify — role-aware layout, drawer identity, conditional word display | + +No new files. No new libraries. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| `displayName` fallback can still mask a name that slips through if a caller bypasses the schema | Schema now validates before `displayName` is reached in normal flows. `displayName` can be simplified to a direct passthrough since the schema guarantees a non-empty trimmed value. | +| `selectWord` result must be stable across calls for the same code | The function is a pure deterministic computation with no randomness — same input always returns same output. | +| `toRoomSnapshot` change affects all four endpoints simultaneously | Intended. All endpoints call `toRoomSnapshot`. The lobby-phase snapshot gains `drawerId: null` and `currentWord: null` which is correct for the lobby state. | +| GamePage has no polling of its own yet | Not required for Scenario 2. The start-game snapshot already delivers the drawer and word. Polling is needed in Scenario 3 for guess sync. | +| A guesser navigating directly to `/game` via URL without going through the lobby | The existing redirect guard (`if (!room) navigate("/")`) already handles this. Room state is only set by create/join flows. | From 0f8b73a3f7215a1b0e46c6bfb500f398bf8fbb71 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:15:19 +0530 Subject: [PATCH 14/25] add scenario 2 work breakdown --- speckit.tasks | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/speckit.tasks b/speckit.tasks index b39d033..9f18953 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -280,3 +280,262 @@ No code changes — validation only. - Create Room A in Tab 1. Create Room B in Tab 2. - Join Room A in a third tab. - Expected: Room B participant list is unaffected. + +--- + +--- + +# Tasks — Scenario 2: Game Start & Drawer Flow + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group L — Backend Schemas + +### L1 — Tighten playerName in createRoomSchema +- File: `backend/src/api/schemas.ts` +- Change `createRoomSchema.playerName` from `z.string().optional()` to `z.string().trim().min(1, "Player name is required")`. +- No other changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### L2 — Tighten playerName in joinRoomSchema +- File: `backend/src/api/schemas.ts` +- Apply the same change as L1 to `joinRoomSchema.playerName`. +- No other changes in this task. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group M — Backend Model + +### M1 — Add drawerId and currentWord to Room interface +- File: `backend/src/models/game.ts` +- Add `drawerId: string | null` to the `Room` interface. +- Add `currentWord: string | null` to the `Room` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Type errors in the service are expected and fixed in Group N. + +### M2 — Add drawerId and currentWord to RoomSnapshot interface +- File: `backend/src/models/game.ts` +- Add `drawerId: string | null` to the `RoomSnapshot` interface. +- Add `currentWord: string | null` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group N — Backend Service + +### N1 — Initialise drawerId and currentWord in createRoom +- File: `backend/src/services/roomStore.ts` +- In the `createRoom` function, add `drawerId: null` and `currentWord: null` to the room object literal. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### N2 — Add selectWord helper function +- File: `backend/src/services/roomStore.ts` +- Add a private (non-exported) `selectWord(code: string)` function. +- The function derives an index by summing the char codes of each character in `code`, then taking the result modulo `STARTER_WORDS.length`. +- Returns `STARTER_WORDS[index]`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### N3 — Assign drawer and word in startGame +- File: `backend/src/services/roomStore.ts` +- In the `startGame` function, before saving the room, add: + - `room.drawerId = room.hostId` + - `room.currentWord = selectWord(room.code)` +- No other changes to the function. +- Verify: `npm run build` in `backend/` passes. + +### N4 — Add per-viewer filtering to toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- Remove the `void viewerParticipantId` line. +- Add `drawerId: room.drawerId` to the returned snapshot object. +- Add `currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null` to the returned snapshot object. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group O — Backend Tests + +### O1 — Test createRoomSchema rejects empty playerName +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: "" })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### O2 — Test createRoomSchema rejects whitespace-only playerName +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: " " })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### O3 — Test createRoomSchema trims a valid name with surrounding spaces +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: " Alice " })` succeeds and the parsed value is `"Alice"`. +- Run: `npm test` in `backend/` — all tests pass. + +### O4 — Test joinRoomSchema rejects empty and whitespace-only playerName +- File: `backend/src/api/schemas.test.ts` +- Add test cases mirroring O1 and O2 for `joinRoomSchema`. +- Run: `npm test` in `backend/` — all tests pass. + +### O5 — Test createRoom initialises drawerId and currentWord as null +- File: `backend/src/services/roomStore.test.ts` +- Add a test: call `createRoom("Alice")` and assert `result.room.drawerId === null` and `result.room.currentWord === null`. +- Run: `npm test` in `backend/` — all tests pass. + +### O6 — Test startGame assigns drawerId to the host +- File: `backend/src/services/roomStore.test.ts` +- Create a room and join a second player. Call `startGame` with the host id. Assert the returned snapshot has `drawerId === hostId`. +- Run: `npm test` in `backend/` — all tests pass. + +### O7 — Test startGame selects a word from the starter list +- File: `backend/src/services/roomStore.test.ts` +- Create a room and join a second player. Call `startGame` with the host id. Assert `snapshot.currentWord` is one of the five starter words. +- Run: `npm test` in `backend/` — all tests pass. + +### O8 — Test startGame word selection is deterministic +- File: `backend/src/services/roomStore.test.ts` +- Create two rooms with the same code (or simulate the same code by calling the selection logic directly). Assert both produce the same word. +- Run: `npm test` in `backend/` — all tests pass. + +### O9 — Test toRoomSnapshot returns word to drawer, null to others +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `toRoomSnapshot(room, hostId)` and assert `currentWord` is non-null. Call `toRoomSnapshot(room, secondPlayerId)` and assert `currentWord` is `null`. Call `toRoomSnapshot(room, undefined)` and assert `currentWord` is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### O10 — Test toRoomSnapshot always includes drawerId +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `toRoomSnapshot` with both the drawer id and a guesser id. Assert `drawerId` is non-null and identical in both snapshots. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group P — Frontend Types + +### P1 — Add drawerId and currentWord to RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `drawerId: string | null` to the `RoomSnapshot` interface. +- Add `currentWord: string | null` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. Type errors in pages are expected until Group R is done. + +--- + +## Group Q — Frontend Validation + +### Q1 — Add name validation to CreateRoomPage +- File: `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the player name. +- If the trimmed value is empty, set an inline error "Player name is required" and return without calling the API. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Q2 — Add name validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before the existing room code check, trim the player name. +- If the trimmed name is empty, set an inline error "Player name is required" and return without calling the API. +- The name check runs before the code check — both errors are possible on the same submission but name is checked first. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group R — Frontend GamePage + +### R1 — Derive isDrawer and drawerName from room state +- File: `frontend/src/pages/GamePage.tsx` +- Derive `isDrawer = room.drawerId === participantId`. +- Derive `drawerParticipant` by finding the participant in `room.participants` whose id matches `room.drawerId`. +- No rendering changes yet — build-verify only. +- Verify: `npm run build` in `frontend/` passes. + +### R2 — Update Player Info card to show role +- File: `frontend/src/pages/GamePage.tsx` +- In the Player Info card, replace the hardcoded `"Playing"` status with `"Drawer"` when `isDrawer` is true and `"Guesser"` otherwise. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### R3 — Update canvas area for drawer: show secret word +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is true, replace the static placeholder with a message that tells the player they are the drawer and displays `room.currentWord`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### R4 — Update canvas area for guesser: show drawer name +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is false, replace the static placeholder with `"Waiting for [drawerParticipant name] to draw..."`. Fall back to `"Waiting for the drawer..."` if the participant is not found. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group S — Frontend Tests + +### S1 — Test api service: createRoom sends playerName in body +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.createRoom("Alice")` makes a `POST` to `/rooms` with `{ playerName: "Alice" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +### S2 — Test api service: joinRoom sends playerName and code +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.joinRoom("ABCD", "Bob")` makes a `POST` to `/rooms/ABCD/join` with `{ playerName: "Bob" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group T — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### T1 — Validate: empty name rejected on Create Room +- Open Create Room, submit with an empty name field. +- Expected: inline error "Player name is required" appears, no network request is made. + +### T2 — Validate: whitespace-only name rejected on Create Room +- Open Create Room, type several spaces into the name field, submit. +- Expected: inline error appears, no network request is made. + +### T3 — Validate: name with surrounding spaces is accepted and trimmed +- Open Create Room, type `" Alice "` into the name field, submit. +- Expected: room is created successfully, player appears in the lobby as `"Alice"` (not `" Alice "`). + +### T4 — Validate: empty name rejected on Join Room +- Open Join Room, leave the name field empty, enter a valid code, submit. +- Expected: inline error "Player name is required" appears, no network request is made. + +### T5 — Validate: whitespace-only name rejected on Join Room +- Open Join Room, type spaces into the name field, enter a valid code, submit. +- Expected: inline error appears, no network request is made. + +### T6 — Validate: drawer is assigned on game start +- Tab 1: create a room. Tab 2: join the same room. Tab 1: click "Start Game". +- Expected: Tab 1 navigates to the game screen. The Player Info card shows "Drawer". + +### T7 — Validate: guesser sees correct role +- Tab 2 (from T6): wait for or manually navigate to `/game`. +- Expected: the Player Info card shows "Guesser". + +### T8 — Validate: drawer sees the secret word +- Tab 1 (drawer, from T6): inspect the game screen canvas area. +- Expected: the secret word is shown. It is one of: rocket, pizza, castle, guitar, sunflower. + +### T9 — Validate: guesser does not see the secret word +- Tab 2 (guesser, from T6): inspect the game screen canvas area. +- Expected: the secret word is not visible. The drawer's name appears in the canvas area instead. + +### T10 — Validate: word selection is deterministic +- Note the room code and word shown in T8. +- Restart the backend, recreate a room with a different player name but confirm the same room code is used if possible, or repeat across multiple sessions to observe consistency. +- Expected: the same room code always produces the same word across restarts. + +### T11 — Validate: GET /rooms/:code does not leak word to guesser +- After game start (from T6), call `GET /rooms/:code?participantId=` directly (e.g. via browser devtools or curl). +- Expected: `currentWord` in the response is `null`. + +### T12 — Validate: GET /rooms/:code returns word to drawer +- After game start (from T6), call `GET /rooms/:code?participantId=` directly. +- Expected: `currentWord` in the response is the secret word string. From bea9b6907ee4b1f51ace31489d2f8f112dd8830f Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:26:12 +0530 Subject: [PATCH 15/25] complete scenario 2 game start and drawer flow --- backend/src/api/schemas.ts | 4 ++-- backend/src/models/game.ts | 4 ++++ backend/src/services/roomStore.ts | 13 +++++++++++-- frontend/src/pages/CreateRoomPage.tsx | 7 ++++++- frontend/src/pages/GamePage.tsx | 15 ++++++++++++--- frontend/src/pages/JoinRoomPage.tsx | 7 ++++++- frontend/src/services/api.ts | 2 ++ 7 files changed, 43 insertions(+), 9 deletions(-) diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index c309e8e..3908014 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,11 @@ import { z } from "zod"; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name is required") }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name is required") }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index b3af2f0..7a72068 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -11,6 +11,8 @@ export interface Room { code: string; status: RoomStatus; hostId: string; + drawerId: string | null; + currentWord: string | null; participants: Participant[]; createdAt: string; updatedAt: string; @@ -20,6 +22,8 @@ export interface RoomSnapshot { code: string; status: RoomStatus; hostId: string; + drawerId: string | null; + currentWord: string | null; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 85a115c..c674b43 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -55,6 +55,8 @@ export function createRoom(playerName?: string) { code: generateUniqueCode(), status: "lobby", hostId: participant.id, + drawerId: null, + currentWord: null, participants: [participant], createdAt: now(), updatedAt: now() @@ -97,6 +99,11 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +function selectWord(code: string): string { + const index = Array.from(code).reduce((sum, char) => sum + char.charCodeAt(0), 0) % STARTER_WORDS.length; + return STARTER_WORDS[index]; +} + export function startGame(code: string, participantId: string) { const room = rooms.get(code); @@ -105,6 +112,8 @@ export function startGame(code: string, participantId: string) { if (room.participants.length < 2) return "not-enough-players" as const; room.status = "playing"; + room.drawerId = room.hostId; + room.currentWord = selectWord(room.code); room.updatedAt = now(); rooms.set(room.code, room); @@ -112,12 +121,12 @@ export function startGame(code: string, participantId: string) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; - return { code: room.code, status: room.status, hostId: room.hostId, + drawerId: room.drawerId, + currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee..7fa0615 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,14 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (playerName.trim() === "") { + setError("Player name is required"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183..39d2b74 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -22,6 +22,8 @@ export function GamePage() { } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.drawerId === participantId; + const drawerParticipant = room.participants.find((p) => p.id === room.drawerId) ?? null; return (
    @@ -42,7 +44,14 @@ export function GamePage() {
    - Waiting for drawer... + {isDrawer ? ( +
    +

    You are drawing!

    +

    Your word: {room.currentWord}

    +
    + ) : ( +

    Waiting for {drawerParticipant?.name ?? "the drawer"} to draw...

    + )}
    @@ -55,8 +64,8 @@ export function GamePage() {
    {viewer?.name ?? "Unknown player"}
    -
    Status
    -
    Playing
    +
    Role
    +
    {isDrawer ? "Drawer" : "Guesser"}
    diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index 02d7f68..9c0c136 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,6 +13,11 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (playerName.trim() === "") { + setError("Player name is required"); + return; + } + if (roomCode.trim() === "") { setError("Room code is required"); return; @@ -20,7 +25,7 @@ export function JoinRoomPage() { try { setError(null); - await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName); + await roomStore.joinRoom(roomCode.trim().toUpperCase(), playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5ead7c8..aa690f5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,6 +10,8 @@ export interface RoomSnapshot { code: string; status: "lobby" | "playing"; hostId: string; + drawerId: string | null; + currentWord: string | null; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; From 99ea6e9eef009375347f12dae2ef4a29f7474238 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:31:48 +0530 Subject: [PATCH 16/25] add scenario 3 gameplay requirements --- speckit.specify | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/speckit.specify b/speckit.specify index df2012f..d84396d 100644 --- a/speckit.specify +++ b/speckit.specify @@ -167,3 +167,118 @@ When the host starts the game, two things must happen that currently do not: a d - [ ] A guesser's game screen shows their role as "Guesser" in the Player Info panel. - [ ] A guesser's game screen shows the drawer's name in the canvas area (e.g. "Waiting for Alice to draw..."). - [ ] The guesser's game screen does not show the secret word anywhere. + +--- + +## Scenario 3 — Gameplay Interaction + +### Problem + +The game screen currently has no real interactivity. The canvas is a static placeholder — neither the drawer nor any guesser can do anything on it. There is no mechanism for submitting a guess, no backend storage for guesses, and no way for players to see what others have guessed. Scores do not exist on any player record. The game is in a running state but nothing actually happens once it starts. + +--- + +### Requirements + +#### Drawing Canvas +- The game screen must provide an interactive drawing surface for the drawer. +- The drawer can draw freely on the canvas using pointer or mouse events. +- The canvas is visible to all players on the game screen. +- Only the drawer can draw; guessers see the canvas as read-only. +- The canvas renders the same drawing for the drawer and guessers (drawing state is not synced via WebSockets — the canvas is local to the drawer's screen; guessers see a static or placeholder view until Scenario 3 polling is wired). + +#### Clear Canvas +- The drawer has access to a "Clear Canvas" button. +- Clicking it wipes the canvas back to a blank state. +- Guessers do not see the clear button. +- Clearing is a local action on the drawer's canvas — it does not sync to other players. + +#### Guess Submission +- Guessers can submit a guess using the existing `GuessForm` component. +- The drawer cannot submit a guess — the guess form is not shown or is disabled for the drawer. +- A submitted guess is sent to the backend and stored against the room. +- The backend stores the guesser's participant id, their name, the submitted text, whether it was correct, and the timestamp. + +#### Guess Validation +- A guess must be trimmed of leading and trailing whitespace before processing. +- A guess that is empty or whitespace-only after trimming is rejected with a clear inline error. No API call is made. +- These rules are enforced on both the frontend (before the API call) and the backend (schema validation). + +#### Guess Comparison +- The backend compares the trimmed, lowercased guess against the trimmed, lowercased `currentWord`. +- Comparison is case-insensitive: `"PIZZA"`, `"pizza"`, and `"Pizza"` all match if the word is `"pizza"`. +- The correct/incorrect result is determined by the backend — the frontend does not apply any comparison logic. + +#### Scoring +- Every participant starts with a score of zero. +- A correct guess awards the guesser 100 points. +- An incorrect guess awards the guesser 0 points (score unchanged). +- Scores are stored on each participant on the backend. +- The drawer does not receive points for this scenario. +- A player who guesses correctly can still submit further guesses; additional correct guesses do not award additional points (score stays at 100 once reached). + +#### Guess History Synchronisation +- All players (drawer and guessers) see a live guess history on the game screen. +- The guess history is fetched via the existing polling mechanism on the game screen (same ~2-second cadence as the lobby). +- Each entry in the history shows the guesser's name, their guess text, and whether it was correct or incorrect. +- The history is ordered by submission time, oldest first. +- All players see the same complete history — there is no per-viewer filtering of guesses. + +--- + +### Edge Cases + +- A whitespace-only guess (e.g. `" "`) must be rejected the same way as an empty guess — after trimming the result is empty. +- A guess with surrounding spaces (e.g. `" pizza "`) must be trimmed before comparison; `" pizza "` must match `"pizza"`. +- Guess comparison is case-insensitive in both directions: the guess is lowercased, the stored word is lowercased, and only then compared. +- A guesser who has already guessed correctly may continue submitting guesses. Subsequent correct guesses do not increase their score beyond 100. +- The drawer must not be able to submit a guess — the guess form must not be present or must be disabled on the drawer's view. +- An empty canvas cleared by the drawer remains blank — it does not restore any previous drawing state. +- If the backend is temporarily unavailable during polling, the guess history does not disappear — it retains the last successfully fetched state until the next successful poll. +- Scores shown in the Scoreboard are read from the room snapshot, not computed on the frontend — the backend is the single source of truth for scores. +- Submitting a guess after the game has ended (Scenario 4 state) is out of scope for this scenario. + +--- + +### Acceptance Criteria + +**Drawing canvas** +- [ ] The drawer's game screen shows an interactive canvas element (not a static placeholder). +- [ ] The drawer can draw on the canvas using a mouse or pointer device. +- [ ] The guesser's game screen shows the canvas area but cannot interact with it. + +**Clear canvas** +- [ ] The drawer's game screen has a "Clear Canvas" button. +- [ ] Clicking "Clear Canvas" resets the canvas to a blank state. +- [ ] The clear button is not visible on a guesser's game screen. + +**Guess submission** +- [ ] The guess form is visible and active for guessers. +- [ ] The guess form is not shown (or is disabled) for the drawer. +- [ ] Submitting a valid guess sends a request to the backend. +- [ ] After a successful submission, the guess appears in the guess history. + +**Guess validation** +- [ ] Submitting an empty guess shows an inline error and does not call the API. +- [ ] Submitting a whitespace-only guess shows the same error and does not call the API. +- [ ] A guess with surrounding spaces is trimmed and submitted as the trimmed value. +- [ ] Sending an empty or whitespace-only guess directly to the backend returns a 400 error. + +**Guess comparison** +- [ ] Submitting the exact secret word (same case) is marked as correct by the backend. +- [ ] Submitting the secret word in a different case (e.g. all caps) is also marked as correct. +- [ ] Submitting a wrong word is marked as incorrect. +- [ ] The correct/incorrect result is determined by the backend, not the frontend. + +**Scoring** +- [ ] All participants start with a score of 0 visible in the Scoreboard. +- [ ] A correct guess updates the guesser's score to 100. +- [ ] An incorrect guess does not change the guesser's score. +- [ ] Scores are visible in the Scoreboard for all players after each poll. +- [ ] A second correct guess by the same player does not increase their score beyond 100. + +**Guess history synchronisation** +- [ ] The guess history on the game screen updates within ~2 seconds of a guess being submitted by any player. +- [ ] Each history entry shows the guesser's name, their guess, and whether it was correct or incorrect. +- [ ] All players (drawer and guessers) see the same history. +- [ ] The history is ordered oldest-first. From f286c948b352664bd4885c7ee9f80acb05ff63eb Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:35:40 +0530 Subject: [PATCH 17/25] add scenario 3 implementation plan --- speckit.plan | 375 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) diff --git a/speckit.plan b/speckit.plan index acd0622..f0018ba 100644 --- a/speckit.plan +++ b/speckit.plan @@ -520,3 +520,378 @@ No new files. No new libraries. | `toRoomSnapshot` change affects all four endpoints simultaneously | Intended. All endpoints call `toRoomSnapshot`. The lobby-phase snapshot gains `drawerId: null` and `currentWord: null` which is correct for the lobby state. | | GamePage has no polling of its own yet | Not required for Scenario 2. The start-game snapshot already delivers the drawer and word. Polling is needed in Scenario 3 for guess sync. | | A guesser navigating directly to `/game` via URL without going through the lobby | The existing redirect guard (`if (!room) navigate("/")`) already handles this. Room state is only set by create/join flows. | + +--- + +--- + +# Plan — Scenario 3: Gameplay Interaction + +--- + +## Findings + +### What exists and is relevant (post Scenario 2) + +| Area | File | Current behavior | +|---|---|---| +| Participant model | `backend/src/models/game.ts` | Has `id`, `name`, `joinedAt`. No `score` field. | +| Room model | `backend/src/models/game.ts` | Has `code`, `status`, `hostId`, `drawerId`, `currentWord`, `participants`, `createdAt`, `updatedAt`. No `guesses` field. | +| RoomSnapshot model | `backend/src/models/game.ts` | Has `code`, `status`, `hostId`, `drawerId`, `currentWord`, `participants`, `availableWords`, `roles`. No `guesses`. | +| Schemas | `backend/src/api/schemas.ts` | Has `createRoomSchema`, `joinRoomSchema`, `startGameSchema`, `roomCodeParamsSchema`, `roomViewerQuerySchema`. No `submitGuessSchema`. | +| Room service | `backend/src/services/roomStore.ts` | `createRoom` and `joinRoom` do not initialise a `guesses` array or participant `score`. `toRoomSnapshot` returns participants without scores and no guess history. No `submitGuess` function. | +| Rooms router | `backend/src/api/rooms.ts` | Four endpoints: `POST /rooms`, `POST /rooms/:code/join`, `POST /rooms/:code/start`, `GET /rooms/:code`. No guess endpoint. | +| Frontend snapshot type | `frontend/src/services/api.ts` | `Participant` has `id`, `name`, `joinedAt` — no `score`. `RoomSnapshot` has no `guesses` field. No `submitGuess` client method. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`. No `submitGuess` action. No polling on the game screen. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Has role-aware canvas text. `GuessForm` is rendered for all players regardless of role — the drawer also sees it. No polling `useEffect`. No drawing canvas element. | +| GuessForm | `frontend/src/components/GuessForm.tsx` | Has local `guessText` state and a `disabled` prop. `handleSubmit` prevents default but makes no API call — the form is wired to nothing. | +| Scoreboard | `frontend/src/components/Scoreboard.tsx` | Static placeholder showing hardcoded `"Waiting for players..."` and `0`. Reads no data from the store. | +| ResultPanel | `frontend/src/components/ResultPanel.tsx` | Renamed "Activity". Shows static placeholder text. Reads no data from the store. | + +### What is missing + +1. **`score` on `Participant`** — no score field exists anywhere in the model. All participants are scoreless. +2. **`Guess` type** — no type definition for a guess record (participant id, name, text, result, timestamp). +3. **`guesses` on `Room`** — no array to hold submitted guesses. +4. **`guesses` in `RoomSnapshot`** — guess history is never surfaced to the frontend. +5. **`submitGuessSchema`** — no Zod schema to validate guess submissions. +6. **`submitGuess` service function** — no backend logic to trim/compare/score/store a guess. +7. **`POST /rooms/:code/guess` endpoint** — no route to receive guesses. +8. **Frontend `Participant.score`** — type missing from the interface. +9. **Frontend `Guess` type and `RoomSnapshot.guesses`** — type missing from the interface. +10. **Frontend `submitGuess` client method** — not in `api.ts`. +11. **Frontend `submitGuess` store action** — not in `roomStore.ts`. +12. **GamePage polling** — `GamePage` has no `setInterval`. The game screen never refreshes after the start snapshot. +13. **GuessForm wiring** — form submits nothing; no validation, no store call, no feedback. +14. **Drawer exclusion from guessing** — `GuessForm` is always rendered; the drawer sees and can interact with it. +15. **Drawing canvas** — the canvas area is a `
    ` placeholder. The drawer has no interactive surface. +16. **Clear canvas button** — does not exist. +17. **Scoreboard real data** — component reads nothing from the store. +18. **ResultPanel real data** — component reads nothing from the store. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Participant (before) Participant (after) +────────────────────────── ───────────────────────────── +id: string id: string +name: string name: string +joinedAt: string joinedAt: string + score: number ← NEW (initialised to 0) +``` + +``` +New type: Guess (added to game.ts) +────────────────────────── +participantId: string +participantName: string +text: string +isCorrect: boolean +submittedAt: string +``` + +``` +Room (before) Room (after) +────────────────────────── ───────────────────────────── +code, status, hostId code, status, hostId +drawerId, currentWord drawerId, currentWord +participants, createdAt participants, createdAt +updatedAt updatedAt + guesses: Guess[] ← NEW (initialised to []) +``` + +``` +RoomSnapshot (before) RoomSnapshot (after) +────────────────────────── ───────────────────────────── +code, status, hostId code, status, hostId +drawerId, currentWord drawerId, currentWord +participants participants (now includes score per participant) +availableWords, roles availableWords, roles + guesses: Guess[] ← NEW +``` + +### Frontend — `frontend/src/services/api.ts` + +``` +Participant interface (before) Participant interface (after) +────────────────────────── ───────────────────────────── +id: string id: string +name: string name: string +joinedAt: string joinedAt: string + score: number ← NEW +``` + +``` +New interface: Guess (added to api.ts) +────────────────────────── +participantId: string +participantName: string +text: string +isCorrect: boolean +submittedAt: string +``` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +────────────────────────── ───────────────────────────── +...existing fields... ...existing fields... + guesses: Guess[] ← NEW +``` + +--- + +## Required API Changes + +### Modified: `createRoom` and `joinRoom` service functions + +- `createRoom`: initialise `participant.score = 0` when creating the participant; initialise `room.guesses = []`. +- `joinRoom`: initialise `participant.score = 0` when creating the joining participant. +- These are initialisation-only changes — no logic change. + +### Modified: `toRoomSnapshot` + +- `participants` map must now include `score: participant.score` in each entry. +- Add `guesses: room.guesses.map(g => ({ ...g }))` to the returned object. +- No per-viewer filtering on guesses — all players see the full history. + +### New schema: `submitGuessSchema` + +``` +z.object({ + participantId: z.string().min(1), + text: z.string().trim().min(1, "Guess cannot be empty") +}) +``` + +`z.string().trim()` on `text` ensures whitespace-only guesses fail the `.min(1)` check. `participantId` identifies the submitter. + +### New endpoint: `POST /rooms/:code/guess` + +- **Purpose:** Accept, validate, score, and store a single guess. +- **Request:** Body `{ participantId: string, text: string }`. +- **Validations (in order):** + 1. Room must exist → 404 "Room not found". + 2. Room `status` must be `"playing"` → 422 "Game is not in progress". + 3. `participantId` must match a participant in the room → 404 "Participant not found". + 4. `participantId` must not equal `room.drawerId` → 403 "Drawer cannot submit a guess". +- **Processing:** + 1. Trim and lowercase `text`. + 2. Compare to `room.currentWord.trim().toLowerCase()`. + 3. Set `isCorrect = true` if they match. + 4. If `isCorrect` and `participant.score < 100`: set `participant.score = 100`. + 5. Append `{ participantId, participantName: participant.name, text: trimmedText, isCorrect, submittedAt: now() }` to `room.guesses`. + 6. Save room. +- **Response:** `{ room: RoomSnapshot }` — the full updated snapshot, so the frontend updates scores and guess history in one step. + +### New client method: `submitGuess` + +- **File:** `frontend/src/services/api.ts` +- Calls `POST /rooms/:code/guess` with `{ participantId, text }`. +- Returns `{ room: RoomSnapshot }`. + +--- + +## Polling Changes + +### GamePage gains a polling `useEffect` + +`GamePage` currently has no polling — it renders from the snapshot set at game start and never refreshes. For Scenario 3, all players need to see updated guess history and scores. + +The pattern is identical to `LobbyPage`: +- `useEffect` starts a `setInterval` at 2000ms calling `roomStore.fetchRoom()`. +- Cleanup clears the interval on unmount. +- Dependency on `room?.code` — starts when a room is present, stops on unmount. + +`fetchRoom` already calls `GET /rooms/:code?participantId=...` which calls `toRoomSnapshot`, which will now include guesses and scores. No new endpoint or store action is needed for polling. + +--- + +## Scoring Changes + +Scores are computed and stored entirely on the backend. The frontend never computes correctness. + +- `Participant.score` initialises to `0` in `createRoom` and `joinRoom`. +- `submitGuess` awards `100` if the guess matches and the participant's score is currently below `100`. +- The cap at `100` means a second correct guess by the same player does not increase their score. +- The drawer has no score changes in this scenario. +- Scores are included in `toRoomSnapshot` via the participants array — the `Scoreboard` component reads `room.participants` from the store snapshot. + +--- + +## Data Flow + +### Guess submission +``` +GamePage (guesser only — drawer does not see GuessForm) + → GuessForm local state: guessText + → [trim guessText; if empty → "Guess cannot be empty", stop] + → onSubmit(trimmedText) callback from GamePage + → roomStore.submitGuess(trimmedText) + → POST /rooms/:code/guess { participantId, text } + → backend: + trim + lowercase text + compare to currentWord.trim().toLowerCase() + isCorrect = match + if isCorrect && score < 100 → participant.score = 100 + room.guesses.push({ ..., isCorrect, submittedAt }) + save room + → response { room: RoomSnapshot } (includes updated guesses + scores) + → roomStore.setRoomSnapshot(room) + → GuessForm clears input; all players see update on next poll +``` + +### GamePage polling +``` +GamePage mounts + → useEffect starts setInterval(2000ms) + → roomStore.fetchRoom() + → GET /rooms/:code?participantId= + → toRoomSnapshot: includes guesses[], participants[].score + → roomStore.setRoomSnapshot(room) + → Scoreboard and ResultPanel re-render with latest data + → useEffect cleanup clears interval on unmount +``` + +### Drawing canvas (drawer only) +``` +GamePage (isDrawer = true) + → renders element with pointer event handlers + → drawer draws with mouse/pointer — state is local to the browser + → "Clear Canvas" button calls canvas.getContext("2d").clearRect(...) + → canvas state is never sent to the backend — no sync with guessers +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: add `Guess` type and extend `Participant` and `Room` models +- **File:** `backend/src/models/game.ts` +- Add `score: number` to `Participant`. +- Define and export `Guess` interface. +- Add `guesses: Guess[]` to `Room`. +- Add `guesses: Guess[]` to `RoomSnapshot`. +- Verify: `npm run build` in `backend/` passes (service type errors expected — fixed in Step 2). + +### Step 2 — Backend: initialise score and guesses in room service +- **File:** `backend/src/services/roomStore.ts` +- In `createParticipant`: add `score: 0`. +- In `createRoom`: add `guesses: []` to the room literal. +- In `toRoomSnapshot`: add `score` to the participants map; add `guesses: room.guesses.map(g => ({ ...g }))`. +- Verify: `npm run build` in `backend/` passes. + +### Step 3 — Backend: add `submitGuessSchema` +- **File:** `backend/src/api/schemas.ts` +- Add `submitGuessSchema = z.object({ participantId: z.string().min(1), text: z.string().trim().min(1, "Guess cannot be empty") })`. +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add `submitGuess` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `submitGuess(code, participantId, text)` function with all four validations and the scoring and storage logic. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Backend: add `POST /rooms/:code/guess` endpoint +- **File:** `backend/src/api/rooms.ts` +- Import `submitGuessSchema` and `submitGuess`. +- Add the route handler with all guard clauses mapped to correct HTTP status codes. +- Verify: `npm run build` in `backend/` passes. + +### Step 6 — Frontend: update types in `api.ts` +- **File:** `frontend/src/services/api.ts` +- Add `score: number` to the `Participant` interface. +- Add `Guess` interface. +- Add `guesses: Guess[]` to `RoomSnapshot`. +- Add `submitGuess(code, participantId, text)` client method. +- Verify: `npm run build` in `frontend/` passes. + +### Step 7 — Frontend: add `submitGuess` action to store +- **File:** `frontend/src/state/roomStore.ts` +- Add `submitGuess(text: string)` method that reads `room.code` and `participantId` from state, calls `api.submitGuess`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### Step 8 — Frontend: wire `GuessForm` +- **File:** `frontend/src/components/GuessForm.tsx` +- Add `onSubmit: (text: string) => Promise` prop. +- On submit: trim, reject empty with inline error, call `onSubmit`, clear input on success. +- Verify: `npm run build` in `frontend/` passes. + +### Step 9 — Frontend: update `GamePage` — polling, drawer exclusion, canvas, callbacks +- **File:** `frontend/src/pages/GamePage.tsx` +- Add polling `useEffect` (same pattern as `LobbyPage`). +- Render `GuessForm` only when `!isDrawer`; pass `onSubmit` callback that calls `roomStore.submitGuess`. +- Drawer canvas area: replace the placeholder `
    ` with an HTML `` element and pointer event handlers for drawing. +- Add a "Clear Canvas" button visible only to the drawer. +- Verify: `npm run build` in `frontend/` passes. + +### Step 10 — Frontend: update `Scoreboard` +- **File:** `frontend/src/components/Scoreboard.tsx` +- Accept `participants` as a prop (or read from the store directly). +- Render each participant's name and score from the snapshot. +- Verify: `npm run build` in `frontend/` passes. + +### Step 11 — Frontend: update `ResultPanel` with guess history +- **File:** `frontend/src/components/ResultPanel.tsx` +- Accept `guesses` as a prop (or read from the store directly). +- Render each guess entry: guesser name, guess text, correct/incorrect label. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `score` to `Participant`, new `Guess` type, `guesses` to `Room` and `RoomSnapshot` | +| `backend/src/api/schemas.ts` | Modify — add `submitGuessSchema` | +| `backend/src/services/roomStore.ts` | Modify — init `score` in `createParticipant`, `guesses` in `createRoom`, include both in `toRoomSnapshot`, add `submitGuess` | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/guess` handler | +| `frontend/src/services/api.ts` | Modify — add `score` to `Participant`, `Guess` type, `guesses` to `RoomSnapshot`, `submitGuess` method | +| `frontend/src/state/roomStore.ts` | Modify — add `submitGuess` action | +| `frontend/src/components/GuessForm.tsx` | Modify — add `onSubmit` prop, client-side validation, input clear on success | +| `frontend/src/components/Scoreboard.tsx` | Modify — render real participant scores from snapshot | +| `frontend/src/components/ResultPanel.tsx` | Modify — render real guess history from snapshot | +| `frontend/src/pages/GamePage.tsx` | Modify — add polling, hide GuessForm from drawer, add canvas + clear button for drawer | + +No new files. No new libraries. + +--- + +## Testing Strategy + +### Backend unit tests (`backend/src/services/roomStore.test.ts`) +- `submitGuess`: room not found returns the not-found sentinel. +- `submitGuess`: room not in "playing" status returns the not-in-progress sentinel. +- `submitGuess`: unknown `participantId` returns the not-found sentinel. +- `submitGuess`: drawer `participantId` returns the drawer sentinel. +- `submitGuess`: correct guess (case-insensitive) sets `isCorrect: true` and awards 100 points. +- `submitGuess`: incorrect guess sets `isCorrect: false` and does not change score. +- `submitGuess`: second correct guess by same player does not raise score above 100. +- `submitGuess`: guess is appended to `room.guesses` with correct fields. +- `toRoomSnapshot`: participants include `score`. +- `toRoomSnapshot`: `guesses` array is included and matches stored guesses. + +### Backend schema tests (`backend/src/api/schemas.test.ts`) +- `submitGuessSchema`: empty `text` is rejected. +- `submitGuessSchema`: whitespace-only `text` is rejected. +- `submitGuessSchema`: `text` with surrounding spaces is trimmed and accepted. +- `submitGuessSchema`: missing `participantId` is rejected. + +### Frontend service tests (`frontend/src/services/api.test.ts`) +- `api.submitGuess`: makes a `POST` to `/rooms/:code/guess` with the correct body shape. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Canvas drawing state is local to the drawer's browser — guessers cannot see the drawing | Accepted per the out-of-scope rule (no WebSockets). The spec says the drawing is visible "on the drawer's screen" which is satisfied. | +| Polling from both `LobbyPage` and `GamePage` must not overlap | Each page manages its own `setInterval` within its own `useEffect`. Navigating from lobby to game runs the lobby cleanup before the game mounts. | +| `submitGuess` response includes the full room snapshot — this doubles as an immediate update without waiting for the next poll | Intentional — the submitter sees their guess reflected immediately. Other players see it within ~2 seconds via polling. | +| `GuessForm` needs a new `onSubmit` prop — it is currently rendered in `GamePage` without any prop | `GamePage` already holds `roomStore` and `participantId`. The callback is a one-liner defined in `GamePage` and passed down. | +| `createParticipant` is a private function — adding `score: 0` there automatically covers both `createRoom` and `joinRoom` | Correct. Both functions call `createParticipant`. Initialising `score` there means no change is needed in `joinRoom` for the participant. | +| A player submits a guess after navigating away from the game screen | Polling stops on unmount via cleanup. Any in-flight request will still complete but the response snapshot update is a no-op because the store update triggers no re-render on an unmounted component. | From 48e59d9f2e8709d88d2699a76d287a9105b58f2f Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 16:41:16 +0530 Subject: [PATCH 18/25] add scenario 3 work breakdown --- speckit.tasks | 356 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) diff --git a/speckit.tasks b/speckit.tasks index 9f18953..75c2184 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -539,3 +539,359 @@ No code changes — validation only. ### T12 — Validate: GET /rooms/:code returns word to drawer - After game start (from T6), call `GET /rooms/:code?participantId=` directly. - Expected: `currentWord` in the response is the secret word string. + +--- + +--- + +# Tasks — Scenario 3: Gameplay Interaction + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group U — Backend Model + +### U1 — Add score to Participant interface +- File: `backend/src/models/game.ts` +- Add `score: number` to the `Participant` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Type errors in the service are expected — fixed in U3. + +### U2 — Add Guess interface +- File: `backend/src/models/game.ts` +- Define and export a `Guess` interface with fields: `participantId: string`, `participantName: string`, `text: string`, `isCorrect: boolean`, `submittedAt: string`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### U3 — Add guesses to Room and RoomSnapshot interfaces +- File: `backend/src/models/game.ts` +- Add `guesses: Guess[]` to the `Room` interface. +- Add `guesses: Guess[]` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Service type errors are expected — fixed in Group V. + +--- + +## Group V — Backend Service + +### V1 — Initialise score in createParticipant +- File: `backend/src/services/roomStore.ts` +- In the `createParticipant` function, add `score: 0` to the returned participant object. +- This covers both `createRoom` and `joinRoom` since both call `createParticipant`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V2 — Initialise guesses array in createRoom +- File: `backend/src/services/roomStore.ts` +- In `createRoom`, add `guesses: []` to the room object literal. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V3 — Include score and guesses in toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- In the participants map inside `toRoomSnapshot`, add `score: participant.score` to each mapped entry. +- Add `guesses: room.guesses.map(g => ({ ...g }))` to the returned snapshot object. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V4 — Add submitGuess service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `submitGuess(code: string, participantId: string, text: string)` function. +- The function must perform checks in this order: + 1. Return `"room-not-found"` if the room does not exist. + 2. Return `"not-playing"` if `room.status !== "playing"`. + 3. Return `"participant-not-found"` if no participant in the room matches `participantId`. + 4. Return `"is-drawer"` if `participantId === room.drawerId`. +- If all checks pass: + 1. Trim and lowercase `text`. + 2. Compare to `room.currentWord!.trim().toLowerCase()`. + 3. Set `isCorrect` accordingly. + 4. If `isCorrect` and `participant.score < 100`, set `participant.score = 100`. + 5. Push a new `Guess` object to `room.guesses`. + 6. Save the room. + 7. Return the updated room snapshot via `toRoomSnapshot`. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group W — Backend Schema and Endpoint + +### W1 — Add submitGuessSchema +- File: `backend/src/api/schemas.ts` +- Add `submitGuessSchema = z.object({ participantId: z.string().min(1), text: z.string().trim().min(1, "Guess cannot be empty") })`. +- Export it alongside existing schemas. +- Verify: `npm run build` in `backend/` passes. + +### W2 — Add POST /rooms/:code/guess route handler +- File: `backend/src/api/rooms.ts` +- Import `submitGuessSchema` and `submitGuess` from their respective modules. +- Add `router.post("/:code/guess", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `submitGuessSchema`. + 3. Calls `submitGuess(code, participantId, text)`. + 4. Returns 404 if result is `"room-not-found"` or `"participant-not-found"`. + 5. Returns 422 with message "Game is not in progress" if result is `"not-playing"`. + 6. Returns 403 with message "Drawer cannot submit a guess" if result is `"is-drawer"`. + 7. Returns 200 with `{ room: snapshot }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group X — Backend Tests + +### X1 — Test Participant is initialised with score 0 +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Assert `result.room.participants[0].score === 0`. +- Run: `npm test` in `backend/` — all tests pass. + +### X2 — Test joining player is initialised with score 0 +- File: `backend/src/services/roomStore.test.ts` +- Create a room, join a second player. Assert the second participant's score is `0` in the returned snapshot. +- Run: `npm test` in `backend/` — all tests pass. + +### X3 — Test createRoom initialises guesses as empty array +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Assert `result.room.guesses` is an empty array. +- Run: `npm test` in `backend/` — all tests pass. + +### X4 — Test toRoomSnapshot includes score on each participant +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Call `toRoomSnapshot`. Assert each participant in the snapshot has a `score` field equal to `0`. +- Run: `npm test` in `backend/` — all tests pass. + +### X5 — Test toRoomSnapshot includes guesses array +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Call `toRoomSnapshot`. Assert `snapshot.guesses` is an empty array. +- Run: `npm test` in `backend/` — all tests pass. + +### X6 — Test submitGuessSchema rejects empty text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: "" })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### X7 — Test submitGuessSchema rejects whitespace-only text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: " " })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### X8 — Test submitGuessSchema trims valid text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: " pizza " })` succeeds and the parsed `text` is `"pizza"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X9 — Test submitGuess: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `submitGuess("XXXX", "any", "pizza")`. Assert result is `"room-not-found"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X10 — Test submitGuess: room not in playing status +- File: `backend/src/services/roomStore.test.ts` +- Create a room (status is "lobby"). Call `submitGuess` with the host id and any text. Assert result is `"not-playing"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X11 — Test submitGuess: unknown participant +- File: `backend/src/services/roomStore.test.ts` +- Start a game with two players. Call `submitGuess` with a made-up participant id. Assert result is `"participant-not-found"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X12 — Test submitGuess: drawer is rejected +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `submitGuess` with the drawer's `participantId`. Assert result is `"is-drawer"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X13 — Test submitGuess: correct guess (case-insensitive) +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Determine the current word from the snapshot. Submit the word in uppercase via the guesser. Assert the returned snapshot has `guesses[0].isCorrect === true`. +- Run: `npm test` in `backend/` — all tests pass. + +### X14 — Test submitGuess: correct guess awards 100 points +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a correct guess as the guesser. Assert the guesser's score in the returned snapshot is `100`. +- Run: `npm test` in `backend/` — all tests pass. + +### X15 — Test submitGuess: incorrect guess awards 0 points +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a wrong guess as the guesser. Assert the guesser's score is `0` and `guesses[0].isCorrect === false`. +- Run: `npm test` in `backend/` — all tests pass. + +### X16 — Test submitGuess: second correct guess does not increase score above 100 +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a correct guess (score becomes 100). Submit another correct guess. Assert score is still `100`. +- Run: `npm test` in `backend/` — all tests pass. + +### X17 — Test submitGuess: guess is appended to room.guesses +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit two guesses. Assert the returned snapshot has `guesses.length === 2` and both entries have the correct `text`, `participantId`, and `submittedAt` fields. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group Y — Frontend Types + +### Y1 — Add score to Participant interface +- File: `frontend/src/services/api.ts` +- Add `score: number` to the `Participant` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y2 — Add Guess interface +- File: `frontend/src/services/api.ts` +- Add a `Guess` interface with fields: `participantId: string`, `participantName: string`, `text: string`, `isCorrect: boolean`, `submittedAt: string`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y3 — Add guesses to RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `guesses: Guess[]` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y4 — Add submitGuess client method +- File: `frontend/src/services/api.ts` +- Add `submitGuess(code: string, participantId: string, text: string)` to the `api` object. +- It calls `POST /rooms/:code/guess` with body `{ participantId, text }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group Z — Frontend Store and GuessForm + +### Z1 — Add submitGuess action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `submitGuess(text: string)` method that reads `room.code` and `participantId` from state, calls `api.submitGuess` inside `withLoading`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### Z2 — Add onSubmit prop and validation to GuessForm +- File: `frontend/src/components/GuessForm.tsx` +- Add `onSubmit: (text: string) => Promise` to the `GuessFormProps` interface. +- In `handleSubmit`: trim `guessText`. If the trimmed value is empty, set a local inline error "Guess cannot be empty" and return without calling `onSubmit`. +- If valid: call `onSubmit(trimmedText)`, await it, and clear `guessText` on success. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Z3 — Add error display to GuessForm +- File: `frontend/src/components/GuessForm.tsx` +- Add a local `error` state. +- Render an inline error message below the input when `error` is non-null. +- Clear the error when the input changes. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AA — Frontend Polling and GamePage + +### AA1 — Add polling useEffect to GamePage +- File: `frontend/src/pages/GamePage.tsx` +- Add a `useEffect` that starts a `setInterval` calling `roomStore.fetchRoom()` every 2000ms. +- The cleanup function must call `clearInterval` to stop polling on unmount. +- Dependency on `room?.code` — same pattern as LobbyPage. +- No rendering changes in this task. +- Verify: `npm run build` in `frontend/` passes. + +### AA2 — Hide GuessForm from drawer; pass onSubmit to guessers +- File: `frontend/src/pages/GamePage.tsx` +- Render the `GuessForm` only when `!isDrawer`. +- Pass an `onSubmit` callback to `GuessForm` that calls `roomStore.submitGuess(text)`. +- Remove the unconditional `` render. +- Verify: `npm run build` in `frontend/` passes. + +### AA3 — Add drawing canvas for drawer +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is true, render an HTML `` element inside the canvas area. +- Wire `onMouseDown`, `onMouseMove`, and `onMouseUp` (or equivalent pointer events) to draw lines on the canvas using `getContext("2d")`. +- Drawing state is local to the component — it is never sent to the backend. +- The secret word display (from Scenario 2) remains visible above the canvas. +- Verify: `npm run build` in `frontend/` passes. + +### AA4 — Add Clear Canvas button for drawer +- File: `frontend/src/pages/GamePage.tsx` +- Below the canvas, render a "Clear Canvas" button visible only when `isDrawer` is true. +- On click, call `clearRect` on the canvas context to blank the canvas. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AB — Frontend Components + +### AB1 — Update Scoreboard with real participant data +- File: `frontend/src/components/Scoreboard.tsx` +- Accept `participants` as a prop (type: `{ name: string; score: number }[]`). +- Render one row per participant showing name and score. +- Remove the static placeholder content. +- Update `GamePage` to pass `room.participants` to ``. +- Verify: `npm run build` in `frontend/` passes. + +### AB2 — Update ResultPanel with real guess history +- File: `frontend/src/components/ResultPanel.tsx` +- Accept `guesses` as a prop (type: `{ participantName: string; text: string; isCorrect: boolean }[]`). +- Render one row per guess showing the guesser's name, their guess text, and a correct/incorrect label. +- If `guesses` is empty, show a "No guesses yet" placeholder. +- Update `GamePage` to pass `room.guesses` to ``. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AC — Frontend Tests + +### AC1 — Test api service: submitGuess sends correct request +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.submitGuess("ABCD", "participant-id", "pizza")` makes a `POST` to `/rooms/ABCD/guess` with `{ participantId: "participant-id", text: "pizza" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group AD — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### AD1 — Validate: all players start with score 0 +- Tab 1: create a room. Tab 2: join the room. Tab 1: start the game. +- Expected: Scoreboard shows both players at 0 points. + +### AD2 — Validate: empty guess is rejected +- Tab 2 (guesser): submit the guess form with an empty input. +- Expected: inline error "Guess cannot be empty" appears, no network request is made. + +### AD3 — Validate: whitespace-only guess is rejected +- Tab 2 (guesser): type spaces only into the guess field, submit. +- Expected: inline error appears, no network request is made. + +### AD4 — Validate: incorrect guess does not change score +- Tab 2 (guesser): submit a wrong word. +- Expected: guess appears in the activity panel marked as incorrect. Score remains 0. + +### AD5 — Validate: correct guess awards 100 points +- Tab 2 (guesser): submit the correct secret word (visible in Tab 1 drawer view). +- Expected: guess appears as correct in the activity panel. Guesser's score updates to 100 in the Scoreboard within ~2 seconds. + +### AD6 — Validate: correct guess is case-insensitive +- Tab 2 (guesser): submit the correct word in all caps. +- Expected: guess is marked as correct. Score updates to 100. + +### AD7 — Validate: second correct guess does not exceed 100 points +- Tab 2 (guesser): after AD5, submit the correct word again. +- Expected: score stays at 100. Guess is still marked correct in history. + +### AD8 — Validate: guess history syncs to all players +- Tab 1 (drawer): observe the activity panel within ~2 seconds after Tab 2 submits a guess. +- Expected: the guess appears in Tab 1's activity panel without any manual action. + +### AD9 — Validate: drawer cannot submit a guess +- Tab 1 (drawer): confirm the guess form is not visible on the game screen. +- Expected: no guess input or submit button is present in the drawer's view. + +### AD10 — Validate: drawer can draw on the canvas +- Tab 1 (drawer): click and drag on the canvas area. +- Expected: lines appear on the canvas as the mouse moves. + +### AD11 — Validate: clear canvas resets the drawing +- Tab 1 (drawer): draw something, then click "Clear Canvas". +- Expected: the canvas returns to a blank white state. + +### AD12 — Validate: clear canvas button is not visible to guessers +- Tab 2 (guesser): inspect the game screen. +- Expected: no "Clear Canvas" button is visible. From 26ed4c564af81edead3bd6d0db4aa0bb8d5004c7 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:00:29 +0530 Subject: [PATCH 19/25] complete scenario 3 gameplay interaction --- backend/src/api/rooms.ts | 30 ++++++- backend/src/api/schemas.ts | 5 ++ backend/src/models/game.ts | 11 +++ backend/src/services/roomStore.ts | 39 ++++++++- frontend/src/components/GuessForm.tsx | 25 +++++- frontend/src/components/ResultPanel.tsx | 24 +++++- frontend/src/components/Scoreboard.tsx | 25 ++++-- frontend/src/pages/GamePage.tsx | 104 ++++++++++++++++++++---- frontend/src/services/api.ts | 16 ++++ frontend/src/state/roomStore.ts | 10 +++ 10 files changed, 255 insertions(+), 34 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 92800cb..f39c563 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -5,9 +5,10 @@ import { joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, - startGameSchema + startGameSchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -67,6 +68,31 @@ export function createRoomsRouter() { } }); + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, text } = submitGuessSchema.parse(request.body); + const result = submitGuess(code.toUpperCase(), participantId, text); + + if (result === "room-not-found") { + throw new HttpError(404, "Room not found"); + } + if (result === "not-playing") { + throw new HttpError(422, "Game is not in progress"); + } + if (result === "participant-not-found") { + throw new HttpError(404, "Participant not found"); + } + if (result === "is-drawer") { + throw new HttpError(403, "Drawer cannot submit a guess"); + } + + response.json({ room: result }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 3908014..50f559e 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -20,6 +20,11 @@ export const startGameSchema = z.object({ participantId: z.string().min(1) }); +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + text: z.string().trim().min(1, "Guess cannot be empty") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 7a72068..b11b91c 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -5,6 +5,15 @@ export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + submittedAt: string; } export interface Room { @@ -13,6 +22,7 @@ export interface Room { hostId: string; drawerId: string | null; currentWord: string | null; + guesses: Guess[]; participants: Participant[]; createdAt: string; updatedAt: string; @@ -24,6 +34,7 @@ export interface RoomSnapshot { hostId: string; drawerId: string | null; currentWord: string | null; + guesses: Guess[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index c674b43..1509ba3 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -37,7 +37,8 @@ function createParticipant(name?: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + score: 0 }; } @@ -57,6 +58,7 @@ export function createRoom(playerName?: string) { hostId: participant.id, drawerId: null, currentWord: null, + guesses: [], participants: [participant], createdAt: now(), updatedAt: now() @@ -120,6 +122,38 @@ export function startGame(code: string, participantId: string) { return toRoomSnapshot(cloneRoom(room), participantId); } +export function submitGuess(code: string, participantId: string, text: string) { + const room = rooms.get(code); + + if (!room) return "room-not-found" as const; + if (room.status !== "playing") return "not-playing" as const; + + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) return "participant-not-found" as const; + if (participantId === room.drawerId) return "is-drawer" as const; + + const trimmed = text.trim().toLowerCase(); + const isCorrect = trimmed === room.currentWord!.trim().toLowerCase(); + + if (isCorrect && participant.score < 100) { + participant.score = 100; + } + + const guess: Guess = { + participantId, + participantName: participant.name, + text: text.trim(), + isCorrect, + submittedAt: now() + }; + + room.guesses.push(guess); + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room), participantId); +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { return { code: room.code, @@ -127,6 +161,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn hostId: room.hostId, drawerId: room.drawerId, currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null, + guesses: room.guesses.map((g) => ({ ...g })), 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 0a1ec47..c4eb7bd 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,29 @@ import { useState } from "react"; interface GuessFormProps { + onSubmit: (text: string) => Promise; disabled?: boolean; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ onSubmit, disabled = false }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + if (guessText.trim() === "") { + setError("Guess cannot be empty"); + return; + } + + try { + setError(null); + await onSubmit(guessText.trim()); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to submit guess"); + } } return ( @@ -17,11 +32,15 @@ export function GuessForm({ disabled = false }: GuessFormProps) { setGuessText(event.target.value)} + onChange={(event) => { + setGuessText(event.target.value); + setError(null); + }} placeholder="Type your guess here..." disabled={disabled} /> + {error ?

    {error}

    : null}
    - ) : ( +
    + ) : ( +

    Waiting for {drawerParticipant?.name ?? "the drawer"} to draw...

    - )} -
    +
    + )}
    @@ -70,9 +140,11 @@ export function GamePage() { - - - + {!isDrawer && ( + + + + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index aa690f5..083440c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,6 +4,15 @@ export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + submittedAt: string; } export interface RoomSnapshot { @@ -12,6 +21,7 @@ export interface RoomSnapshot { hostId: string; drawerId: string | null; currentWord: string | null; + guesses: Guess[]; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -76,4 +86,10 @@ export const api = { body: JSON.stringify({ participantId }), }); }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, text }), + }); + }, }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 2b65935..919a190 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -108,6 +108,16 @@ class RoomStore { this.setRoomSnapshot(response.room); return response; } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) return; + + const response = await this.withLoading(() => + api.submitGuess(this.state.room!.code, this.state.participantId!, text) + ); + this.setRoomSnapshot(response.room); + return response; + } } const RoomStoreContext = createContext(null); From 4de6882b6856e5ac3f243943462bd7a87f7a06e9 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:07:50 +0530 Subject: [PATCH 20/25] add scenario 4 result and restart requirements --- speckit.specify | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/speckit.specify b/speckit.specify index d84396d..bcdc278 100644 --- a/speckit.specify +++ b/speckit.specify @@ -282,3 +282,86 @@ The game screen currently has no real interactivity. The canvas is a static plac - [ ] Each history entry shows the guesser's name, their guess, and whether it was correct or incorrect. - [ ] All players (drawer and guessers) see the same history. - [ ] The history is ordered oldest-first. + +--- + +## Scenario 4 — Result, Restart & Final Validation + +### Problem + +There is currently no way to end a round. The game screen stays in the playing state indefinitely — no player ever sees a summary of what happened, and there is no path back to the lobby. The host has no mechanism to declare the round over or to reset the game for another session. The correct word, which the drawer knew throughout the round, is never revealed to guessers. Scores accumulated during the round are visible in the scoreboard, but there is no dedicated result view that brings all information together after the round ends. Without a restart flow, the only way to play again is to create a brand new room, losing all players. + +--- + +### Requirements + +#### End-of-Round State +- The host triggers the end of the round by clicking an "End Round" button on the game screen. +- Ending the round transitions the room status from `"playing"` to `"finished"` on the backend. +- Only the host can end the round; other players do not see the button. +- Once the room is in `"finished"` status, no further guesses can be submitted. + +#### Result Display +- When the room status is `"finished"`, all players see a result screen in place of the game screen. +- The result screen shows the correct word that was being drawn. +- The result screen shows the final scores for all participants. +- The result screen shows the complete guess history from the round, in submission order. +- The correct word is visible to all players on the result screen — the per-viewer filtering used during the round does not apply in `"finished"` status. +- The result state is fetched via the existing polling mechanism; players transition to the result view automatically when the status changes. + +#### Restart Flow +- The host sees a "Play Again" button on the result screen. +- Non-host players see a waiting message instead of the button. +- Clicking "Play Again" calls a backend endpoint that resets the room to lobby state. +- On restart, all current participants are preserved with their names and ids intact. +- On restart, all round-specific state is cleared: `status` returns to `"lobby"`, `drawerId`, `currentWord`, and `guesses` are reset, and all participant scores are reset to zero. +- After a successful restart, all players are navigated back to the lobby screen. +- The lobby resumes automatic polling so players see the participant list without manual action. + +--- + +### Edge Cases + +- A non-host player must never see the "End Round" or "Play Again" buttons, even if they inspect the page source or make direct API calls — the backend enforces host-only permission on both endpoints. +- A guess submitted after the round ends must be rejected by the backend with a clear error. The game screen's guess form should become disabled or hidden once the status is `"finished"`. +- If a non-host player's polling tick fires while the host is mid-restart, the player may briefly see stale `"playing"` data before the status transitions — this is acceptable and resolves on the next poll. +- The correct word on the result screen must be visible to all players, including guessers who never saw it during the round. The backend must return `currentWord` without filtering when the room is `"finished"`. +- Scores shown on the result screen must match the scores recorded at the moment the round ended — they must not change after the round is over. +- After a restart, participants that were in the room continue to exist; no player is removed. A player who left the browser during the round but still has a valid `participantId` remains in the room after restart. +- Restarting clears scores to zero — the scores from the previous round do not carry over. +- The room code does not change on restart — players who were in the room can still identify it by the same code. + +--- + +### Acceptance Criteria + +**End-of-round state** +- [ ] The host's game screen shows an "End Round" button. +- [ ] A non-host player's game screen does not show the "End Round" button. +- [ ] Clicking "End Round" transitions the room status to `"finished"` on the backend. +- [ ] After status is `"finished"`, a direct call to the guess endpoint returns an error. + +**Result display** +- [ ] All players' screens transition to a result view when the room status becomes `"finished"` (via polling, within ~2 seconds of the host ending the round). +- [ ] The result view shows the correct word that was being drawn. +- [ ] The correct word is visible to all players on the result view, including guessers. +- [ ] The result view shows the final score for every participant. +- [ ] The result view shows the complete guess history in submission order. +- [ ] The result view matches what was in the scoreboard and activity panel at the moment the round ended. + +**Restart flow** +- [ ] The host sees a "Play Again" button on the result screen. +- [ ] A non-host player does not see the "Play Again" button. +- [ ] Clicking "Play Again" calls a backend restart endpoint. +- [ ] After a successful restart, the host is navigated back to the lobby. +- [ ] After the next polling tick, non-host players are also navigated back to the lobby. +- [ ] The lobby shows the same set of participants that were in the room before the restart. +- [ ] All participant scores are reset to zero after restart. +- [ ] The room code is unchanged after restart. + +**Round state cleared** +- [ ] After restart, `status` is `"lobby"`. +- [ ] After restart, `drawerId` is `null`. +- [ ] After restart, `currentWord` is `null`. +- [ ] After restart, `guesses` is an empty array. +- [ ] After restart, every participant's score is `0`. From f3cb028bbe89ffc619ddc6f907a7b0c67494d3b9 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:12:28 +0530 Subject: [PATCH 21/25] add scenario 4 implementation plan --- speckit.plan | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) diff --git a/speckit.plan b/speckit.plan index f0018ba..56e342b 100644 --- a/speckit.plan +++ b/speckit.plan @@ -895,3 +895,358 @@ No new files. No new libraries. | `GuessForm` needs a new `onSubmit` prop — it is currently rendered in `GamePage` without any prop | `GamePage` already holds `roomStore` and `participantId`. The callback is a one-liner defined in `GamePage` and passed down. | | `createParticipant` is a private function — adding `score: 0` there automatically covers both `createRoom` and `joinRoom` | Correct. Both functions call `createParticipant`. Initialising `score` there means no change is needed in `joinRoom` for the participant. | | A player submits a guess after navigating away from the game screen | Polling stops on unmount via cleanup. Any in-flight request will still complete but the response snapshot update is a no-op because the store update triggers no re-render on an unmounted component. | + +--- + +--- + +# Plan — Scenario 4: Result, Restart & Final Validation + +--- + +## Findings + +### What exists and is relevant (post Scenario 3) + +| Area | File | Current behavior | +|---|---|---| +| RoomStatus type | `backend/src/models/game.ts` | `"lobby" \| "playing"`. No `"finished"` value. | +| Room and RoomSnapshot | `backend/src/models/game.ts` | All needed fields exist: `status`, `hostId`, `drawerId`, `currentWord`, `guesses`, `participants` with `score`. No structural changes needed for Scenario 4. | +| toRoomSnapshot | `backend/src/services/roomStore.ts` | Filters `currentWord` with `viewerParticipantId === room.drawerId ? room.currentWord : null`. This must be relaxed when status is `"finished"` — all players should see the word. | +| submitGuess | `backend/src/services/roomStore.ts` | Returns `"not-playing"` when `room.status !== "playing"`. This correctly blocks guesses in `"finished"` state once the status is added. No change needed. | +| Schemas | `backend/src/api/schemas.ts` | Has `startGameSchema = z.object({ participantId: z.string().min(1) })`. Both `endRound` and `restartGame` need the same shape — can reuse the existing schema or add named aliases. | +| Rooms router | `backend/src/api/rooms.ts` | Five endpoints. No `POST /:code/end` or `POST /:code/restart`. | +| Frontend status type | `frontend/src/services/api.ts` | `status: "lobby" \| "playing"`. No `"finished"`. | +| Frontend api | `frontend/src/services/api.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`, `submitGuess`. No `endRound` or `restartGame` methods. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`, `submitGuess`. No `endRound` or `restartGame` actions. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Polls every 2s. Renders game content unconditionally when `room` is present. No status check that would switch to a result view. No "End Round" button. No navigation triggered by status change. | +| LobbyPage | `frontend/src/pages/LobbyPage.tsx` | Polls every 2s. No check for `room.status === "playing"` — non-host players are never auto-navigated to `/game` after the host starts a game. This gap matters for Scenario 4 restart. | + +### What is missing + +1. **`"finished"` status** — `RoomStatus` has no `"finished"` value. The backend has no way to express a completed round. +2. **`currentWord` visibility in finished state** — `toRoomSnapshot` always hides the word from non-drawers. In `"finished"` state, all players must see the correct word. +3. **`endRound` service function** — no logic to transition a room from `"playing"` to `"finished"`. +4. **`restartGame` service function** — no logic to reset a room back to `"lobby"` with players preserved and round state cleared. +5. **`POST /rooms/:code/end` endpoint** — does not exist. +6. **`POST /rooms/:code/restart` endpoint** — does not exist. +7. **Frontend result view** — `GamePage` renders the same playing layout regardless of status. There is no result view showing the correct word, final scores, and guess history after the round ends. +8. **"End Round" button in GamePage** — the host has no way to end the round from the UI. +9. **"Play Again" button on result view** — the host has no way to restart from the UI. +10. **Status-driven navigation in GamePage** — `GamePage` does not navigate to `/lobby` when `room.status` becomes `"lobby"` (required for the restart flow for non-host players). +11. **Status-driven navigation in LobbyPage** — `LobbyPage` does not navigate to `/game` when `room.status` becomes `"playing"` (required so non-host players auto-join the game screen after start, and so all players reach the game screen after a restart that goes through lobby then triggers another start). + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +RoomStatus (before) RoomStatus (after) +────────────────────────── ───────────────────────────── +"lobby" | "playing" "lobby" | "playing" | "finished" ← NEW +``` + +No other model changes. `Room` and `RoomSnapshot` already carry all fields needed to display results: `currentWord`, `guesses`, and `participants` with `score`. + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot.status (before) RoomSnapshot.status (after) +────────────────────────── ───────────────────────────── +"lobby" | "playing" "lobby" | "playing" | "finished" ← NEW +``` + +--- + +## Required API Changes + +### Modified: `toRoomSnapshot` — `backend/src/services/roomStore.ts` + +``` +currentWord (before): + viewerParticipantId === room.drawerId ? room.currentWord : null + +currentWord (after): + room.status === "finished" || viewerParticipantId === room.drawerId + ? room.currentWord + : null +``` + +In `"finished"` state the secret word is revealed to everyone. The existing filtering logic is preserved for the `"playing"` state. + +### New schemas — `backend/src/api/schemas.ts` + +Both new endpoints require only a `participantId` to identify the caller. The existing `startGameSchema` has the same shape. Two named exports are added for clarity: + +``` +endRoundSchema = z.object({ participantId: z.string().min(1) }) +restartGameSchema = z.object({ participantId: z.string().min(1) }) +``` + +### New service: `endRound(code, participantId)` + +- **File:** `backend/src/services/roomStore.ts` +- Returns `null` if room not found. +- Returns `"not-host"` if caller is not the host. +- Returns `"not-playing"` if room status is not `"playing"`. +- Sets `room.status = "playing"` → `"finished"`, saves, returns snapshot. +- Response snapshot is called with the caller's `participantId` — but since the status is now `"finished"`, `toRoomSnapshot` exposes `currentWord` to all anyway. + +### New service: `restartGame(code, participantId)` + +- **File:** `backend/src/services/roomStore.ts` +- Returns `null` if room not found. +- Returns `"not-host"` if caller is not the host. +- Returns `"not-finished"` if room status is not `"finished"`. +- Resets the room in place: `status = "lobby"`, `drawerId = null`, `currentWord = null`, `guesses = []`, each participant's `score = 0`. +- Room code, `hostId`, participants (names and ids) are preserved. +- Saves and returns the updated snapshot. + +### New endpoint: `POST /rooms/:code/end` + +- Parses params with `roomCodeParamsSchema`, body with `endRoundSchema`. +- Calls `endRound(code, participantId)`. +- Returns 404 if room not found, 403 if not host, 422 if not in playing state. +- Returns 200 with `{ room: snapshot }` on success. + +### New endpoint: `POST /rooms/:code/restart` + +- Parses params with `roomCodeParamsSchema`, body with `restartGameSchema`. +- Calls `restartGame(code, participantId)`. +- Returns 404 if room not found, 403 if not host, 422 if not in finished state. +- Returns 200 with `{ room: snapshot }` on success. + +### New client methods — `frontend/src/services/api.ts` + +``` +endRound(code, participantId) → POST /rooms/:code/end { participantId } +restartGame(code, participantId) → POST /rooms/:code/restart { participantId } +``` + +Both return `{ room: RoomSnapshot }`. + +--- + +## Result State Flow + +``` +GamePage (host, status = "playing") + → "End Round" button visible only when isHost + → click "End Round" → roomStore.endRound() + → POST /rooms/:code/end { participantId: hostId } + → endRound(): status = "finished", save + → toRoomSnapshot: currentWord visible to all + → response { room } — store updates snapshot + → room.status is now "finished" + → GamePage renders result view (conditional on status) + → polling delivers "finished" snapshot to all other players within ~2s + → all players see result view automatically +``` + +--- + +## Restart Flow + +``` +GamePage result view (host, status = "finished") + → "Play Again" button visible only when isHost + → click "Play Again" → roomStore.restartGame() + → POST /rooms/:code/restart { participantId: hostId } + → restartGame(): status = "lobby", drawerId = null, + currentWord = null, guesses = [], all scores = 0 + → response { room } — store updates snapshot + → room.status is now "lobby" + → GamePage useEffect detects status === "lobby" → navigate("/lobby") + → non-host players: next poll returns status = "lobby" + → GamePage useEffect fires for each → navigate("/lobby") + → all players land on LobbyPage with same participants, zero scores +``` + +--- + +## Status-Driven Navigation + +Both `GamePage` and `LobbyPage` need `useEffect` hooks that watch `room.status` and navigate based on status transitions. These replace the need for any page to manually navigate after an action — the store update from a poll or action response triggers the effect. + +### GamePage additions + +``` +useEffect(() => { + if (room?.status === "lobby") navigate("/lobby", { replace: true }); +}, [room?.status, navigate]); +``` + +This covers: +- Host after restart (explicit navigate also fires, whichever is first is fine) +- Non-host players after restart (status changes to "lobby" via polling) + +### LobbyPage addition + +``` +useEffect(() => { + if (room?.status === "playing") navigate("/game", { replace: true }); +}, [room?.status, navigate]); +``` + +This covers: +- Non-host players after the host starts the game (lobby polling delivers "playing" status) +- All players landing on the lobby after a restart — they stay there because status is "lobby" + +--- + +## Data Flow + +### End round +``` +GamePage (host, status = "playing") + → click "End Round" + → roomStore.endRound() + → POST /rooms/:code/end + → status becomes "finished", currentWord now visible to all + → store updates, GamePage re-renders result view immediately + → other players see result view via next poll (~2s) +``` + +### Result view polling +``` +GamePage (status = "finished") + → polling continues every 2s (same useEffect) + → GET /rooms/:code?participantId=... + → toRoomSnapshot returns currentWord to all (status is "finished") + → result view refreshes — scores and guesses stable at this point +``` + +### Restart +``` +GamePage result view (host) + → click "Play Again" + → roomStore.restartGame() + → POST /rooms/:code/restart + → room reset: status = "lobby", round fields cleared + → store updates snapshot + → GamePage status-navigation useEffect fires → navigate("/lobby") + → non-host players: poll returns status = "lobby" → same effect → navigate("/lobby") + → LobbyPage mounts, polling resumes for all players +``` + +### Non-host auto-navigation to game (gap fixed) +``` +LobbyPage (non-host, status = "lobby") + → host clicks "Start Game" → status becomes "playing" + → non-host's next poll returns status = "playing" + → LobbyPage status-navigation useEffect fires → navigate("/game") + → GamePage mounts for non-host player +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: expand RoomStatus +- **File:** `backend/src/models/game.ts` +- Add `"finished"` to `RoomStatus`. +- Verify: `npm run build` in `backend/` passes. + +### Step 2 — Backend: update `toRoomSnapshot` for finished state +- **File:** `backend/src/services/roomStore.ts` +- Change `currentWord` filtering to also expose the word when `room.status === "finished"`. +- Verify: `npm run build` in `backend/` passes. + +### Step 3 — Backend: add `endRoundSchema` and `restartGameSchema` +- **File:** `backend/src/api/schemas.ts` +- Add both schemas (both are `z.object({ participantId: z.string().min(1) })`). +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add `endRound` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `endRound(code, participantId)` with host check, status check, and transition to `"finished"`. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Backend: add `restartGame` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `restartGame(code, participantId)` with host check, status check, and full round-state reset. +- Verify: `npm run build` in `backend/` passes. + +### Step 6 — Backend: add `POST /rooms/:code/end` and `POST /rooms/:code/restart` handlers +- **File:** `backend/src/api/rooms.ts` +- Add both route handlers using the new schemas and service functions. +- Verify: `npm run build` in `backend/` passes. + +### Step 7 — Frontend: expand status type and add client methods +- **File:** `frontend/src/services/api.ts` +- Add `"finished"` to `status` in `RoomSnapshot`. +- Add `endRound(code, participantId)` and `restartGame(code, participantId)` to the `api` object. +- Verify: `npm run build` in `frontend/` passes. + +### Step 8 — Frontend: add `endRound` and `restartGame` store actions +- **File:** `frontend/src/state/roomStore.ts` +- Add `endRound()` and `restartGame()` methods following the same `withLoading` pattern as `startGame`. +- Verify: `npm run build` in `frontend/` passes. + +### Step 9 — Frontend: update `GamePage` — result view, End Round, status navigation +- **File:** `frontend/src/pages/GamePage.tsx` +- Add `useEffect` that navigates to `/lobby` when `room.status === "lobby"`. +- When `room.status === "finished"`: render a result view showing correct word, final scores, full guess history, and "Play Again" button for host only. +- When `room.status === "playing"`: render the existing playing layout, plus "End Round" button for host only. +- Verify: `npm run build` in `frontend/` passes. + +### Step 10 — Frontend: update `LobbyPage` — auto-navigate to game on status change +- **File:** `frontend/src/pages/LobbyPage.tsx` +- Add `useEffect` that navigates to `/game` when `room.status === "playing"`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `"finished"` to `RoomStatus` | +| `backend/src/api/schemas.ts` | Modify — add `endRoundSchema` and `restartGameSchema` | +| `backend/src/services/roomStore.ts` | Modify — update `toRoomSnapshot`, add `endRound`, add `restartGame` | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/end` and `POST /:code/restart` handlers | +| `frontend/src/services/api.ts` | Modify — add `"finished"` to status, add `endRound` and `restartGame` methods | +| `frontend/src/state/roomStore.ts` | Modify — add `endRound` and `restartGame` actions | +| `frontend/src/pages/GamePage.tsx` | Modify — result view, End Round button, Play Again button, status navigation | +| `frontend/src/pages/LobbyPage.tsx` | Modify — auto-navigate to game when status becomes "playing" | + +No new files. No new libraries. + +--- + +## Testing Strategy + +### Backend unit tests (`backend/src/services/roomStore.test.ts`) +- `endRound`: room not found returns null. +- `endRound`: non-host returns `"not-host"`. +- `endRound`: room not in "playing" returns `"not-playing"`. +- `endRound`: success sets status to `"finished"` and returns snapshot. +- `restartGame`: room not found returns null. +- `restartGame`: non-host returns `"not-host"`. +- `restartGame`: room not in "finished" returns `"not-finished"`. +- `restartGame`: success sets status to `"lobby"`, clears `drawerId`, `currentWord`, `guesses`, and all participant scores. +- `restartGame`: participants (names, ids) are preserved after restart. +- `toRoomSnapshot`: in `"finished"` status, `currentWord` is returned for all viewers regardless of `viewerParticipantId`. +- `toRoomSnapshot`: in `"playing"` status, `currentWord` is still hidden from non-drawers. + +### Backend schema tests (`backend/src/api/schemas.test.ts`) +- `endRoundSchema`: missing `participantId` is rejected. +- `restartGameSchema`: missing `participantId` is rejected. + +### Frontend service tests (`frontend/src/services/api.test.ts`) +- `api.endRound`: makes a `POST` to `/rooms/:code/end` with `{ participantId }`. +- `api.restartGame`: makes a `POST` to `/rooms/:code/restart` with `{ participantId }`. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Status-navigation effects in `LobbyPage` and `GamePage` could create navigation loops | The effects are guarded by specific status values. `LobbyPage` navigates only when `"playing"`, `GamePage` navigates only when `"lobby"`. Neither fires on its target page's expected status, so no loop is possible. | +| Non-host players on `GamePage` receive `status = "lobby"` from a poll mid-restart before they navigate — stale render | The navigation effect fires immediately when the store update delivers `"lobby"`. At worst the result view renders one extra frame. | +| `restartGame` must reset all participant scores — mutating participants directly on the stored room object | `structuredClone` in `cloneRoom` is used for all returns, so the mutation is safe on the live object before cloning. The pattern is identical to how `submitGuess` mutates participant scores. | +| Guess submissions arriving between the host clicking "End Round" and the backend saving `"finished"` status | Unlikely but possible with concurrent tabs. The `endRound` handler runs synchronously in Node's event loop. Any guess request that arrives after the status is set will receive `"not-playing"`. Any that arrived before will be processed before `endRound` runs. No data loss. | +| `toRoomSnapshot` change to expose word in `"finished"` state affects all five endpoints | Intended. All endpoints call `toRoomSnapshot`. In lobby state `currentWord` is null anyway, so the condition `room.status === "finished"` is safely a no-op for lobby snapshots. | From d7661150f38cc986485fb686f3798c6ee71f2130 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:15:00 +0530 Subject: [PATCH 22/25] add scenario 4 work breakdown --- speckit.tasks | 321 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) diff --git a/speckit.tasks b/speckit.tasks index 75c2184..d2d3acd 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -895,3 +895,324 @@ No code changes — validation only. ### AD12 — Validate: clear canvas button is not visible to guessers - Tab 2 (guesser): inspect the game screen. - Expected: no "Clear Canvas" button is visible. + +--- + +--- + +# Tasks — Scenario 4: Result, Restart & Final Validation + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group AE — Backend Model + +### AE1 — Add "finished" to RoomStatus +- File: `backend/src/models/game.ts` +- Change `RoomStatus` from `"lobby" | "playing"` to `"lobby" | "playing" | "finished"`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AF — Backend Schemas + +### AF1 — Add endRoundSchema +- File: `backend/src/api/schemas.ts` +- Add `endRoundSchema = z.object({ participantId: z.string().min(1) })` and export it. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### AF2 — Add restartGameSchema +- File: `backend/src/api/schemas.ts` +- Add `restartGameSchema = z.object({ participantId: z.string().min(1) })` and export it. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AG — Backend Service + +### AG1 — Update toRoomSnapshot to expose currentWord in finished state +- File: `backend/src/services/roomStore.ts` +- Change the `currentWord` line in `toRoomSnapshot` so it returns `room.currentWord` when `room.status === "finished"` OR when `viewerParticipantId === room.drawerId`, and `null` otherwise. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### AG2 — Add endRound service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `endRound(code: string, participantId: string)` function. +- The function must return, in order: + 1. `null` if the room does not exist. + 2. `"not-host"` if `participantId` does not match `room.hostId`. + 3. `"not-playing"` if `room.status !== "playing"`. +- On success: set `room.status = "finished"`, save, return `toRoomSnapshot(cloneRoom(room), participantId)`. +- Verify: `npm run build` in `backend/` passes. + +### AG3 — Add restartGame service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `restartGame(code: string, participantId: string)` function. +- The function must return, in order: + 1. `null` if the room does not exist. + 2. `"not-host"` if `participantId` does not match `room.hostId`. + 3. `"not-finished"` if `room.status !== "finished"`. +- On success: reset `room.status = "lobby"`, `room.drawerId = null`, `room.currentWord = null`, `room.guesses = []`, and set every participant's `score = 0`. Preserve all participant `id`, `name`, and `joinedAt` fields. Save and return the updated snapshot. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AH — Backend Endpoints + +### AH1 — Add POST /rooms/:code/end route handler +- File: `backend/src/api/rooms.ts` +- Import `endRoundSchema` and `endRound` from their respective modules. +- Add `router.post("/:code/end", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `endRoundSchema`. + 3. Calls `endRound(code.toUpperCase(), participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with "Only the host can end the round" if result is `"not-host"`. + 6. Returns 422 with "Game is not in progress" if result is `"not-playing"`. + 7. Returns 200 with `{ room: result }` on success. +- Verify: `npm run build` in `backend/` passes. + +### AH2 — Add POST /rooms/:code/restart route handler +- File: `backend/src/api/rooms.ts` +- Import `restartGameSchema` and `restartGame`. +- Add `router.post("/:code/restart", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `restartGameSchema`. + 3. Calls `restartGame(code.toUpperCase(), participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with "Only the host can restart the game" if result is `"not-host"`. + 6. Returns 422 with "Round has not finished yet" if result is `"not-finished"`. + 7. Returns 200 with `{ room: result }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AI — Backend Tests + +### AI1 — Test toRoomSnapshot exposes currentWord to all in finished state +- File: `backend/src/services/roomStore.test.ts` +- Start a game, call `endRound`, then call `toRoomSnapshot` with a guesser's `participantId`. Assert `currentWord` is non-null. +- Run: `npm test` in `backend/` — all tests pass. + +### AI2 — Test toRoomSnapshot still hides currentWord from non-drawer in playing state +- File: `backend/src/services/roomStore.test.ts` +- Start a game (status = "playing"). Call `toRoomSnapshot` with a guesser's id. Assert `currentWord` is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI3 — Test endRound: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `endRound("XXXX", "any")`. Assert result is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI4 — Test endRound: non-host is rejected +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `endRound` with the guesser's id. Assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI5 — Test endRound: room not in playing state +- File: `backend/src/services/roomStore.test.ts` +- Create a room (status = "lobby"). Call `endRound` with the host id. Assert result is `"not-playing"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI6 — Test endRound: success sets status to finished +- File: `backend/src/services/roomStore.test.ts` +- Start a game with two players. Call `endRound` with the host id. Assert the returned snapshot has `status === "finished"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI7 — Test restartGame: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `restartGame("XXXX", "any")`. Assert result is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI8 — Test restartGame: non-host is rejected +- File: `backend/src/services/roomStore.test.ts` +- End a game. Call `restartGame` with the guesser's id. Assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI9 — Test restartGame: room not in finished state +- File: `backend/src/services/roomStore.test.ts` +- Start a game (status = "playing"). Call `restartGame` with the host id. Assert result is `"not-finished"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI10 — Test restartGame: success resets round fields +- File: `backend/src/services/roomStore.test.ts` +- End a game. Call `restartGame` with the host id. Assert the returned snapshot has `status === "lobby"`, `drawerId === null`, `currentWord === null`, and `guesses.length === 0`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI11 — Test restartGame: participant scores reset to zero +- File: `backend/src/services/roomStore.test.ts` +- Submit a correct guess (score = 100). End the game. Restart. Assert all participant scores in the returned snapshot are `0`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI12 — Test restartGame: participants are preserved +- File: `backend/src/services/roomStore.test.ts` +- Create a room with two players. End and restart. Assert the participant names and ids in the returned snapshot match those from before the restart. +- Run: `npm test` in `backend/` — all tests pass. + +### AI13 — Test endRoundSchema: missing participantId is rejected +- File: `backend/src/api/schemas.test.ts` +- Assert `endRoundSchema.parse({})` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### AI14 — Test restartGameSchema: missing participantId is rejected +- File: `backend/src/api/schemas.test.ts` +- Assert `restartGameSchema.parse({})` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group AJ — Frontend Types and Client + +### AJ1 — Add "finished" to status in RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Change `status: "lobby" | "playing"` to `status: "lobby" | "playing" | "finished"` in the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### AJ2 — Add endRound client method +- File: `frontend/src/services/api.ts` +- Add `endRound(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/end` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +### AJ3 — Add restartGame client method +- File: `frontend/src/services/api.ts` +- Add `restartGame(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/restart` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AK — Frontend Store + +### AK1 — Add endRound action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `endRound()` method that reads `room.code` and `participantId` from state, calls `api.endRound` inside `withLoading`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### AK2 — Add restartGame action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `restartGame()` method following the same pattern as `endRound()`, calling `api.restartGame`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AL — Frontend GamePage: Result View + +### AL1 — Add status-navigation useEffect to GamePage +- File: `frontend/src/pages/GamePage.tsx` +- Add a `useEffect` that calls `navigate("/lobby", { replace: true })` when `room?.status === "lobby"`. +- Dependency array: `[room?.status, navigate]`. +- This handles the restart navigation for all players (host navigates explicitly; non-hosts are driven by this effect on the next poll). +- No rendering changes. +- Verify: `npm run build` in `frontend/` passes. + +### AL2 — Add "End Round" button for host in playing state +- File: `frontend/src/pages/GamePage.tsx` +- In the playing layout, render an "End Round" button visible only when `isHost` is true. +- On click, call `roomStore.endRound()`. No explicit navigation — the status change causes the result view to render. +- Verify: `npm run build` in `frontend/` passes. + +### AL3 — Add result view for finished state +- File: `frontend/src/pages/GamePage.tsx` +- When `room.status === "finished"`, render a result layout instead of the playing layout. +- The result layout must include: + - The correct word (`room.currentWord`). + - The final scoreboard (all participants with scores). + - The complete guess history (all guesses in order). +- No "Play Again" button yet — added in AL4. +- Verify: `npm run build` in `frontend/` passes. + +### AL4 — Add "Play Again" button for host on result view +- File: `frontend/src/pages/GamePage.tsx` +- On the result view, render a "Play Again" button visible only when `isHost` is true. +- Non-host players see a "Waiting for the host to restart..." message. +- On click: call `roomStore.restartGame()` and navigate to `/lobby` on success. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AM — Frontend LobbyPage + +### AM1 — Add status-navigation useEffect to LobbyPage +- File: `frontend/src/pages/LobbyPage.tsx` +- Add a `useEffect` that calls `navigate("/game", { replace: true })` when `room?.status === "playing"`. +- Dependency array: `[room?.status, navigate]`. +- This auto-navigates non-host players to the game screen when the host starts a new game, and handles anyone arriving at the lobby mid-game. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AN — Frontend Tests + +### AN1 — Test api service: endRound sends correct request +- File: `frontend/src/services/api.test.ts` +- Assert `api.endRound("ABCD", "host-id")` makes a `POST` to `/rooms/ABCD/end` with `{ participantId: "host-id" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +### AN2 — Test api service: restartGame sends correct request +- File: `frontend/src/services/api.test.ts` +- Assert `api.restartGame("ABCD", "host-id")` makes a `POST` to `/rooms/ABCD/restart` with `{ participantId: "host-id" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group AO — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### AO1 — Validate: only the host sees the End Round button +- Tab 1 (host, drawer): inspect the game screen during an active game. +- Tab 2 (guesser): inspect the game screen. +- Expected: Tab 1 shows "End Round" button. Tab 2 does not. + +### AO2 — Validate: ending the round reveals the correct word to all players +- Tab 1 (host): click "End Round". +- Expected: both Tab 1 and Tab 2 transition to the result view within ~2 seconds. The correct word is visible on both screens. + +### AO3 — Validate: result view shows final scores +- After AO2, inspect both screens. +- Expected: both tabs show the final score for every participant. + +### AO4 — Validate: result view shows full guess history +- After AO2, inspect both screens. +- Expected: all guesses submitted during the round are visible in the result view on both tabs, in submission order, with correct/incorrect labels. + +### AO5 — Validate: guesses cannot be submitted after the round ends +- After AO2 (status = "finished"), attempt to submit a guess directly via the API (e.g. curl or devtools). +- Expected: backend returns a 422 error. The guess form is not visible on the result view. + +### AO6 — Validate: only the host sees Play Again +- After AO2, inspect both tabs on the result view. +- Expected: Tab 1 shows "Play Again". Tab 2 shows a waiting message. + +### AO7 — Validate: host is navigated to lobby after restart +- Tab 1 (host): click "Play Again". +- Expected: Tab 1 navigates to the lobby screen immediately. + +### AO8 — Validate: non-host players are navigated to lobby after restart +- After AO7, wait for Tab 2's next poll. +- Expected: Tab 2 navigates to the lobby within ~2 seconds without any manual action. + +### AO9 — Validate: participants are preserved after restart +- After AO8, inspect both tabs on the lobby screen. +- Expected: both players appear in the participant list with their original names. + +### AO10 — Validate: scores are reset to zero after restart +- After AO8, inspect the lobby or start a new game and check the scoreboard. +- Expected: all participant scores are 0. + +### AO11 — Validate: round state is cleared after restart +- After AO8, inspect the room snapshot (via devtools or curl to GET /rooms/:code). +- Expected: `drawerId` is null, `currentWord` is null, `guesses` is an empty array, `status` is "lobby". + +### AO12 — Validate: room code is unchanged after restart +- Compare the room code shown in Tab 1 lobby before and after restart. +- Expected: the same 4-character code is displayed. From b910c535d3016e285e7d8d1784076053536560d8 Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:19:42 +0530 Subject: [PATCH 23/25] complete scenario 4 result and restart flow --- backend/src/api/rooms.ts | 48 ++++++++++++++++++++++++- backend/src/api/schemas.ts | 8 +++++ backend/src/models/game.ts | 2 +- backend/src/services/roomStore.ts | 34 +++++++++++++++++- frontend/src/pages/GamePage.tsx | 59 ++++++++++++++++++++++++++++++- frontend/src/pages/LobbyPage.tsx | 6 ++++ frontend/src/services/api.ts | 14 +++++++- frontend/src/state/roomStore.ts | 20 +++++++++++ 8 files changed, 186 insertions(+), 5 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index f39c563..2b40cc8 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,14 +1,16 @@ import { Router } from "express"; import { createRoomSchema, + endRoundSchema, HttpError, joinRoomSchema, + restartGameSchema, roomCodeParamsSchema, roomViewerQuerySchema, startGameSchema, submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, endRound, getRoom, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -93,6 +95,50 @@ export function createRoomsRouter() { } }); + router.post("/:code/end", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = endRoundSchema.parse(request.body); + const result = endRound(code.toUpperCase(), participantId); + + if (result === null) { + throw new HttpError(404, "Room not found"); + } + if (result === "not-host") { + throw new HttpError(403, "Only the host can end the round"); + } + if (result === "not-playing") { + throw new HttpError(422, "Game is not in progress"); + } + + response.json({ room: result }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartGameSchema.parse(request.body); + const result = restartGame(code.toUpperCase(), participantId); + + if (result === null) { + throw new HttpError(404, "Room not found"); + } + if (result === "not-host") { + throw new HttpError(403, "Only the host can restart the game"); + } + if (result === "not-finished") { + throw new HttpError(422, "Round has not finished yet"); + } + + response.json({ room: result }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 50f559e..3cf2771 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -25,6 +25,14 @@ export const submitGuessSchema = z.object({ text: z.string().trim().min(1, "Guess cannot be empty") }); +export const endRoundSchema = z.object({ + participantId: z.string().min(1) +}); + +export const restartGameSchema = z.object({ + participantId: z.string().min(1) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index b11b91c..9d65fba 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" | "finished"; export interface Participant { id: string; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 1509ba3..55e200d 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -154,13 +154,45 @@ export function submitGuess(code: string, participantId: string, text: string) { return toRoomSnapshot(cloneRoom(room), participantId); } +export function endRound(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) return null; + if (room.hostId !== participantId) return "not-host" as const; + if (room.status !== "playing") return "not-playing" as const; + + room.status = "finished"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room), participantId); +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) return null; + if (room.hostId !== participantId) return "not-host" as const; + if (room.status !== "finished") return "not-finished" as const; + + room.status = "lobby"; + room.drawerId = null; + room.currentWord = null; + room.guesses = []; + room.participants.forEach((p) => { p.score = 0; }); + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room), participantId); +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { return { code: room.code, status: room.status, hostId: room.hostId, drawerId: room.drawerId, - currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null, + currentWord: room.status === "finished" || viewerParticipantId === room.drawerId ? room.currentWord : null, guesses: room.guesses.map((g) => ({ ...g })), participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 7d13da9..a6b3f04 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -21,6 +21,12 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } + }, [room?.status, navigate]); + useEffect(() => { if (!room) return; @@ -35,8 +41,9 @@ export function GamePage() { return null; } - const viewer = room.participants.find((p) => p.id === participantId) ?? null; + const isHost = room.hostId === participantId; const isDrawer = room.drawerId === participantId; + const viewer = room.participants.find((p) => p.id === participantId) ?? null; const drawerParticipant = room.participants.find((p) => p.id === room.drawerId) ?? null; function getCanvasPos(e: React.MouseEvent) { @@ -79,6 +86,51 @@ export function GamePage() { await roomStore.submitGuess(text); } + async function handleEndRound() { + await roomStore.endRound(); + } + + async function handlePlayAgain() { + await roomStore.restartGame(); + navigate("/lobby"); + } + + if (room.status === "finished") { + return ( +
    +
    +
    + Round over +

    Results

    +
    + +
    + +
    + +

    {room.currentWord}

    +
    + + + + +
    + + + +
    + {isHost ? ( + + ) : ( +

    Waiting for the host to restart...

    + )} +
    +
    + ); + } + return (
    @@ -149,6 +201,11 @@ export function GamePage() {
    + {isHost && ( + + )} diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 9c64d24..92dca50 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -16,6 +16,12 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "playing") { + navigate("/game", { replace: true }); + } + }, [room?.status, navigate]); + useEffect(() => { if (!room) return; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 083440c..1d2842f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -17,7 +17,7 @@ export interface Guess { export interface RoomSnapshot { code: string; - status: "lobby" | "playing"; + status: "lobby" | "playing" | "finished"; hostId: string; drawerId: string | null; currentWord: string | null; @@ -92,4 +92,16 @@ export const api = { body: JSON.stringify({ participantId, text }), }); }, + endRound(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/end`, { + method: "POST", + body: JSON.stringify({ participantId }), + }); + }, + 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 919a190..7ac6ffb 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -118,6 +118,26 @@ class RoomStore { this.setRoomSnapshot(response.room); return response; } + + async endRound() { + if (!this.state.room || !this.state.participantId) return; + + const response = await this.withLoading(() => + api.endRound(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response; + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) return; + + const response = await this.withLoading(() => + api.restartGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response; + } } const RoomStoreContext = createContext(null); From 3733a4b98731fee63cc3118ffbb90881c2ca49fb Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 17:32:00 +0530 Subject: [PATCH 24/25] add assignment reflection report --- reflection.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 reflection.md diff --git a/reflection.md b/reflection.md new file mode 100644 index 0000000..aa144a0 --- /dev/null +++ b/reflection.md @@ -0,0 +1,103 @@ +# Reflection — Scribble Brownfield Assignment + +## 1. What the Starter Application Provided + +The starter was a functional but incomplete scaffold for a Scribble-style drawing and guessing game. It included: + +- A React 18 + Vite + TypeScript frontend with five page routes: Start, Create Room, Join Room, Lobby, and Game. +- A Node.js + Express + TypeScript backend with three API endpoints: `POST /rooms`, `POST /rooms/:code/join`, and `GET /rooms/:code`. +- In-memory room storage using a `Map` on the backend, with unique 4-character room codes. +- A `RoomStore` class on the frontend using React Context and `useSyncExternalStore` for state management. +- A lobby screen with a manual refresh button that fetched the latest participant list. +- A game screen with static placeholders for the canvas, guess form, scoreboard, and activity panel. +- Seed data: five words (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) and two roles (`drawer`, `guesser`). +- Zod validation on the backend and a global error handler. +- Vitest configured for both frontend and backend. +- GitHub Actions workflows for CI and PR validation. + +The starter established the routing, state architecture, and API patterns. All gameplay behavior was absent. + +--- + +## 2. Scenario 1 — Room Setup & Lobby + +**Implemented:** + +- **Host tracking** — the room creator is assigned as host at creation time. `Room.hostId` is stored on the backend and included in every snapshot via `toRoomSnapshot`. +- **Room join validation** — empty and whitespace-only room codes are rejected client-side before any API call. Unknown room codes return a user-friendly "Room not found" error from the backend. +- **Lobby polling** — replaced the manual refresh button with a `setInterval` (2-second cadence) in a `useEffect` on `LobbyPage`. The interval is cleaned up on unmount. +- **Host-only start** — the "Start Game" button is rendered only for the host. It is disabled when fewer than 2 players are present and shows an explanatory message. Non-host players see a waiting message. +- **Minimum player guard** — `POST /rooms/:code/start` enforces the 2-player minimum on the backend, returning 422 if not met. + +--- + +## 3. Scenario 2 — Game Start & Drawer Flow + +**Implemented:** + +- **Player name validation** — both `createRoomSchema` and `joinRoomSchema` now use `z.string().trim().min(1)`, rejecting empty and whitespace-only names with a 400 error. Both forms also validate client-side before calling the API. +- **Drawer assignment** — `startGame` sets `room.drawerId = room.hostId` when the game begins. The drawer id is included in every snapshot and visible to all players. +- **Deterministic word selection** — `selectWord` derives a stable index by summing the char codes of the room code characters and taking the modulo of the word list length. The same room code always produces the same word. +- **Secret word visibility** — `toRoomSnapshot` uses `viewerParticipantId` to conditionally return `currentWord`. Non-drawers and unauthenticated callers receive `null`. +- **Role-aware game screen** — `GamePage` derives `isDrawer` and renders different content per role: the drawer sees the secret word and a drawing placeholder, guessers see who is drawing. + +--- + +## 4. Scenario 3 — Gameplay Interaction + +**Implemented:** + +- **Drawing canvas** — an HTML `` element with mouse event handlers (`onMouseDown`, `onMouseMove`, `onMouseUp`, `onMouseLeave`) renders for the drawer. Drawing state is local to the browser. +- **Clear canvas** — a "Clear Canvas" button calls `clearRect` on the canvas context. It is only visible to the drawer. +- **Guess submission** — `POST /rooms/:code/guess` stores guesses on the backend. The `GuessForm` component now accepts an `onSubmit` prop, validates and trims the input client-side before calling the store action, clears on success, and displays API errors inline. +- **Guess validation** — empty and whitespace-only guesses are rejected both client-side and via the backend schema. The drawer is blocked from guessing (403). +- **Case-insensitive comparison** — the backend trims and lowercases both the submission and `currentWord` before comparing. The frontend applies no comparison logic. +- **Scoring** — participants initialise with `score: 0`. A correct guess awards 100 points, capped at 100 per player regardless of subsequent correct guesses. Scores are stored on `Participant` and included in every snapshot. +- **Guess history synchronisation** — `GamePage` uses the same 2-second polling pattern as `LobbyPage`. The `ResultPanel` and `Scoreboard` components were updated to render real data from the snapshot. + +--- + +## 5. Scenario 4 — Result, Restart & Final Validation + +**Implemented:** + +- **End-of-round state** — `POST /rooms/:code/end` transitions the room from `"playing"` to `"finished"`. Only the host can trigger this. Once finished, `submitGuess` rejects further attempts with 422. +- **Correct word reveal** — `toRoomSnapshot` was updated to return `currentWord` to all viewers when `room.status === "finished"`, removing the per-viewer filter applied during play. +- **Result view** — `GamePage` renders a dedicated result layout when `room.status === "finished"`, showing the correct word, final scores, and the complete guess history. +- **Restart flow** — `POST /rooms/:code/restart` resets the room: `status = "lobby"`, `drawerId = null`, `currentWord = null`, `guesses = []`, and all participant scores reset to zero. Participant identities and the room code are preserved. +- **Status-driven navigation** — `GamePage` now includes a `useEffect` that navigates all players to `/lobby` when `room.status` becomes `"lobby"` (driven by polling). `LobbyPage` includes a matching effect that navigates to `/game` when status becomes `"playing"`, closing the gap where non-host players were not auto-routed after game start. + +--- + +## 6. How AI Was Used + +AI assistance (Claude Code) was used throughout the assignment in a structured, spec-first manner: + +- **Specification generation** — the `speckit.specify`, `speckit.plan`, and `speckit.tasks` artifacts were drafted iteratively with AI, scenario by scenario, before any code was written. This forced ambiguity to surface before implementation. +- **Discovery** — the `docs/discovery.md` file was generated by analysing the full repository and cataloguing gaps against the spec, rather than writing code immediately. +- **Code implementation** — each scenario was implemented feature by feature following the task list. AI generated the code; each output was reviewed before committing. +- **Review discipline** — AI-generated code was checked against the spec rather than accepted on appearance. Several cases required correcting output that was structurally plausible but misaligned with the acceptance criteria (e.g. `toRoomSnapshot` filtering logic, polling cleanup patterns). +- **Build verification** — every implementation step was verified with `npm run build` before proceeding. The build served as the primary gate, not the AI's confidence. + +AI was not used to make architectural decisions autonomously. All decisions about state model shape, endpoint design, and navigation patterns were reasoned through in the plan before any code was produced. + +--- + +## 7. Challenges Encountered + +- **Cascading type errors** — adding fields to `Room` and `RoomSnapshot` on the backend caused type errors in `toRoomSnapshot`, `createRoom`, and any caller that returned a snapshot. The fix was to update all sites in the same logical step rather than committing intermediate broken states. +- **IDE diagnostic timing** — the VS Code extension reported stale type errors immediately after edits because the hook fires before the TypeScript server has processed the full file write. This required using `npm run build` as the authoritative check rather than relying on inline hints. +- **Per-viewer snapshot filtering** — the `viewerParticipantId` parameter on `toRoomSnapshot` existed from the scaffold but was voided. Threading the correct id through all five endpoints and handling the `"finished"` status exception required care to avoid regressions. +- **Polling cleanup** — ensuring `clearInterval` fires correctly on unmount, particularly when navigating between pages, required explicit dependency arrays. A missed cleanup would cause stale fetch calls after the component was gone. +- **Status-driven navigation** — the gap where non-host players were never auto-navigated to the game screen was only discovered during Scenario 4 planning. The fix (a `useEffect` on `room.status` in `LobbyPage`) addressed both the initial game start flow and the post-restart flow simultaneously. + +--- + +## 8. Key Learnings from Spec Kit and Specification-Driven Development + +- **Spec before code removes ambiguity at the right time.** Writing acceptance criteria before planning forced decisions about edge cases (e.g. what counts as an empty name, who can see the secret word) that would otherwise have been discovered mid-implementation and resolved inconsistently. +- **The plan is a contract, not a suggestion.** Having a file-level implementation sequence meant each change had a clear scope. Changes that felt like they "belonged together" were often best kept separate to keep each commit independently reviewable. +- **Small commits expose reasoning.** Each commit covering one logical change made it straightforward to trace why a line exists. A commit touching six files simultaneously obscures intent and makes rollback harder. +- **Artifacts must stay in sync with the code.** When the implementation deviated from the plan (e.g. the non-host navigation gap discovered late), updating the plan and spec before committing kept the artifacts traceable. Artifacts that lag behind code become noise rather than documentation. +- **AI assistance amplifies the value of a good spec.** Prompting AI with a detailed plan produced more accurate output than prompting with a vague description. The spec was effectively the prompt — the more precise the acceptance criteria, the less the AI output needed correction. +- **Review is not optional.** The constitution's rule to read AI-generated code line by line prevented several issues: invented fields, silent error suppression, and logic that matched the description but not the spec. The build caught type errors; careful reading caught semantic ones. From d139e2cc7c2396b999d82622cc23a0e5276098ea Mon Sep 17 00:00:00 2001 From: Shailaja Nimmagari Date: Sat, 30 May 2026 18:00:42 +0530 Subject: [PATCH 25/25] add required spec kit artifacts --- .specify/memory/constitution.md | 35 ++ specs/001-room-setup-and-lobby/plan.md | 242 ++++++++++++ specs/001-room-setup-and-lobby/spec.md | 73 ++++ specs/001-room-setup-and-lobby/tasks.md | 282 +++++++++++++ specs/002-game-start-and-drawer-flow/plan.md | 275 +++++++++++++ specs/002-game-start-and-drawer-flow/spec.md | 89 +++++ specs/002-game-start-and-drawer-flow/tasks.md | 254 ++++++++++++ specs/003-gameplay-interaction/plan.md | 370 ++++++++++++++++++ specs/003-gameplay-interaction/spec.md | 112 ++++++ specs/003-gameplay-interaction/tasks.md | 351 +++++++++++++++++ .../004-result-restart-and-validation/plan.md | 350 +++++++++++++++++ .../004-result-restart-and-validation/spec.md | 80 ++++ .../tasks.md | 316 +++++++++++++++ 13 files changed, 2829 insertions(+) create mode 100644 .specify/memory/constitution.md create mode 100644 specs/001-room-setup-and-lobby/plan.md create mode 100644 specs/001-room-setup-and-lobby/spec.md create mode 100644 specs/001-room-setup-and-lobby/tasks.md create mode 100644 specs/002-game-start-and-drawer-flow/plan.md create mode 100644 specs/002-game-start-and-drawer-flow/spec.md create mode 100644 specs/002-game-start-and-drawer-flow/tasks.md create mode 100644 specs/003-gameplay-interaction/plan.md create mode 100644 specs/003-gameplay-interaction/spec.md create mode 100644 specs/003-gameplay-interaction/tasks.md create mode 100644 specs/004-result-restart-and-validation/plan.md create mode 100644 specs/004-result-restart-and-validation/spec.md create mode 100644 specs/004-result-restart-and-validation/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..b519f8b --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,35 @@ +# Constitution + +Rules that govern every decision made during this assignment. +Deviating from these rules requires an explicit note in the commit message. + +--- + +## 1. No Extra Features + +Build only what the current scenario requires. +Do not add fields, endpoints, components, or behavior that are not demanded by the acceptance criteria being worked on. +Out-of-scope items listed in the README (WebSockets, timers, multiple rounds, auth, etc.) are permanently off the table. +If something seems useful but is not in the spec, write it down as a future note — do not build it. + +## 2. Test Before Commit + +Every commit must leave the test suite passing. +Run `npm test` in both `backend/` and `frontend/` before staging. +Run `npm run build` in both directories to confirm there are no type errors. +If a change breaks an existing test, fix the test or fix the code — do not skip or delete the test. +New logic that can be unit tested (validation, scoring, snapshot filtering) must have a test before the code is committed. + +## 3. Small Commits + +Each commit should cover exactly one logical change: a model update, a single endpoint, a single component, a state action, or a test. +A commit that touches both backend and frontend is a signal to split it. +Commit messages must state what changed and why in plain language. +Large "everything works now" commits are not acceptable. + +## 4. Review AI-Generated Code + +Never commit AI-generated code without reading it line by line first. +Verify that the code matches the spec, not just that it looks reasonable. +Check for: invented fields or endpoints not in the plan, silent error suppression, logic that differs from acceptance criteria, and unnecessary abstractions. +If the AI output requires more than minor edits to be correct, treat the output as a draft and rewrite the relevant parts before committing. diff --git a/specs/001-room-setup-and-lobby/plan.md b/specs/001-room-setup-and-lobby/plan.md new file mode 100644 index 0000000..1f7b79f --- /dev/null +++ b/specs/001-room-setup-and-lobby/plan.md @@ -0,0 +1,242 @@ +# Plan — Scenario 1: Room Setup & Lobby + +--- + +## Findings + +### What exists and is relevant + +| Area | File | Observation | +|---|---|---| +| Room model | `backend/src/models/game.ts` | `Room` has `code`, `status`, `participants`, `createdAt`, `updatedAt`. No `hostId`. `RoomStatus` is the literal `"lobby"` only. | +| Participant model | `backend/src/models/game.ts` | `Participant` has `id`, `name`, `joinedAt`. No role or host marker. | +| Room snapshot | `backend/src/models/game.ts` | `RoomSnapshot` has `participants`, `availableWords`, `roles`. No `hostId`. | +| Room store | `backend/src/services/roomStore.ts` | `createRoom` creates the first participant but never records them as host. `toRoomSnapshot` receives `viewerParticipantId` but immediately voids it — no filtering applied. | +| Schemas | `backend/src/api/schemas.ts` | `createRoomSchema` and `joinRoomSchema` both mark `playerName` as `z.string().optional()` — empty and whitespace-only names pass validation. `roomCodeParamsSchema` accepts any string with no minimum length. | +| Rooms router | `backend/src/api/rooms.ts` | Three endpoints exist: `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code`. No start endpoint. | +| API router | `backend/src/api/router.ts` | Mounts `/rooms` router. Global error handler handles `ZodError` (400), `HttpError` (statusCode), and generic (500). Pattern is reusable. | +| Frontend types | `frontend/src/services/api.ts` | `RoomSnapshot` interface mirrors backend but has no `hostId`. No `startGame` client method. | +| Create Room | `frontend/src/pages/CreateRoomPage.tsx` | Calls `roomStore.createRoom(playerName)` directly — no client-side validation. Empty name is sent to the API. | +| Join Room | `frontend/src/pages/JoinRoomPage.tsx` | Calls `roomStore.joinRoom(roomCode, playerName)` directly — no client-side validation. Empty name and empty code are sent to the API. | +| Lobby | `frontend/src/pages/LobbyPage.tsx` | Only manual refresh via `handleRefresh`. No polling interval. "Start Game" button is visible to all players and navigates to `/game` without any API call or host/player-count check. | +| Room store (frontend) | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom` actions. No `startGame` action. No polling helper. | + +### What is missing + +1. `Room.hostId` — no field records which participant created the room. +2. `RoomSnapshot.hostId` — not surfaced to the frontend. +3. Name validation — empty and whitespace-only names are accepted by schemas and forms. +4. Code validation — empty room code is accepted by the join form. +5. Lobby polling — no `setInterval` anywhere in the frontend lobby flow. +6. Host-only start button — button is shown to everyone and is always enabled. +7. Start game endpoint — no `POST /rooms/:code/start` exists. +8. Start game minimum player guard — no check that at least 2 participants are present before starting. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Room (before) Room (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +createdAt: string createdAt: string +updatedAt: string updatedAt: string + hostId: string ← NEW +``` + +``` +RoomSnapshot (before) RoomSnapshot (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + hostId: string ← NEW +``` + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +───────────────────────── ──────────────────────────── +code: string code: string +status: "lobby" status: "lobby" | "playing" +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + hostId: string ← NEW +``` + +--- + +## Required API Changes + +### Modified: `POST /rooms` +- Schema change: `playerName` becomes required, trimmed, minimum 1 character after trim. +- Service change: `createRoom` writes `hostId: participant.id` on the new `Room`. +- Snapshot change: `toRoomSnapshot` includes `hostId` in the returned object. +- No change to route handler or response shape beyond the snapshot addition. + +### Modified: `POST /rooms/:code/join` +- Schema change: `playerName` becomes required, trimmed, minimum 1 character after trim. +- No other changes to handler or service. + +### Modified: Zod schemas — `backend/src/api/schemas.ts` +``` +createRoomSchema.playerName: z.string().optional() + → z.string().min(1, "Player name is required").transform(s => s.trim()).refine(s => s.length > 0, "Player name cannot be blank") + +joinRoomSchema.playerName: z.string().optional() + → same as above +``` + +### New endpoint: `POST /rooms/:code/start` +- **Purpose:** Transition a room from `"lobby"` to `"playing"` with host and player-count guards. +- **Request:** Body `{ participantId: string }` — identifies the caller. +- **Validations (in order):** + 1. Room must exist → 404 if not. + 2. `participantId` must match `room.hostId` → 403 "Only the host can start the game" if not. + 3. Room must have at least 2 participants → 422 "Need at least 2 players to start" if not. +- **Success:** Sets `room.status = "playing"`, saves, returns `{ room: RoomSnapshot }` with status 200. +- **Schema needed:** `startGameSchema = z.object({ participantId: z.string().min(1) })` + +--- + +## Data Flow + +### Create Room +``` +CreateRoomPage + → [validate name client-side, reject if blank] + → POST /rooms { playerName } + → backend validates name (Zod, trimmed, non-empty) + → createRoom() sets hostId = participant.id + → toRoomSnapshot() includes hostId + → response { participantId, room: { ...hostId } } + → roomStore.setRoomSession() + → navigate("/lobby") +``` + +### Join Room +``` +JoinRoomPage + → [validate name client-side, reject if blank] + → [validate code client-side, reject if blank] + → POST /rooms/:code/join { playerName } + → backend validates name (Zod, trimmed, non-empty) + → joinRoom() adds participant, hostId unchanged + → response { participantId, room: { ...hostId } } + → roomStore.setRoomSession() + → navigate("/lobby") +``` + +### Lobby Polling +``` +LobbyPage mounts + → useEffect starts setInterval(~2000ms) + → GET /rooms/:code?participantId=... + → roomStore.setRoomSnapshot(room) + → participant list re-renders + → useEffect cleanup clears interval on unmount +``` + +### Start Game +``` +LobbyPage (host only, ≥2 players) + → click "Start Game" + → POST /rooms/:code/start { participantId } + → backend checks hostId, checks participant count + → sets status = "playing" + → response { room } + → roomStore.setRoomSnapshot(room) + → navigate("/game") +``` + +--- + +## Implementation Sequence + +Steps are ordered so each one is independently testable before moving to the next. + +### Step 1 — Backend: extend the Room model +- **File:** `backend/src/models/game.ts` +- Add `hostId: string` to the `Room` interface. +- Expand `RoomStatus` to `"lobby" | "playing"`. +- Add `hostId: string` to the `RoomSnapshot` interface. + +### Step 2 — Backend: tighten validation schemas +- **File:** `backend/src/api/schemas.ts` +- Update `createRoomSchema.playerName` to required, trimmed, non-empty. +- Update `joinRoomSchema.playerName` to required, trimmed, non-empty. +- Add `startGameSchema` for the new endpoint. + +### Step 3 — Backend: set hostId in the room service +- **File:** `backend/src/services/roomStore.ts` +- In `createRoom`: assign `room.hostId = participant.id`. +- In `toRoomSnapshot`: include `hostId: room.hostId` in the returned object. + +### Step 4 — Backend: add the start game endpoint +- **File:** `backend/src/api/rooms.ts` +- Add `POST /:code/start` handler using `startGameSchema`. +- Add a `startGame(code, participantId)` function to `roomStore.ts` that enforces host check, player count, and status transition. + +### Step 5 — Frontend: update RoomSnapshot type +- **File:** `frontend/src/services/api.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- Add `status: "lobby" | "playing"` to the `RoomSnapshot` interface. +- Add `startGame(code: string, participantId: string)` client method calling `POST /rooms/:code/start`. + +### Step 6 — Frontend: add name and code validation to forms +- **File:** `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the name and reject if empty with an inline error message. +- **File:** `frontend/src/pages/JoinRoomPage.tsx` +- Trim name and reject if empty; trim code and reject if empty. Both show distinct inline errors. + +### Step 7 — Frontend: add startGame action to the store +- **File:** `frontend/src/state/roomStore.ts` +- Add `startGame()` method that calls `api.startGame` and updates the snapshot on success. + +### Step 8 — Frontend: replace manual refresh with polling and add host UI +- **File:** `frontend/src/pages/LobbyPage.tsx` +- Replace the manual refresh `useEffect`/button with a `setInterval` polling loop (~2000ms) that calls `roomStore.fetchRoom()`. +- Derive `isHost = room.hostId === participantId`. +- Show "Host" badge next to the host's name in the participant list. +- Show "Start Game" button only when `isHost` is true. +- Disable the button and show a reason when `room.participants.length < 2`. +- On success, navigate to `/game`. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `hostId`, expand `RoomStatus` | +| `backend/src/api/schemas.ts` | Modify — tighten name schemas, add `startGameSchema` | +| `backend/src/services/roomStore.ts` | Modify — set `hostId` in `createRoom`, include in `toRoomSnapshot`, add `startGame` function | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/start` handler | +| `frontend/src/services/api.ts` | Modify — add `hostId` + `status` to `RoomSnapshot`, add `startGame` client method | +| `frontend/src/pages/CreateRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/JoinRoomPage.tsx` | Modify — add client-side name and code validation | +| `frontend/src/state/roomStore.ts` | Modify — add `startGame` action | +| `frontend/src/pages/LobbyPage.tsx` | Modify — replace manual refresh with polling, add host UI, add conditional start button | + +No new files need to be created. No new libraries are required. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Polling fires after component unmounts (memory leak / stale state update) | Clear the interval in the `useEffect` cleanup function | +| Backend name validation rejects names that were previously accepted | Acceptable — the spec requires it; existing in-memory rooms are cleared on restart anyway | +| `hostId` missing from old rooms if the backend restarts during testing | Not a concern — in-memory store is wiped on restart; all rooms are fresh | +| Start game navigates to `/game` before game state (drawer, word) is ready | Acceptable for Scenario 1; game screen displays placeholder until Scenario 2 is implemented | +| Two players simultaneously click "Start Game" | Only one will win the host check; second gets 403 — handled by the error path in the store | diff --git a/specs/001-room-setup-and-lobby/spec.md b/specs/001-room-setup-and-lobby/spec.md new file mode 100644 index 0000000..8fe9966 --- /dev/null +++ b/specs/001-room-setup-and-lobby/spec.md @@ -0,0 +1,73 @@ +## Scenario 1 — Room Setup & Lobby + +### Problem + +Players need a reliable way to create and join a game room before play begins. +The current scaffold lets players create and join rooms but treats every player identically — there is no host, no start action, and the lobby only updates on manual refresh. +A player has no way to know who else is waiting, and nobody can actually begin the game. + +--- + +### Requirements + +#### Host Tracking +- The player who creates a room is automatically the host. +- Host status must be stored on the backend and included in every room snapshot. +- The frontend must visually distinguish the host from other participants in the lobby. +- Host status does not transfer if the host leaves (out of scope for this scenario). + +#### Room Validation +- A player name must be non-empty after trimming whitespace. Empty or whitespace-only names are rejected with a clear error message before any API call is made. +- Joining with an unknown or missing room code returns a clear error message ("Room not found" or equivalent). The player stays on the join screen. +- Joining with an empty room code is rejected on the frontend before any API call is made. +- Each room is isolated: participants, state, and game data from one room never appear in another. + +#### Lobby Polling +- While a player is on the lobby screen, the room snapshot is fetched automatically at approximately 2-second intervals. +- Polling starts when the lobby mounts and stops when the player navigates away. +- The participant list updates without any manual user action. +- Polling uses the existing `GET /rooms/:code` endpoint — no new endpoint is needed. + +#### Host-Only Start +- Only the host sees the "Start Game" button in the lobby. +- The button is disabled (and shows why) when fewer than 2 players are present. +- The button is enabled when at least 2 players have joined. +- Non-host players see a waiting message instead of the button. + +#### Minimum 2 Players +- The game cannot be started with fewer than 2 participants. +- This rule is enforced on the backend: a start request with only 1 participant returns an error. +- The frontend reflects this state by disabling the start button until the count is met. + +--- + +### Acceptance Criteria + +**Host tracking** +- [ ] Creating a room returns a snapshot where one participant is marked as host. +- [ ] Joining a room returns a snapshot that still correctly identifies the original creator as host. +- [ ] The host participant is visually marked in the lobby (e.g. a "Host" badge). +- [ ] A non-host participant does not have the host marker. + +**Room validation** +- [ ] Submitting an empty player name on Create Room shows an inline error and does not call the API. +- [ ] Submitting an empty player name on Join Room shows an inline error and does not call the API. +- [ ] Submitting a whitespace-only player name is treated as empty and rejected with the same message. +- [ ] Joining with a code that does not match any room shows "Room not found" (or equivalent) and keeps the player on the join screen. +- [ ] Joining with an empty code shows an inline error and does not call the API. +- [ ] Two rooms created in the same session have independent participant lists — joining room A does not affect room B. + +**Lobby polling** +- [ ] Opening the lobby screen starts automatic polling; the participant list updates within ~2 seconds when a second browser tab joins the same room. +- [ ] Navigating away from the lobby stops polling (no further requests fired after leaving). +- [ ] Polling does not require any manual user interaction. + +**Host-only start** +- [ ] The host sees a "Start Game" button; a non-host player does not. +- [ ] The button is disabled when only 1 player is in the room. +- [ ] The button is enabled when 2 or more players are in the room. +- [ ] Clicking the enabled button by the host triggers the start game action (transitions to game screen — full game start behavior is specified in Scenario 2). + +**Minimum 2 players** +- [ ] A start request sent with only 1 participant in the room returns a 4xx error from the backend. +- [ ] The frontend does not allow the host to trigger the start request when fewer than 2 players are present. diff --git a/specs/001-room-setup-and-lobby/tasks.md b/specs/001-room-setup-and-lobby/tasks.md new file mode 100644 index 0000000..b39d033 --- /dev/null +++ b/specs/001-room-setup-and-lobby/tasks.md @@ -0,0 +1,282 @@ +# Tasks — Scenario 1: Room Setup & Lobby + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group A — Backend Model + +### A1 — Expand RoomStatus type +- File: `backend/src/models/game.ts` +- Change `RoomStatus` from the literal `"lobby"` to `"lobby" | "playing"`. +- No other changes. +- Verify: `npm run build` in `backend/` passes with no type errors. + +### A2 — Add hostId to Room interface +- File: `backend/src/models/game.ts` +- Add `hostId: string` to the `Room` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Existing `RoomSnapshot` and service files will show type errors — those are fixed in A3 and A4. + +### A3 — Add hostId to RoomSnapshot interface +- File: `backend/src/models/game.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group B — Backend Validation + +### B1 — Tighten playerName in createRoomSchema +- File: `backend/src/api/schemas.ts` +- Change `playerName` from `z.string().optional()` to required, trimmed, and non-empty (min 1 character after trim). +- No other schema changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### B2 — Tighten playerName in joinRoomSchema +- File: `backend/src/api/schemas.ts` +- Apply the same validation as B1 to `joinRoomSchema.playerName`. +- No other schema changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### B3 — Add startGameSchema +- File: `backend/src/api/schemas.ts` +- Add `startGameSchema = z.object({ participantId: z.string().min(1) })`. +- Export it alongside the existing schemas. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group C — Backend Service + +### C1 — Set hostId when creating a room +- File: `backend/src/services/roomStore.ts` +- In `createRoom`, assign `room.hostId = participant.id` before storing. +- Verify: `npm run build` in `backend/` passes. + +### C2 — Include hostId in toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- In `toRoomSnapshot`, add `hostId: room.hostId` to the returned snapshot object. +- Verify: `npm run build` in `backend/` passes. + +### C3 — Add startGame function to room service +- File: `backend/src/services/roomStore.ts` +- Add an exported `startGame(code: string, participantId: string)` function. +- The function must: + 1. Return `null` if the room does not exist. + 2. Return `"not-host"` if `participantId` does not match `room.hostId`. + 3. Return `"not-enough-players"` if `room.participants.length < 2`. + 4. Set `room.status = "playing"`, save, and return the updated room snapshot. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group D — Backend Endpoint + +### D1 — Add POST /rooms/:code/start route handler +- File: `backend/src/api/rooms.ts` +- Import `startGameSchema` and `startGame` from their respective modules. +- Add `router.post("/:code/start", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `startGameSchema`. + 3. Calls `startGame(code, participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with message "Only the host can start the game" if result is `"not-host"`. + 6. Returns 422 with message "Need at least 2 players to start" if result is `"not-enough-players"`. + 7. Returns 200 with `{ room: snapshot }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group E — Backend Tests + +### E1 — Test createRoomSchema rejects empty and whitespace-only names +- File: `backend/src/api/schemas.test.ts` +- Add test cases: empty string, whitespace-only string, valid name. +- Run: `npm test` in `backend/` — all tests pass. + +### E2 — Test joinRoomSchema rejects empty and whitespace-only names +- File: `backend/src/api/schemas.test.ts` +- Add test cases matching E1 for `joinRoomSchema`. +- Run: `npm test` in `backend/` — all tests pass. + +### E3 — Test createRoom sets hostId to first participant +- File: `backend/src/services/roomStore.test.ts` +- Add a test that calls `createRoom` and asserts `result.room.hostId === result.participantId`. +- Run: `npm test` in `backend/` — all passes. + +### E4 — Test toRoomSnapshot includes hostId +- File: `backend/src/services/roomStore.test.ts` +- Add a test that creates a room, calls `toRoomSnapshot`, and asserts `snapshot.hostId` matches the creator's id. +- Run: `npm test` in `backend/` — all passes. + +### E5 — Test startGame: room not found returns null +- File: `backend/src/services/roomStore.test.ts` +- Add a test calling `startGame("XXXX", "any-id")` and asserting the result is `null`. +- Run: `npm test` in `backend/` — all passes. + +### E6 — Test startGame: non-host participant is rejected +- File: `backend/src/services/roomStore.test.ts` +- Create a room, call `startGame` with a different `participantId`, assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all passes. + +### E7 — Test startGame: fewer than 2 participants is rejected +- File: `backend/src/services/roomStore.test.ts` +- Create a room (1 participant), call `startGame` with the host id, assert result is `"not-enough-players"`. +- Run: `npm test` in `backend/` — all passes. + +### E8 — Test startGame: succeeds with host and 2+ participants +- File: `backend/src/services/roomStore.test.ts` +- Create a room, join a second player, call `startGame` with the host id, assert returned snapshot has `status: "playing"`. +- Run: `npm test` in `backend/` — all passes. + +--- + +## Group F — Frontend Types + +### F1 — Add hostId and expand status in RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `hostId: string` to the `RoomSnapshot` interface. +- Change `status: "lobby"` to `status: "lobby" | "playing"`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes (type errors in pages are expected until F2–F5 are done). + +### F2 — Add startGame client method +- File: `frontend/src/services/api.ts` +- Add `startGame(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/start` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group G — Frontend Validation + +### G1 — Add name validation to CreateRoomPage +- File: `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the player name. +- If the trimmed value is empty, set an inline error ("Player name is required") and return without calling the API. +- Verify: `npm run build` in `frontend/` passes. + +### G2 — Add name validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before calling the store, trim the player name. +- If trimmed name is empty, set an inline error ("Player name is required") and return. +- Verify: `npm run build` in `frontend/` passes. + +### G3 — Add room code validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before calling the store, trim the room code. +- If trimmed code is empty, set an inline error ("Room code is required") and return. +- Both name and code are checked; both errors can show independently. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group H — Frontend Store + +### H1 — Add startGame action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add a `startGame()` method to the `RoomStore` class. +- It reads `this.state.room.code` and `this.state.participantId` from current state. +- It calls `api.startGame(code, participantId)` inside `withLoading`. +- On success, calls `this.setRoomSnapshot(response.room)`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group I — Frontend Lobby + +### I1 — Replace manual refresh with polling +- File: `frontend/src/pages/LobbyPage.tsx` +- Remove the `handleRefresh` function and the manual "Refresh Room" button. +- Add a `useEffect` that starts a `setInterval` calling `roomStore.fetchRoom()` every 2000ms. +- The cleanup function must call `clearInterval` to stop polling on unmount. +- Verify: `npm run build` in `frontend/` passes. + +### I2 — Derive isHost and add host badge to participant list +- File: `frontend/src/pages/LobbyPage.tsx` +- Derive `isHost = room.hostId === participantId` using state from the store. +- In the participant list, render a "Host" badge next to the participant whose id matches `room.hostId`. +- Verify: `npm run build` in `frontend/` passes. + +### I3 — Show Start Game button only to host +- File: `frontend/src/pages/LobbyPage.tsx` +- Render the "Start Game" button only when `isHost` is true. +- Non-host players see a static "Waiting for the host to start..." message instead. +- Verify: `npm run build` in `frontend/` passes. + +### I4 — Disable Start Game when fewer than 2 players +- File: `frontend/src/pages/LobbyPage.tsx` +- Compute `canStart = room.participants.length >= 2`. +- Disable the button when `!canStart`. +- Show a reason below the button ("Need at least 2 players") when the button is disabled. +- Verify: `npm run build` in `frontend/` passes. + +### I5 — Wire Start Game button to store action and navigate +- File: `frontend/src/pages/LobbyPage.tsx` +- On click, call `roomStore.startGame()`. +- On success, navigate to `/game`. +- On error, display the error message from the store (403 or 422 surface naturally via the existing error field). +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group J — Frontend Tests + +### J1 — Test api service: startGame sends correct request +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.startGame` makes a `POST` to `/rooms/:code/start` with the correct body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group K — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### K1 — Validate: empty name is rejected on Create Room +- Open Create Room, submit with empty name. +- Expected: inline error appears, no network request made. + +### K2 — Validate: whitespace-only name is rejected on Create Room +- Open Create Room, submit with spaces only. +- Expected: inline error appears, no network request made. + +### K3 — Validate: empty name is rejected on Join Room +- Open Join Room, submit with empty name and a valid code. +- Expected: inline error appears, no network request made. + +### K4 — Validate: empty code is rejected on Join Room +- Open Join Room, submit with a valid name and empty code. +- Expected: inline error appears, no network request made. + +### K5 — Validate: unknown room code shows error +- Open Join Room, submit with a valid name and a code that does not exist ("ZZZZ"). +- Expected: error message shown, player stays on join screen. + +### K6 — Validate: lobby polls automatically +- Tab 1: create a room, land in lobby. +- Tab 2: join the same room. +- Expected: Tab 1 participant list updates within ~2 seconds without any manual action. + +### K7 — Validate: non-host does not see Start Game +- Tab 1: create a room (host). Tab 2: join the room. +- In Tab 2, confirm "Start Game" button is not visible. +- Expected: Tab 2 shows "Waiting for the host to start..." message. + +### K8 — Validate: host cannot start with 1 player +- Tab 1: create a room, stay in lobby alone. +- Expected: "Start Game" button is disabled and shows the minimum player message. + +### K9 — Validate: host can start with 2 players and navigates to game +- Tab 1: create a room. Tab 2: join the room. +- In Tab 1, click "Start Game". +- Expected: Tab 1 navigates to `/game`. No errors shown. + +### K10 — Validate: rooms are isolated +- Create Room A in Tab 1. Create Room B in Tab 2. +- Join Room A in a third tab. +- Expected: Room B participant list is unaffected. diff --git a/specs/002-game-start-and-drawer-flow/plan.md b/specs/002-game-start-and-drawer-flow/plan.md new file mode 100644 index 0000000..0b1fa67 --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/plan.md @@ -0,0 +1,275 @@ +# Plan — Scenario 2: Game Start & Drawer Flow + +--- + +## Findings + +### What exists and is relevant (post Scenario 1) + +| Area | File | Current behavior | +|---|---|---| +| Room model | `backend/src/models/game.ts` | `Room` has `code`, `status` (`"lobby" \| "playing"`), `hostId`, `participants`, `createdAt`, `updatedAt`. No `drawerId`. No `currentWord`. | +| RoomSnapshot model | `backend/src/models/game.ts` | `RoomSnapshot` has `code`, `status`, `hostId`, `participants`, `availableWords`, `roles`. No `drawerId`. No `currentWord`. | +| Name schemas | `backend/src/api/schemas.ts` | `createRoomSchema.playerName` is `z.string().optional()`. `joinRoomSchema.playerName` is `z.string().optional()`. Neither trims nor enforces a minimum length. | +| displayName helper | `backend/src/services/roomStore.ts` | `displayName(name?)` returns `name \|\| "Player"`. Falsy check passes empty strings to the `"Player"` fallback, but `" "` (spaces only) is truthy and would be stored as-is. | +| startGame service | `backend/src/services/roomStore.ts` | Sets `room.status = "playing"` and saves. Returns the snapshot. Does not assign a drawer. Does not select a word. | +| toRoomSnapshot | `backend/src/services/roomStore.ts` | Receives `viewerParticipantId` as a parameter but immediately voids it (`void viewerParticipantId`). Returns identical data to every caller — no per-viewer filtering of any kind. | +| Rooms router | `backend/src/api/rooms.ts` | Four endpoints: `POST /rooms`, `POST /rooms/:code/join`, `POST /rooms/:code/start`, `GET /rooms/:code`. The `GET` endpoint already passes `participantId` from the query string into `toRoomSnapshot` — the wiring is there, the filtering logic is not. | +| Frontend snapshot type | `frontend/src/services/api.ts` | `RoomSnapshot` interface has `code`, `status`, `hostId`, `participants`, `availableWords`, `roles`. No `drawerId`. No `currentWord`. | +| CreateRoomPage | `frontend/src/pages/CreateRoomPage.tsx` | Calls `roomStore.createRoom(playerName)` with no prior validation. An empty or whitespace-only name is sent directly to the API. Backend accepts it and stores `"Player"` (empty) or the raw whitespace (spaces-only). | +| JoinRoomPage | `frontend/src/pages/JoinRoomPage.tsx` | Has client-side code validation (empty code rejected). No name validation. Empty or whitespace-only names are sent to the API without error. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Canvas area contains a static `
    ` with `"Waiting for drawer..."`. Player Info card shows hardcoded `"Playing"` status. No drawer is identified. No word is shown. No role-aware branching exists anywhere in the component. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame` actions. No game-screen polling. | + +### What is missing + +1. **Name trimming and rejection (schemas)** — `playerName` in both create and join schemas accepts any string including empty and whitespace-only. The `displayName` fallback masks empty strings with `"Player"` but cannot reject them or trim them. +2. **Name validation (frontend forms)** — `CreateRoomPage` has no client-side name check. `JoinRoomPage` has code validation but no name check. Both must reject before making an API call. +3. **Drawer field on Room** — `Room` has no `drawerId` field. `startGame` transitions status but assigns no drawer. +4. **Current word field on Room** — `Room` has no `currentWord` field. `startGame` selects no word. +5. **Drawer and word in RoomSnapshot** — `RoomSnapshot` has no `drawerId` or `currentWord`. Even if the fields existed on `Room`, they would not be returned to the frontend. +6. **Per-viewer filtering in toRoomSnapshot** — `viewerParticipantId` is received but voided. The secret word must be returned only when the viewer is the drawer. The infrastructure (the parameter, the query-string wiring in `GET /rooms/:code`) already exists; only the conditional logic is missing. +7. **Role-aware GamePage** — the game screen renders identically for every player. It must branch on whether the viewer is the drawer or a guesser and display accordingly. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Room (before) Room (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: RoomStatus status: RoomStatus +hostId: string hostId: string +participants: Participant[] participants: Participant[] +createdAt: string createdAt: string +updatedAt: string updatedAt: string + drawerId: string | null ← NEW + currentWord: string | null ← NEW +``` + +Both fields are `null` when the room is in `"lobby"` status and are set at the moment `startGame` transitions the room to `"playing"`. + +``` +RoomSnapshot (before) RoomSnapshot (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: RoomStatus status: RoomStatus +hostId: string hostId: string +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + drawerId: string | null ← NEW (visible to all) + currentWord: string | null ← NEW (visible only to drawer) +``` + +`drawerId` is always included — every player needs to know who is drawing. +`currentWord` is conditionally populated: non-null only when `viewerParticipantId === drawerId`. + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +────────────────────────── ───────────────────────────── +code: string code: string +status: "lobby" | "playing" status: "lobby" | "playing" +hostId: string hostId: string +participants: Participant[] participants: Participant[] +availableWords: string[] availableWords: string[] +roles: ParticipantRole[] roles: ParticipantRole[] + drawerId: string | null ← NEW + currentWord: string | null ← NEW +``` + +--- + +## Required API Changes + +### Modified: Zod schemas — `backend/src/api/schemas.ts` + +``` +createRoomSchema.playerName: z.string().optional() + → z.string().trim().min(1, "Player name is required") + +joinRoomSchema.playerName: z.string().optional() + → z.string().trim().min(1, "Player name is required") +``` + +`z.string().trim()` runs before `.min(1)` is evaluated. A whitespace-only string trims to `""` and fails the length check, returning a 400. No `.refine()` is needed. + +### Modified: `createRoom` service — `backend/src/services/roomStore.ts` + +Initialise the two new fields on every new room: +``` +drawerId: null +currentWord: null +``` +No other logic change. The schema now guarantees the name is trimmed before reaching the service. + +### Modified: `startGame` service — `backend/src/services/roomStore.ts` + +Add two assignments before saving: +``` +room.drawerId = room.hostId +room.currentWord = selectWord(room.code) +``` + +`selectWord` is a private pure function that derives a stable index from the room code: +``` +index = sum of char codes of room.code % STARTER_WORDS.length +``` +The same room code always yields the same index. The function has no side effects and requires no new dependencies. + +### Modified: `toRoomSnapshot` — `backend/src/services/roomStore.ts` + +Remove `void viewerParticipantId`. Add two fields to the returned object: +``` +drawerId: room.drawerId +currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null +``` + +The word is `null` for any caller whose id does not match the drawer, including callers who omit `participantId` entirely. + +### No new endpoints + +The existing four endpoints are sufficient. +- `POST /rooms` — benefits automatically from schema change (name trimmed + validated). +- `POST /rooms/:code/join` — same. +- `POST /rooms/:code/start` — calls `startGame` which will gain drawer + word assignment. +- `GET /rooms/:code` — already passes `participantId` to `toRoomSnapshot`; filtering logic slots in there with no handler change. + +--- + +## Data Flow + +### Create Room (name validation enforced) +``` +CreateRoomPage + → [trim name client-side; if empty → "Player name is required", stop] + → POST /rooms { playerName } + → Zod: z.string().trim().min(1) — rejects whitespace-only with 400 + → createRoom(trimmedName): room.drawerId = null, room.currentWord = null + → toRoomSnapshot(room, participantId): drawerId null, currentWord null + → response unchanged shape +``` + +### Join Room (name validation added) +``` +JoinRoomPage + → [trim name client-side; if empty → "Player name is required", stop] ← NEW + → [trim code client-side; if empty → "Room code is required"] (exists) + → POST /rooms/:code/join { playerName } + → Zod: z.string().trim().min(1) — rejects whitespace-only with 400 + → joinRoom(code, trimmedName) + → response unchanged shape +``` + +### Start Game (drawer + word assigned) +``` +LobbyPage (host, ≥2 players) + → POST /rooms/:code/start { participantId: hostId } + → startGame(code, hostId): + room.drawerId = room.hostId + room.currentWord = selectWord(room.code) + room.status = "playing" + → toRoomSnapshot(room, hostId): + drawerId: hostId ← non-null + currentWord: room.currentWord ← visible: viewer IS the drawer + → response { room } — host's snapshot includes the word + → roomStore.setRoomSnapshot(room) + → navigate("/game") +``` + +### Polling from game screen (per-viewer word filtering) +``` +GamePage (every ~2s) + → GET /rooms/:code?participantId= + → toRoomSnapshot(room, viewerId): + drawerId: room.drawerId (always included) + currentWord: room.currentWord if viewerId === room.drawerId + currentWord: null otherwise + → roomStore.setRoomSnapshot(room) + → GamePage re-renders: drawer sees word, guessers see null +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: tighten name schemas +- **File:** `backend/src/api/schemas.ts` +- Change `createRoomSchema.playerName` to `z.string().trim().min(1, "Player name is required")`. +- Change `joinRoomSchema.playerName` to the same. +- Verify: `npm run build` in `backend/` passes. + +### Step 2 — Backend: extend Room and RoomSnapshot models +- **File:** `backend/src/models/game.ts` +- Add `drawerId: string | null` and `currentWord: string | null` to `Room`. +- Add `drawerId: string | null` and `currentWord: string | null` to `RoomSnapshot`. +- Verify: `npm run build` in `backend/` passes (type errors in the service are expected — fixed in Step 3). + +### Step 3 — Backend: update createRoom and startGame in the room service +- **File:** `backend/src/services/roomStore.ts` +- In `createRoom`: add `drawerId: null` and `currentWord: null` to the new room literal. +- Add private `selectWord(code: string)` function using char-code-sum mod word-list-length. +- In `startGame`: assign `room.drawerId = room.hostId` and `room.currentWord = selectWord(room.code)` before saving. +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add per-viewer filtering to toRoomSnapshot +- **File:** `backend/src/services/roomStore.ts` +- Remove `void viewerParticipantId`. +- Add `drawerId: room.drawerId` to the returned object. +- Add `currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null` to the returned object. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Frontend: update RoomSnapshot interface +- **File:** `frontend/src/services/api.ts` +- Add `drawerId: string | null` and `currentWord: string | null` to the `RoomSnapshot` interface. +- Verify: `npm run build` in `frontend/` passes (type errors in pages expected — fixed in Step 7). + +### Step 6 — Frontend: add name validation to forms +- **File:** `frontend/src/pages/CreateRoomPage.tsx` + - Trim name before submitting. If empty after trim, show "Player name is required" inline and return without calling the API. +- **File:** `frontend/src/pages/JoinRoomPage.tsx` + - Apply the same name check before the existing code check. Show "Player name is required" inline and return. +- Verify: `npm run build` in `frontend/` passes. + +### Step 7 — Frontend: update GamePage for role-aware rendering +- **File:** `frontend/src/pages/GamePage.tsx` +- Derive `isDrawer = room.drawerId === participantId`. +- Derive `drawerName` by finding the participant whose id matches `room.drawerId`. +- Player Info card: show role as `"Drawer"` or `"Guesser"` based on `isDrawer`. +- Canvas area: + - Drawer: show their secret word (`room.currentWord`) and a label indicating they are drawing. + - Guesser: show `"Waiting for [drawerName] to draw..."`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/api/schemas.ts` | Modify — tighten `playerName` in create and join schemas | +| `backend/src/models/game.ts` | Modify — add `drawerId` and `currentWord` to `Room` and `RoomSnapshot` | +| `backend/src/services/roomStore.ts` | Modify — initialise fields in `createRoom`, add `selectWord`, assign in `startGame`, filter in `toRoomSnapshot` | +| `frontend/src/services/api.ts` | Modify — add `drawerId` and `currentWord` to `RoomSnapshot` interface | +| `frontend/src/pages/CreateRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/JoinRoomPage.tsx` | Modify — add client-side name validation | +| `frontend/src/pages/GamePage.tsx` | Modify — role-aware layout, drawer identity, conditional word display | + +No new files. No new libraries. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| `displayName` fallback can still mask a name that slips through if a caller bypasses the schema | Schema now validates before `displayName` is reached in normal flows. `displayName` can be simplified to a direct passthrough since the schema guarantees a non-empty trimmed value. | +| `selectWord` result must be stable across calls for the same code | The function is a pure deterministic computation with no randomness — same input always returns same output. | +| `toRoomSnapshot` change affects all four endpoints simultaneously | Intended. All endpoints call `toRoomSnapshot`. The lobby-phase snapshot gains `drawerId: null` and `currentWord: null` which is correct for the lobby state. | +| GamePage has no polling of its own yet | Not required for Scenario 2. The start-game snapshot already delivers the drawer and word. Polling is needed in Scenario 3 for guess sync. | +| A guesser navigating directly to `/game` via URL without going through the lobby | The existing redirect guard (`if (!room) navigate("/")`) already handles this. Room state is only set by create/join flows. | diff --git a/specs/002-game-start-and-drawer-flow/spec.md b/specs/002-game-start-and-drawer-flow/spec.md new file mode 100644 index 0000000..74baf4d --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/spec.md @@ -0,0 +1,89 @@ +## Scenario 2 — Game Start & Drawer Flow + +### Problem + +When the host starts the game, two things must happen that currently do not: a drawer must be assigned, and a secret word must be chosen. The game screen is a placeholder — it shows the same static content to every player regardless of their role. Players have no way to know who is drawing, and the drawer has no word to draw. The backend transitions the room to `"playing"` but assigns no drawer and selects no word. The `toRoomSnapshot` function returns the same data to everyone, meaning sensitive information (the secret word) cannot be hidden from guessers even once it exists. Additionally, player names are accepted without trimming or emptiness checks, so whitespace-only names can enter the game. + +--- + +### Requirements + +#### Player Name Validation +- Player names must be trimmed of leading and trailing whitespace before being stored. +- A name that is empty or whitespace-only after trimming must be rejected with a clear inline error message. +- Rejection happens on the frontend before any API call is made. +- The backend also enforces the rule: a request with an empty or whitespace-only name returns a 400 error. +- Both Create Room and Join Room enforce this rule identically. + +#### Drawer Assignment +- When a game starts, the host is assigned as the drawer for the first round. +- Drawer assignment is recorded on the backend in the room's state. +- The drawer identity (`drawerId`) is included in every room snapshot and is visible to all players — everyone needs to know who is drawing. +- Only one player can be the drawer at a time. + +#### Secret Word Selection +- A secret word is selected when the game starts, at the same moment the drawer is assigned. +- The word is chosen deterministically from the starter word list using the room code as the seed — the same room code always produces the same word. +- The word is stored on the backend in the room's state. +- The word is never randomly selected; the selection rule must be reproducible. + +#### Secret Word Visibility +- The secret word is visible only to the drawer. +- All other players receive `null` (or no value) for the current word in their snapshot. +- The backend enforces this rule in `toRoomSnapshot` by comparing the requesting participant's id to the drawer's id. +- The frontend renders the word only when the viewer is the drawer; guessers never see it. + +#### Drawer Identification in the UI +- The game screen clearly identifies who the drawer is by name. +- The drawer's own view shows their role ("You are drawing") and their secret word. +- A guesser's view shows who is drawing ("Waiting for [name] to draw...") and does not show the word. +- The Player Info panel on the game screen shows the viewer's role as "Drawer" or "Guesser". + +--- + +### Edge Cases + +- A whitespace-only name (e.g. `" "`) must be rejected the same way as a fully empty name — after trimming, the result is empty. +- A name that is only spaces on one side (e.g. `" Alice"`) must be accepted after trimming and stored as `"Alice"`. +- If a player joins with a valid name that has surrounding spaces, the stored name must be trimmed; the raw input must not be persisted. +- The drawer is assigned at the moment of game start — joining the room after the game has started does not change the drawer. +- The secret word must not change between polls. Once assigned, `currentWord` is fixed until the game is restarted. +- The same viewer id used in the polling query (`GET /rooms/:code?participantId=...`) is what determines word visibility — if `participantId` is missing or does not match the drawer, the word is hidden. +- If a player opens the game URL directly without a room in state, they are redirected to the start screen (existing behavior, unchanged). + +--- + +### Acceptance Criteria + +**Player name validation** +- [ ] Submitting an empty name on Create Room shows "Player name is required" and does not call the API. +- [ ] Submitting a whitespace-only name on Create Room shows the same error and does not call the API. +- [ ] Submitting an empty name on Join Room shows "Player name is required" and does not call the API. +- [ ] Submitting a whitespace-only name on Join Room shows the same error and does not call the API. +- [ ] A name with surrounding spaces (e.g. `" Alice "`) is accepted and stored as `"Alice"` — the trimmed value is displayed in the lobby and game screen. +- [ ] Sending an empty or whitespace-only name directly to the backend returns a 400 error. + +**Drawer assignment** +- [ ] After the host starts the game, the room snapshot includes a `drawerId` matching the host's participant id. +- [ ] Every player's snapshot (fetched via polling) includes the same `drawerId`. +- [ ] The drawer is the host — no other participant is ever assigned as drawer in this scenario. + +**Secret word selection** +- [ ] After the host starts the game, a `currentWord` is set on the backend. +- [ ] The selected word is one of the five starter words (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`). +- [ ] Starting a game with the same room code always selects the same word (deterministic). +- [ ] Different room codes produce different words (at least across the range of the starter list). + +**Secret word visibility** +- [ ] The drawer's snapshot includes the secret word as a non-null string. +- [ ] A guesser's snapshot returns `null` (or no value) for `currentWord`. +- [ ] Calling `GET /rooms/:code` without a `participantId` does not expose the word. +- [ ] Calling `GET /rooms/:code?participantId=` does not expose the word. +- [ ] Calling `GET /rooms/:code?participantId=` returns the word. + +**Drawer identification in the UI** +- [ ] The drawer's game screen shows their role as "Drawer" (or equivalent) in the Player Info panel. +- [ ] The drawer's game screen shows the secret word clearly. +- [ ] A guesser's game screen shows their role as "Guesser" in the Player Info panel. +- [ ] A guesser's game screen shows the drawer's name in the canvas area (e.g. "Waiting for Alice to draw..."). +- [ ] The guesser's game screen does not show the secret word anywhere. diff --git a/specs/002-game-start-and-drawer-flow/tasks.md b/specs/002-game-start-and-drawer-flow/tasks.md new file mode 100644 index 0000000..d65a726 --- /dev/null +++ b/specs/002-game-start-and-drawer-flow/tasks.md @@ -0,0 +1,254 @@ +# Tasks — Scenario 2: Game Start & Drawer Flow + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group L — Backend Schemas + +### L1 — Tighten playerName in createRoomSchema +- File: `backend/src/api/schemas.ts` +- Change `createRoomSchema.playerName` from `z.string().optional()` to `z.string().trim().min(1, "Player name is required")`. +- No other changes in this task. +- Verify: `npm run build` in `backend/` passes. + +### L2 — Tighten playerName in joinRoomSchema +- File: `backend/src/api/schemas.ts` +- Apply the same change as L1 to `joinRoomSchema.playerName`. +- No other changes in this task. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group M — Backend Model + +### M1 — Add drawerId and currentWord to Room interface +- File: `backend/src/models/game.ts` +- Add `drawerId: string | null` to the `Room` interface. +- Add `currentWord: string | null` to the `Room` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Type errors in the service are expected and fixed in Group N. + +### M2 — Add drawerId and currentWord to RoomSnapshot interface +- File: `backend/src/models/game.ts` +- Add `drawerId: string | null` to the `RoomSnapshot` interface. +- Add `currentWord: string | null` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group N — Backend Service + +### N1 — Initialise drawerId and currentWord in createRoom +- File: `backend/src/services/roomStore.ts` +- In the `createRoom` function, add `drawerId: null` and `currentWord: null` to the room object literal. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### N2 — Add selectWord helper function +- File: `backend/src/services/roomStore.ts` +- Add a private (non-exported) `selectWord(code: string)` function. +- The function derives an index by summing the char codes of each character in `code`, then taking the result modulo `STARTER_WORDS.length`. +- Returns `STARTER_WORDS[index]`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### N3 — Assign drawer and word in startGame +- File: `backend/src/services/roomStore.ts` +- In the `startGame` function, before saving the room, add: + - `room.drawerId = room.hostId` + - `room.currentWord = selectWord(room.code)` +- No other changes to the function. +- Verify: `npm run build` in `backend/` passes. + +### N4 — Add per-viewer filtering to toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- Remove the `void viewerParticipantId` line. +- Add `drawerId: room.drawerId` to the returned snapshot object. +- Add `currentWord: viewerParticipantId === room.drawerId ? room.currentWord : null` to the returned snapshot object. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group O — Backend Tests + +### O1 — Test createRoomSchema rejects empty playerName +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: "" })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### O2 — Test createRoomSchema rejects whitespace-only playerName +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: " " })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### O3 — Test createRoomSchema trims a valid name with surrounding spaces +- File: `backend/src/api/schemas.test.ts` +- Add a test: `createRoomSchema.parse({ playerName: " Alice " })` succeeds and the parsed value is `"Alice"`. +- Run: `npm test` in `backend/` — all tests pass. + +### O4 — Test joinRoomSchema rejects empty and whitespace-only playerName +- File: `backend/src/api/schemas.test.ts` +- Add test cases mirroring O1 and O2 for `joinRoomSchema`. +- Run: `npm test` in `backend/` — all tests pass. + +### O5 — Test createRoom initialises drawerId and currentWord as null +- File: `backend/src/services/roomStore.test.ts` +- Add a test: call `createRoom("Alice")` and assert `result.room.drawerId === null` and `result.room.currentWord === null`. +- Run: `npm test` in `backend/` — all tests pass. + +### O6 — Test startGame assigns drawerId to the host +- File: `backend/src/services/roomStore.test.ts` +- Create a room and join a second player. Call `startGame` with the host id. Assert the returned snapshot has `drawerId === hostId`. +- Run: `npm test` in `backend/` — all tests pass. + +### O7 — Test startGame selects a word from the starter list +- File: `backend/src/services/roomStore.test.ts` +- Create a room and join a second player. Call `startGame` with the host id. Assert `snapshot.currentWord` is one of the five starter words. +- Run: `npm test` in `backend/` — all tests pass. + +### O8 — Test startGame word selection is deterministic +- File: `backend/src/services/roomStore.test.ts` +- Create two rooms with the same code (or simulate the same code by calling the selection logic directly). Assert both produce the same word. +- Run: `npm test` in `backend/` — all tests pass. + +### O9 — Test toRoomSnapshot returns word to drawer, null to others +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `toRoomSnapshot(room, hostId)` and assert `currentWord` is non-null. Call `toRoomSnapshot(room, secondPlayerId)` and assert `currentWord` is `null`. Call `toRoomSnapshot(room, undefined)` and assert `currentWord` is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### O10 — Test toRoomSnapshot always includes drawerId +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `toRoomSnapshot` with both the drawer id and a guesser id. Assert `drawerId` is non-null and identical in both snapshots. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group P — Frontend Types + +### P1 — Add drawerId and currentWord to RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `drawerId: string | null` to the `RoomSnapshot` interface. +- Add `currentWord: string | null` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. Type errors in pages are expected until Group R is done. + +--- + +## Group Q — Frontend Validation + +### Q1 — Add name validation to CreateRoomPage +- File: `frontend/src/pages/CreateRoomPage.tsx` +- Before calling the store, trim the player name. +- If the trimmed value is empty, set an inline error "Player name is required" and return without calling the API. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Q2 — Add name validation to JoinRoomPage +- File: `frontend/src/pages/JoinRoomPage.tsx` +- Before the existing room code check, trim the player name. +- If the trimmed name is empty, set an inline error "Player name is required" and return without calling the API. +- The name check runs before the code check — both errors are possible on the same submission but name is checked first. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group R — Frontend GamePage + +### R1 — Derive isDrawer and drawerName from room state +- File: `frontend/src/pages/GamePage.tsx` +- Derive `isDrawer = room.drawerId === participantId`. +- Derive `drawerParticipant` by finding the participant in `room.participants` whose id matches `room.drawerId`. +- No rendering changes yet — build-verify only. +- Verify: `npm run build` in `frontend/` passes. + +### R2 — Update Player Info card to show role +- File: `frontend/src/pages/GamePage.tsx` +- In the Player Info card, replace the hardcoded `"Playing"` status with `"Drawer"` when `isDrawer` is true and `"Guesser"` otherwise. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### R3 — Update canvas area for drawer: show secret word +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is true, replace the static placeholder with a message that tells the player they are the drawer and displays `room.currentWord`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### R4 — Update canvas area for guesser: show drawer name +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is false, replace the static placeholder with `"Waiting for [drawerParticipant name] to draw..."`. Fall back to `"Waiting for the drawer..."` if the participant is not found. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group S — Frontend Tests + +### S1 — Test api service: createRoom sends playerName in body +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.createRoom("Alice")` makes a `POST` to `/rooms` with `{ playerName: "Alice" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +### S2 — Test api service: joinRoom sends playerName and code +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.joinRoom("ABCD", "Bob")` makes a `POST` to `/rooms/ABCD/join` with `{ playerName: "Bob" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group T — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### T1 — Validate: empty name rejected on Create Room +- Open Create Room, submit with an empty name field. +- Expected: inline error "Player name is required" appears, no network request is made. + +### T2 — Validate: whitespace-only name rejected on Create Room +- Open Create Room, type several spaces into the name field, submit. +- Expected: inline error appears, no network request is made. + +### T3 — Validate: name with surrounding spaces is accepted and trimmed +- Open Create Room, type `" Alice "` into the name field, submit. +- Expected: room is created successfully, player appears in the lobby as `"Alice"` (not `" Alice "`). + +### T4 — Validate: empty name rejected on Join Room +- Open Join Room, leave the name field empty, enter a valid code, submit. +- Expected: inline error "Player name is required" appears, no network request is made. + +### T5 — Validate: whitespace-only name rejected on Join Room +- Open Join Room, type spaces into the name field, enter a valid code, submit. +- Expected: inline error appears, no network request is made. + +### T6 — Validate: drawer is assigned on game start +- Tab 1: create a room. Tab 2: join the same room. Tab 1: click "Start Game". +- Expected: Tab 1 navigates to the game screen. The Player Info card shows "Drawer". + +### T7 — Validate: guesser sees correct role +- Tab 2 (from T6): wait for or manually navigate to `/game`. +- Expected: the Player Info card shows "Guesser". + +### T8 — Validate: drawer sees the secret word +- Tab 1 (drawer, from T6): inspect the game screen canvas area. +- Expected: the secret word is shown. It is one of: rocket, pizza, castle, guitar, sunflower. + +### T9 — Validate: guesser does not see the secret word +- Tab 2 (guesser, from T6): inspect the game screen canvas area. +- Expected: the secret word is not visible. The drawer's name appears in the canvas area instead. + +### T10 — Validate: word selection is deterministic +- Note the room code and word shown in T8. +- Restart the backend, recreate a room with a different player name but confirm the same room code is used if possible, or repeat across multiple sessions to observe consistency. +- Expected: the same room code always produces the same word across restarts. + +### T11 — Validate: GET /rooms/:code does not leak word to guesser +- After game start (from T6), call `GET /rooms/:code?participantId=` directly (e.g. via browser devtools or curl). +- Expected: `currentWord` in the response is `null`. + +### T12 — Validate: GET /rooms/:code returns word to drawer +- After game start (from T6), call `GET /rooms/:code?participantId=` directly. +- Expected: `currentWord` in the response is the secret word string. diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 0000000..7bb8139 --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,370 @@ +# Plan — Scenario 3: Gameplay Interaction + +--- + +## Findings + +### What exists and is relevant (post Scenario 2) + +| Area | File | Current behavior | +|---|---|---| +| Participant model | `backend/src/models/game.ts` | Has `id`, `name`, `joinedAt`. No `score` field. | +| Room model | `backend/src/models/game.ts` | Has `code`, `status`, `hostId`, `drawerId`, `currentWord`, `participants`, `createdAt`, `updatedAt`. No `guesses` field. | +| RoomSnapshot model | `backend/src/models/game.ts` | Has `code`, `status`, `hostId`, `drawerId`, `currentWord`, `participants`, `availableWords`, `roles`. No `guesses`. | +| Schemas | `backend/src/api/schemas.ts` | Has `createRoomSchema`, `joinRoomSchema`, `startGameSchema`, `roomCodeParamsSchema`, `roomViewerQuerySchema`. No `submitGuessSchema`. | +| Room service | `backend/src/services/roomStore.ts` | `createRoom` and `joinRoom` do not initialise a `guesses` array or participant `score`. `toRoomSnapshot` returns participants without scores and no guess history. No `submitGuess` function. | +| Rooms router | `backend/src/api/rooms.ts` | Four endpoints: `POST /rooms`, `POST /rooms/:code/join`, `POST /rooms/:code/start`, `GET /rooms/:code`. No guess endpoint. | +| Frontend snapshot type | `frontend/src/services/api.ts` | `Participant` has `id`, `name`, `joinedAt` — no `score`. `RoomSnapshot` has no `guesses` field. No `submitGuess` client method. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`. No `submitGuess` action. No polling on the game screen. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Has role-aware canvas text. `GuessForm` is rendered for all players regardless of role — the drawer also sees it. No polling `useEffect`. No drawing canvas element. | +| GuessForm | `frontend/src/components/GuessForm.tsx` | Has local `guessText` state and a `disabled` prop. `handleSubmit` prevents default but makes no API call — the form is wired to nothing. | +| Scoreboard | `frontend/src/components/Scoreboard.tsx` | Static placeholder showing hardcoded `"Waiting for players..."` and `0`. Reads no data from the store. | +| ResultPanel | `frontend/src/components/ResultPanel.tsx` | Renamed "Activity". Shows static placeholder text. Reads no data from the store. | + +### What is missing + +1. **`score` on `Participant`** — no score field exists anywhere in the model. All participants are scoreless. +2. **`Guess` type** — no type definition for a guess record (participant id, name, text, result, timestamp). +3. **`guesses` on `Room`** — no array to hold submitted guesses. +4. **`guesses` in `RoomSnapshot`** — guess history is never surfaced to the frontend. +5. **`submitGuessSchema`** — no Zod schema to validate guess submissions. +6. **`submitGuess` service function** — no backend logic to trim/compare/score/store a guess. +7. **`POST /rooms/:code/guess` endpoint** — no route to receive guesses. +8. **Frontend `Participant.score`** — type missing from the interface. +9. **Frontend `Guess` type and `RoomSnapshot.guesses`** — type missing from the interface. +10. **Frontend `submitGuess` client method** — not in `api.ts`. +11. **Frontend `submitGuess` store action** — not in `roomStore.ts`. +12. **GamePage polling** — `GamePage` has no `setInterval`. The game screen never refreshes after the start snapshot. +13. **GuessForm wiring** — form submits nothing; no validation, no store call, no feedback. +14. **Drawer exclusion from guessing** — `GuessForm` is always rendered; the drawer sees and can interact with it. +15. **Drawing canvas** — the canvas area is a `
    ` placeholder. The drawer has no interactive surface. +16. **Clear canvas button** — does not exist. +17. **Scoreboard real data** — component reads nothing from the store. +18. **ResultPanel real data** — component reads nothing from the store. + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +Participant (before) Participant (after) +────────────────────────── ───────────────────────────── +id: string id: string +name: string name: string +joinedAt: string joinedAt: string + score: number ← NEW (initialised to 0) +``` + +``` +New type: Guess (added to game.ts) +────────────────────────── +participantId: string +participantName: string +text: string +isCorrect: boolean +submittedAt: string +``` + +``` +Room (before) Room (after) +────────────────────────── ───────────────────────────── +code, status, hostId code, status, hostId +drawerId, currentWord drawerId, currentWord +participants, createdAt participants, createdAt +updatedAt updatedAt + guesses: Guess[] ← NEW (initialised to []) +``` + +``` +RoomSnapshot (before) RoomSnapshot (after) +────────────────────────── ───────────────────────────── +code, status, hostId code, status, hostId +drawerId, currentWord drawerId, currentWord +participants participants (now includes score per participant) +availableWords, roles availableWords, roles + guesses: Guess[] ← NEW +``` + +### Frontend — `frontend/src/services/api.ts` + +``` +Participant interface (before) Participant interface (after) +────────────────────────── ───────────────────────────── +id: string id: string +name: string name: string +joinedAt: string joinedAt: string + score: number ← NEW +``` + +``` +New interface: Guess (added to api.ts) +────────────────────────── +participantId: string +participantName: string +text: string +isCorrect: boolean +submittedAt: string +``` + +``` +RoomSnapshot interface (before) RoomSnapshot interface (after) +────────────────────────── ───────────────────────────── +...existing fields... ...existing fields... + guesses: Guess[] ← NEW +``` + +--- + +## Required API Changes + +### Modified: `createRoom` and `joinRoom` service functions + +- `createRoom`: initialise `participant.score = 0` when creating the participant; initialise `room.guesses = []`. +- `joinRoom`: initialise `participant.score = 0` when creating the joining participant. +- These are initialisation-only changes — no logic change. + +### Modified: `toRoomSnapshot` + +- `participants` map must now include `score: participant.score` in each entry. +- Add `guesses: room.guesses.map(g => ({ ...g }))` to the returned object. +- No per-viewer filtering on guesses — all players see the full history. + +### New schema: `submitGuessSchema` + +``` +z.object({ + participantId: z.string().min(1), + text: z.string().trim().min(1, "Guess cannot be empty") +}) +``` + +`z.string().trim()` on `text` ensures whitespace-only guesses fail the `.min(1)` check. `participantId` identifies the submitter. + +### New endpoint: `POST /rooms/:code/guess` + +- **Purpose:** Accept, validate, score, and store a single guess. +- **Request:** Body `{ participantId: string, text: string }`. +- **Validations (in order):** + 1. Room must exist → 404 "Room not found". + 2. Room `status` must be `"playing"` → 422 "Game is not in progress". + 3. `participantId` must match a participant in the room → 404 "Participant not found". + 4. `participantId` must not equal `room.drawerId` → 403 "Drawer cannot submit a guess". +- **Processing:** + 1. Trim and lowercase `text`. + 2. Compare to `room.currentWord.trim().toLowerCase()`. + 3. Set `isCorrect = true` if they match. + 4. If `isCorrect` and `participant.score < 100`: set `participant.score = 100`. + 5. Append `{ participantId, participantName: participant.name, text: trimmedText, isCorrect, submittedAt: now() }` to `room.guesses`. + 6. Save room. +- **Response:** `{ room: RoomSnapshot }` — the full updated snapshot, so the frontend updates scores and guess history in one step. + +### New client method: `submitGuess` + +- **File:** `frontend/src/services/api.ts` +- Calls `POST /rooms/:code/guess` with `{ participantId, text }`. +- Returns `{ room: RoomSnapshot }`. + +--- + +## Polling Changes + +### GamePage gains a polling `useEffect` + +`GamePage` currently has no polling — it renders from the snapshot set at game start and never refreshes. For Scenario 3, all players need to see updated guess history and scores. + +The pattern is identical to `LobbyPage`: +- `useEffect` starts a `setInterval` at 2000ms calling `roomStore.fetchRoom()`. +- Cleanup clears the interval on unmount. +- Dependency on `room?.code` — starts when a room is present, stops on unmount. + +`fetchRoom` already calls `GET /rooms/:code?participantId=...` which calls `toRoomSnapshot`, which will now include guesses and scores. No new endpoint or store action is needed for polling. + +--- + +## Scoring Changes + +Scores are computed and stored entirely on the backend. The frontend never computes correctness. + +- `Participant.score` initialises to `0` in `createRoom` and `joinRoom`. +- `submitGuess` awards `100` if the guess matches and the participant's score is currently below `100`. +- The cap at `100` means a second correct guess by the same player does not increase their score. +- The drawer has no score changes in this scenario. +- Scores are included in `toRoomSnapshot` via the participants array — the `Scoreboard` component reads `room.participants` from the store snapshot. + +--- + +## Data Flow + +### Guess submission +``` +GamePage (guesser only — drawer does not see GuessForm) + → GuessForm local state: guessText + → [trim guessText; if empty → "Guess cannot be empty", stop] + → onSubmit(trimmedText) callback from GamePage + → roomStore.submitGuess(trimmedText) + → POST /rooms/:code/guess { participantId, text } + → backend: + trim + lowercase text + compare to currentWord.trim().toLowerCase() + isCorrect = match + if isCorrect && score < 100 → participant.score = 100 + room.guesses.push({ ..., isCorrect, submittedAt }) + save room + → response { room: RoomSnapshot } (includes updated guesses + scores) + → roomStore.setRoomSnapshot(room) + → GuessForm clears input; all players see update on next poll +``` + +### GamePage polling +``` +GamePage mounts + → useEffect starts setInterval(2000ms) + → roomStore.fetchRoom() + → GET /rooms/:code?participantId= + → toRoomSnapshot: includes guesses[], participants[].score + → roomStore.setRoomSnapshot(room) + → Scoreboard and ResultPanel re-render with latest data + → useEffect cleanup clears interval on unmount +``` + +### Drawing canvas (drawer only) +``` +GamePage (isDrawer = true) + → renders element with pointer event handlers + → drawer draws with mouse/pointer — state is local to the browser + → "Clear Canvas" button calls canvas.getContext("2d").clearRect(...) + → canvas state is never sent to the backend — no sync with guessers +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: add `Guess` type and extend `Participant` and `Room` models +- **File:** `backend/src/models/game.ts` +- Add `score: number` to `Participant`. +- Define and export `Guess` interface. +- Add `guesses: Guess[]` to `Room`. +- Add `guesses: Guess[]` to `RoomSnapshot`. +- Verify: `npm run build` in `backend/` passes (service type errors expected — fixed in Step 2). + +### Step 2 — Backend: initialise score and guesses in room service +- **File:** `backend/src/services/roomStore.ts` +- In `createParticipant`: add `score: 0`. +- In `createRoom`: add `guesses: []` to the room literal. +- In `toRoomSnapshot`: add `score` to the participants map; add `guesses: room.guesses.map(g => ({ ...g }))`. +- Verify: `npm run build` in `backend/` passes. + +### Step 3 — Backend: add `submitGuessSchema` +- **File:** `backend/src/api/schemas.ts` +- Add `submitGuessSchema = z.object({ participantId: z.string().min(1), text: z.string().trim().min(1, "Guess cannot be empty") })`. +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add `submitGuess` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `submitGuess(code, participantId, text)` function with all four validations and the scoring and storage logic. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Backend: add `POST /rooms/:code/guess` endpoint +- **File:** `backend/src/api/rooms.ts` +- Import `submitGuessSchema` and `submitGuess`. +- Add the route handler with all guard clauses mapped to correct HTTP status codes. +- Verify: `npm run build` in `backend/` passes. + +### Step 6 — Frontend: update types in `api.ts` +- **File:** `frontend/src/services/api.ts` +- Add `score: number` to the `Participant` interface. +- Add `Guess` interface. +- Add `guesses: Guess[]` to `RoomSnapshot`. +- Add `submitGuess(code, participantId, text)` client method. +- Verify: `npm run build` in `frontend/` passes. + +### Step 7 — Frontend: add `submitGuess` action to store +- **File:** `frontend/src/state/roomStore.ts` +- Add `submitGuess(text: string)` method that reads `room.code` and `participantId` from state, calls `api.submitGuess`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### Step 8 — Frontend: wire `GuessForm` +- **File:** `frontend/src/components/GuessForm.tsx` +- Add `onSubmit: (text: string) => Promise` prop. +- On submit: trim, reject empty with inline error, call `onSubmit`, clear input on success. +- Verify: `npm run build` in `frontend/` passes. + +### Step 9 — Frontend: update `GamePage` — polling, drawer exclusion, canvas, callbacks +- **File:** `frontend/src/pages/GamePage.tsx` +- Add polling `useEffect` (same pattern as `LobbyPage`). +- Render `GuessForm` only when `!isDrawer`; pass `onSubmit` callback that calls `roomStore.submitGuess`. +- Drawer canvas area: replace the placeholder `
    ` with an HTML `` element and pointer event handlers for drawing. +- Add a "Clear Canvas" button visible only to the drawer. +- Verify: `npm run build` in `frontend/` passes. + +### Step 10 — Frontend: update `Scoreboard` +- **File:** `frontend/src/components/Scoreboard.tsx` +- Accept `participants` as a prop (or read from the store directly). +- Render each participant's name and score from the snapshot. +- Verify: `npm run build` in `frontend/` passes. + +### Step 11 — Frontend: update `ResultPanel` with guess history +- **File:** `frontend/src/components/ResultPanel.tsx` +- Accept `guesses` as a prop (or read from the store directly). +- Render each guess entry: guesser name, guess text, correct/incorrect label. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `score` to `Participant`, new `Guess` type, `guesses` to `Room` and `RoomSnapshot` | +| `backend/src/api/schemas.ts` | Modify — add `submitGuessSchema` | +| `backend/src/services/roomStore.ts` | Modify — init `score` in `createParticipant`, `guesses` in `createRoom`, include both in `toRoomSnapshot`, add `submitGuess` | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/guess` handler | +| `frontend/src/services/api.ts` | Modify — add `score` to `Participant`, `Guess` type, `guesses` to `RoomSnapshot`, `submitGuess` method | +| `frontend/src/state/roomStore.ts` | Modify — add `submitGuess` action | +| `frontend/src/components/GuessForm.tsx` | Modify — add `onSubmit` prop, client-side validation, input clear on success | +| `frontend/src/components/Scoreboard.tsx` | Modify — render real participant scores from snapshot | +| `frontend/src/components/ResultPanel.tsx` | Modify — render real guess history from snapshot | +| `frontend/src/pages/GamePage.tsx` | Modify — add polling, hide GuessForm from drawer, add canvas + clear button for drawer | + +No new files. No new libraries. + +--- + +## Testing Strategy + +### Backend unit tests (`backend/src/services/roomStore.test.ts`) +- `submitGuess`: room not found returns the not-found sentinel. +- `submitGuess`: room not in "playing" status returns the not-in-progress sentinel. +- `submitGuess`: unknown `participantId` returns the not-found sentinel. +- `submitGuess`: drawer `participantId` returns the drawer sentinel. +- `submitGuess`: correct guess (case-insensitive) sets `isCorrect: true` and awards 100 points. +- `submitGuess`: incorrect guess sets `isCorrect: false` and does not change score. +- `submitGuess`: second correct guess by same player does not raise score above 100. +- `submitGuess`: guess is appended to `room.guesses` with correct fields. +- `toRoomSnapshot`: participants include `score`. +- `toRoomSnapshot`: `guesses` array is included and matches stored guesses. + +### Backend schema tests (`backend/src/api/schemas.test.ts`) +- `submitGuessSchema`: empty `text` is rejected. +- `submitGuessSchema`: whitespace-only `text` is rejected. +- `submitGuessSchema`: `text` with surrounding spaces is trimmed and accepted. +- `submitGuessSchema`: missing `participantId` is rejected. + +### Frontend service tests (`frontend/src/services/api.test.ts`) +- `api.submitGuess`: makes a `POST` to `/rooms/:code/guess` with the correct body shape. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Canvas drawing state is local to the drawer's browser — guessers cannot see the drawing | Accepted per the out-of-scope rule (no WebSockets). The spec says the drawing is visible "on the drawer's screen" which is satisfied. | +| Polling from both `LobbyPage` and `GamePage` must not overlap | Each page manages its own `setInterval` within its own `useEffect`. Navigating from lobby to game runs the lobby cleanup before the game mounts. | +| `submitGuess` response includes the full room snapshot — this doubles as an immediate update without waiting for the next poll | Intentional — the submitter sees their guess reflected immediately. Other players see it within ~2 seconds via polling. | +| `GuessForm` needs a new `onSubmit` prop — it is currently rendered in `GamePage` without any prop | `GamePage` already holds `roomStore` and `participantId`. The callback is a one-liner defined in `GamePage` and passed down. | +| `createParticipant` is a private function — adding `score: 0` there automatically covers both `createRoom` and `joinRoom` | Correct. Both functions call `createParticipant`. Initialising `score` there means no change is needed in `joinRoom` for the participant. | +| A player submits a guess after navigating away from the game screen | Polling stops on unmount via cleanup. Any in-flight request will still complete but the response snapshot update is a no-op because the store update triggers no re-render on an unmounted component. | diff --git a/specs/003-gameplay-interaction/spec.md b/specs/003-gameplay-interaction/spec.md new file mode 100644 index 0000000..8ab9e4a --- /dev/null +++ b/specs/003-gameplay-interaction/spec.md @@ -0,0 +1,112 @@ +## Scenario 3 — Gameplay Interaction + +### Problem + +The game screen currently has no real interactivity. The canvas is a static placeholder — neither the drawer nor any guesser can do anything on it. There is no mechanism for submitting a guess, no backend storage for guesses, and no way for players to see what others have guessed. Scores do not exist on any player record. The game is in a running state but nothing actually happens once it starts. + +--- + +### Requirements + +#### Drawing Canvas +- The game screen must provide an interactive drawing surface for the drawer. +- The drawer can draw freely on the canvas using pointer or mouse events. +- The canvas is visible to all players on the game screen. +- Only the drawer can draw; guessers see the canvas as read-only. +- The canvas renders the same drawing for the drawer and guessers (drawing state is not synced via WebSockets — the canvas is local to the drawer's screen; guessers see a static or placeholder view until Scenario 3 polling is wired). + +#### Clear Canvas +- The drawer has access to a "Clear Canvas" button. +- Clicking it wipes the canvas back to a blank state. +- Guessers do not see the clear button. +- Clearing is a local action on the drawer's canvas — it does not sync to other players. + +#### Guess Submission +- Guessers can submit a guess using the existing `GuessForm` component. +- The drawer cannot submit a guess — the guess form is not shown or is disabled for the drawer. +- A submitted guess is sent to the backend and stored against the room. +- The backend stores the guesser's participant id, their name, the submitted text, whether it was correct, and the timestamp. + +#### Guess Validation +- A guess must be trimmed of leading and trailing whitespace before processing. +- A guess that is empty or whitespace-only after trimming is rejected with a clear inline error. No API call is made. +- These rules are enforced on both the frontend (before the API call) and the backend (schema validation). + +#### Guess Comparison +- The backend compares the trimmed, lowercased guess against the trimmed, lowercased `currentWord`. +- Comparison is case-insensitive: `"PIZZA"`, `"pizza"`, and `"Pizza"` all match if the word is `"pizza"`. +- The correct/incorrect result is determined by the backend — the frontend does not apply any comparison logic. + +#### Scoring +- Every participant starts with a score of zero. +- A correct guess awards the guesser 100 points. +- An incorrect guess awards the guesser 0 points (score unchanged). +- Scores are stored on each participant on the backend. +- The drawer does not receive points for this scenario. +- A player who guesses correctly can still submit further guesses; additional correct guesses do not award additional points (score stays at 100 once reached). + +#### Guess History Synchronisation +- All players (drawer and guessers) see a live guess history on the game screen. +- The guess history is fetched via the existing polling mechanism on the game screen (same ~2-second cadence as the lobby). +- Each entry in the history shows the guesser's name, their guess text, and whether it was correct or incorrect. +- The history is ordered by submission time, oldest first. +- All players see the same complete history — there is no per-viewer filtering of guesses. + +--- + +### Edge Cases + +- A whitespace-only guess (e.g. `" "`) must be rejected the same way as an empty guess — after trimming the result is empty. +- A guess with surrounding spaces (e.g. `" pizza "`) must be trimmed before comparison; `" pizza "` must match `"pizza"`. +- Guess comparison is case-insensitive in both directions: the guess is lowercased, the stored word is lowercased, and only then compared. +- A guesser who has already guessed correctly may continue submitting guesses. Subsequent correct guesses do not increase their score beyond 100. +- The drawer must not be able to submit a guess — the guess form must not be present or must be disabled on the drawer's view. +- An empty canvas cleared by the drawer remains blank — it does not restore any previous drawing state. +- If the backend is temporarily unavailable during polling, the guess history does not disappear — it retains the last successfully fetched state until the next successful poll. +- Scores shown in the Scoreboard are read from the room snapshot, not computed on the frontend — the backend is the single source of truth for scores. +- Submitting a guess after the game has ended (Scenario 4 state) is out of scope for this scenario. + +--- + +### Acceptance Criteria + +**Drawing canvas** +- [ ] The drawer's game screen shows an interactive canvas element (not a static placeholder). +- [ ] The drawer can draw on the canvas using a mouse or pointer device. +- [ ] The guesser's game screen shows the canvas area but cannot interact with it. + +**Clear canvas** +- [ ] The drawer's game screen has a "Clear Canvas" button. +- [ ] Clicking "Clear Canvas" resets the canvas to a blank state. +- [ ] The clear button is not visible on a guesser's game screen. + +**Guess submission** +- [ ] The guess form is visible and active for guessers. +- [ ] The guess form is not shown (or is disabled) for the drawer. +- [ ] Submitting a valid guess sends a request to the backend. +- [ ] After a successful submission, the guess appears in the guess history. + +**Guess validation** +- [ ] Submitting an empty guess shows an inline error and does not call the API. +- [ ] Submitting a whitespace-only guess shows the same error and does not call the API. +- [ ] A guess with surrounding spaces is trimmed and submitted as the trimmed value. +- [ ] Sending an empty or whitespace-only guess directly to the backend returns a 400 error. + +**Guess comparison** +- [ ] Submitting the exact secret word (same case) is marked as correct by the backend. +- [ ] Submitting the secret word in a different case (e.g. all caps) is also marked as correct. +- [ ] Submitting a wrong word is marked as incorrect. +- [ ] The correct/incorrect result is determined by the backend, not the frontend. + +**Scoring** +- [ ] All participants start with a score of 0 visible in the Scoreboard. +- [ ] A correct guess updates the guesser's score to 100. +- [ ] An incorrect guess does not change the guesser's score. +- [ ] Scores are visible in the Scoreboard for all players after each poll. +- [ ] A second correct guess by the same player does not increase their score beyond 100. + +**Guess history synchronisation** +- [ ] The guess history on the game screen updates within ~2 seconds of a guess being submitted by any player. +- [ ] Each history entry shows the guesser's name, their guess, and whether it was correct or incorrect. +- [ ] All players (drawer and guessers) see the same history. +- [ ] The history is ordered oldest-first. diff --git a/specs/003-gameplay-interaction/tasks.md b/specs/003-gameplay-interaction/tasks.md new file mode 100644 index 0000000..4f5d532 --- /dev/null +++ b/specs/003-gameplay-interaction/tasks.md @@ -0,0 +1,351 @@ +# Tasks — Scenario 3: Gameplay Interaction + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group U — Backend Model + +### U1 — Add score to Participant interface +- File: `backend/src/models/game.ts` +- Add `score: number` to the `Participant` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Type errors in the service are expected — fixed in U3. + +### U2 — Add Guess interface +- File: `backend/src/models/game.ts` +- Define and export a `Guess` interface with fields: `participantId: string`, `participantName: string`, `text: string`, `isCorrect: boolean`, `submittedAt: string`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### U3 — Add guesses to Room and RoomSnapshot interfaces +- File: `backend/src/models/game.ts` +- Add `guesses: Guess[]` to the `Room` interface. +- Add `guesses: Guess[]` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `backend/` passes. Service type errors are expected — fixed in Group V. + +--- + +## Group V — Backend Service + +### V1 — Initialise score in createParticipant +- File: `backend/src/services/roomStore.ts` +- In the `createParticipant` function, add `score: 0` to the returned participant object. +- This covers both `createRoom` and `joinRoom` since both call `createParticipant`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V2 — Initialise guesses array in createRoom +- File: `backend/src/services/roomStore.ts` +- In `createRoom`, add `guesses: []` to the room object literal. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V3 — Include score and guesses in toRoomSnapshot +- File: `backend/src/services/roomStore.ts` +- In the participants map inside `toRoomSnapshot`, add `score: participant.score` to each mapped entry. +- Add `guesses: room.guesses.map(g => ({ ...g }))` to the returned snapshot object. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### V4 — Add submitGuess service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `submitGuess(code: string, participantId: string, text: string)` function. +- The function must perform checks in this order: + 1. Return `"room-not-found"` if the room does not exist. + 2. Return `"not-playing"` if `room.status !== "playing"`. + 3. Return `"participant-not-found"` if no participant in the room matches `participantId`. + 4. Return `"is-drawer"` if `participantId === room.drawerId`. +- If all checks pass: + 1. Trim and lowercase `text`. + 2. Compare to `room.currentWord!.trim().toLowerCase()`. + 3. Set `isCorrect` accordingly. + 4. If `isCorrect` and `participant.score < 100`, set `participant.score = 100`. + 5. Push a new `Guess` object to `room.guesses`. + 6. Save the room. + 7. Return the updated room snapshot via `toRoomSnapshot`. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group W — Backend Schema and Endpoint + +### W1 — Add submitGuessSchema +- File: `backend/src/api/schemas.ts` +- Add `submitGuessSchema = z.object({ participantId: z.string().min(1), text: z.string().trim().min(1, "Guess cannot be empty") })`. +- Export it alongside existing schemas. +- Verify: `npm run build` in `backend/` passes. + +### W2 — Add POST /rooms/:code/guess route handler +- File: `backend/src/api/rooms.ts` +- Import `submitGuessSchema` and `submitGuess` from their respective modules. +- Add `router.post("/:code/guess", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `submitGuessSchema`. + 3. Calls `submitGuess(code, participantId, text)`. + 4. Returns 404 if result is `"room-not-found"` or `"participant-not-found"`. + 5. Returns 422 with message "Game is not in progress" if result is `"not-playing"`. + 6. Returns 403 with message "Drawer cannot submit a guess" if result is `"is-drawer"`. + 7. Returns 200 with `{ room: snapshot }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group X — Backend Tests + +### X1 — Test Participant is initialised with score 0 +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Assert `result.room.participants[0].score === 0`. +- Run: `npm test` in `backend/` — all tests pass. + +### X2 — Test joining player is initialised with score 0 +- File: `backend/src/services/roomStore.test.ts` +- Create a room, join a second player. Assert the second participant's score is `0` in the returned snapshot. +- Run: `npm test` in `backend/` — all tests pass. + +### X3 — Test createRoom initialises guesses as empty array +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Assert `result.room.guesses` is an empty array. +- Run: `npm test` in `backend/` — all tests pass. + +### X4 — Test toRoomSnapshot includes score on each participant +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Call `toRoomSnapshot`. Assert each participant in the snapshot has a `score` field equal to `0`. +- Run: `npm test` in `backend/` — all tests pass. + +### X5 — Test toRoomSnapshot includes guesses array +- File: `backend/src/services/roomStore.test.ts` +- Create a room. Call `toRoomSnapshot`. Assert `snapshot.guesses` is an empty array. +- Run: `npm test` in `backend/` — all tests pass. + +### X6 — Test submitGuessSchema rejects empty text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: "" })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### X7 — Test submitGuessSchema rejects whitespace-only text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: " " })` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### X8 — Test submitGuessSchema trims valid text +- File: `backend/src/api/schemas.test.ts` +- Add a test: `submitGuessSchema.parse({ participantId: "abc", text: " pizza " })` succeeds and the parsed `text` is `"pizza"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X9 — Test submitGuess: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `submitGuess("XXXX", "any", "pizza")`. Assert result is `"room-not-found"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X10 — Test submitGuess: room not in playing status +- File: `backend/src/services/roomStore.test.ts` +- Create a room (status is "lobby"). Call `submitGuess` with the host id and any text. Assert result is `"not-playing"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X11 — Test submitGuess: unknown participant +- File: `backend/src/services/roomStore.test.ts` +- Start a game with two players. Call `submitGuess` with a made-up participant id. Assert result is `"participant-not-found"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X12 — Test submitGuess: drawer is rejected +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `submitGuess` with the drawer's `participantId`. Assert result is `"is-drawer"`. +- Run: `npm test` in `backend/` — all tests pass. + +### X13 — Test submitGuess: correct guess (case-insensitive) +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Determine the current word from the snapshot. Submit the word in uppercase via the guesser. Assert the returned snapshot has `guesses[0].isCorrect === true`. +- Run: `npm test` in `backend/` — all tests pass. + +### X14 — Test submitGuess: correct guess awards 100 points +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a correct guess as the guesser. Assert the guesser's score in the returned snapshot is `100`. +- Run: `npm test` in `backend/` — all tests pass. + +### X15 — Test submitGuess: incorrect guess awards 0 points +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a wrong guess as the guesser. Assert the guesser's score is `0` and `guesses[0].isCorrect === false`. +- Run: `npm test` in `backend/` — all tests pass. + +### X16 — Test submitGuess: second correct guess does not increase score above 100 +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit a correct guess (score becomes 100). Submit another correct guess. Assert score is still `100`. +- Run: `npm test` in `backend/` — all tests pass. + +### X17 — Test submitGuess: guess is appended to room.guesses +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Submit two guesses. Assert the returned snapshot has `guesses.length === 2` and both entries have the correct `text`, `participantId`, and `submittedAt` fields. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group Y — Frontend Types + +### Y1 — Add score to Participant interface +- File: `frontend/src/services/api.ts` +- Add `score: number` to the `Participant` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y2 — Add Guess interface +- File: `frontend/src/services/api.ts` +- Add a `Guess` interface with fields: `participantId: string`, `participantName: string`, `text: string`, `isCorrect: boolean`, `submittedAt: string`. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y3 — Add guesses to RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Add `guesses: Guess[]` to the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Y4 — Add submitGuess client method +- File: `frontend/src/services/api.ts` +- Add `submitGuess(code: string, participantId: string, text: string)` to the `api` object. +- It calls `POST /rooms/:code/guess` with body `{ participantId, text }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group Z — Frontend Store and GuessForm + +### Z1 — Add submitGuess action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `submitGuess(text: string)` method that reads `room.code` and `participantId` from state, calls `api.submitGuess` inside `withLoading`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### Z2 — Add onSubmit prop and validation to GuessForm +- File: `frontend/src/components/GuessForm.tsx` +- Add `onSubmit: (text: string) => Promise` to the `GuessFormProps` interface. +- In `handleSubmit`: trim `guessText`. If the trimmed value is empty, set a local inline error "Guess cannot be empty" and return without calling `onSubmit`. +- If valid: call `onSubmit(trimmedText)`, await it, and clear `guessText` on success. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### Z3 — Add error display to GuessForm +- File: `frontend/src/components/GuessForm.tsx` +- Add a local `error` state. +- Render an inline error message below the input when `error` is non-null. +- Clear the error when the input changes. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AA — Frontend Polling and GamePage + +### AA1 — Add polling useEffect to GamePage +- File: `frontend/src/pages/GamePage.tsx` +- Add a `useEffect` that starts a `setInterval` calling `roomStore.fetchRoom()` every 2000ms. +- The cleanup function must call `clearInterval` to stop polling on unmount. +- Dependency on `room?.code` — same pattern as LobbyPage. +- No rendering changes in this task. +- Verify: `npm run build` in `frontend/` passes. + +### AA2 — Hide GuessForm from drawer; pass onSubmit to guessers +- File: `frontend/src/pages/GamePage.tsx` +- Render the `GuessForm` only when `!isDrawer`. +- Pass an `onSubmit` callback to `GuessForm` that calls `roomStore.submitGuess(text)`. +- Remove the unconditional `` render. +- Verify: `npm run build` in `frontend/` passes. + +### AA3 — Add drawing canvas for drawer +- File: `frontend/src/pages/GamePage.tsx` +- When `isDrawer` is true, render an HTML `` element inside the canvas area. +- Wire `onMouseDown`, `onMouseMove`, and `onMouseUp` (or equivalent pointer events) to draw lines on the canvas using `getContext("2d")`. +- Drawing state is local to the component — it is never sent to the backend. +- The secret word display (from Scenario 2) remains visible above the canvas. +- Verify: `npm run build` in `frontend/` passes. + +### AA4 — Add Clear Canvas button for drawer +- File: `frontend/src/pages/GamePage.tsx` +- Below the canvas, render a "Clear Canvas" button visible only when `isDrawer` is true. +- On click, call `clearRect` on the canvas context to blank the canvas. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AB — Frontend Components + +### AB1 — Update Scoreboard with real participant data +- File: `frontend/src/components/Scoreboard.tsx` +- Accept `participants` as a prop (type: `{ name: string; score: number }[]`). +- Render one row per participant showing name and score. +- Remove the static placeholder content. +- Update `GamePage` to pass `room.participants` to ``. +- Verify: `npm run build` in `frontend/` passes. + +### AB2 — Update ResultPanel with real guess history +- File: `frontend/src/components/ResultPanel.tsx` +- Accept `guesses` as a prop (type: `{ participantName: string; text: string; isCorrect: boolean }[]`). +- Render one row per guess showing the guesser's name, their guess text, and a correct/incorrect label. +- If `guesses` is empty, show a "No guesses yet" placeholder. +- Update `GamePage` to pass `room.guesses` to ``. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AC — Frontend Tests + +### AC1 — Test api service: submitGuess sends correct request +- File: `frontend/src/services/api.test.ts` +- Add a test asserting `api.submitGuess("ABCD", "participant-id", "pizza")` makes a `POST` to `/rooms/ABCD/guess` with `{ participantId: "participant-id", text: "pizza" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group AD — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### AD1 — Validate: all players start with score 0 +- Tab 1: create a room. Tab 2: join the room. Tab 1: start the game. +- Expected: Scoreboard shows both players at 0 points. + +### AD2 — Validate: empty guess is rejected +- Tab 2 (guesser): submit the guess form with an empty input. +- Expected: inline error "Guess cannot be empty" appears, no network request is made. + +### AD3 — Validate: whitespace-only guess is rejected +- Tab 2 (guesser): type spaces only into the guess field, submit. +- Expected: inline error appears, no network request is made. + +### AD4 — Validate: incorrect guess does not change score +- Tab 2 (guesser): submit a wrong word. +- Expected: guess appears in the activity panel marked as incorrect. Score remains 0. + +### AD5 — Validate: correct guess awards 100 points +- Tab 2 (guesser): submit the correct secret word (visible in Tab 1 drawer view). +- Expected: guess appears as correct in the activity panel. Guesser's score updates to 100 in the Scoreboard within ~2 seconds. + +### AD6 — Validate: correct guess is case-insensitive +- Tab 2 (guesser): submit the correct word in all caps. +- Expected: guess is marked as correct. Score updates to 100. + +### AD7 — Validate: second correct guess does not exceed 100 points +- Tab 2 (guesser): after AD5, submit the correct word again. +- Expected: score stays at 100. Guess is still marked correct in history. + +### AD8 — Validate: guess history syncs to all players +- Tab 1 (drawer): observe the activity panel within ~2 seconds after Tab 2 submits a guess. +- Expected: the guess appears in Tab 1's activity panel without any manual action. + +### AD9 — Validate: drawer cannot submit a guess +- Tab 1 (drawer): confirm the guess form is not visible on the game screen. +- Expected: no guess input or submit button is present in the drawer's view. + +### AD10 — Validate: drawer can draw on the canvas +- Tab 1 (drawer): click and drag on the canvas area. +- Expected: lines appear on the canvas as the mouse moves. + +### AD11 — Validate: clear canvas resets the drawing +- Tab 1 (drawer): draw something, then click "Clear Canvas". +- Expected: the canvas returns to a blank white state. + +### AD12 — Validate: clear canvas button is not visible to guessers +- Tab 2 (guesser): inspect the game screen. +- Expected: no "Clear Canvas" button is visible. diff --git a/specs/004-result-restart-and-validation/plan.md b/specs/004-result-restart-and-validation/plan.md new file mode 100644 index 0000000..5eb4a1a --- /dev/null +++ b/specs/004-result-restart-and-validation/plan.md @@ -0,0 +1,350 @@ +# Plan — Scenario 4: Result, Restart & Final Validation + +--- + +## Findings + +### What exists and is relevant (post Scenario 3) + +| Area | File | Current behavior | +|---|---|---| +| RoomStatus type | `backend/src/models/game.ts` | `"lobby" \| "playing"`. No `"finished"` value. | +| Room and RoomSnapshot | `backend/src/models/game.ts` | All needed fields exist: `status`, `hostId`, `drawerId`, `currentWord`, `guesses`, `participants` with `score`. No structural changes needed for Scenario 4. | +| toRoomSnapshot | `backend/src/services/roomStore.ts` | Filters `currentWord` with `viewerParticipantId === room.drawerId ? room.currentWord : null`. This must be relaxed when status is `"finished"` — all players should see the word. | +| submitGuess | `backend/src/services/roomStore.ts` | Returns `"not-playing"` when `room.status !== "playing"`. This correctly blocks guesses in `"finished"` state once the status is added. No change needed. | +| Schemas | `backend/src/api/schemas.ts` | Has `startGameSchema = z.object({ participantId: z.string().min(1) })`. Both `endRound` and `restartGame` need the same shape — can reuse the existing schema or add named aliases. | +| Rooms router | `backend/src/api/rooms.ts` | Five endpoints. No `POST /:code/end` or `POST /:code/restart`. | +| Frontend status type | `frontend/src/services/api.ts` | `status: "lobby" \| "playing"`. No `"finished"`. | +| Frontend api | `frontend/src/services/api.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`, `submitGuess`. No `endRound` or `restartGame` methods. | +| Frontend store | `frontend/src/state/roomStore.ts` | Has `createRoom`, `joinRoom`, `fetchRoom`, `startGame`, `submitGuess`. No `endRound` or `restartGame` actions. | +| GamePage | `frontend/src/pages/GamePage.tsx` | Polls every 2s. Renders game content unconditionally when `room` is present. No status check that would switch to a result view. No "End Round" button. No navigation triggered by status change. | +| LobbyPage | `frontend/src/pages/LobbyPage.tsx` | Polls every 2s. No check for `room.status === "playing"` — non-host players are never auto-navigated to `/game` after the host starts a game. This gap matters for Scenario 4 restart. | + +### What is missing + +1. **`"finished"` status** — `RoomStatus` has no `"finished"` value. The backend has no way to express a completed round. +2. **`currentWord` visibility in finished state** — `toRoomSnapshot` always hides the word from non-drawers. In `"finished"` state, all players must see the correct word. +3. **`endRound` service function** — no logic to transition a room from `"playing"` to `"finished"`. +4. **`restartGame` service function** — no logic to reset a room back to `"lobby"` with players preserved and round state cleared. +5. **`POST /rooms/:code/end` endpoint** — does not exist. +6. **`POST /rooms/:code/restart` endpoint** — does not exist. +7. **Frontend result view** — `GamePage` renders the same playing layout regardless of status. There is no result view showing the correct word, final scores, and guess history after the round ends. +8. **"End Round" button in GamePage** — the host has no way to end the round from the UI. +9. **"Play Again" button on result view** — the host has no way to restart from the UI. +10. **Status-driven navigation in GamePage** — `GamePage` does not navigate to `/lobby` when `room.status` becomes `"lobby"` (required for the restart flow for non-host players). +11. **Status-driven navigation in LobbyPage** — `LobbyPage` does not navigate to `/game` when `room.status` becomes `"playing"` (required so non-host players auto-join the game screen after start, and so all players reach the game screen after a restart that goes through lobby then triggers another start). + +--- + +## State Model Changes + +### Backend — `backend/src/models/game.ts` + +``` +RoomStatus (before) RoomStatus (after) +────────────────────────── ───────────────────────────── +"lobby" | "playing" "lobby" | "playing" | "finished" ← NEW +``` + +No other model changes. `Room` and `RoomSnapshot` already carry all fields needed to display results: `currentWord`, `guesses`, and `participants` with `score`. + +### Frontend — `frontend/src/services/api.ts` + +``` +RoomSnapshot.status (before) RoomSnapshot.status (after) +────────────────────────── ───────────────────────────── +"lobby" | "playing" "lobby" | "playing" | "finished" ← NEW +``` + +--- + +## Required API Changes + +### Modified: `toRoomSnapshot` — `backend/src/services/roomStore.ts` + +``` +currentWord (before): + viewerParticipantId === room.drawerId ? room.currentWord : null + +currentWord (after): + room.status === "finished" || viewerParticipantId === room.drawerId + ? room.currentWord + : null +``` + +In `"finished"` state the secret word is revealed to everyone. The existing filtering logic is preserved for the `"playing"` state. + +### New schemas — `backend/src/api/schemas.ts` + +Both new endpoints require only a `participantId` to identify the caller. The existing `startGameSchema` has the same shape. Two named exports are added for clarity: + +``` +endRoundSchema = z.object({ participantId: z.string().min(1) }) +restartGameSchema = z.object({ participantId: z.string().min(1) }) +``` + +### New service: `endRound(code, participantId)` + +- **File:** `backend/src/services/roomStore.ts` +- Returns `null` if room not found. +- Returns `"not-host"` if caller is not the host. +- Returns `"not-playing"` if room status is not `"playing"`. +- Sets `room.status = "playing"` → `"finished"`, saves, returns snapshot. +- Response snapshot is called with the caller's `participantId` — but since the status is now `"finished"`, `toRoomSnapshot` exposes `currentWord` to all anyway. + +### New service: `restartGame(code, participantId)` + +- **File:** `backend/src/services/roomStore.ts` +- Returns `null` if room not found. +- Returns `"not-host"` if caller is not the host. +- Returns `"not-finished"` if room status is not `"finished"`. +- Resets the room in place: `status = "lobby"`, `drawerId = null`, `currentWord = null`, `guesses = []`, each participant's `score = 0`. +- Room code, `hostId`, participants (names and ids) are preserved. +- Saves and returns the updated snapshot. + +### New endpoint: `POST /rooms/:code/end` + +- Parses params with `roomCodeParamsSchema`, body with `endRoundSchema`. +- Calls `endRound(code, participantId)`. +- Returns 404 if room not found, 403 if not host, 422 if not in playing state. +- Returns 200 with `{ room: snapshot }` on success. + +### New endpoint: `POST /rooms/:code/restart` + +- Parses params with `roomCodeParamsSchema`, body with `restartGameSchema`. +- Calls `restartGame(code, participantId)`. +- Returns 404 if room not found, 403 if not host, 422 if not in finished state. +- Returns 200 with `{ room: snapshot }` on success. + +### New client methods — `frontend/src/services/api.ts` + +``` +endRound(code, participantId) → POST /rooms/:code/end { participantId } +restartGame(code, participantId) → POST /rooms/:code/restart { participantId } +``` + +Both return `{ room: RoomSnapshot }`. + +--- + +## Result State Flow + +``` +GamePage (host, status = "playing") + → "End Round" button visible only when isHost + → click "End Round" → roomStore.endRound() + → POST /rooms/:code/end { participantId: hostId } + → endRound(): status = "finished", save + → toRoomSnapshot: currentWord visible to all + → response { room } — store updates snapshot + → room.status is now "finished" + → GamePage renders result view (conditional on status) + → polling delivers "finished" snapshot to all other players within ~2s + → all players see result view automatically +``` + +--- + +## Restart Flow + +``` +GamePage result view (host, status = "finished") + → "Play Again" button visible only when isHost + → click "Play Again" → roomStore.restartGame() + → POST /rooms/:code/restart { participantId: hostId } + → restartGame(): status = "lobby", drawerId = null, + currentWord = null, guesses = [], all scores = 0 + → response { room } — store updates snapshot + → room.status is now "lobby" + → GamePage useEffect detects status === "lobby" → navigate("/lobby") + → non-host players: next poll returns status = "lobby" + → GamePage useEffect fires for each → navigate("/lobby") + → all players land on LobbyPage with same participants, zero scores +``` + +--- + +## Status-Driven Navigation + +Both `GamePage` and `LobbyPage` need `useEffect` hooks that watch `room.status` and navigate based on status transitions. These replace the need for any page to manually navigate after an action — the store update from a poll or action response triggers the effect. + +### GamePage additions + +``` +useEffect(() => { + if (room?.status === "lobby") navigate("/lobby", { replace: true }); +}, [room?.status, navigate]); +``` + +This covers: +- Host after restart (explicit navigate also fires, whichever is first is fine) +- Non-host players after restart (status changes to "lobby" via polling) + +### LobbyPage addition + +``` +useEffect(() => { + if (room?.status === "playing") navigate("/game", { replace: true }); +}, [room?.status, navigate]); +``` + +This covers: +- Non-host players after the host starts the game (lobby polling delivers "playing" status) +- All players landing on the lobby after a restart — they stay there because status is "lobby" + +--- + +## Data Flow + +### End round +``` +GamePage (host, status = "playing") + → click "End Round" + → roomStore.endRound() + → POST /rooms/:code/end + → status becomes "finished", currentWord now visible to all + → store updates, GamePage re-renders result view immediately + → other players see result view via next poll (~2s) +``` + +### Result view polling +``` +GamePage (status = "finished") + → polling continues every 2s (same useEffect) + → GET /rooms/:code?participantId=... + → toRoomSnapshot returns currentWord to all (status is "finished") + → result view refreshes — scores and guesses stable at this point +``` + +### Restart +``` +GamePage result view (host) + → click "Play Again" + → roomStore.restartGame() + → POST /rooms/:code/restart + → room reset: status = "lobby", round fields cleared + → store updates snapshot + → GamePage status-navigation useEffect fires → navigate("/lobby") + → non-host players: poll returns status = "lobby" → same effect → navigate("/lobby") + → LobbyPage mounts, polling resumes for all players +``` + +### Non-host auto-navigation to game (gap fixed) +``` +LobbyPage (non-host, status = "lobby") + → host clicks "Start Game" → status becomes "playing" + → non-host's next poll returns status = "playing" + → LobbyPage status-navigation useEffect fires → navigate("/game") + → GamePage mounts for non-host player +``` + +--- + +## Implementation Sequence + +### Step 1 — Backend: expand RoomStatus +- **File:** `backend/src/models/game.ts` +- Add `"finished"` to `RoomStatus`. +- Verify: `npm run build` in `backend/` passes. + +### Step 2 — Backend: update `toRoomSnapshot` for finished state +- **File:** `backend/src/services/roomStore.ts` +- Change `currentWord` filtering to also expose the word when `room.status === "finished"`. +- Verify: `npm run build` in `backend/` passes. + +### Step 3 — Backend: add `endRoundSchema` and `restartGameSchema` +- **File:** `backend/src/api/schemas.ts` +- Add both schemas (both are `z.object({ participantId: z.string().min(1) })`). +- Verify: `npm run build` in `backend/` passes. + +### Step 4 — Backend: add `endRound` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `endRound(code, participantId)` with host check, status check, and transition to `"finished"`. +- Verify: `npm run build` in `backend/` passes. + +### Step 5 — Backend: add `restartGame` service function +- **File:** `backend/src/services/roomStore.ts` +- Add exported `restartGame(code, participantId)` with host check, status check, and full round-state reset. +- Verify: `npm run build` in `backend/` passes. + +### Step 6 — Backend: add `POST /rooms/:code/end` and `POST /rooms/:code/restart` handlers +- **File:** `backend/src/api/rooms.ts` +- Add both route handlers using the new schemas and service functions. +- Verify: `npm run build` in `backend/` passes. + +### Step 7 — Frontend: expand status type and add client methods +- **File:** `frontend/src/services/api.ts` +- Add `"finished"` to `status` in `RoomSnapshot`. +- Add `endRound(code, participantId)` and `restartGame(code, participantId)` to the `api` object. +- Verify: `npm run build` in `frontend/` passes. + +### Step 8 — Frontend: add `endRound` and `restartGame` store actions +- **File:** `frontend/src/state/roomStore.ts` +- Add `endRound()` and `restartGame()` methods following the same `withLoading` pattern as `startGame`. +- Verify: `npm run build` in `frontend/` passes. + +### Step 9 — Frontend: update `GamePage` — result view, End Round, status navigation +- **File:** `frontend/src/pages/GamePage.tsx` +- Add `useEffect` that navigates to `/lobby` when `room.status === "lobby"`. +- When `room.status === "finished"`: render a result view showing correct word, final scores, full guess history, and "Play Again" button for host only. +- When `room.status === "playing"`: render the existing playing layout, plus "End Round" button for host only. +- Verify: `npm run build` in `frontend/` passes. + +### Step 10 — Frontend: update `LobbyPage` — auto-navigate to game on status change +- **File:** `frontend/src/pages/LobbyPage.tsx` +- Add `useEffect` that navigates to `/game` when `room.status === "playing"`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Files Touched + +| File | Change type | +|---|---| +| `backend/src/models/game.ts` | Modify — add `"finished"` to `RoomStatus` | +| `backend/src/api/schemas.ts` | Modify — add `endRoundSchema` and `restartGameSchema` | +| `backend/src/services/roomStore.ts` | Modify — update `toRoomSnapshot`, add `endRound`, add `restartGame` | +| `backend/src/api/rooms.ts` | Modify — add `POST /:code/end` and `POST /:code/restart` handlers | +| `frontend/src/services/api.ts` | Modify — add `"finished"` to status, add `endRound` and `restartGame` methods | +| `frontend/src/state/roomStore.ts` | Modify — add `endRound` and `restartGame` actions | +| `frontend/src/pages/GamePage.tsx` | Modify — result view, End Round button, Play Again button, status navigation | +| `frontend/src/pages/LobbyPage.tsx` | Modify — auto-navigate to game when status becomes "playing" | + +No new files. No new libraries. + +--- + +## Testing Strategy + +### Backend unit tests (`backend/src/services/roomStore.test.ts`) +- `endRound`: room not found returns null. +- `endRound`: non-host returns `"not-host"`. +- `endRound`: room not in "playing" returns `"not-playing"`. +- `endRound`: success sets status to `"finished"` and returns snapshot. +- `restartGame`: room not found returns null. +- `restartGame`: non-host returns `"not-host"`. +- `restartGame`: room not in "finished" returns `"not-finished"`. +- `restartGame`: success sets status to `"lobby"`, clears `drawerId`, `currentWord`, `guesses`, and all participant scores. +- `restartGame`: participants (names, ids) are preserved after restart. +- `toRoomSnapshot`: in `"finished"` status, `currentWord` is returned for all viewers regardless of `viewerParticipantId`. +- `toRoomSnapshot`: in `"playing"` status, `currentWord` is still hidden from non-drawers. + +### Backend schema tests (`backend/src/api/schemas.test.ts`) +- `endRoundSchema`: missing `participantId` is rejected. +- `restartGameSchema`: missing `participantId` is rejected. + +### Frontend service tests (`frontend/src/services/api.test.ts`) +- `api.endRound`: makes a `POST` to `/rooms/:code/end` with `{ participantId }`. +- `api.restartGame`: makes a `POST` to `/rooms/:code/restart` with `{ participantId }`. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Status-navigation effects in `LobbyPage` and `GamePage` could create navigation loops | The effects are guarded by specific status values. `LobbyPage` navigates only when `"playing"`, `GamePage` navigates only when `"lobby"`. Neither fires on its target page's expected status, so no loop is possible. | +| Non-host players on `GamePage` receive `status = "lobby"` from a poll mid-restart before they navigate — stale render | The navigation effect fires immediately when the store update delivers `"lobby"`. At worst the result view renders one extra frame. | +| `restartGame` must reset all participant scores — mutating participants directly on the stored room object | `structuredClone` in `cloneRoom` is used for all returns, so the mutation is safe on the live object before cloning. The pattern is identical to how `submitGuess` mutates participant scores. | +| Guess submissions arriving between the host clicking "End Round" and the backend saving `"finished"` status | Unlikely but possible with concurrent tabs. The `endRound` handler runs synchronously in Node's event loop. Any guess request that arrives after the status is set will receive `"not-playing"`. Any that arrived before will be processed before `endRound` runs. No data loss. | +| `toRoomSnapshot` change to expose word in `"finished"` state affects all five endpoints | Intended. All endpoints call `toRoomSnapshot`. In lobby state `currentWord` is null anyway, so the condition `room.status === "finished"` is safely a no-op for lobby snapshots. | diff --git a/specs/004-result-restart-and-validation/spec.md b/specs/004-result-restart-and-validation/spec.md new file mode 100644 index 0000000..4bf8353 --- /dev/null +++ b/specs/004-result-restart-and-validation/spec.md @@ -0,0 +1,80 @@ +## Scenario 4 — Result, Restart & Final Validation + +### Problem + +There is currently no way to end a round. The game screen stays in the playing state indefinitely — no player ever sees a summary of what happened, and there is no path back to the lobby. The host has no mechanism to declare the round over or to reset the game for another session. The correct word, which the drawer knew throughout the round, is never revealed to guessers. Scores accumulated during the round are visible in the scoreboard, but there is no dedicated result view that brings all information together after the round ends. Without a restart flow, the only way to play again is to create a brand new room, losing all players. + +--- + +### Requirements + +#### End-of-Round State +- The host triggers the end of the round by clicking an "End Round" button on the game screen. +- Ending the round transitions the room status from `"playing"` to `"finished"` on the backend. +- Only the host can end the round; other players do not see the button. +- Once the room is in `"finished"` status, no further guesses can be submitted. + +#### Result Display +- When the room status is `"finished"`, all players see a result screen in place of the game screen. +- The result screen shows the correct word that was being drawn. +- The result screen shows the final scores for all participants. +- The result screen shows the complete guess history from the round, in submission order. +- The correct word is visible to all players on the result screen — the per-viewer filtering used during the round does not apply in `"finished"` status. +- The result state is fetched via the existing polling mechanism; players transition to the result view automatically when the status changes. + +#### Restart Flow +- The host sees a "Play Again" button on the result screen. +- Non-host players see a waiting message instead of the button. +- Clicking "Play Again" calls a backend endpoint that resets the room to lobby state. +- On restart, all current participants are preserved with their names and ids intact. +- On restart, all round-specific state is cleared: `status` returns to `"lobby"`, `drawerId`, `currentWord`, and `guesses` are reset, and all participant scores are reset to zero. +- After a successful restart, all players are navigated back to the lobby screen. +- The lobby resumes automatic polling so players see the participant list without manual action. + +--- + +### Edge Cases + +- A non-host player must never see the "End Round" or "Play Again" buttons, even if they inspect the page source or make direct API calls — the backend enforces host-only permission on both endpoints. +- A guess submitted after the round ends must be rejected by the backend with a clear error. The game screen's guess form should become disabled or hidden once the status is `"finished"`. +- If a non-host player's polling tick fires while the host is mid-restart, the player may briefly see stale `"playing"` data before the status transitions — this is acceptable and resolves on the next poll. +- The correct word on the result screen must be visible to all players, including guessers who never saw it during the round. The backend must return `currentWord` without filtering when the room is `"finished"`. +- Scores shown on the result screen must match the scores recorded at the moment the round ended — they must not change after the round is over. +- After a restart, participants that were in the room continue to exist; no player is removed. A player who left the browser during the round but still has a valid `participantId` remains in the room after restart. +- Restarting clears scores to zero — the scores from the previous round do not carry over. +- The room code does not change on restart — players who were in the room can still identify it by the same code. + +--- + +### Acceptance Criteria + +**End-of-round state** +- [ ] The host's game screen shows an "End Round" button. +- [ ] A non-host player's game screen does not show the "End Round" button. +- [ ] Clicking "End Round" transitions the room status to `"finished"` on the backend. +- [ ] After status is `"finished"`, a direct call to the guess endpoint returns an error. + +**Result display** +- [ ] All players' screens transition to a result view when the room status becomes `"finished"` (via polling, within ~2 seconds of the host ending the round). +- [ ] The result view shows the correct word that was being drawn. +- [ ] The correct word is visible to all players on the result view, including guessers. +- [ ] The result view shows the final score for every participant. +- [ ] The result view shows the complete guess history in submission order. +- [ ] The result view matches what was in the scoreboard and activity panel at the moment the round ended. + +**Restart flow** +- [ ] The host sees a "Play Again" button on the result screen. +- [ ] A non-host player does not see the "Play Again" button. +- [ ] Clicking "Play Again" calls a backend restart endpoint. +- [ ] After a successful restart, the host is navigated back to the lobby. +- [ ] After the next polling tick, non-host players are also navigated back to the lobby. +- [ ] The lobby shows the same set of participants that were in the room before the restart. +- [ ] All participant scores are reset to zero after restart. +- [ ] The room code is unchanged after restart. + +**Round state cleared** +- [ ] After restart, `status` is `"lobby"`. +- [ ] After restart, `drawerId` is `null`. +- [ ] After restart, `currentWord` is `null`. +- [ ] After restart, `guesses` is an empty array. +- [ ] After restart, every participant's score is `0`. diff --git a/specs/004-result-restart-and-validation/tasks.md b/specs/004-result-restart-and-validation/tasks.md new file mode 100644 index 0000000..3efce31 --- /dev/null +++ b/specs/004-result-restart-and-validation/tasks.md @@ -0,0 +1,316 @@ +# Tasks — Scenario 4: Result, Restart & Final Validation + +Tasks are ordered. Complete each one before starting the next. +Each task is a single commit. + +--- + +## Group AE — Backend Model + +### AE1 — Add "finished" to RoomStatus +- File: `backend/src/models/game.ts` +- Change `RoomStatus` from `"lobby" | "playing"` to `"lobby" | "playing" | "finished"`. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AF — Backend Schemas + +### AF1 — Add endRoundSchema +- File: `backend/src/api/schemas.ts` +- Add `endRoundSchema = z.object({ participantId: z.string().min(1) })` and export it. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### AF2 — Add restartGameSchema +- File: `backend/src/api/schemas.ts` +- Add `restartGameSchema = z.object({ participantId: z.string().min(1) })` and export it. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AG — Backend Service + +### AG1 — Update toRoomSnapshot to expose currentWord in finished state +- File: `backend/src/services/roomStore.ts` +- Change the `currentWord` line in `toRoomSnapshot` so it returns `room.currentWord` when `room.status === "finished"` OR when `viewerParticipantId === room.drawerId`, and `null` otherwise. +- No other changes. +- Verify: `npm run build` in `backend/` passes. + +### AG2 — Add endRound service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `endRound(code: string, participantId: string)` function. +- The function must return, in order: + 1. `null` if the room does not exist. + 2. `"not-host"` if `participantId` does not match `room.hostId`. + 3. `"not-playing"` if `room.status !== "playing"`. +- On success: set `room.status = "finished"`, save, return `toRoomSnapshot(cloneRoom(room), participantId)`. +- Verify: `npm run build` in `backend/` passes. + +### AG3 — Add restartGame service function +- File: `backend/src/services/roomStore.ts` +- Add an exported `restartGame(code: string, participantId: string)` function. +- The function must return, in order: + 1. `null` if the room does not exist. + 2. `"not-host"` if `participantId` does not match `room.hostId`. + 3. `"not-finished"` if `room.status !== "finished"`. +- On success: reset `room.status = "lobby"`, `room.drawerId = null`, `room.currentWord = null`, `room.guesses = []`, and set every participant's `score = 0`. Preserve all participant `id`, `name`, and `joinedAt` fields. Save and return the updated snapshot. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AH — Backend Endpoints + +### AH1 — Add POST /rooms/:code/end route handler +- File: `backend/src/api/rooms.ts` +- Import `endRoundSchema` and `endRound` from their respective modules. +- Add `router.post("/:code/end", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `endRoundSchema`. + 3. Calls `endRound(code.toUpperCase(), participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with "Only the host can end the round" if result is `"not-host"`. + 6. Returns 422 with "Game is not in progress" if result is `"not-playing"`. + 7. Returns 200 with `{ room: result }` on success. +- Verify: `npm run build` in `backend/` passes. + +### AH2 — Add POST /rooms/:code/restart route handler +- File: `backend/src/api/rooms.ts` +- Import `restartGameSchema` and `restartGame`. +- Add `router.post("/:code/restart", ...)` handler that: + 1. Parses params with `roomCodeParamsSchema`. + 2. Parses body with `restartGameSchema`. + 3. Calls `restartGame(code.toUpperCase(), participantId)`. + 4. Returns 404 if result is `null`. + 5. Returns 403 with "Only the host can restart the game" if result is `"not-host"`. + 6. Returns 422 with "Round has not finished yet" if result is `"not-finished"`. + 7. Returns 200 with `{ room: result }` on success. +- Verify: `npm run build` in `backend/` passes. + +--- + +## Group AI — Backend Tests + +### AI1 — Test toRoomSnapshot exposes currentWord to all in finished state +- File: `backend/src/services/roomStore.test.ts` +- Start a game, call `endRound`, then call `toRoomSnapshot` with a guesser's `participantId`. Assert `currentWord` is non-null. +- Run: `npm test` in `backend/` — all tests pass. + +### AI2 — Test toRoomSnapshot still hides currentWord from non-drawer in playing state +- File: `backend/src/services/roomStore.test.ts` +- Start a game (status = "playing"). Call `toRoomSnapshot` with a guesser's id. Assert `currentWord` is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI3 — Test endRound: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `endRound("XXXX", "any")`. Assert result is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI4 — Test endRound: non-host is rejected +- File: `backend/src/services/roomStore.test.ts` +- Start a game. Call `endRound` with the guesser's id. Assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI5 — Test endRound: room not in playing state +- File: `backend/src/services/roomStore.test.ts` +- Create a room (status = "lobby"). Call `endRound` with the host id. Assert result is `"not-playing"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI6 — Test endRound: success sets status to finished +- File: `backend/src/services/roomStore.test.ts` +- Start a game with two players. Call `endRound` with the host id. Assert the returned snapshot has `status === "finished"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI7 — Test restartGame: room not found +- File: `backend/src/services/roomStore.test.ts` +- Call `restartGame("XXXX", "any")`. Assert result is `null`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI8 — Test restartGame: non-host is rejected +- File: `backend/src/services/roomStore.test.ts` +- End a game. Call `restartGame` with the guesser's id. Assert result is `"not-host"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI9 — Test restartGame: room not in finished state +- File: `backend/src/services/roomStore.test.ts` +- Start a game (status = "playing"). Call `restartGame` with the host id. Assert result is `"not-finished"`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI10 — Test restartGame: success resets round fields +- File: `backend/src/services/roomStore.test.ts` +- End a game. Call `restartGame` with the host id. Assert the returned snapshot has `status === "lobby"`, `drawerId === null`, `currentWord === null`, and `guesses.length === 0`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI11 — Test restartGame: participant scores reset to zero +- File: `backend/src/services/roomStore.test.ts` +- Submit a correct guess (score = 100). End the game. Restart. Assert all participant scores in the returned snapshot are `0`. +- Run: `npm test` in `backend/` — all tests pass. + +### AI12 — Test restartGame: participants are preserved +- File: `backend/src/services/roomStore.test.ts` +- Create a room with two players. End and restart. Assert the participant names and ids in the returned snapshot match those from before the restart. +- Run: `npm test` in `backend/` — all tests pass. + +### AI13 — Test endRoundSchema: missing participantId is rejected +- File: `backend/src/api/schemas.test.ts` +- Assert `endRoundSchema.parse({})` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +### AI14 — Test restartGameSchema: missing participantId is rejected +- File: `backend/src/api/schemas.test.ts` +- Assert `restartGameSchema.parse({})` throws a ZodError. +- Run: `npm test` in `backend/` — all tests pass. + +--- + +## Group AJ — Frontend Types and Client + +### AJ1 — Add "finished" to status in RoomSnapshot interface +- File: `frontend/src/services/api.ts` +- Change `status: "lobby" | "playing"` to `status: "lobby" | "playing" | "finished"` in the `RoomSnapshot` interface. +- No other changes. +- Verify: `npm run build` in `frontend/` passes. + +### AJ2 — Add endRound client method +- File: `frontend/src/services/api.ts` +- Add `endRound(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/end` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +### AJ3 — Add restartGame client method +- File: `frontend/src/services/api.ts` +- Add `restartGame(code: string, participantId: string)` to the `api` object. +- It calls `POST /rooms/:code/restart` with body `{ participantId }` and returns `{ room: RoomSnapshot }`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AK — Frontend Store + +### AK1 — Add endRound action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `endRound()` method that reads `room.code` and `participantId` from state, calls `api.endRound` inside `withLoading`, and calls `setRoomSnapshot` on success. +- Verify: `npm run build` in `frontend/` passes. + +### AK2 — Add restartGame action to RoomStore +- File: `frontend/src/state/roomStore.ts` +- Add `restartGame()` method following the same pattern as `endRound()`, calling `api.restartGame`. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AL — Frontend GamePage: Result View + +### AL1 — Add status-navigation useEffect to GamePage +- File: `frontend/src/pages/GamePage.tsx` +- Add a `useEffect` that calls `navigate("/lobby", { replace: true })` when `room?.status === "lobby"`. +- Dependency array: `[room?.status, navigate]`. +- This handles the restart navigation for all players (host navigates explicitly; non-hosts are driven by this effect on the next poll). +- No rendering changes. +- Verify: `npm run build` in `frontend/` passes. + +### AL2 — Add "End Round" button for host in playing state +- File: `frontend/src/pages/GamePage.tsx` +- In the playing layout, render an "End Round" button visible only when `isHost` is true. +- On click, call `roomStore.endRound()`. No explicit navigation — the status change causes the result view to render. +- Verify: `npm run build` in `frontend/` passes. + +### AL3 — Add result view for finished state +- File: `frontend/src/pages/GamePage.tsx` +- When `room.status === "finished"`, render a result layout instead of the playing layout. +- The result layout must include: + - The correct word (`room.currentWord`). + - The final scoreboard (all participants with scores). + - The complete guess history (all guesses in order). +- No "Play Again" button yet — added in AL4. +- Verify: `npm run build` in `frontend/` passes. + +### AL4 — Add "Play Again" button for host on result view +- File: `frontend/src/pages/GamePage.tsx` +- On the result view, render a "Play Again" button visible only when `isHost` is true. +- Non-host players see a "Waiting for the host to restart..." message. +- On click: call `roomStore.restartGame()` and navigate to `/lobby` on success. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AM — Frontend LobbyPage + +### AM1 — Add status-navigation useEffect to LobbyPage +- File: `frontend/src/pages/LobbyPage.tsx` +- Add a `useEffect` that calls `navigate("/game", { replace: true })` when `room?.status === "playing"`. +- Dependency array: `[room?.status, navigate]`. +- This auto-navigates non-host players to the game screen when the host starts a new game, and handles anyone arriving at the lobby mid-game. +- Verify: `npm run build` in `frontend/` passes. + +--- + +## Group AN — Frontend Tests + +### AN1 — Test api service: endRound sends correct request +- File: `frontend/src/services/api.test.ts` +- Assert `api.endRound("ABCD", "host-id")` makes a `POST` to `/rooms/ABCD/end` with `{ participantId: "host-id" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +### AN2 — Test api service: restartGame sends correct request +- File: `frontend/src/services/api.test.ts` +- Assert `api.restartGame("ABCD", "host-id")` makes a `POST` to `/rooms/ABCD/restart` with `{ participantId: "host-id" }` in the body. +- Run: `npm test` in `frontend/` — all tests pass. + +--- + +## Group AO — Manual Validation + +Run these checks in two browser tabs after all tasks are complete. +No code changes — validation only. + +### AO1 — Validate: only the host sees the End Round button +- Tab 1 (host, drawer): inspect the game screen during an active game. +- Tab 2 (guesser): inspect the game screen. +- Expected: Tab 1 shows "End Round" button. Tab 2 does not. + +### AO2 — Validate: ending the round reveals the correct word to all players +- Tab 1 (host): click "End Round". +- Expected: both Tab 1 and Tab 2 transition to the result view within ~2 seconds. The correct word is visible on both screens. + +### AO3 — Validate: result view shows final scores +- After AO2, inspect both screens. +- Expected: both tabs show the final score for every participant. + +### AO4 — Validate: result view shows full guess history +- After AO2, inspect both screens. +- Expected: all guesses submitted during the round are visible in the result view on both tabs, in submission order, with correct/incorrect labels. + +### AO5 — Validate: guesses cannot be submitted after the round ends +- After AO2 (status = "finished"), attempt to submit a guess directly via the API (e.g. curl or devtools). +- Expected: backend returns a 422 error. The guess form is not visible on the result view. + +### AO6 — Validate: only the host sees Play Again +- After AO2, inspect both tabs on the result view. +- Expected: Tab 1 shows "Play Again". Tab 2 shows a waiting message. + +### AO7 — Validate: host is navigated to lobby after restart +- Tab 1 (host): click "Play Again". +- Expected: Tab 1 navigates to the lobby screen immediately. + +### AO8 — Validate: non-host players are navigated to lobby after restart +- After AO7, wait for Tab 2's next poll. +- Expected: Tab 2 navigates to the lobby within ~2 seconds without any manual action. + +### AO9 — Validate: participants are preserved after restart +- After AO8, inspect both tabs on the lobby screen. +- Expected: both players appear in the participant list with their original names. + +### AO10 — Validate: scores are reset to zero after restart +- After AO8, inspect the lobby or start a new game and check the scoreboard. +- Expected: all participant scores are 0. + +### AO11 — Validate: round state is cleared after restart +- After AO8, inspect the room snapshot (via devtools or curl to GET /rooms/:code). +- Expected: `drawerId` is null, `currentWord` is null, `guesses` is an empty array, `status` is "lobby". + +### AO12 — Validate: room code is unchanged after restart +- Compare the room code shown in Tab 1 lobby before and after restart. +- Expected: the same 4-character code is displayed.