From 89afc4c95f504ac1c608421ca764ec620d1c29d6 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Fri, 29 May 2026 16:50:08 +0530 Subject: [PATCH 01/18] fix: correct fallback API base URL in frontend --- backend/package-lock.json | 3 --- frontend/package-lock.json | 10 ---------- frontend/src/services/api.ts | 2 +- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 38f3d3c..9839cda 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1879,7 +1879,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2298,7 +2297,6 @@ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2379,7 +2377,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d05..c7ac263 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -414,7 +413,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -438,7 +436,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1333,7 +1330,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1538,7 +1534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1900,7 +1895,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2083,7 +2077,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2135,7 +2128,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2148,7 +2140,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2506,7 +2497,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..26e8bf5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -19,7 +19,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { From 7b70a5d227a750bd467cc39f7cbe0abc717cae90 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:17:45 +0530 Subject: [PATCH 02/18] speckit.constitution: define engineering principles, AI usage guidelines, and review discipline. --- speckit.constitution | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 speckit.constitution diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 0000000..067600c --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,90 @@ +# Spec Kit Constitution: Scribble Starter Lab + +This constitution defines the standards, rules, and constraints governing the engineering, AI-assisted development, review, and scope of the Scribble project. + +--- + +## 1. Engineering and Coding Principles + +### General Guidelines +* **TypeScript First**: All new code and refactorings must be fully typed. The use of `any` is strictly prohibited. Use `unknown` for truly dynamic types. +* **Imports**: Use standard relative and absolute ES module imports. For backend code, omit file extensions or handle them via `.js` standard if required. +* **Immutability**: Prefer immutable data structures. Write pure functions wherever possible to prevent side effects. +* **Error Handling**: Fail fast and gracefully. Use centralized error handlers on the backend, and ensure the frontend UI does not crash when API exceptions occur. + +### Backend Guidelines (`/backend`) +* **Validation**: All request payloads and response shapes must be strictly validated using `Zod`. +* **Structure**: Maintain the established directory structure: + * `src/api` for routes and request handling. + * `src/services` for core business logic (e.g., room lifecycle). + * `src/models` for data types and entity representations. +* **State Management**: Keep the memory footprint for active game rooms minimal. Explicitly delete inactive rooms to prevent stateful bloat. + +### Frontend Guidelines (`/frontend`) +* **React Patterns**: Use functional components and strict React hooks (`useState`, `useEffect`, etc.). +* **Routing**: Use `react-router-dom` v6 paradigms exclusively. +* **State Management**: Complex state must be held in `src/state` (e.g., via Zustand or Context API) following the pattern established in `roomStore.ts`. +* **Styling**: Classes must reside in `app.css` or CSS modules. Keep components structurally clean and avoid ad-hoc styling utilities. + +--- + +## 2. AI-Assisted Development Rules + +* **Scope Enforcement**: The AI must not generate files, routes, or features outside of the explicitly defined project scope. +* **No Unnecessary Rewrites**: The AI must not rewrite entire files or components if a localized, incremental change is sufficient. +* **Code Verification**: All code generated by AI must be inspected for type safety, syntax correctness, and compliance with the project's styling and architectural patterns. +* **Prompt Discipline**: Do not ask the AI to implement features that violate the "Strictly Forbidden" constraints. + +--- + +## 3. Self-Review and Code Review Requirements + +* **Local Compilation**: Before any commit or pull request, both the frontend and backend must build cleanly without warnings or errors: + * Run `npm run build` in `/backend` + * Run `npm run build` in `/frontend` +* **Schema Integrity**: Ensure all Zod schemas correspond accurately to frontend state models and API contracts. +* **Resource Cleanup**: Verify that rooms are cleaned up when empty or inactive to avoid memory leaks. +* **Dead Code**: Ensure no debugging logs (`console.log`), unused variables, or dead code remain in the committed changes. + +--- + +## 4. Testing and Validation Expectations + +* **Multiplayer Validation**: All changes must be manually validated using at least two concurrent browser tabs to verify multiplayer synchronization, state isolation, and host permissions. +* **Lobby Polling Cadence**: Verify that room updates are polled at a regular interval (~2 seconds) and do not cause performance degradation. +* **Boundary and Edge Case Validation**: + * Verify that empty or whitespace-only usernames and invalid room codes are rejected with clear error feedback. + * Validate case-insensitivity of guesses. + * Ensure room state remains completely isolated across different room codes. + +--- + +## 5. Commit and Pull Request Discipline + +* **Granular Commits**: Commit changes in small, logical, atomic units (e.g., "Add Zod schema for joining room", "Implement lobby polling hooks"). +* **Traceability**: Each commit should be explainable and directly traceable to a requirement in the specification or task checklist. +* **Pull Request Requirements**: Raise the PR from your branch to `main` on your fork. Include your email, role, and fill out the provided PR template in detail. + +--- + +## 6. Working in an Existing Codebase + +* **Respect Architecture**: Do not introduce new state-management, routing, or database libraries. Enhance the existing Express/React/Zod scaffolding. +* **Minimize Footprint**: Make minimal changes required to implement the features. Leave unrelated files untouched. +* **Read Before Coding**: Always read the existing files and understand their design before adding or altering code. + +--- + +## 7. Scope-Control & Out-of-Scope Rules + +The following items are **strictly out of scope**. They must not be implemented, specified, planned, or included in any task checklist: + +* **No WebSockets**: Do not use WebSockets, Socket.io, or any real-time push protocol. All synchronization must use HTTP polling. +* **No Databases**: Do not use any database (SQL, NoSQL, SQLite, etc.). All data is stored in-memory. +* **No Authentication**: Do not add user authentication, sessions, JWT, or OAuth. +* **No Deployment/CI**: Do not configure hosting, CI/CD pipelines, Docker, or infrastructure. +* **No Library Proliferation**: Do not install new state-management or routing libraries. +* **No Game Over-Engineering**: Do not implement multiple rounds, drawer rotation, timers, countdowns, speed bonuses, or drawer scoring bonuses. +* **No Content Customization**: Do not support custom or random word packs (use only the starter list). +* **No Spectators/Moderation**: Do not implement spectator mode, or moderation features like mute and kick. +* **No Invite Utilities**: Do not implement room passwords or invite link sharing. From cc13d60b54d6b0b6df5186149570c10179be91c8 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:18:02 +0530 Subject: [PATCH 03/18] speckit.discovery: identify existing gaps, verify starter codebase state, and detail assumptions. --- speckit.discovery | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 speckit.discovery diff --git a/speckit.discovery b/speckit.discovery new file mode 100644 index 0000000..c77d24c --- /dev/null +++ b/speckit.discovery @@ -0,0 +1,50 @@ +# Spec Kit Discovery Notes: Scribble + +This document highlights the discovery findings, identified gaps, assumptions, affected areas, and potential risks based on inspection of the Scribble starter repository. + +--- + +## 1. Gaps and Incomplete/Missing Behaviors + +Based on the README, the following behaviors are missing or incomplete in the starter code: + +1. **Lobby State Polling**: The lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually. Automatic polling (~2s cadence) is not implemented. +2. **Host Designation and Start Game Controls**: There is no tracking of which participant is the host (creator of the room). The "Start Game" flow and host-only permissions to trigger it are not implemented. +3. **Drawer and Word Selection**: Selecting the drawer (first player/host) and picking the secret word deterministically from the starter list (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) is not implemented. +4. **Drawing Sync and Canvas Actions**: Synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented. +5. **Guess Input, Matching, and Scoring**: Rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented. +6. **Shared Result State and Game Restart**: Transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game (returning everyone to lobby with cleared round state) are not implemented. + +--- + +## 2. Assumptions + +1. **HTTP Polling Cadence**: Polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend. +2. **Deterministic State Progression**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. +3. **No Stateful Persistency**: All game state exists solely in-memory within the backend node process. Any crash or restart of the backend clears all existing rooms. + +--- + +## 3. Relevant Frontend Files & Areas + +* **`frontend/src/services/api.ts`**: API model interfaces (`RoomSnapshot`, `Participant`) need updates to match new backend room fields. Service methods for starting the game, sending drawings, submitting guesses, and resetting rooms must be added. +* **`frontend/src/state/roomStore.ts`**: Update the Zustand/external store state management to handle loading flags, errors, current user permissions, canvas actions, and round status. +* **`frontend/src/pages/LobbyPage.tsx`**: Must incorporate the polling logic (`useEffect` timer) to refresh participants, check host status, and disable/enable the start game button. +* **`frontend/src/pages/GamePage.tsx`**: Update this screen to render separate layouts for the drawer (interactive drawing canvas, secret word indicator) and guesser (read-only canvas, guess submission form, score cards). Integrate drawing synchronization and guess log polling. + +--- + +## 4. Relevant Backend Files & Areas + +* **`backend/src/models/game.ts`**: Enhance models to track room states (`hostId`, `drawerId`, `secretWord`, `guesses`, `drawingData`, `status` updates like `"game"` and `"result"`). Add `score` tracking to `Participant`. +* **`backend/src/services/roomStore.ts`**: Modify `createRoom()` and `joinRoom()` to assign the host and validate player inputs (trim names, reject duplicates/empty names). Implement helper services for starting games, saving drawings, appending guesses, and resetting room state. +* **`backend/src/api/schemas.ts`**: Add Zod validation schemas for action queries/payloads (start game parameters, drawing coordinate payloads, guess submissions). +* **`backend/src/api/rooms.ts`**: Setup new routing routes and handler callbacks for game lifecycle status updates (`POST /rooms/:code/start`, `POST /rooms/:code/drawing`, `POST /rooms/:code/guess`, `POST /rooms/:code/restart`). + +--- + +## 5. Potential Risks and Unknowns + +* **Canvas Coordination Bandwidth**: Serializing and transmitting canvas coordinate points over periodic HTTP polling might introduce noticeable stroke lag or high load if drawing data grows too large. Mitigating this with concise coordinate structures or optimized strokes is essential. +* **Lobby Polling Cadence Tuning**: Polling precisely every 2 seconds might create a race condition if players hit endpoints simultaneously. Backend services must ensure operations are synchronous and atomic in-memory to prevent state duplication. +* **Host Leaving Mid-Session**: If the host player leaves the room in the lobby, during gameplay, or in the result state, a mechanism to promote another player to host is needed to prevent rooms from getting orphaned/stuck. From d8b251b65ab1f5756ed788de23d826f972248e11 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:18:32 +0530 Subject: [PATCH 04/18] speckit.specify: document room lifecycle setup, lobby polling frequency, and host control rules. --- speckit.specify | 137 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 speckit.specify diff --git a/speckit.specify b/speckit.specify new file mode 100644 index 0000000..a144a61 --- /dev/null +++ b/speckit.specify @@ -0,0 +1,137 @@ +# Spec Kit Specification: Scribble + +This document specifies the functional requirements, validation rules, edge cases, and acceptance criteria for the Scribble game. + +--- + +## 1. Room Setup, Lobby, & Lifecycle Requirements + +### 1.1 Room Creation and Host Assignment +* **Requirement**: Any user can create a game room. The creator of the room is automatically designated as the host. +* **Acceptance Criteria**: + * Upon successful room creation, a unique room code is generated. + * The user who initiated creation is marked as the room host and gets host-specific controls (e.g., ability to start the game). + +### 1.2 Joining a Room +* **Requirement**: A user can join an existing room by entering a unique room code. +* **Acceptance Criteria**: + * Users must provide a non-empty name to join. + * Room codes must be validated (exist and be active). + +### 1.3 Lobby Polling +* **Requirement**: The lobby state must automatically refresh via periodic polling at an interval of approximately 2 seconds. +* **Acceptance Criteria**: + * When a new player joins the room, all other players in the lobby see the updated list of participants within 2 seconds without manual refresh. + +### 1.4 Room Isolation +* **Requirement**: Each game room must be completely isolated from all other rooms. +* **Acceptance Criteria**: + * Actions in Room A (such as player joins, drawings, or guesses) must not impact or leak into Room B. + +### 1.5 Validation Rules & Edge Cases +* **Validation**: + * **Invalid Room Codes**: Attempting to join with an empty, whitespace-only, or non-existent room code must be rejected with a clear error message. + * **Duplicate Names**: (Edge Case) If a user attempts to join a room with a username that is already taken by an active player in that room, the system should reject the join or append a modifier to make it unique. + * **Room Capacity**: (Edge Case) Attempting to join a room that does not exist or has been deleted must return an error. + +--- + +## 2. Game Start & Drawer Flow Requirements + +### 2.1 Game Start Preconditions +* **Requirement**: Only the host is permitted to start the game. The option to start the game is disabled or hidden for non-host players. +* **Acceptance Criteria**: + * The host can only trigger the game start when there are at least 2 players in the lobby (including the host). + * If there are fewer than 2 players, the host cannot start the game. + +### 2.2 Player Name Validation +* **Requirement**: Player names must be trimmed. +* **Acceptance Criteria**: + * Whitespace-only or empty names must be rejected. + * Leading and trailing spaces must be automatically stripped. + +### 2.3 Drawer Assignment +* **Requirement**: When the game begins, the host (or first player in the room list) is assigned as the drawer. All other players are assigned as guessers. +* **Acceptance Criteria**: + * The drawer must be clearly identified to all players (e.g., visually indicated in the player list). + +### 2.4 Secret Word Selection and Visibility +* **Requirement**: The secret word for the round must be deterministically selected from the starter word list: `rocket`, `pizza`, `castle`, `guitar`, `sunflower`. +* **Acceptance Criteria**: + * The secret word is visible *only* to the drawer. + * Guessers must not see the secret word; they must only see indicators (such as underscores or blank spaces) or nothing indicating the letters of the word. + +### 2.5 Validation Rules & Edge Cases +* **Edge Cases**: + * **Player Disconnection during Start**: If the host disconnects before starting, the next remaining player must be promoted to host. + * **Word Selection Depletion**: Since only one round is supported in the scope, word selection is done once per round. + +--- + +## 3. Gameplay Interaction Requirements + +### 3.1 Drawing Canvas +* **Requirement**: The drawer can draw freehand on the canvas and clear the canvas. +* **Acceptance Criteria**: + * Any strokes drawn on the canvas by the drawer must be synced and visible on the screens of all guessers in the same room. + * The drawer has a "Clear Canvas" action that immediately clears the drawing on their canvas and synchronizes the clear state to all guessers. + * Guessers cannot draw on the canvas or clear it. + +### 3.2 Guess Submission +* **Requirement**: Guessers can submit text guesses to identify the secret word. +* **Acceptance Criteria**: + * Only guessers can submit guesses. + * Empty or whitespace-only guesses must be rejected and not added to the guess history. + +### 3.3 Guess Validation and Comparison +* **Requirement**: Guesses must be trimmed and compared case-insensitively with the secret word. +* **Acceptance Criteria**: + * A guess of "Rocket", "ROCKET", or " rocket " must match the secret word "rocket". + +### 3.4 Guess History Sync +* **Requirement**: All submitted guesses must be added to a guess history log and synchronized to all players in the room via polling. +* **Acceptance Criteria**: + * The list of guesses is visible to everyone in the room. + * When a guesser submits a guess, it appears in the guess history log for all players within the polling interval. + +### 3.5 Validation Rules & Edge Cases +* **Validation**: + * **No Drawer Guesses**: The drawer cannot submit guesses. + * **Correct Guess Scoring**: When a guesser submits the exact correct word, their score increases by 100 points. Incorrect guesses add 0 points. + * **Subsequent Guesses**: (Edge Case) Once a guesser guesses the correct word, they are awarded points and their status is updated, but they cannot gain additional points by submitting the word again. + +--- + +## 4. Result, Restart, & Post-Game Requirements + +### 4.1 Result State +* **Requirement**: When the round ends, a shared result state is displayed to all players in the room. +* **Acceptance Criteria**: + * All players must see: + * The correct secret word. + * The final scores of all participants. + * The complete guess history log. + +### 4.2 Game Restart +* **Requirement**: Only the host can restart the game from the result screen. +* **Acceptance Criteria**: + * When the host clicks restart, all players in the room are automatically returned to the Lobby. + * The list of players in the room is preserved on restart. + * All round-specific state (scores, drawings, guess history, drawer assignments, and secret words) is cleared. + +### 4.3 Validation Rules & Edge Cases +* **Edge Cases**: + * **Host Leaves during Result State**: If the host leaves the room while in the result state, another player must be promoted to host so they can restart the game. + * **Late Joins**: New players cannot join a room that is currently in an active game session or result state; they can only join during the lobby phase. + +--- + +## 5. Scope-Control & Out-of-Scope Constraints + +To prevent scope creep, the following behaviors are explicitly disallowed: +* **No Real-Time Sync Protocols**: No WebSocket, Server-Sent Events (SSE), or WebRTC. All state synchronization must occur via periodic HTTP polling. +* **No Persistent Storage**: No database, cookie storage, local storage, or session storage. All state must exist solely in the server's volatile memory. +* **No User Identity Management**: No sign-up, sign-in, session tokens, JWTs, or OAuth integrations. Players are identified solely by the transient name they enter when joining. +* **No Multi-Round Loop**: There is no support for automatic drawer rotation, multiple rounds, guess/drawer timers, countdowns, speed bonuses, or drawer scoring bonuses. The game consists of a single round that moves to the result state when finished. +* **No Custom Word Libraries**: The game must only use the predefined list of words: `rocket`, `pizza`, `castle`, `guitar`, `sunflower`. +* **No Advanced Room Features**: No spectator modes, moderation actions (mute, kick, ban), room passwords, or invite links. From a59db41f1dbeb27c8c1bca0a3f24b62eb5c279c3 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:18:48 +0530 Subject: [PATCH 05/18] speckit.plan: map required backend and frontend state model modifications and data flow. --- speckit.plan | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 speckit.plan diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 0000000..4db425e --- /dev/null +++ b/speckit.plan @@ -0,0 +1,175 @@ +# Spec Kit Plan: Scribble + +This document outlines the discovery findings, architecture adjustments, state model changes, data flow, implementation sequence, and testing strategy for the Scribble game. + +--- + +## 1. Discovery Findings + +### 1.1 Codebase Inspection and Gaps +* **Room Schema limitations**: The current `Room` model in the backend (`backend/src/models/game.ts`) only supports the `"lobby"` status and does not store essential round state like the host, current drawer, secret word, drawing data, guesses, or participant scores. +* **Lack of Host Tracking**: There is no explicit identifier tracking which participant is the room host. The backend currently assigns the creator of the room as a participant but does not designate them as the host in the data model. +* **No Room Lifecycle Transitions**: No API routes exist to transition a room's status from `"lobby"` to `"game"`, or from `"game"` to `"result"`, or to reset the room state back to `"lobby"`. +* **Static Snapshot Filtering**: The `toRoomSnapshot` function in the room store service does not hide the secret word from guessers, which violates the requirement that the secret word must only be visible to the drawer. +* **No Drawing or Guess Handlers**: The API lacks route handlers for submitting drawing data, clearing the canvas, or submitting guesses. + +### 1.2 Assumptions +* **Single Round Lifetime**: A game consists of exactly one round. When a guesser makes a correct guess, scores are updated and the game remains in progress or transitions to the result screen depending on the host's actions. +* **Canvas Serialization**: Freehand drawings are serialized into a lightweight representation (e.g., an array of strokes, coordinates, or paths) stored in memory on the backend and polled by the frontend. +* **Transient States**: All room data is stored in memory. Restarting the backend server resets all room sessions. + +--- + +## 2. Affected Code Areas + +### 2.1 Backend (`/backend`) +* `src/models/game.ts`: Enhance `Room`, `Participant`, and `RoomSnapshot` interfaces to include game-state fields. +* `src/services/roomStore.ts`: + * Update `createRoom` and `joinRoom` to handle host assignment and player state. + * Implement deterministic word selection, drawer assignment logic, drawing management, and score mapping. + * Modify `toRoomSnapshot` to filter the secret word for non-drawers. +* `src/api/schemas.ts`: Add Zod validation schemas for drawing payloads, guess submissions, name validations, and action routes. +* `src/api/rooms.ts`: Register new routes for `/rooms/:code/start`, `/rooms/:code/drawing`, `/rooms/:code/guess`, and `/rooms/:code/restart`. + +### 2.2 Frontend (`/frontend`) +* `src/services/api.ts`: Update frontend API signatures and TypeScript models to match backend updates. +* `src/state/roomStore.ts`: + * Add state management logic for drawing strokes, guess history, and room status updates. + * Integrate helper methods for starting, drawing, guessing, and restarting the game. +* `src/pages/LobbyPage.tsx`: Add polling hook (~2 seconds), validate host status, enforce 2-player minimum, and add a start game trigger. +* `src/pages/GamePage.tsx`: Implement the game screen: + * Drawer view (active canvas drawing/clearing, secret word display). + * Guesser view (read-only canvas, text guess input, score indicator). + * Guess history logs and canvas update polling (~2 seconds). + * Transition logic to Result display when the game terminates. + +--- + +## 3. Required State Model Changes + +### 3.1 Backend Model Updates (`backend/src/models/game.ts`) + +```typescript +// Add new room statuses +export type RoomStatus = "lobby" | "game" | "result"; + +export interface Participant { + id: string; + name: string; + joinedAt: string; + score: number; // Track player scores +} + +export interface Guess { + senderId: string; + senderName: string; + text: string; + correct: boolean; + timestamp: string; +} + +export interface Room { + code: string; + status: RoomStatus; + participants: Participant[]; + hostId: string; // Identify room host + drawerId: string | null; // Identify current drawer + secretWord: string | null; // Selected round word + drawingData: string; // Serialized drawing canvas state (e.g. JSON string or empty) + guesses: Guess[]; + createdAt: string; + updatedAt: string; +} + +export interface RoomSnapshot { + code: string; + status: RoomStatus; + participants: Participant[]; + hostId: string; + drawerId: string | null; + secretWord: string | null; // Filtered to null for guessers on snapshot delivery + drawingData: string; + guesses: Guess[]; + availableWords: string[]; +} +``` + +--- + +## 4. Data Flow + +### 4.1 Room Setup & Lobby Flow +1. A user fills in their username and clicks **Create Room**. The client issues `POST /rooms` payload. The server returns a 4-character code, registers the user as `hostId`, and initializes the room status as `"lobby"`. +2. Other players join using `POST /rooms/:code/join` with their name. Name string is trimmed and validated. +3. Every client in the room starts polling `GET /rooms/:code?participantId=...` every 2 seconds. The server returns the updated participant list. +4. When the participant list length is $\ge 2$, the host client enables the **Start Game** button. + +### 4.2 Game Initiation Flow +1. The host clicks **Start Game**, executing a `POST /rooms/:code/start`. +2. The server assigns the host (or first player) as the `drawerId`, deterministically picks a `secretWord` from the starter word list, and transitions `status` to `"game"`. +3. In the next poll cycle, all clients receive the state update. The client matching the `drawerId` renders the interactive canvas and secret word. Guessers render the read-only canvas and guess text box. + +### 4.3 Gameplay & Sync Flow +1. **Drawing**: The drawer draws on the canvas. As lines are drawn, the client periodically sends drawing coordinates/paths via `POST /rooms/:code/drawing`. The server caches this drawing data in-memory. Guessers download the canvas state during their 2-second poll cycles. +2. **Guessing**: Guessers type and submit guesses via `POST /rooms/:code/guess`. Guesses are trimmed and verified case-insensitively. The server records guesses in the `guesses` log. +3. **Scoring**: If a guess matches the secret word, the server awards 100 points to that participant, labels the guess as correct, updates the room status to `"result"`, and updates scores. +4. **Polling Sync**: Clients poll the backend to display the guess history, canvas, and updated participant list. + +### 4.4 Result & Restart Flow +1. Once the room transitions to `"result"`, all clients update their layout to display the result screen, showing the correct secret word, scores, and guess history. +2. The host clicks **Restart**, executing `POST /rooms/:code/restart`. +3. The server clears the drawing data, guess logs, round scores, resets statuses to `"lobby"`, and keeps the participant list. Clients poll the lobby status and transition back to the lobby page. + +--- + +## 5. Implementation Sequence + +### 5.1 Scenario 1: Room Setup & Lobby Enhancement +1. **Backend**: Update `Room` state model with `hostId`. Assign `hostId` to the room creator inside `createRoom()`. +2. **Backend**: Add a validator in `joinRoom()` to reject joining if the room code does not exist. +3. **Frontend**: Add a React `useEffect`-based interval hook in `LobbyPage.tsx` to call `fetchRoom()` every 2 seconds. +4. **Frontend**: Render a "Start Game" action button visible only to the room host when $\ge 2$ players are in the room. + +### 5.2 Scenario 2: Game Start & Drawer Assignment +1. **Backend**: Implement `POST /rooms/:code/start` route. Enforce that only the `hostId` can call it and the room has $\ge 2$ participants. +2. **Backend**: Implement drawer selection (assign host) and word selection (deterministically pull from list). Update status to `"game"`. +3. **Backend**: Filter `secretWord` in `toRoomSnapshot()` so it is hidden (set to `null` or omitted) unless `viewerParticipantId === drawerId`. +4. **Frontend**: Set up polling in `GamePage.tsx`. Split rendering based on whether the current user's ID matches `drawerId`. + +### 5.3 Scenario 3: Drawing Sync & Guess Submissions +1. **Backend**: Implement `POST /rooms/:code/drawing` route. Cache the drawing strokes payload. +2. **Backend**: Implement `POST /rooms/:code/guess` route. Compare guesses case-insensitively. Record correct/incorrect outcomes. Award 100 points for correct guesses and transition room status to `"result"`. +3. **Frontend**: Add stroke-drawing logic on the canvas for the drawer, transmitting canvas state periodically. +4. **Frontend**: Add canvas-render polling and guess-history display for guessers. + +### 5.4 Scenario 4: Results & Restart Lifecycle +1. **Backend**: Implement `POST /rooms/:code/restart` route. Validate that only the host triggers it. Clear room state (reset scores to 0, clear drawings/guesses, set status back to `"lobby"`). +2. **Frontend**: Implement the Result screen showing all metrics. Enable the restart button for the host. + +--- + +## 6. Testing Strategy + +### 6.1 Automated Verification +* Unit tests in the backend (`backend/src/services/roomStore.test.ts` and `backend/src/api/schemas.test.ts`) checking: + * Host assignment on creation. + * Correct guess comparison (case-insensitivity, trim handling). + * Schema rejection of empty names. + * Filtering of the secret word in the snapshot payload. + +### 6.2 Manual Verification (Multiplayer Flows) +* Open two separate web browser sessions side-by-side: + 1. **Tab 1**: Create a room, entering name "Alice". Verify she is designated host and cannot start yet. + 2. **Tab 2**: Join the room with code, entering name "Bob". Verify the lobby displays both players within 2 seconds. + 3. **Tab 1**: Host starts game. Verify drawer screen loads for Alice (shows secret word) and guesser screen loads for Bob (word hidden). + 4. **Tab 1**: Draw on canvas. Verify strokes appear in Tab 2. + 5. **Tab 2**: Submit an incorrect guess. Verify it appears in guess history. Submit correct guess. Verify both tabs transition to result page. + 6. **Tab 1**: Host restarts. Verify both tabs return to lobby with scores cleared. + +--- + +## 7. Risks and Assumptions + +* **Out-of-Scope Constraints**: Because WebSockets are not permitted, polling frequency is crucial. Setting it to 2 seconds provides a reasonable balance between synchronization lag and server load. +* **Race Conditions in Polling**: Multiple players guessing simultaneously will be sequenced by the server's HTTP handler. The first correct guess processed by the server transitions the state to `"result"`. +* **In-Memory Storage**: There is no persistent data. Any crash or restart of the backend Node.js process immediately deletes all rooms and sessions. From d6c849340538444cf130e38b783be3919fd8261f Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:19:04 +0530 Subject: [PATCH 06/18] speckit.tasks: define checkpoints, task orders, and dependencies for the assignment. --- speckit.tasks | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 speckit.tasks diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 0000000..7ad8db7 --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,143 @@ +# Spec Kit Tasks: Scribble + +A detailed task list to guide the implementation of the Scribble multiplayer drawing game scenarios. + +--- + +## Phase 0: Discovery and Setup + +- [ ] **Task 0.1**: Inspect the codebase. + - Find state management structures in `/frontend/src/state/roomStore.ts` and models in `/backend/src/models/game.ts`. +- [ ] **Task 0.2**: Document discovery findings. + - Verify that the app builds using `npm run build` in both directories. + - Dependencies: None. + +--- + +## Scenario 1: Room Setup & Lobby + +### Backend Tasks +- [ ] **Task 1.1**: Update backend state models. + - Add `hostId` to `Room` and `RoomSnapshot` interfaces in `backend/src/models/game.ts`. + - Add `score` (starting at 0) to the `Participant` model. + - Dependencies: Task 0.1 +- [ ] **Task 1.2**: Implement host assignment logic. + - Update `createRoom()` in `backend/src/services/roomStore.ts` to assign the room creator's participant ID to the room's `hostId`. + - Dependencies: Task 1.1 +- [ ] **Task 1.3**: Validate join parameters. + - Modify `joinRoom()` in `backend/src/services/roomStore.ts` to reject joins if the room status is not `"lobby"`, or if the code does not exist. + - Validate username (trim leading/trailing spaces, reject empty or whitespace-only inputs). + - Dependencies: Task 1.2 +- [ ] **Task 1.4**: Add schemas. + - Add Zod validations in `backend/src/api/schemas.ts` for room codes and input player names. + - Dependencies: Task 1.3 + +### Frontend Tasks +- [ ] **Task 1.5**: Update frontend API and Store. + - Update room models in `frontend/src/services/api.ts` to include `hostId` and `score`. + - Update `RoomStore` in `frontend/src/state/roomStore.ts` to reflect model updates. + - Dependencies: Task 1.1 +- [ ] **Task 1.6**: Implement Lobby Polling. + - Add automated polling using an interval timer (~2s) in `frontend/src/pages/LobbyPage.tsx` to pull latest room states. + - Dependencies: Task 1.5 +- [ ] **Task 1.7**: Enable Host controls. + - Update `LobbyPage.tsx` to render the "Start Game" button only if the current user's participant ID matches the room's `hostId`. + - Enable the "Start Game" button only when the room participant list has $\ge 2$ players. + - Dependencies: Task 1.6 + +### Validation & Verification Tasks +- [ ] **Task 1.8**: Test Room Setup manually. + - Open two browser tabs. Let Alice create a room (verify she is host and Start button is disabled). Let Bob join with room code (verify Bob sees Lobby and Alice sees Bob appear within 2 seconds). + - Test joining with empty and invalid names to verify error feedback. + - Dependencies: Task 1.7 + +--- + +## Scenario 2: Game Start & Drawer Flow + +### Backend Tasks +- [ ] **Task 2.1**: Implement Game Start endpoint. + - Add `POST /rooms/:code/start` route. Verify requesting user matches `hostId` and participant count is $\ge 2$. + - Change room status to `"game"`. + - Deterministically assign the host (or first player in the list) as the room's `drawerId`. + - Deterministically pick a secret word from `STARTER_WORDS`. + - Dependencies: Task 1.4 +- [ ] **Task 2.2**: Implement Secret Word filtering. + - Modify `toRoomSnapshot()` in `backend/src/services/roomStore.ts` to set `secretWord` to `null` if the request query parameter `participantId` does not match the room's `drawerId`. + - Dependencies: Task 2.1 + +### Frontend Tasks +- [ ] **Task 2.3**: Map start actions in store. + - Add `startGame()` method to `RoomStore` to post to the start endpoint. + - Dependencies: Task 1.5, Task 2.1 +- [ ] **Task 2.4**: Build Drawer/Guesser UI Layout. + - Update `frontend/src/pages/GamePage.tsx` to poll room state. + - Switch layout based on whether the participant is the drawer: + - **Drawer**: Render active canvas, display the secret word. + - **Guesser**: Render read-only canvas, hide the secret word, render guess input. + - Dependencies: Task 1.6, Task 2.3 + +### Validation & Verification Tasks +- [ ] **Task 2.5**: Test Game Start flow. + - Alice starts the game. Verify both Alice and Bob transition to `GamePage` via polling. + - Verify Alice (drawer) sees the secret word and Bob (guesser) does not. + - Dependencies: Task 2.4 + +--- + +## Scenario 3: Gameplay Interaction + +### Backend Tasks +- [ ] **Task 3.1**: Implement Canvas Drawing endpoints. + - Add `POST /rooms/:code/drawing` route. Keep drawing coordinate state in-memory under `Room`'s `drawingData`. + - Implement canvas clearing endpoint or clear payload handling. + - Dependencies: Task 2.1 +- [ ] **Task 3.2**: Implement Guess Submission endpoint. + - Add `POST /rooms/:code/guess` route. Trim and validate input. + - Compare guess case-insensitively with `secretWord`. + - Log guess in `Room`'s `guesses` log. + - If correct, award 100 points to the guesser and set room status to `"result"`. + - Dependencies: Task 2.1 + +### Frontend Tasks +- [ ] **Task 3.3**: Connect Drawing and Guessing state actions. + - Add methods to `RoomStore` to post canvas drawing updates and submit guess entries. + - Dependencies: Task 2.3 +- [ ] **Task 3.4**: Integrate Drawing Canvas interaction. + - Connect freehand mouse/touch listeners to the drawer's canvas. Send updates to backend. + - Hook canvas clear action to the "Clear Canvas" button. + - Setup drawing polling in `GamePage.tsx` for guessers to render the canvas coordinates received from the server. + - Dependencies: Task 2.4, Task 3.3 +- [ ] **Task 3.5**: Integrate Guessing and Log UI. + - Render the guess logs list dynamically on the Game Page. + - Connect the guess input form to submit guesses to the backend. + - Dependencies: Task 2.4, Task 3.3 + +### Validation & Verification Tasks +- [ ] **Task 3.6**: Verify gameplay mechanics. + - Verify drawing on Alice's screen draws on Bob's screen within the poll interval. + - Submit wrong guesses from Bob; verify they show in the log. + - Submit correct guess; verify both tabs transition to result status and Bob's score is updated. + - Dependencies: Task 3.4, Task 3.5 + +--- + +## Scenario 4: Result, Restart & Final Validation + +### Backend Tasks +- [ ] **Task 4.1**: Implement Restart endpoint. + - Add `POST /rooms/:code/restart` route. Only allow host execution. + - Reset room status to `"lobby"`, clear canvas drawings, empty guess history, and set scores back to 0. Keep the participant list intact. + - Dependencies: Task 2.1 + +### Frontend Tasks +- [ ] **Task 4.2**: Build Result Screen and Restart triggers. + - Build UI layout for the `"result"` status. Show final scores, correct word, and complete guess history. + - Add a "Return to Lobby" button visible only to the host that triggers the restart action. + - Dependencies: Task 3.3, Task 4.1 + +### Validation & Verification Tasks +- [ ] **Task 4.3**: End-to-end flow validation. + - Verify Alice restarts the game. Both players must land back on Lobby with empty scores. + - Verify that both projects build cleanly using `npm run build`. + - Dependencies: Task 4.2 From 0a754fd83f0e754f055116a45c81b2a2d71c4d69 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:19:31 +0530 Subject: [PATCH 07/18] feat: implement Scenario 1 - Room Setup & Lobby lifecycle with polling --- backend/src/api/rooms.ts | 20 ++++- backend/src/api/schemas.test.ts | 22 ++++- backend/src/api/schemas.ts | 6 +- backend/src/models/game.ts | 9 +- backend/src/services/roomStore.test.ts | 73 +++++++++++++++- backend/src/services/roomStore.ts | 62 +++++++++++-- frontend/src/pages/CreateRoomPage.tsx | 4 +- frontend/src/pages/GamePage.tsx | 44 ++++++++-- frontend/src/pages/JoinRoomPage.tsx | 4 +- frontend/src/pages/LobbyPage.tsx | 116 +++++++++++++++++++++---- frontend/src/services/api.ts | 11 ++- frontend/src/state/roomStore.ts | 45 +++++++++- 12 files changed, 371 insertions(+), 45 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..3e6d196 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -6,7 +6,7 @@ import { roomCodeParamsSchema, roomViewerQuerySchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -44,6 +44,24 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = startGame(code.toUpperCase(), participantId); + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea..ab94ca5 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { createRoomSchema, roomCodeParamsSchema, joinRoomSchema } from "./schemas.js"; describe("schemas", () => { - it("createRoomSchema accepts a valid body with playerName", () => { - const result = createRoomSchema.parse({ playerName: "Alice" }); + it("createRoomSchema accepts a valid body with playerName and trims it", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); expect(result.playerName).toBe("Alice"); }); - it("roomCodeParamsSchema rejects missing code", () => { + it("createRoomSchema rejects empty or whitespace-only name", () => { + expect(() => createRoomSchema.parse({ playerName: "" })).toThrow(); + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow(); + expect(() => createRoomSchema.parse({})).toThrow(); + }); + + it("joinRoomSchema rejects empty or whitespace-only name", () => { + expect(() => joinRoomSchema.parse({ playerName: "" })).toThrow(); + expect(() => joinRoomSchema.parse({ playerName: " " })).toThrow(); + expect(() => joinRoomSchema.parse({})).toThrow(); + }); + + it("roomCodeParamsSchema rejects missing or empty code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); + expect(() => roomCodeParamsSchema.parse({ code: "" })).toThrow(); + expect(() => roomCodeParamsSchema.parse({ code: " " })).toThrow(); }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba0..feabbc1 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,15 +1,15 @@ import { z } from "zod"; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name cannot be empty") }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Player name cannot be empty") }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: z.string().trim().min(1, "Room code cannot be empty") }); export const roomViewerQuerySchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..749eb65 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,16 +1,20 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "game" | "result"; export interface Participant { id: string; name: string; joinedAt: string; + score: number; } export interface Room { code: string; status: RoomStatus; participants: Participant[]; + hostId: string; + drawerId: string | null; + secretWord: string | null; createdAt: string; updatedAt: string; } @@ -21,6 +25,9 @@ export interface RoomSnapshot { participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + hostId: string; + drawerId: string | null; + secretWord: string | null; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77..09ef493 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,88 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot } from "./roomStore.js"; +import { HttpError } from "../api/schemas.js"; describe("roomStore", () => { - it("createRoom returns a room with a 4-character uppercase code", () => { + it("createRoom returns a room with a 4-character uppercase code and host assignment", () => { const result = createRoom("Alice"); expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); + expect(result.room.participants[0].score).toBe(0); + expect(result.room.hostId).toBe(result.participantId); expect(result.participantId).toBeDefined(); }); + it("createRoom rejects empty or whitespace-only name", () => { + expect(() => createRoom(" ")).toThrow(HttpError); + expect(() => createRoom("")).toThrow(HttpError); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + + it("joinRoom rejects empty or whitespace-only name", () => { + const { room } = createRoom("Alice"); + expect(() => joinRoom(room.code, " ")).toThrow(HttpError); + }); + + it("joinRoom trims names and rejects duplicates (case-insensitive)", () => { + const { room } = createRoom("Alice"); + + // Test trimming + const joinResult = joinRoom(room.code, " Bob "); + expect(joinResult).not.toBeNull(); + const updatedRoom = getRoom(room.code); + expect(updatedRoom?.participants[1].name).toBe("Bob"); + + // Test duplicate name + expect(() => joinRoom(room.code, "alice")).toThrow(HttpError); + expect(() => joinRoom(room.code, "Bob")).toThrow(HttpError); + }); + + it("joinRoom rejects joining when room is not in lobby status", () => { + const { room } = createRoom("Alice"); + + const fetchedRoom = getRoom(room.code); + if (fetchedRoom) { + fetchedRoom.status = "game"; + saveRoom(fetchedRoom); + } + + expect(() => joinRoom(room.code, "Bob")).toThrow(HttpError); + }); + + it("startGame transitions room status to game, sets host as drawer, and selects secret word", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + expect(joinResult).not.toBeNull(); + const guestId = joinResult!.participantId; + + const activeRoom = startGame(room.code, hostId); + expect(activeRoom.status).toBe("game"); + expect(activeRoom.drawerId).toBe(hostId); + expect(activeRoom.secretWord).toBe("rocket"); + + // Verify snapshot visibility + const hostSnapshot = toRoomSnapshot(activeRoom, hostId); + expect(hostSnapshot.secretWord).toBe("rocket"); + + const guestSnapshot = toRoomSnapshot(activeRoom, guestId); + expect(guestSnapshot.secretWord).toBeNull(); + }); + + it("startGame rejects request if not host or not enough players", () => { + const { room, participantId: hostId } = createRoom("Alice"); + + expect(() => startGame(room.code, hostId)).toThrow(HttpError); + + const joinResult = joinRoom(room.code, "Bob"); + const guestId = joinResult!.participantId; + + expect(() => startGame(room.code, guestId)).toThrow(HttpError); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a..5aa5f31 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +import { HttpError } from "../api/schemas.js"; const rooms = new Map(); @@ -37,7 +38,8 @@ function createParticipant(name?: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + score: 0 }; } @@ -50,11 +52,19 @@ export function listWords() { } export function createRoom(playerName?: string) { - const participant = createParticipant(playerName); + const trimmedName = (playerName || "").trim(); + if (trimmedName.length === 0) { + throw new HttpError(400, "Player name cannot be empty"); + } + + const participant = createParticipant(trimmedName); const room: Room = { code: generateUniqueCode(), status: "lobby", participants: [participant], + hostId: participant.id, + drawerId: null, + secretWord: null, createdAt: now(), updatedAt: now() }; @@ -74,7 +84,23 @@ export function joinRoom(code: string, playerName?: string) { return null; } - const participant = createParticipant(playerName); + if (room.status !== "lobby") { + throw new HttpError(400, "Room is not in lobby status"); + } + + const trimmedName = (playerName || "").trim(); + if (trimmedName.length === 0) { + throw new HttpError(400, "Player name cannot be empty"); + } + + const nameExists = room.participants.some( + (p) => p.name.toLowerCase() === trimmedName.toLowerCase() + ); + if (nameExists) { + throw new HttpError(400, "Player name is already taken"); + } + + const participant = createParticipant(trimmedName); room.participants.push(participant); room.updatedAt = now(); rooms.set(room.code, room); @@ -85,6 +111,29 @@ export function joinRoom(code: string, playerName?: string) { }; } +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + if (room.hostId !== participantId) { + throw new HttpError(403, "Only the host can start the game"); + } + + if (room.participants.length < 2) { + throw new HttpError(400, "At least 2 players are required to start the game"); + } + + room.status = "game"; + room.drawerId = room.hostId; + room.secretWord = STARTER_WORDS[0]; // "rocket" + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function getRoom(code: string) { const room = rooms.get(code); return room ? cloneRoom(room) : null; @@ -97,13 +146,16 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawer = viewerParticipantId && room.drawerId && viewerParticipantId === room.drawerId; return { code: room.code, status: room.status, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), - roles: [...STARTER_ROLES] + roles: [...STARTER_ROLES], + hostId: room.hostId, + drawerId: room.drawerId, + secretWord: isDrawer ? room.secretWord : null }; } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee..1aace8e 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -14,8 +14,8 @@ export function CreateRoomPage() { try { setError(null); - await roomStore.createRoom(playerName); - navigate("/lobby"); + const res = await roomStore.createRoom(playerName); + navigate(`/lobby?code=${res.room.code}&participantId=${res.participantId}`); } 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..4899849 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,21 +1,48 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); - const { room, participantId } = useRoomState(); + const roomStore = useRoomStore(); + const { room, error, isLoading, participantId } = useRoomState(); + const [searchParams] = useSearchParams(); + const [isRestoring, setIsRestoring] = useState(true); useEffect(() => { + const code = searchParams.get("code"); + const pid = searchParams.get("participantId"); + if (!room) { - navigate("/", { replace: true }); + if (code && pid) { + roomStore + .initializeFromUrl(code, pid) + .then(() => setIsRestoring(false)) + .catch(() => { + setIsRestoring(false); + navigate("/", { replace: true }); + }); + } else { + setIsRestoring(false); + navigate("/", { replace: true }); + } + } else { + setIsRestoring(false); } - }, [navigate, room]); + }, [room, searchParams, roomStore, navigate]); + + if (isRestoring || (isLoading && !room)) { + return ( +
+

Loading session...

+
+ ); + } if (!room) { return null; @@ -68,7 +95,10 @@ export function GamePage() {
-
diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f530..00f4443 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -15,8 +15,8 @@ export function JoinRoomPage() { try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); - navigate("/lobby"); + const res = await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + navigate(`/lobby?code=${res.room.code}&participantId=${res.participantId}`); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); } diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd2..9bb0b8a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; @@ -8,28 +8,78 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, error, isLoading, participantId } = useRoomState(); const [refreshError, setRefreshError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [searchParams] = useSearchParams(); + const [isRestoring, setIsRestoring] = useState(true); useEffect(() => { + const code = searchParams.get("code"); + const pid = searchParams.get("participantId"); + if (!room) { - navigate("/", { replace: true }); + if (code && pid) { + roomStore + .initializeFromUrl(code, pid) + .then(() => setIsRestoring(false)) + .catch(() => { + setIsRestoring(false); + navigate("/", { replace: true }); + }); + } else { + setIsRestoring(false); + navigate("/", { replace: true }); + } + } else { + setIsRestoring(false); + } + }, [room, searchParams, roomStore, navigate]); + + useEffect(() => { + if (room?.status === "game" && participantId) { + navigate(`/game?code=${room.code}&participantId=${participantId}`); } - }, [navigate, room]); + }, [room?.status, room?.code, participantId, navigate]); + + useEffect(() => { + if (!room) return; + + const interval = setInterval(() => { + roomStore.fetchRoom().catch((caughtError) => { + console.error("Lobby polling error:", caughtError); + }); + }, 2000); + + return () => clearInterval(interval); + }, [room, roomStore]); async function handleRefresh() { try { + setIsRefreshing(true); setRefreshError(null); await roomStore.fetchRoom(); } catch (caughtError) { setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + } finally { + setIsRefreshing(false); } } + if (isRestoring || (isLoading && !room)) { + return ( +
+

Loading session...

+
+ ); + } + if (!room) { return null; } + const isHost = room && participantId === room.hostId; + return (
@@ -47,12 +97,32 @@ export function LobbyPage() {

No participants are connected to this room yet.

) : (
    - {room.participants.map((participant) => ( -
  • - {participant.name} - joined -
  • - ))} + {room.participants.map((participant) => { + const isParticipantHost = participant.id === room.hostId; + return ( +
  • +
    + {participant.name} + {isParticipantHost && ( + + Host + + )} +
    + {participant.score} pts +
  • + ); + })}
)} @@ -66,12 +136,28 @@ export function LobbyPage() {
- - + {isHost && ( + + )}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 26e8bf5..36c4c35 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,14 +4,18 @@ export interface Participant { id: string; name: string; joinedAt: string; + score: number; } export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "game" | "result"; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + hostId: string; + drawerId: string | null; + secretWord: string | null; } export interface RoomSessionResponse { @@ -57,5 +61,10 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start?participantId=${encodeURIComponent(participantId)}`, { + method: "POST" + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd373..c876696 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -78,13 +78,31 @@ class RoomStore { } async createRoom(playerName: string) { - const response = await this.withLoading(() => api.createRoom(playerName)); + const trimmedName = playerName.trim(); + if (!trimmedName) { + const error = new Error("Player name cannot be empty"); + this.setState({ error: error.message }); + throw error; + } + const response = await this.withLoading(() => api.createRoom(trimmedName)); this.setRoomSession(response); return response; } async joinRoom(code: string, playerName: string) { - const response = await this.withLoading(() => api.joinRoom(code, playerName)); + const trimmedCode = code.trim(); + const trimmedName = playerName.trim(); + if (!trimmedCode) { + const error = new Error("Room code cannot be empty"); + this.setState({ error: error.message }); + throw error; + } + if (!trimmedName) { + const error = new Error("Player name cannot be empty"); + this.setState({ error: error.message }); + throw error; + } + const response = await this.withLoading(() => api.joinRoom(trimmedCode, trimmedName)); this.setRoomSession(response); return response; } @@ -98,6 +116,29 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async initializeFromUrl(code: string, participantId: string) { + return this.withLoading(async () => { + const response = await api.fetchRoom(code, participantId); + this.setState({ + room: response.room, + participantId, + error: null + }); + return response.room; + }); + } + + async startGame() { + if (!this.state.room || !this.state.participantId) { + return null; + } + return this.withLoading(async () => { + const response = await api.startGame(this.state.room!.code, this.state.participantId!); + this.setRoomSnapshot(response.room); + return response.room; + }); + } } const RoomStoreContext = createContext(null); From 0c412abf8605a2aafb1c06483969b3d19894a170 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 21:54:33 +0530 Subject: [PATCH 08/18] feat: implement Scenario 2 - Game Start & Drawer Flow layout and state polling --- frontend/src/components/Scoreboard.tsx | 60 ++++++++++++++-- frontend/src/pages/GamePage.tsx | 95 +++++++++++++++++++++++--- speckit.tasks | 15 ++-- 3 files changed, 148 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734..fe31302 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,64 @@ import { Card } from "./Card"; +import { useRoomState } from "../state/roomStore"; export function Scoreboard() { + const { room } = useRoomState(); + + if (!room) { + return ( + +
+
+ No active room +
+
+
+ ); + } + return ( -
-
- Waiting for players... - 0 -
+
+ {room.participants.map((participant) => { + const isDrawer = room.drawerId === participant.id; + return ( +
+
+ {participant.name} + {isDrawer && ( + + Drawer + + )} +
+ {participant.score} pts +
+ ); + })}
); } + diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 4899849..8af7f7c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -6,7 +6,6 @@ import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; import { useRoomState, useRoomStore } from "../state/roomStore"; - export function GamePage() { const navigate = useNavigate(); const roomStore = useRoomStore(); @@ -36,6 +35,26 @@ export function GamePage() { } }, [room, searchParams, roomStore, navigate]); + // Game Page Polling (every 2 seconds) + useEffect(() => { + if (!room) return; + + const interval = setInterval(() => { + roomStore.fetchRoom().catch((caughtError) => { + console.error("Game polling error:", caughtError); + }); + }, 2000); + + return () => clearInterval(interval); + }, [room, roomStore]); + + // Redirection when room status transitions back to lobby + useEffect(() => { + if (room?.status === "lobby" && participantId) { + navigate(`/lobby?code=${room.code}&participantId=${participantId}`); + } + }, [room?.status, room?.code, participantId, navigate]); + if (isRestoring || (isLoading && !room)) { return (
@@ -49,13 +68,36 @@ 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); + const drawerName = drawerParticipant ? drawerParticipant.name : "the drawer"; return (
Round 1 -

Guess the Word!

+ {isDrawer ? ( +

+ You are drawing! + + Secret Word: {room.secretWord} + +

+ ) : ( +

Guess the Word!

+ )}
@@ -68,9 +110,41 @@ export function GamePage() {
-
- Waiting for drawer... -
+ {isDrawer ? ( +
+ ✏️ Active Canvas +

Start drawing your word: {room.secretWord}

+
+ ) : ( +
+ 👁️ Read-Only Canvas +

Waiting for {drawerName} to draw...

+
+ )}
@@ -83,14 +157,16 @@ export function GamePage() {
Status
-
Playing
+
{isDrawer ? "Drawing" : "Guessing"}
- - - + {!isDrawer && ( + + + + )} @@ -105,3 +181,4 @@ export function GamePage() { ); } + diff --git a/speckit.tasks b/speckit.tasks index 7ad8db7..5419622 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -67,18 +67,17 @@ A detailed task list to guide the implementation of the Scribble multiplayer dra - Dependencies: Task 2.1 ### Frontend Tasks -- [ ] **Task 2.3**: Map start actions in store. +- [x] **Task 2.3**: Map start actions in store. - Add `startGame()` method to `RoomStore` to post to the start endpoint. - Dependencies: Task 1.5, Task 2.1 -- [ ] **Task 2.4**: Build Drawer/Guesser UI Layout. - - Update `frontend/src/pages/GamePage.tsx` to poll room state. - - Switch layout based on whether the participant is the drawer: - - **Drawer**: Render active canvas, display the secret word. - - **Guesser**: Render read-only canvas, hide the secret word, render guess input. - - Dependencies: Task 1.6, Task 2.3 +- [x] **Task 2.1**: Modify frontend `Scoreboard.tsx` to read from the RoomStore state and dynamically show player scores. +- [x] **Task 2.2**: Update `GamePage.tsx` to poll room state every 2 seconds. +- [x] **Task 2.3**: Update `GamePage.tsx` with conditional redirect logic for room status changes. +- [x] **Task 2.4**: Implement the Drawer vs. Guesser view partition in `GamePage.tsx` with the correct layout, secret word visibility, and form access. +- [x] **Task 2.5**: Verify implementation with automated and manual testing. ### Validation & Verification Tasks -- [ ] **Task 2.5**: Test Game Start flow. +- [x] **Task 2.5**: Test Game Start flow. - Alice starts the game. Verify both Alice and Bob transition to `GamePage` via polling. - Verify Alice (drawer) sees the secret word and Bob (guesser) does not. - Dependencies: Task 2.4 From 92f6abdaf7da84c5964cdc55c800213a7becdbca Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 22:29:29 +0530 Subject: [PATCH 09/18] feat: implement canvas drawing synchronization and guess submissions (Scenario 3) --- backend/src/api/rooms.ts | 44 +++- backend/src/api/schemas.ts | 8 + backend/src/models/game.ts | 12 + backend/src/services/roomStore.test.ts | 42 +++- backend/src/services/roomStore.ts | 75 +++++- frontend/src/components/DrawingCanvas.tsx | 290 ++++++++++++++++++++++ frontend/src/components/GuessForm.tsx | 34 ++- frontend/src/components/ResultPanel.tsx | 63 ++++- frontend/src/pages/GamePage.tsx | 42 +--- frontend/src/services/api.ts | 22 ++ frontend/src/state/roomStore.ts | 34 +++ speckit.tasks | 12 +- 12 files changed, 629 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 3e6d196..d3e7cb4 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,11 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + updateDrawingSchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame, updateDrawing, submitGuess } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -62,6 +64,44 @@ export function createRoomsRouter() { } }); + router.post("/:code/drawing", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + const { drawingData } = updateDrawingSchema.parse(request.body); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = updateDrawing(code.toUpperCase(), participantId, drawingData); + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + const { guessText } = submitGuessSchema.parse(request.body); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = submitGuess(code.toUpperCase(), participantId, guessText); + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } 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 feabbc1..bf89238 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -16,6 +16,14 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const updateDrawingSchema = z.object({ + drawingData: z.string() +}); + +export const submitGuessSchema = z.object({ + guessText: z.string().trim().min(1, "Please enter a guess.") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 749eb65..8091c69 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -8,6 +8,14 @@ export interface Participant { score: number; } +export interface Guess { + senderId: string; + senderName: string; + text: string; + correct: boolean; + timestamp: string; +} + export interface Room { code: string; status: RoomStatus; @@ -15,6 +23,8 @@ export interface Room { hostId: string; drawerId: string | null; secretWord: string | null; + drawingData: string; + guesses: Guess[]; createdAt: string; updatedAt: string; } @@ -28,6 +38,8 @@ export interface RoomSnapshot { hostId: string; drawerId: string | null; secretWord: string | null; + drawingData: string; + guesses: Guess[]; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 09ef493..8871f95 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot } from "./roomStore.js"; +import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot, updateDrawing, submitGuess } from "./roomStore.js"; import { HttpError } from "../api/schemas.js"; describe("roomStore", () => { @@ -85,4 +85,44 @@ describe("roomStore", () => { expect(() => startGame(room.code, guestId)).toThrow(HttpError); }); + + it("updateDrawing saves drawing data when called by drawer, rejects otherwise", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const guestId = joinResult!.participantId; + startGame(room.code, hostId); + + const testDrawingData = JSON.stringify([{ x: 0.1, y: 0.2 }]); + const updatedRoom = updateDrawing(room.code, hostId, testDrawingData); + expect(updatedRoom.drawingData).toBe(testDrawingData); + + // Rejects if drawerId does not match participantId + expect(() => updateDrawing(room.code, guestId, testDrawingData)).toThrow(HttpError); + }); + + it("submitGuess logs guess, awards points and transitions to result state on correct guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const guestId = joinResult!.participantId; + startGame(room.code, hostId); + + // Incorrect guess + const resultAfterWrong = submitGuess(room.code, guestId, "guitar"); + expect(resultAfterWrong.status).toBe("game"); + expect(resultAfterWrong.guesses).toHaveLength(1); + expect(resultAfterWrong.guesses[0].text).toBe("guitar"); + expect(resultAfterWrong.guesses[0].correct).toBe(false); + expect(resultAfterWrong.participants.find(p => p.id === guestId)?.score).toBe(0); + + // Correct guess (case-insensitive & trimmed) + const resultAfterRight = submitGuess(room.code, guestId, " RoCkeT "); + expect(resultAfterRight.status).toBe("result"); + expect(resultAfterRight.guesses).toHaveLength(2); + expect(resultAfterRight.guesses[1].text).toBe("RoCkeT"); + expect(resultAfterRight.guesses[1].correct).toBe(true); + expect(resultAfterRight.participants.find(p => p.id === guestId)?.score).toBe(100); + + // Reject drawer guesses + expect(() => submitGuess(room.code, hostId, "rocket")).toThrow(HttpError); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 5aa5f31..ce7de8c 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -65,6 +65,8 @@ export function createRoom(playerName?: string) { hostId: participant.id, drawerId: null, secretWord: null, + drawingData: "", + guesses: [], createdAt: now(), updatedAt: now() }; @@ -128,6 +130,75 @@ export function startGame(code: string, participantId: string) { room.status = "game"; room.drawerId = room.hostId; room.secretWord = STARTER_WORDS[0]; // "rocket" + room.drawingData = ""; + room.guesses = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function updateDrawing(code: string, participantId: string, drawingData: string) { + const room = rooms.get(code); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + if (room.status !== "game") { + throw new HttpError(400, "Drawing is only allowed during active game"); + } + + if (room.drawerId !== participantId) { + throw new HttpError(403, "Only the drawer can update the drawing"); + } + + room.drawingData = drawingData; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function submitGuess(code: string, participantId: string, guessText: string) { + const room = rooms.get(code); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + if (room.status !== "game") { + throw new HttpError(400, "Guesses are only allowed during active game"); + } + + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) { + throw new HttpError(400, "Participant not in room"); + } + + if (room.drawerId === participantId) { + throw new HttpError(400, "Drawer cannot submit guesses"); + } + + const trimmedGuessText = guessText.trim(); + if (trimmedGuessText.length === 0) { + throw new HttpError(400, "Please enter a guess."); + } + + const isCorrect = trimmedGuessText.toLowerCase() === room.secretWord?.toLowerCase(); + + if (isCorrect) { + participant.score += 100; + room.status = "result"; + } + + const guess = { + senderId: participantId, + senderName: participant.name, + text: trimmedGuessText, + correct: isCorrect, + timestamp: now() + }; + + room.guesses.push(guess); room.updatedAt = now(); rooms.set(room.code, room); @@ -156,6 +227,8 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn roles: [...STARTER_ROLES], hostId: room.hostId, drawerId: room.drawerId, - secretWord: isDrawer ? room.secretWord : null + secretWord: isDrawer ? room.secretWord : null, + drawingData: room.drawingData, + guesses: room.guesses.map((g) => ({ ...g })) }; } diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 0000000..e617688 --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,290 @@ +import { useEffect, useRef, useState } from "react"; + +interface Point { + x: number; + y: number; +} + +type Stroke = Point[]; + +interface DrawingCanvasProps { + readOnly: boolean; + drawingData: string; + onChange?: (drawingData: string) => void; +} + +export function DrawingCanvas({ readOnly, drawingData, onChange }: DrawingCanvasProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [strokes, setStrokes] = useState([]); + const isDrawingRef = useRef(false); + const currentStrokeRef = useRef([]); + + // Parse strokes from drawingData prop when readOnly is true + useEffect(() => { + if (readOnly) { + try { + const parsed = JSON.parse(drawingData || "[]") as Stroke[]; + if (Array.isArray(parsed)) { + setStrokes(parsed); + } else { + setStrokes([]); + } + } catch { + setStrokes([]); + } + } + }, [drawingData, readOnly]); + + // Redraw all strokes when strokes or canvas size changes + const redraw = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw all strokes + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#1f2937"; // Dark slate + ctx.lineWidth = 4; + + const listToDraw = readOnly ? strokes : strokes; // standard list + + for (const stroke of listToDraw) { + if (stroke.length === 0) continue; + + ctx.beginPath(); + const first = stroke[0]; + ctx.moveTo(first.x * canvas.width, first.y * canvas.height); + + if (stroke.length === 1) { + ctx.lineTo(first.x * canvas.width, first.y * canvas.height); + } else { + for (let idx = 1; idx < stroke.length; idx += 1) { + const pt = stroke[idx]; + ctx.lineTo(pt.x * canvas.width, pt.y * canvas.height); + } + } + ctx.stroke(); + } + }; + + // Resize canvas to match container layout size + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleResize = () => { + const parent = canvas.parentElement; + if (parent) { + canvas.width = parent.clientWidth; + canvas.height = Math.max(parent.clientHeight, 450); + redraw(); + } + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [strokes]); + + // Trigger redraw whenever strokes changes + useEffect(() => { + redraw(); + }, [strokes]); + + // Touch handlers attached directly to DOM to prevent scrolling on touch devices + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || readOnly) return; + + const getTouchCoords = (e: TouchEvent): Point | null => { + if (e.touches.length === 0) return null; + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + return { + x: (touch.clientX - rect.left) / rect.width, + y: (touch.clientY - rect.top) / rect.height + }; + }; + + const handleTouchStart = (e: TouchEvent) => { + e.preventDefault(); + const pt = getTouchCoords(e); + if (!pt) return; + + isDrawingRef.current = true; + currentStrokeRef.current = [pt]; + + // Draw point instantly for visual feedback + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#1f2937"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.lineTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.stroke(); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isDrawingRef.current) return; + e.preventDefault(); + const pt = getTouchCoords(e); + if (!pt) return; + + const lastPt = currentStrokeRef.current[currentStrokeRef.current.length - 1]; + currentStrokeRef.current.push(pt); + + const ctx = canvas.getContext("2d"); + if (ctx && lastPt) { + ctx.beginPath(); + ctx.moveTo(lastPt.x * canvas.width, lastPt.y * canvas.height); + ctx.lineTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.stroke(); + } + }; + + const handleTouchEnd = (e: TouchEvent) => { + if (!isDrawingRef.current) return; + e.preventDefault(); + isDrawingRef.current = false; + + if (currentStrokeRef.current.length > 0) { + const updatedStrokes = [...strokes, currentStrokeRef.current]; + setStrokes(updatedStrokes); + currentStrokeRef.current = []; + if (onChange) { + onChange(JSON.stringify(updatedStrokes)); + } + } + }; + + canvas.addEventListener("touchstart", handleTouchStart, { passive: false }); + canvas.addEventListener("touchmove", handleTouchMove, { passive: false }); + canvas.addEventListener("touchend", handleTouchEnd, { passive: false }); + + return () => { + canvas.removeEventListener("touchstart", handleTouchStart); + canvas.removeEventListener("touchmove", handleTouchMove); + canvas.removeEventListener("touchend", handleTouchEnd); + }; + }, [readOnly, strokes, onChange]); + + // Mouse handlers + const handleMouseDown = (e: React.MouseEvent) => { + if (readOnly) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const pt = { + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height + }; + + isDrawingRef.current = true; + currentStrokeRef.current = [pt]; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#1f2937"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.lineTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.stroke(); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDrawingRef.current || readOnly) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const pt = { + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height + }; + + const lastPt = currentStrokeRef.current[currentStrokeRef.current.length - 1]; + currentStrokeRef.current.push(pt); + + const ctx = canvas.getContext("2d"); + if (ctx && lastPt) { + ctx.beginPath(); + ctx.moveTo(lastPt.x * canvas.width, lastPt.y * canvas.height); + ctx.lineTo(pt.x * canvas.width, pt.y * canvas.height); + ctx.stroke(); + } + }; + + const handleMouseUpOrLeave = () => { + if (!isDrawingRef.current || readOnly) return; + isDrawingRef.current = false; + + if (currentStrokeRef.current.length > 0) { + const updatedStrokes = [...strokes, currentStrokeRef.current]; + setStrokes(updatedStrokes); + currentStrokeRef.current = []; + if (onChange) { + onChange(JSON.stringify(updatedStrokes)); + } + } + }; + + const handleClear = () => { + if (readOnly) return; + setStrokes([]); + if (onChange) { + onChange("[]"); + } + }; + + return ( +
+ + {!readOnly && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec47..16ef9cc 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; interface GuessFormProps { disabled?: boolean; @@ -6,24 +7,49 @@ interface GuessFormProps { export function GuessForm({ disabled = false }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const roomStore = useRoomStore(); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmed = guessText.trim(); + if (submitting) return; + + if (!trimmed) { + setFormError("Please enter a guess."); + return; + } + + setSubmitting(true); + setFormError(null); + try { + await roomStore.submitGuess(trimmed); + setGuessText(""); + } catch (error) { + setFormError(error instanceof Error ? error.message : "Failed to submit guess"); + } finally { + setSubmitting(false); + } } return (
+ {formError &&
{formError}
}
-
diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42..b2dd0c8 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,68 @@ import { Card } from "./Card"; +import { useRoomState } from "../state/roomStore"; export function ResultPanel() { + const { room } = useRoomState(); + + const guesses = room?.guesses ?? []; + return ( -
-

Game activity and guesses will appear here.

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

Game activity and guesses will appear here.

+
+ ) : ( +
+ {guesses.map((guess, index) => ( +
+
+ + {guess.senderName} + + {guess.correct && ( + + Guessed Correctly! + + )} +
+ + {guess.text} + +
+ ))} +
+ )}
); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 8af7f7c..ba76d3c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -5,6 +5,7 @@ import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; +import { DrawingCanvas } from "../components/DrawingCanvas"; import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); @@ -111,39 +112,16 @@ export function GamePage() {
{isDrawer ? ( -
- ✏️ Active Canvas -

Start drawing your word: {room.secretWord}

-
+ roomStore.updateDrawing(data)} + /> ) : ( -
- 👁️ Read-Only Canvas -

Waiting for {drawerName} to draw...

-
+ )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 36c4c35..e372282 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,6 +7,14 @@ export interface Participant { score: number; } +export interface Guess { + senderId: string; + senderName: string; + text: string; + correct: boolean; + timestamp: string; +} + export interface RoomSnapshot { code: string; status: "lobby" | "game" | "result"; @@ -16,6 +24,8 @@ export interface RoomSnapshot { hostId: string; drawerId: string | null; secretWord: string | null; + drawingData: string; + guesses: Guess[]; } export interface RoomSessionResponse { @@ -66,5 +76,17 @@ export const api = { return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start?participantId=${encodeURIComponent(participantId)}`, { method: "POST" }); + }, + updateDrawing(code: string, participantId: string, drawingData: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing?participantId=${encodeURIComponent(participantId)}`, { + method: "POST", + body: JSON.stringify({ drawingData }) + }); + }, + submitGuess(code: string, participantId: string, guessText: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess?participantId=${encodeURIComponent(participantId)}`, { + method: "POST", + body: JSON.stringify({ guessText }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index c876696..ea17adc 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -139,6 +139,40 @@ class RoomStore { return response.room; }); } + + async updateDrawing(drawingData: string) { + if (!this.state.room || !this.state.participantId) { + return null; + } + try { + const response = await api.updateDrawing( + this.state.room.code, + this.state.participantId, + drawingData + ); + this.setRoomSnapshot(response.room); + return response.room; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update drawing"; + this.setState({ error: message }); + throw error; + } + } + + async submitGuess(guessText: string) { + if (!this.state.room || !this.state.participantId) { + return null; + } + return this.withLoading(async () => { + const response = await api.submitGuess( + this.state.room!.code, + this.state.participantId!, + guessText + ); + this.setRoomSnapshot(response.room); + return response.room; + }); + } } const RoomStoreContext = createContext(null); diff --git a/speckit.tasks b/speckit.tasks index 5419622..a1a4050 100644 --- a/speckit.tasks +++ b/speckit.tasks @@ -87,11 +87,11 @@ A detailed task list to guide the implementation of the Scribble multiplayer dra ## Scenario 3: Gameplay Interaction ### Backend Tasks -- [ ] **Task 3.1**: Implement Canvas Drawing endpoints. +- [x] **Task 3.1**: Implement Canvas Drawing endpoints. - Add `POST /rooms/:code/drawing` route. Keep drawing coordinate state in-memory under `Room`'s `drawingData`. - Implement canvas clearing endpoint or clear payload handling. - Dependencies: Task 2.1 -- [ ] **Task 3.2**: Implement Guess Submission endpoint. +- [x] **Task 3.2**: Implement Guess Submission endpoint. - Add `POST /rooms/:code/guess` route. Trim and validate input. - Compare guess case-insensitively with `secretWord`. - Log guess in `Room`'s `guesses` log. @@ -99,21 +99,21 @@ A detailed task list to guide the implementation of the Scribble multiplayer dra - Dependencies: Task 2.1 ### Frontend Tasks -- [ ] **Task 3.3**: Connect Drawing and Guessing state actions. +- [x] **Task 3.3**: Connect Drawing and Guessing state actions. - Add methods to `RoomStore` to post canvas drawing updates and submit guess entries. - Dependencies: Task 2.3 -- [ ] **Task 3.4**: Integrate Drawing Canvas interaction. +- [x] **Task 3.4**: Integrate Drawing Canvas interaction. - Connect freehand mouse/touch listeners to the drawer's canvas. Send updates to backend. - Hook canvas clear action to the "Clear Canvas" button. - Setup drawing polling in `GamePage.tsx` for guessers to render the canvas coordinates received from the server. - Dependencies: Task 2.4, Task 3.3 -- [ ] **Task 3.5**: Integrate Guessing and Log UI. +- [x] **Task 3.5**: Integrate Guessing and Log UI. - Render the guess logs list dynamically on the Game Page. - Connect the guess input form to submit guesses to the backend. - Dependencies: Task 2.4, Task 3.3 ### Validation & Verification Tasks -- [ ] **Task 3.6**: Verify gameplay mechanics. +- [x] **Task 3.6**: Verify gameplay mechanics. - Verify drawing on Alice's screen draws on Bob's screen within the poll interval. - Submit wrong guesses from Bob; verify they show in the log. - Submit correct guess; verify both tabs transition to result status and Bob's score is updated. From b4e636eae832740e72ce3f0be5167aff8987d03f Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sat, 30 May 2026 22:42:09 +0530 Subject: [PATCH 10/18] feat: implement game restart, results view, and player exit with host promotion --- backend/src/api/rooms.ts | 39 +++++- backend/src/services/roomStore.test.ts | 76 ++++++++++- backend/src/services/roomStore.ts | 55 +++++++- frontend/src/pages/GamePage.tsx | 173 ++++++++++++++++++++++++- frontend/src/pages/LobbyPage.tsx | 33 ++++- frontend/src/services/api.ts | 12 ++ frontend/src/state/roomStore.ts | 32 +++++ 7 files changed, 405 insertions(+), 15 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index d3e7cb4..e362e05 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -8,7 +8,7 @@ import { updateDrawingSchema, submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame, updateDrawing, submitGuess } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame, updateDrawing, submitGuess, leaveRoom, restartGame } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -102,6 +102,43 @@ export function createRoomsRouter() { } }); + router.post("/:code/leave", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = leaveRoom(code.toUpperCase(), participantId); + response.json({ + success: true, + room: room ? toRoomSnapshot(room, participantId) : null + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = restartGame(code.toUpperCase(), participantId); + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 8871f95..e8e939c 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot, updateDrawing, submitGuess } from "./roomStore.js"; +import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot, updateDrawing, submitGuess, leaveRoom, restartGame } from "./roomStore.js"; import { HttpError } from "../api/schemas.js"; describe("roomStore", () => { @@ -73,6 +73,13 @@ describe("roomStore", () => { const guestSnapshot = toRoomSnapshot(activeRoom, guestId); expect(guestSnapshot.secretWord).toBeNull(); + + // Verify snapshot visibility in result state + activeRoom.status = "result"; + const resultHostSnapshot = toRoomSnapshot(activeRoom, hostId); + expect(resultHostSnapshot.secretWord).toBe("rocket"); + const resultGuestSnapshot = toRoomSnapshot(activeRoom, guestId); + expect(resultGuestSnapshot.secretWord).toBe("rocket"); }); it("startGame rejects request if not host or not enough players", () => { @@ -125,4 +132,71 @@ describe("roomStore", () => { // Reject drawer guesses expect(() => submitGuess(room.code, hostId, "rocket")).toThrow(HttpError); }); + + describe("leaveRoom", () => { + it("removes the participant from the room", () => { + const { room } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + expect(joinResult).not.toBeNull(); + const bobId = joinResult!.participantId; + + const updated = leaveRoom(room.code, bobId); + expect(updated).not.toBeNull(); + expect(updated!.participants).toHaveLength(1); + expect(updated!.participants[0].name).toBe("Alice"); + }); + + it("promotes the next participant to host if host leaves", () => { + const { room, participantId: aliceId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const bobId = joinResult!.participantId; + + const updated = leaveRoom(room.code, aliceId); + expect(updated).not.toBeNull(); + expect(updated!.participants).toHaveLength(1); + expect(updated!.participants[0].id).toBe(bobId); + expect(updated!.hostId).toBe(bobId); + }); + + it("removes the room if all participants leave", () => { + const { room, participantId: aliceId } = createRoom("Alice"); + const updated = leaveRoom(room.code, aliceId); + expect(updated).toBeNull(); + expect(getRoom(room.code)).toBeNull(); + }); + }); + + describe("restartGame", () => { + it("resets game status to lobby, clears scores, canvas, and guesses", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const bobId = joinResult!.participantId; + startGame(room.code, hostId); + + // Submit guesses and score + submitGuess(room.code, bobId, "rocket"); + + const beforeRestart = getRoom(room.code); + expect(beforeRestart!.status).toBe("result"); + expect(beforeRestart!.participants.find(p => p.id === bobId)!.score).toBe(100); + + const restarted = restartGame(room.code, hostId); + expect(restarted.status).toBe("lobby"); + expect(restarted.drawingData).toBe(""); + expect(restarted.guesses).toHaveLength(0); + expect(restarted.drawerId).toBeNull(); + expect(restarted.secretWord).toBeNull(); + expect(restarted.participants.find(p => p.id === bobId)!.score).toBe(0); + expect(restarted.participants.find(p => p.id === hostId)!.score).toBe(0); + }); + + it("rejects restart by non-host", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const bobId = joinResult!.participantId; + startGame(room.code, hostId); + + expect(() => restartGame(room.code, bobId)).toThrow(HttpError); + }); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index ce7de8c..0bdb44e 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -205,6 +205,55 @@ export function submitGuess(code: string, participantId: string, guessText: stri return cloneRoom(room); } +export function leaveRoom(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + return null; + } + + room.participants = room.participants.filter((p) => p.id !== participantId); + + if (room.participants.length === 0) { + rooms.delete(code); + return null; + } + + if (room.hostId === participantId) { + room.hostId = room.participants[0].id; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + if (room.hostId !== participantId) { + throw new HttpError(403, "Only the host can restart the game"); + } + + room.status = "lobby"; + room.drawingData = ""; + room.guesses = []; + room.drawerId = null; + room.secretWord = null; + + for (const participant of room.participants) { + participant.score = 0; + } + + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function getRoom(code: string) { const room = rooms.get(code); return room ? cloneRoom(room) : null; @@ -217,7 +266,9 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - const isDrawer = viewerParticipantId && room.drawerId && viewerParticipantId === room.drawerId; + const isDrawerOrResult = + (viewerParticipantId && room.drawerId && viewerParticipantId === room.drawerId) || + room.status === "result"; return { code: room.code, @@ -227,7 +278,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn roles: [...STARTER_ROLES], hostId: room.hostId, drawerId: room.drawerId, - secretWord: isDrawer ? room.secretWord : null, + secretWord: isDrawerOrResult ? room.secretWord : null, drawingData: room.drawingData, guesses: room.guesses.map((g) => ({ ...g })) }; diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index ba76d3c..28d6011 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -68,10 +68,167 @@ export function GamePage() { return null; } + if (room.status === "result") { + const isHost = room.hostId === participantId; + const sortedParticipants = [...room.participants].sort((a, b) => b.score - a.score); + + return ( +
+
+ + Round Complete! + +

+ Game Results +

+
+ The secret word was: {room.secretWord} +
+
+ +
+ {/* Leaderboard Card */} + +
+ {sortedParticipants.map((p, idx) => { + const isWinner = idx === 0 && p.score > 0; + let rankBadge = `${idx + 1}`; + let rankColor = "#6b7280"; + let rankBg = "#f3f4f6"; + if (idx === 0) { + rankBadge = "🥇"; + rankColor = "#854d0e"; + rankBg = "#fef9c3"; + } else if (idx === 1) { + rankBadge = "🥈"; + rankColor = "#374151"; + rankBg = "#e5e7eb"; + } else if (idx === 2) { + rankBadge = "🥉"; + rankColor = "#78350f"; + rankBg = "#ffedd5"; + } + + return ( +
+
+ + {rankBadge} + + + {p.name} {p.id === room.hostId && "👑"} {p.id === participantId && "(You)"} + +
+ + {p.score} pts + +
+ ); + })} +
+
+ + {/* Activity / Guess history Card */} + +
+ +
+
+ {isHost ? ( +

+ As the host, you can restart the game and return everyone to the lobby. +

+ ) : ( +

+ Waiting for the host to restart the game and return everyone to the lobby... +

+ )} +
+ +
+ + {isHost && ( + + )} +
+
+
+ ); + } + 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); - const drawerName = drawerParticipant ? drawerParticipant.name : "the drawer"; return (
@@ -151,9 +308,17 @@ export function GamePage() {
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 9bb0b8a..d090a01 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -66,6 +66,15 @@ export function LobbyPage() { } } + async function handleLeaveRoom() { + try { + await roomStore.leaveRoom(); + navigate("/"); + } catch (caughtError) { + console.error("Leave room failed:", caughtError); + } + } + if (isRestoring || (isLoading && !room)) { return (
@@ -136,13 +145,23 @@ export function LobbyPage() {
- +
+ + +
{isHost && ( From 5c4d879a650fd4bb3d5e5ffcd2dbd1b0f7c7b587 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sun, 31 May 2026 00:40:26 +0530 Subject: [PATCH 14/18] docs: structure Spec Kit artifacts and reflection report to satisfy CI checks --- .specify/memory/constitution.md | 90 +++++++++++++++++++++++++ reflection.md | 36 ++++++++++ specs/001-room-setup-lobby/plan.md | 32 +++++++++ specs/001-room-setup-lobby/spec.md | 41 +++++++++++ specs/001-room-setup-lobby/tasks.md | 19 ++++++ specs/002-game-start-drawer/plan.md | 30 +++++++++ specs/002-game-start-drawer/spec.md | 39 +++++++++++ specs/002-game-start-drawer/tasks.md | 17 +++++ specs/003-gameplay-interaction/plan.md | 31 +++++++++ specs/003-gameplay-interaction/spec.md | 41 +++++++++++ specs/003-gameplay-interaction/tasks.md | 17 +++++ specs/004-result-restart/plan.md | 35 ++++++++++ specs/004-result-restart/spec.md | 36 ++++++++++ specs/004-result-restart/tasks.md | 16 +++++ 14 files changed, 480 insertions(+) create mode 100644 .specify/memory/constitution.md create mode 100644 reflection.md create mode 100644 specs/001-room-setup-lobby/plan.md create mode 100644 specs/001-room-setup-lobby/spec.md create mode 100644 specs/001-room-setup-lobby/tasks.md create mode 100644 specs/002-game-start-drawer/plan.md create mode 100644 specs/002-game-start-drawer/spec.md create mode 100644 specs/002-game-start-drawer/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/plan.md create mode 100644 specs/004-result-restart/spec.md create mode 100644 specs/004-result-restart/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..067600c --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,90 @@ +# Spec Kit Constitution: Scribble Starter Lab + +This constitution defines the standards, rules, and constraints governing the engineering, AI-assisted development, review, and scope of the Scribble project. + +--- + +## 1. Engineering and Coding Principles + +### General Guidelines +* **TypeScript First**: All new code and refactorings must be fully typed. The use of `any` is strictly prohibited. Use `unknown` for truly dynamic types. +* **Imports**: Use standard relative and absolute ES module imports. For backend code, omit file extensions or handle them via `.js` standard if required. +* **Immutability**: Prefer immutable data structures. Write pure functions wherever possible to prevent side effects. +* **Error Handling**: Fail fast and gracefully. Use centralized error handlers on the backend, and ensure the frontend UI does not crash when API exceptions occur. + +### Backend Guidelines (`/backend`) +* **Validation**: All request payloads and response shapes must be strictly validated using `Zod`. +* **Structure**: Maintain the established directory structure: + * `src/api` for routes and request handling. + * `src/services` for core business logic (e.g., room lifecycle). + * `src/models` for data types and entity representations. +* **State Management**: Keep the memory footprint for active game rooms minimal. Explicitly delete inactive rooms to prevent stateful bloat. + +### Frontend Guidelines (`/frontend`) +* **React Patterns**: Use functional components and strict React hooks (`useState`, `useEffect`, etc.). +* **Routing**: Use `react-router-dom` v6 paradigms exclusively. +* **State Management**: Complex state must be held in `src/state` (e.g., via Zustand or Context API) following the pattern established in `roomStore.ts`. +* **Styling**: Classes must reside in `app.css` or CSS modules. Keep components structurally clean and avoid ad-hoc styling utilities. + +--- + +## 2. AI-Assisted Development Rules + +* **Scope Enforcement**: The AI must not generate files, routes, or features outside of the explicitly defined project scope. +* **No Unnecessary Rewrites**: The AI must not rewrite entire files or components if a localized, incremental change is sufficient. +* **Code Verification**: All code generated by AI must be inspected for type safety, syntax correctness, and compliance with the project's styling and architectural patterns. +* **Prompt Discipline**: Do not ask the AI to implement features that violate the "Strictly Forbidden" constraints. + +--- + +## 3. Self-Review and Code Review Requirements + +* **Local Compilation**: Before any commit or pull request, both the frontend and backend must build cleanly without warnings or errors: + * Run `npm run build` in `/backend` + * Run `npm run build` in `/frontend` +* **Schema Integrity**: Ensure all Zod schemas correspond accurately to frontend state models and API contracts. +* **Resource Cleanup**: Verify that rooms are cleaned up when empty or inactive to avoid memory leaks. +* **Dead Code**: Ensure no debugging logs (`console.log`), unused variables, or dead code remain in the committed changes. + +--- + +## 4. Testing and Validation Expectations + +* **Multiplayer Validation**: All changes must be manually validated using at least two concurrent browser tabs to verify multiplayer synchronization, state isolation, and host permissions. +* **Lobby Polling Cadence**: Verify that room updates are polled at a regular interval (~2 seconds) and do not cause performance degradation. +* **Boundary and Edge Case Validation**: + * Verify that empty or whitespace-only usernames and invalid room codes are rejected with clear error feedback. + * Validate case-insensitivity of guesses. + * Ensure room state remains completely isolated across different room codes. + +--- + +## 5. Commit and Pull Request Discipline + +* **Granular Commits**: Commit changes in small, logical, atomic units (e.g., "Add Zod schema for joining room", "Implement lobby polling hooks"). +* **Traceability**: Each commit should be explainable and directly traceable to a requirement in the specification or task checklist. +* **Pull Request Requirements**: Raise the PR from your branch to `main` on your fork. Include your email, role, and fill out the provided PR template in detail. + +--- + +## 6. Working in an Existing Codebase + +* **Respect Architecture**: Do not introduce new state-management, routing, or database libraries. Enhance the existing Express/React/Zod scaffolding. +* **Minimize Footprint**: Make minimal changes required to implement the features. Leave unrelated files untouched. +* **Read Before Coding**: Always read the existing files and understand their design before adding or altering code. + +--- + +## 7. Scope-Control & Out-of-Scope Rules + +The following items are **strictly out of scope**. They must not be implemented, specified, planned, or included in any task checklist: + +* **No WebSockets**: Do not use WebSockets, Socket.io, or any real-time push protocol. All synchronization must use HTTP polling. +* **No Databases**: Do not use any database (SQL, NoSQL, SQLite, etc.). All data is stored in-memory. +* **No Authentication**: Do not add user authentication, sessions, JWT, or OAuth. +* **No Deployment/CI**: Do not configure hosting, CI/CD pipelines, Docker, or infrastructure. +* **No Library Proliferation**: Do not install new state-management or routing libraries. +* **No Game Over-Engineering**: Do not implement multiple rounds, drawer rotation, timers, countdowns, speed bonuses, or drawer scoring bonuses. +* **No Content Customization**: Do not support custom or random word packs (use only the starter list). +* **No Spectators/Moderation**: Do not implement spectator mode, or moderation features like mute and kick. +* **No Invite Utilities**: Do not implement room passwords or invite link sharing. diff --git a/reflection.md b/reflection.md new file mode 100644 index 0000000..369a24a --- /dev/null +++ b/reflection.md @@ -0,0 +1,36 @@ +# Reflection Report: Scribble Enhancement Lab + +This report reflects on the process, decisions, design tradeoffs, and AI-assisted workflow involved in building the features for the Scribble drawing game. + +## 1. What the Starter App Already Had +The starter scaffold was a brownfield codebase that provided: +* A basic client-server structure: Vite + React (v18) frontend and Node.js + Express backend using TypeScript. +* A basic room api in-memory storage (`rooms` directory/module) on the backend. +* Functional endpoints for room creation (`POST /rooms`), room joining (`POST /rooms/:code/join`), and fetching room snapshot (`GET /rooms/:code`). +* Simple frontend landing, room creation, and room join routes. +* A presentational lobby and game layout showing placeholders for the drawing canvas, guess input field, and scorecards. + +## 2. What We Added +To complete the game requirements across all 4 scenarios, we implemented: +* **Scenario 1: Room Setup & Lobby**: + * Tracked the `hostId` assigned to the creator of each room. + * Added username validation checks (trimming, rejecting empty or whitespace-only inputs) using `Zod` schemas. + * Implemented an automated polling utility (~2-second interval) using React hooks inside `LobbyPage.tsx` to keep player lists synchronized. + * Created host-only permissions to show the "Start Game" button only for hosts when there are $\ge 2$ players. +* **Scenario 2: Game Start & Drawer Flow**: + * Added `POST /rooms/:code/start` endpoint to start the game, assign the host as the drawer, select a secret word, and change room status to `"game"`. + * Implemented data-filtering in `toRoomSnapshot` to restrict secret word visibility solely to the active drawer. +* **Scenario 3: Gameplay Interaction**: + * Hooked up mouse listeners to serialize drawing coordinates on the drawer canvas and post them to `POST /rooms/:code/drawing`. + * Coded drawing synchronization polling on guesser canvases. + * Implemented guess submission routes with case-insensitive validation and point-scoring logic (+100 points for correct guesses, transitions room to `"result"`). +* **Scenario 4: Result, Restart & Final Validation**: + * Designed the results view to show final scores, guess logs, and the secret word. + * Added `POST /rooms/:code/restart` to clear scores, drawings, guesses, reset statuses, and preserve the players in the lobby. + * Added host promotion logic so if a host leaves, a new one is selected to ensure the room never gets stuck. + +## 3. Workflow and AI Constraints Compliance +* **No WebSockets**: We strictly adhered to HTTP polling (~2 seconds cadence) for all synchronization. This worked smoothly for syncing player lists, drawings, guesses, and status transitions without complex socket connections. +* **No Databases**: All state is stored in-memory using JavaScript data structures. +* **No Authentication**: Reused simple transient usernames submitted at room entry. +* **TypeScript Integrity**: Kept all files 100% type-safe without using the `any` keyword. diff --git a/specs/001-room-setup-lobby/plan.md b/specs/001-room-setup-lobby/plan.md new file mode 100644 index 0000000..45236c4 --- /dev/null +++ b/specs/001-room-setup-lobby/plan.md @@ -0,0 +1,32 @@ +# Technical Plan: Room Setup & Lobby + +This document plans the backend model changes, endpoints, frontend component changes, and synchronization data flows required to implement the room setup and lobby polling functionality. + +## 1. State Model Changes +We need to update the in-memory Room and Participant structures to support host tracking and lobby state. + +### 1.1 Backend Models (`backend/src/models/game.ts`) +* Add `hostId: string` to the `Room` interface to track the room owner. +* Add `score: number` to the `Participant` interface, initialized to `0`. +* Add `hostId` to the `RoomSnapshot` sent to the clients. + +## 2. API Endpoints +We modify and validate existing routes: +* `POST /rooms`: Creates a room, sets the host, adds the host to the participants list, and returns the room code. +* `POST /rooms/:code/join`: Validates that the room exists, the player name is non-empty after trimming, and the username is not a duplicate. Adds the participant. +* `GET /rooms/:code`: Returns the room snapshot. Must support a query parameter `participantId` to distinguish client views. + +## 3. Frontend Implementation +* **State Store (`frontend/src/state/roomStore.ts`)**: + * Track `hostId` and the active participant's `participantId`. + * Expose an action `fetchRoom(code: string)` to refresh the store from `GET /rooms/:code`. +* **Lobby Page (`frontend/src/pages/LobbyPage.tsx`)**: + * Implement an interval timer inside a `useEffect` hook that triggers `fetchRoom` every 2 seconds. + * Clear the interval timer on component unmount to prevent memory leaks and zombie network requests. + * Conditionally render the "Start Game" action button if the current user is the host. + * Keep the button disabled if the player count is less than 2. + +## 4. Verification and Risk Management +* **Risk**: High network traffic from frequent polling. +* **Mitigation**: Standard 2-second interval, sending small, optimized JSON payloads. +* **Manual Verification**: Run two browsers side-by-side, verify name trimming rejection, check that participant lists sync automatically, and check host-only start constraints. diff --git a/specs/001-room-setup-lobby/spec.md b/specs/001-room-setup-lobby/spec.md new file mode 100644 index 0000000..170db23 --- /dev/null +++ b/specs/001-room-setup-lobby/spec.md @@ -0,0 +1,41 @@ +# Feature Specification: Room Setup & Lobby + +This feature specification details the requirements, validation rules, edge cases, and acceptance criteria for the room setup and lobby lifecycle in the Scribble drawing game. + +## 1. Description and Goal +The goal of this feature is to enable players to create separate, isolated game rooms and join existing rooms via room codes. Additionally, the lobby screen must support real-time user updates via polling to show the list of currently connected participants, and assign host rights to the room creator to control game start actions. + +## 2. Detailed Requirements + +### 2.1 Room Creation and Host Assignment +* When a user creates a new game room (submitting their name via the room creation form), the backend must generate a unique 4-character room code. +* The backend must register the creator's participant ID as the `hostId` of the room. +* The room's initial status must be `"lobby"`. +* The creator is automatically redirected to the Lobby screen with host-specific administration tools visible (e.g., a "Start Game" button). + +### 2.2 Joining a Room +* A user can join an existing room by typing in the 4-character room code and their desired player username. +* The system must check if the room code exists and is currently in the `"lobby"` phase. If the game is already in progress, the join attempt must be rejected. +* The player's name must be trimmed and validated. Empty or whitespace-only names must be rejected. +* Users must be redirected to the Lobby screen on success. + +### 2.3 Lobby Polling +* The frontend must poll the backend at a regular interval of approximately 2 seconds (`2000ms`) to retrieve the current room snapshot. +* This polling must dynamically update the participant list, ensuring that when new players join, they appear on all screens within the polling interval. +* Manual refresh is supported but automatic polling is the primary sync mechanism. + +### 2.4 Multi-Room Isolation +* All game rooms must be completely isolated from one another. +* Operations such as joining, leaving, drawing, or guessing in Room A must have zero impact or visibility in Room B. + +## 3. Input Validation and Rules +* **Invalid Room Code**: Attempting to join with an empty, non-existent, or malformed room code must fail with an explicit user-facing error message (e.g., "Room not found"). +* **Username Trim and Validation**: Usernames must have leading and trailing whitespaces stripped. If the remaining string is empty, the join request is rejected. +* **Name Uniqueness**: If a user tries to join using a username that matches another active participant's name in that specific room, the system should reject the join or handle it gracefully to avoid name collisions. + +## 4. Acceptance Criteria +* **AC 1**: A user creating a room becomes the host. The room code is generated and shown. +* **AC 2**: Joining requires a valid room code and a non-empty name. Empty names are rejected. +* **AC 3**: The lobby list updates automatically every 2 seconds without requiring manual refresh. +* **AC 4**: Host-specific controls (e.g., "Start Game") are only visible to the host and disabled if there are fewer than 2 players. +* **AC 5**: Rooms are fully isolated; player lists are separate. diff --git a/specs/001-room-setup-lobby/tasks.md b/specs/001-room-setup-lobby/tasks.md new file mode 100644 index 0000000..d747270 --- /dev/null +++ b/specs/001-room-setup-lobby/tasks.md @@ -0,0 +1,19 @@ +# Task Checklist: Room Setup & Lobby + +This document tracks the tasks required to implement and verify the Room Setup & Lobby feature. + +## Tasks + +### Backend Tasks +- [x] **Task 1.1**: Update backend state models (`backend/src/models/game.ts`) to support `hostId` on the `Room` and `RoomSnapshot` and `score` on `Participant`. +- [x] **Task 1.2**: Update `createRoom()` in `backend/src/services/roomStore.ts` to assign `hostId` to the creator's participant ID. +- [x] **Task 1.3**: Update `joinRoom()` to validate that the room exists and is in `"lobby"` status. +- [x] **Task 1.4**: Add Zod validations in `backend/src/api/schemas.ts` to trim player names and reject empty/whitespace-only strings. + +### Frontend Tasks +- [x] **Task 1.5**: Update frontend API models (`frontend/src/services/api.ts`) and store fields (`frontend/src/state/roomStore.ts`). +- [x] **Task 1.6**: Implement automated polling (~2 seconds interval) in `LobbyPage.tsx` using React hooks. +- [x] **Task 1.7**: Enable host-only visual components and button enablement controls based on participant counts. + +### Verification Tasks +- [x] **Task 1.8**: Test with two browsers to verify that list sync is functioning and that validation errors are thrown for empty names. diff --git a/specs/002-game-start-drawer/plan.md b/specs/002-game-start-drawer/plan.md new file mode 100644 index 0000000..bf22750 --- /dev/null +++ b/specs/002-game-start-drawer/plan.md @@ -0,0 +1,30 @@ +# Technical Plan: Game Start & Drawer Flow + +This document details the backend endpoints, data filtering rules, and frontend page rendering updates needed to support transitioning the room to an active game state. + +## 1. State Model Changes +We extend the backend `Room` model: +* `status`: Expose `"game"` status. +* `drawerId`: Store the participant ID of the drawer. +* `secretWord`: Store the secret word. + +## 2. API Endpoints +* **`POST /rooms/:code/start`**: + * Action: Changes room status to `"game"`. + * Validation: Requesting participant ID must match the room's `hostId`, and there must be at least 2 participants in the room. + * Logic: Assign `drawerId = hostId`. Choose the first word from the starter list `rocket` (or select deterministically based on room properties). +* **`GET /rooms/:code`**: + * Security/Rule: Modify the `toRoomSnapshot` mapping service. If the requesting participant ID (received from the headers/query) does not match the room's `drawerId`, replace the `secretWord` value with `null` in the returned JSON object. + +## 3. Frontend Layout Split +* **Store (`frontend/src/state/roomStore.ts`)**: + * Add action `startGame()` to trigger the start endpoint. +* **Game Page (`frontend/src/pages/GamePage.tsx`)**: + * Implement active polling inside a `useEffect` hook. + * Retrieve the room snapshot. If `status` is `"game"`, identify if the local player's ID matches `drawerId`. + * **Drawer UI**: Render an interactive drawing canvas and display the secret word. + * **Guesser UI**: Render a read-only drawing canvas and a text box to submit guesses. + +## 4. Verification Plan +* Test that starting the game changes states across multiple screens. +* Inspect network requests to confirm the secret word is NOT sent to guesser clients. diff --git a/specs/002-game-start-drawer/spec.md b/specs/002-game-start-drawer/spec.md new file mode 100644 index 0000000..155dea6 --- /dev/null +++ b/specs/002-game-start-drawer/spec.md @@ -0,0 +1,39 @@ +# Feature Specification: Game Start & Drawer Flow + +This feature specification details the requirements, validation rules, edge cases, and acceptance criteria for starting a game round and managing the drawer role assignment in Scribble. + +## 1. Description and Goal +The goal of this feature is to allow the host to transition the room from the lobby to an active game state. When the game begins, the system must assign roles to all participants: one player becomes the drawer, while all other players become guessers. Additionally, a secret word must be chosen deterministically from the starter word list, and its visibility must be restricted strictly to the drawer. + +## 2. Detailed Requirements + +### 2.1 Game Start Preconditions +* Only the room host can initiate the game start. +* The "Start Game" action must be blocked unless there are at least 2 participants in the lobby. +* Attempting to start the game as a non-host player must be rejected by the backend. + +### 2.2 Player Name Validation +* All player usernames must be trimmed. +* Empty usernames or names containing only whitespace characters must be rejected during the lobby phase. +* The system must strip leading and trailing spaces from the inputs before saving. + +### 2.3 Drawer Assignment +* When the room transitions from `"lobby"` to `"game"`, the host (who is usually the first player in the room list) is assigned as the drawer (`drawerId` is set to their participant ID). +* All other players in the room are classified as guessers. +* The drawer's identity must be visible to all participants in the game (e.g., highlighted in the player sidebar scoreboard). + +### 2.4 Secret Word Selection and Visibility Rules +* The secret word for the round must be selected deterministically from the predefined list of starter words: `rocket`, `pizza`, `castle`, `guitar`, `sunflower`. +* The secret word must be delivered *only* to the drawer. +* The backend must strip the secret word from the room snapshot sent to any participant who is not the current drawer (setting it to `null` or hiding it in the JSON response). Guessers must not receive the word in the API response. + +## 3. Edge Cases and Custom Scenarios +* **Player Disconnection during Start**: If a player disconnects before the game starts, the participant list updates. If the host leaves, another player must be promoted to host. +* **Insufficient Players**: If the player count drops below 2 while in the lobby, the host's start capability is disabled. + +## 4. Acceptance Criteria +* **AC 1**: Only the host can start the game. Start action is disabled with < 2 players. +* **AC 2**: Starting the game changes the room status to `"game"`. +* **AC 3**: The host is designated as the drawer, and other players are designated as guessers. +* **AC 4**: The secret word is selected deterministically from the list. +* **AC 5**: The secret word is visible *only* to the drawer and is filtered out of the API response for guessers. diff --git a/specs/002-game-start-drawer/tasks.md b/specs/002-game-start-drawer/tasks.md new file mode 100644 index 0000000..501d1a7 --- /dev/null +++ b/specs/002-game-start-drawer/tasks.md @@ -0,0 +1,17 @@ +# Task Checklist: Game Start & Drawer Flow + +This document tracks the tasks required to implement and verify the Game Start & Drawer Flow feature. + +## Tasks + +### Backend Tasks +- [x] **Task 2.1**: Implement the `POST /rooms/:code/start` route. Enforce host authorization check and minimum participant threshold of 2. +- [x] **Task 2.2**: Assign the room host as the drawer and pick a secret word deterministically from the starter word list. +- [x] **Task 2.3**: Update snapshot mapping (`toRoomSnapshot()`) to hide the secret word unless the requesting user is the drawer. + +### Frontend Tasks +- [x] **Task 2.4**: Connect the "Start Game" button in the Lobby Page to trigger the API start command. +- [x] **Task 2.5**: Implement role-based layouts on the Game Page, separating drawing privileges and word visibility between the drawer and guesser. + +### Verification Tasks +- [x] **Task 2.6**: Run manual verification to ensure that the secret word is only visible to the drawer and that only the host can start. diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 0000000..38cc3be --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,31 @@ +# Technical Plan: Gameplay Interaction + +This document outlines the API design, data schemas, and synchronization methods required to support drawing synchronization, guess processing, and scoring updates. + +## 1. State Model Changes +Extend `Room` and `Participant` state models: +* `drawingData`: Save drawing state as a serialized string (e.g. coordinates or image data) in-memory. +* `guesses`: A list of `Guess` objects, where each contains `senderId`, `senderName`, `text`, `correct` flag, and `timestamp`. + +## 2. API Endpoints +* **`POST /rooms/:code/drawing`**: + * Payload: `{ drawingData: string }` + * Action: Updates the room's drawing state. + * Validation: Requesting player must be the drawer. +* **`POST /rooms/:code/guess`**: + * Payload: `{ text: string }` + * Action: Process guess, check case-insensitively, append to guess history, award points, and set status to `"result"` if correct. + * Validation: Zod schema rejects empty guesses. The drawer cannot submit guesses. + +## 3. Frontend Polling & Redraw Loops +* **Drawing Transmission**: + * The drawer component records drawing coordinates on canvas events (mouse/touch down, move, up) and periodically pushes the serialized stroke state to `POST /rooms/:code/drawing`. +* **Drawing Polling**: + * Guessers poll the room snapshot. When the polled `drawingData` changes, they parse the string and redraw the lines on their read-only canvas. +* **Guess Log & Scores**: + * The guess list and participant scores in the room state are updated by polling and rendered dynamically in the UI. + +## 4. Verification Plan +* Verify that drawing strokes sync between two tabs within the 2-second polling window. +* Test that empty guesses are rejected by the UI. +* Confirm that case-insensitive guesses (e.g. "PiZzA" for "pizza") trigger the correct score allocation and transition the room to the result view. diff --git a/specs/003-gameplay-interaction/spec.md b/specs/003-gameplay-interaction/spec.md new file mode 100644 index 0000000..02fde0d --- /dev/null +++ b/specs/003-gameplay-interaction/spec.md @@ -0,0 +1,41 @@ +# Feature Specification: Gameplay Interaction + +This feature specification details the requirements, validation rules, edge cases, and acceptance criteria for drawing synchronization, guess validation, scoring, and message history sync. + +## 1. Description and Goal +The goal of this feature is to enable the core gameplay loop. The designated drawer must be able to draw lines on their canvas and clear their canvas, with these actions synchronizing immediately to all other guessers. At the same time, guessers must be able to submit text guesses that are validated and matched against the secret word, scoring points on success. + +## 2. Detailed Requirements + +### 2.1 Interactive Drawing Canvas +* The drawer has write access to the drawing canvas. They can click/drag to draw freehand. +* The drawer has a "Clear Canvas" button that wipes the canvas. +* Guessers have read-only access to the canvas. They cannot draw or clear. +* The drawer's strokes must be serialized and sent to the server. The guesser clients poll the server and redraw the canvas content to mirror the drawer's canvas. + +### 2.2 Guess Submission and Validation +* Guessers can type guesses into a text input. +* Empty guesses or guesses consisting only of whitespaces must be rejected. +* Guesses must be trimmed (strip leading/trailing whitespaces) and matched case-insensitively against the secret word. For example, if the word is "guitar", guesses of " Guitar ", "GUITAR", or "guitar" must all be marked as correct. +* The drawer cannot submit guesses. + +### 2.3 Guess History & Polling Sync +* All guess submissions are stored in a chronological history list on the backend. +* Every client polls the room state (~2 seconds) and displays the guess history log. +* The guess log shows the player name, their guess, and whether it was correct or incorrect. + +### 2.4 Scoring & Game Transition +* When a guesser submits a correct guess, they are awarded 100 points. +* Correct guesses update the room status to `"result"` immediately, ending the round. +* Incorrect guesses award 0 points. + +## 3. Input Validation Rules & Edge Cases +* **Double Guessing**: (Edge Case) Guesses are blocked once a player has guessed the correct word. +* **Canvas Size**: The canvas rendering should adapt or be of a fixed ratio to prevent drawings from rendering differently across different screen sizes. + +## 4. Acceptance Criteria +* **AC 1**: Drawer can draw, and drawings sync to guessers' screens via polling. +* **AC 2**: Clear Canvas action wipes the board on all screens. +* **AC 3**: Guess inputs are trimmed and matched case-insensitively. +* **AC 4**: Guess history log is updated and synced via polling for all players. +* **AC 5**: Correct guess awards 100 points and triggers the result state transition. diff --git a/specs/003-gameplay-interaction/tasks.md b/specs/003-gameplay-interaction/tasks.md new file mode 100644 index 0000000..4681df8 --- /dev/null +++ b/specs/003-gameplay-interaction/tasks.md @@ -0,0 +1,17 @@ +# Task Checklist: Gameplay Interaction + +This document tracks the tasks required to implement and verify the Gameplay Interaction features. + +## Tasks + +### Backend Tasks +- [x] **Task 3.1**: Implement the `POST /rooms/:code/drawing` endpoint to store serialized drawing points. +- [x] **Task 3.2**: Implement the `POST /rooms/:code/guess` endpoint. Add validations, trim entries, compare case-insensitively, award points, and set status to `"result"` if correct. + +### Frontend Tasks +- [x] **Task 3.3**: Build the interactive drawing canvas for the drawer and the read-only drawing canvas for guessers. +- [x] **Task 3.4**: Connect the canvas polling to redraw coordinates on guesser screens. +- [x] **Task 3.5**: Render the guess logs sidebar and hook up the guess form submission action. + +### Verification Tasks +- [x] **Task 3.6**: Verify multiplayer stroke sync, case-insensitive scoring, and result transition. diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 0000000..940aa73 --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,35 @@ +# Technical Plan: Result, Restart & Final Validation + +This document outlines the API route, controller functions, and UI updates required to support transitioning to a post-game result state and resetting back to the lobby. + +## 1. State Model Changes +The backend room data store handles transitions back to `"lobby"` status. +* `status`: Expose `"result"` status. +* Clean up all gameplay variables in the room state upon a restart trigger. + +## 2. API Endpoints +* **`POST /rooms/:code/restart`**: + * Validation: Requesting player ID must match the room's `hostId`. + * Action: Resets room state: + * `status` is set back to `"lobby"`. + * `drawerId = null`. + * `secretWord = null`. + * `drawingData = ""`. + * `guesses = []`. + * For each participant, set `score = 0`. + * Response: Return the updated room snapshot. + +## 3. Frontend Layout Updates +* **Store (`frontend/src/state/roomStore.ts`)**: + * Add action `restartGame()` to POST to the restart endpoint. +* **Game Page (`frontend/src/pages/GamePage.tsx`)**: + * If the polled room status changes to `"result"`, render the post-game layout instead of the drawer/guesser view. + * Render the final scoreboard, correct word, and guess list. + * Render a "Return to Lobby" button visible only to the host. + * Trigger `restartGame()` on button click. + * If the room status changes to `"lobby"` via polling, redirect the client back to `/rooms/:code/lobby`. + +## 4. Verification Plan +* Validate that correct guesses trigger immediate result view loading on all tabs. +* Check that restarting preserves participants and clears scores. +* Verify the whole workspace builds cleanly. diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 0000000..be0d9fd --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,36 @@ +# Feature Specification: Result, Restart & Final Validation + +This feature specification details the requirements, validation rules, edge cases, and acceptance criteria for showing game results and managing the restart lifecycle back to the lobby. + +## 1. Description and Goal +The goal of this feature is to manage the post-game experience. Once a player guesses the correct word, the game transitions to a shared result state. This screen must show the correct secret word, final player standings, and the guess log. The host must then be able to restart the game, sending all players back to the lobby while resetting round state but keeping the participant list intact. + +## 2. Detailed Requirements + +### 2.1 Shared Result View +* When the game transitions to `"result"`, all players must see the post-game results screen. +* The screen must display: + * The correct secret word (revealed to all players, including guessers who failed to guess it). + * The final scoreboard containing the names and scores of all participants. + * The complete list of guesses submitted during the round. + +### 2.2 Game Restart Lifecycle +* Only the room host is permitted to restart the game. The restart control is hidden or disabled for non-host players. +* Initiating the restart triggers a transition back to the `"lobby"` phase. +* **State Reset**: Upon restart, the following room values must be cleared or reset: + * Reset participant scores to `0`. + * Clear drawing coordinate data (`drawingData = ""`). + * Empty the list of guesses. + * Clear current drawer and word properties (`drawerId = null`, `secretWord = null`). +* **Participant Preservation**: The current list of participants must be preserved. Players do not have to re-enter their names or reconnect. + +## 3. Edge Cases +* **Host Leaving**: If the host player leaves during the result page, the server must select a new host from the remaining participants to ensure the room can be restarted. +* **Late Joining**: New players cannot join a room that is currently in the result state. They must wait for the game to restart to the lobby. + +## 4. Acceptance Criteria +* **AC 1**: All players transition to the result state when a correct guess is processed. +* **AC 2**: The result screen reveals the secret word and shows scores and guesses. +* **AC 3**: Only the host can trigger a restart. +* **AC 4**: Restarting returns all players to the lobby with their names preserved. +* **AC 5**: All game state (scores, drawings, guesses, roles, secret word) is cleared on restart. diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 0000000..3609d7e --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,16 @@ +# Task Checklist: Result, Restart & Final Validation + +This document tracks the tasks required to implement and verify the post-game Results page and Reset loop features. + +## Tasks + +### Backend Tasks +- [x] **Task 4.1**: Implement the `POST /rooms/:code/restart` endpoint. Restrict execution to host, reset status to `"lobby"`, clear drawings/guesses, and reset scores to 0. + +### Frontend Tasks +- [x] **Task 4.2**: Design and implement the Results view showing scores, guess history, and secret word. +- [x] **Task 4.3**: Implement the "Return to Lobby" button visible only to the host, triggering the restart API call. +- [x] **Task 4.4**: Listen to status changes in the poll loop to redirect clients from the results screen back to the lobby. + +### Verification Tasks +- [x] **Task 4.5**: Run final end-to-end tests across two browsers. Verify build tasks compile without error. From 240785408662cb9d6d5e32b9469983924068ae9c Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sun, 31 May 2026 08:19:51 +0530 Subject: [PATCH 15/18] resolve gaps in secret-word selection, canvas clear, and restart UI --- backend/src/api/rooms.ts | 20 +++++++++++- backend/src/services/roomStore.test.ts | 38 ++++++++++++++++------- backend/src/services/roomStore.ts | 30 +++++++++++++----- frontend/src/components/DrawingCanvas.tsx | 8 +++-- frontend/src/pages/GamePage.tsx | 11 +++++-- frontend/src/services/api.ts | 5 +++ frontend/src/state/roomStore.ts | 18 +++++++++++ speckit.discovery | 22 +++++++------ 8 files changed, 118 insertions(+), 34 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index e362e05..c1741f3 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -8,7 +8,7 @@ import { updateDrawingSchema, submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame, updateDrawing, submitGuess, leaveRoom, restartGame } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, toRoomSnapshot, startGame, updateDrawing, clearDrawing, submitGuess, leaveRoom, restartGame } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -83,6 +83,24 @@ export function createRoomsRouter() { } }); + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = roomViewerQuerySchema.parse(request.query); + + if (!participantId) { + throw new HttpError(400, "participantId is required"); + } + + const room = clearDrawing(code.toUpperCase(), participantId); + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.post("/:code/guess", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 34b8078..2c89f70 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot, updateDrawing, submitGuess, leaveRoom, restartGame } from "./roomStore.js"; +import { createRoom, joinRoom, getRoom, saveRoom, startGame, toRoomSnapshot, updateDrawing, clearDrawing, submitGuess, leaveRoom, restartGame } from "./roomStore.js"; import { HttpError } from "../api/schemas.js"; import { STARTER_WORDS } from "../seed/starterData.js"; @@ -206,32 +206,48 @@ describe("roomStore", () => { }); }); - it("randomizes secret word and avoids repeating recently used words in consecutive games", () => { + it("selects secret word deterministically and sequentially from the starter words list", () => { const { room, participantId: hostId } = createRoom("Alice"); const joinResult = joinRoom(room.code, "Bob"); const guestId = joinResult!.participantId; - const chosenWords = new Set(); + const chosenWords: string[] = []; for (let i = 0; i < 5; i++) { const activeRoom = startGame(room.code, hostId); const word = activeRoom.secretWord; - expect(word).not.toBeNull(); - expect(STARTER_WORDS).toContain(word); - expect(chosenWords.has(word!)).toBe(false); - chosenWords.add(word!); + expect(word).toBe(STARTER_WORDS[i]); + chosenWords.push(word!); // Complete the game by correct guess to transition status, then restart submitGuess(room.code, guestId, word!); restartGame(room.code, hostId); } - // After 5 games, all 5 words should have been used exactly once - expect(chosenWords.size).toBe(5); + expect(chosenWords).toEqual(STARTER_WORDS); - // Starting the 6th game should reset the history and pick one of the words again + // Starting the 6th game should wrap around to index 0 again const activeRoom6 = startGame(room.code, hostId); - expect(STARTER_WORDS).toContain(activeRoom6.secretWord); + expect(activeRoom6.secretWord).toBe(STARTER_WORDS[0]); + }); + + it("clearDrawing sets drawingData to empty string if caller is drawer, rejects otherwise", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob"); + const guestId = joinResult!.participantId; + startGame(room.code, hostId); + + // Set some drawing data first + updateDrawing(room.code, hostId, "strokes-data"); + const drawnRoom = getRoom(room.code); + expect(drawnRoom!.drawingData).toBe("strokes-data"); + + // Rejects if non-drawer (guesser) tries to clear + expect(() => clearDrawing(room.code, guestId)).toThrow(HttpError); + + // Clears successfully if drawer calls it + const clearedRoom = clearDrawing(room.code, hostId); + expect(clearedRoom.drawingData).toBe(""); }); it("scenario: Player 3 leaves, then Host restarts, then Host starts game again", () => { diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 8e953fe..621bdc8 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -134,13 +134,8 @@ export function startGame(code: string, participantId: string) { if (!room.previousWords) { room.previousWords = []; } - let pool = STARTER_WORDS.filter((word) => !room.previousWords.includes(word)); - if (pool.length === 0) { - room.previousWords = []; - pool = [...STARTER_WORDS]; - } - const randomIndex = Math.floor(Math.random() * pool.length); - const selectedWord = pool[randomIndex]; + const wordIndex = room.previousWords.length % STARTER_WORDS.length; + const selectedWord = STARTER_WORDS[wordIndex]; room.secretWord = selectedWord; room.previousWords.push(selectedWord); @@ -174,6 +169,27 @@ export function updateDrawing(code: string, participantId: string, drawingData: return cloneRoom(room); } +export function clearDrawing(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + if (room.status !== "game") { + throw new HttpError(400, "Drawing is only allowed during active game"); + } + + if (room.drawerId !== participantId) { + throw new HttpError(403, "Only the drawer can update the drawing"); + } + + room.drawingData = ""; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + export function submitGuess(code: string, participantId: string, guessText: string) { const room = rooms.get(code); if (!room) { diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx index 7adf535..5f93c7a 100644 --- a/frontend/src/components/DrawingCanvas.tsx +++ b/frontend/src/components/DrawingCanvas.tsx @@ -23,11 +23,13 @@ export function DrawingCanvas({ readOnly, drawingData, onChange }: DrawingCanvas const strokesRef = useRef(strokes); strokesRef.current = strokes; - // Parse strokes from drawingData prop when readOnly is true + // Parse strokes from drawingData prop when readOnly is true, or if it is cleared useEffect(() => { - if (readOnly) { + if (!drawingData || drawingData === "[]") { + setStrokes([]); + } else if (readOnly) { try { - const parsed = JSON.parse(drawingData || "[]") as Stroke[]; + const parsed = JSON.parse(drawingData) as Stroke[]; if (Array.isArray(parsed)) { setStrokes(parsed); } else { diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 28d6011..ac8fb5f 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -209,6 +209,7 @@ export function GamePage() { {isHost && ( )}
@@ -272,7 +273,13 @@ export function GamePage() { roomStore.updateDrawing(data)} + onChange={(data) => { + if (data === "" || data === "[]") { + roomStore.clearDrawing(); + } else { + roomStore.updateDrawing(data); + } + }} /> ) : ( (`/rooms/${encodeURIComponent(code)}/clear?participantId=${encodeURIComponent(participantId)}`, { + method: "POST" + }); + }, submitGuess(code: string, participantId: string, guessText: string) { return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess?participantId=${encodeURIComponent(participantId)}`, { method: "POST", diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 1a8150a..35143e3 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -159,6 +159,24 @@ class RoomStore { } } + async clearDrawing() { + if (!this.state.room || !this.state.participantId) { + return null; + } + try { + const response = await api.clearDrawing( + this.state.room.code, + this.state.participantId + ); + this.setRoomSnapshot(response.room); + return response.room; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to clear drawing"; + this.setState({ error: message }); + throw error; + } + } + async submitGuess(guessText: string) { if (!this.state.room || !this.state.participantId) { return null; diff --git a/speckit.discovery b/speckit.discovery index c77d24c..7c0e343 100644 --- a/speckit.discovery +++ b/speckit.discovery @@ -6,22 +6,24 @@ This document highlights the discovery findings, identified gaps, assumptions, a ## 1. Gaps and Incomplete/Missing Behaviors -Based on the README, the following behaviors are missing or incomplete in the starter code: +Based on the README, the following incomplete behaviors and gaps exist in the starter code: -1. **Lobby State Polling**: The lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually. Automatic polling (~2s cadence) is not implemented. -2. **Host Designation and Start Game Controls**: There is no tracking of which participant is the host (creator of the room). The "Start Game" flow and host-only permissions to trigger it are not implemented. -3. **Drawer and Word Selection**: Selecting the drawer (first player/host) and picking the secret word deterministically from the starter list (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) is not implemented. -4. **Drawing Sync and Canvas Actions**: Synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented. -5. **Guess Input, Matching, and Scoring**: Rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented. -6. **Shared Result State and Game Restart**: Transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game (returning everyone to lobby with cleared round state) are not implemented. +* **Gap 1 (Lobby State Polling)**: The lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually. Automatic polling (~2s cadence) is not implemented. +* **Gap 2 (Host Designation and Start Game Controls)**: There is no tracking of which participant is the host (creator of the room). The "Start Game" flow and host-only permissions to trigger it are not implemented. +* **Gap 3 (Drawer and Word Selection)**: Selecting the drawer (first player/host) and picking the secret word deterministically from the starter list (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) is not implemented. +* **Gap 4 (Drawing Sync and Canvas Actions)**: Synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented. +* **Gap 5 (Guess Input, Matching, and Scoring)**: Rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented. +* **Gap 6 (Shared Result State and Game Restart)**: Transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game (returning everyone to lobby with cleared round state) are not implemented. --- ## 2. Assumptions -1. **HTTP Polling Cadence**: Polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend. -2. **Deterministic State Progression**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. -3. **No Stateful Persistency**: All game state exists solely in-memory within the backend node process. Any crash or restart of the backend clears all existing rooms. +Based on our architectural decisions, we hold the following assumptions: + +* **Assumption 1 (HTTP Polling Cadence)**: Polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend. +* **Assumption 2 (Deterministic State Progression)**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. +* **Assumption 3 (No Stateful Persistency)**: All game state exists solely in-memory within the backend node process. Any crash or restart of the backend clears all existing rooms. --- From 4cea883f01ccb40f73977b67a610d068ce171801 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sun, 31 May 2026 08:26:18 +0530 Subject: [PATCH 16/18] docs: use explicit top-level headings for gaps and assumptions in speckit.discovery --- speckit.discovery | 70 +++++++++++++++-------------------------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/speckit.discovery b/speckit.discovery index 7c0e343..6d8c5f9 100644 --- a/speckit.discovery +++ b/speckit.discovery @@ -2,51 +2,25 @@ This document highlights the discovery findings, identified gaps, assumptions, affected areas, and potential risks based on inspection of the Scribble starter repository. ---- - -## 1. Gaps and Incomplete/Missing Behaviors - -Based on the README, the following incomplete behaviors and gaps exist in the starter code: - -* **Gap 1 (Lobby State Polling)**: The lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually. Automatic polling (~2s cadence) is not implemented. -* **Gap 2 (Host Designation and Start Game Controls)**: There is no tracking of which participant is the host (creator of the room). The "Start Game" flow and host-only permissions to trigger it are not implemented. -* **Gap 3 (Drawer and Word Selection)**: Selecting the drawer (first player/host) and picking the secret word deterministically from the starter list (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) is not implemented. -* **Gap 4 (Drawing Sync and Canvas Actions)**: Synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented. -* **Gap 5 (Guess Input, Matching, and Scoring)**: Rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented. -* **Gap 6 (Shared Result State and Game Restart)**: Transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game (returning everyone to lobby with cleared round state) are not implemented. - ---- - -## 2. Assumptions - -Based on our architectural decisions, we hold the following assumptions: - -* **Assumption 1 (HTTP Polling Cadence)**: Polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend. -* **Assumption 2 (Deterministic State Progression)**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. -* **Assumption 3 (No Stateful Persistency)**: All game state exists solely in-memory within the backend node process. Any crash or restart of the backend clears all existing rooms. - ---- - -## 3. Relevant Frontend Files & Areas - -* **`frontend/src/services/api.ts`**: API model interfaces (`RoomSnapshot`, `Participant`) need updates to match new backend room fields. Service methods for starting the game, sending drawings, submitting guesses, and resetting rooms must be added. -* **`frontend/src/state/roomStore.ts`**: Update the Zustand/external store state management to handle loading flags, errors, current user permissions, canvas actions, and round status. -* **`frontend/src/pages/LobbyPage.tsx`**: Must incorporate the polling logic (`useEffect` timer) to refresh participants, check host status, and disable/enable the start game button. -* **`frontend/src/pages/GamePage.tsx`**: Update this screen to render separate layouts for the drawer (interactive drawing canvas, secret word indicator) and guesser (read-only canvas, guess submission form, score cards). Integrate drawing synchronization and guess log polling. - ---- - -## 4. Relevant Backend Files & Areas - -* **`backend/src/models/game.ts`**: Enhance models to track room states (`hostId`, `drawerId`, `secretWord`, `guesses`, `drawingData`, `status` updates like `"game"` and `"result"`). Add `score` tracking to `Participant`. -* **`backend/src/services/roomStore.ts`**: Modify `createRoom()` and `joinRoom()` to assign the host and validate player inputs (trim names, reject duplicates/empty names). Implement helper services for starting games, saving drawings, appending guesses, and resetting room state. -* **`backend/src/api/schemas.ts`**: Add Zod validation schemas for action queries/payloads (start game parameters, drawing coordinate payloads, guess submissions). -* **`backend/src/api/rooms.ts`**: Setup new routing routes and handler callbacks for game lifecycle status updates (`POST /rooms/:code/start`, `POST /rooms/:code/drawing`, `POST /rooms/:code/guess`, `POST /rooms/:code/restart`). - ---- - -## 5. Potential Risks and Unknowns - -* **Canvas Coordination Bandwidth**: Serializing and transmitting canvas coordinate points over periodic HTTP polling might introduce noticeable stroke lag or high load if drawing data grows too large. Mitigating this with concise coordinate structures or optimized strokes is essential. -* **Lobby Polling Cadence Tuning**: Polling precisely every 2 seconds might create a race condition if players hit endpoints simultaneously. Backend services must ensure operations are synchronous and atomic in-memory to prevent state duplication. -* **Host Leaving Mid-Session**: If the host player leaves the room in the lobby, during gameplay, or in the result state, a mechanism to promote another player to host is needed to prevent rooms from getting orphaned/stuck. +# Gaps +* **Gap 1**: Lobby State Polling (the lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually; automatic polling ~2s cadence is not implemented). +* **Gap 2**: Host Designation and Start Game Controls (there is no tracking of which participant is the host/creator of the room; the "Start Game" flow and host-only permissions to trigger it are not implemented). +* **Gap 3**: Drawer and Word Selection (selecting the drawer and picking the secret word deterministically from the starter list is not implemented). +* **Gap 4**: Drawing Sync and Canvas Actions (synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented). +* **Gap 5**: Guess Input, Matching, and Scoring (rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented). +* **Gap 6**: Shared Result State and Game Restart (transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game are not implemented). + +# Assumptions +* **Assumption 1**: HTTP Polling Cadence (polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend). +* **Assumption 2**: Deterministic State Progression (a single round progress structure is assumed; once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state). +* **Assumption 3**: No Stateful Persistency (all game state exists solely in-memory within the backend node process; any crash or restart of the backend clears all existing rooms). + +# Relevant Files +* `frontend/src/services/api.ts` +* `frontend/src/state/roomStore.ts` +* `frontend/src/pages/LobbyPage.tsx` +* `frontend/src/pages/GamePage.tsx` +* `backend/src/models/game.ts` +* `backend/src/services/roomStore.ts` +* `backend/src/api/schemas.ts` +* `backend/src/api/rooms.ts` From 5dfcce22d05b2129ba8d9ad5779878d9e4e27485 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sun, 31 May 2026 08:52:30 +0530 Subject: [PATCH 17/18] docs/refactor: address grading feedback for discovery notes, canvas button, and reflection --- frontend/src/components/DrawingCanvas.tsx | 7 ++++++- reflection.md | 10 +++++++++ speckit.discovery | 25 ++++++++++++----------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx index 5f93c7a..0d32361 100644 --- a/frontend/src/components/DrawingCanvas.tsx +++ b/frontend/src/components/DrawingCanvas.tsx @@ -321,13 +321,18 @@ export function DrawingCanvas({ readOnly, drawingData, onChange }: DrawingCanvas {!readOnly && (
-
diff --git a/reflection.md b/reflection.md index 369a24a..1dd6a16 100644 --- a/reflection.md +++ b/reflection.md @@ -34,3 +34,13 @@ To complete the game requirements across all 4 scenarios, we implemented: * **No Databases**: All state is stored in-memory using JavaScript data structures. * **No Authentication**: Reused simple transient usernames submitted at room entry. * **TypeScript Integrity**: Kept all files 100% type-safe without using the `any` keyword. + +## 4. AI Assistance and Corrections +Throughout the development, AI was used for several boilerplate and design tasks, which were carefully reviewed, tested, and corrected where necessary: +* **Boilerplate Routes & Zod Validation**: AI was used to generate initial Express API endpoints, Zod verification schemas (e.g. for game start and guesses), and basic TypeScript interfaces. This saved time on routine wiring. +* **Drawing Canvas Event normalizers**: AI assisted in drafting the canvas Mouse/Touch event coordinate normalizers (converting viewport coordinates to relative `[0..1]` fractions), preventing scaling/distortions. +* **Polling Hooks and Cleanup Corrections**: + * *Correction*: The AI initially drafted polling hooks in the React components without proper cleanup return statements or loading indicators. We corrected this to ensure intervals are always cleared on component unmount to prevent leaks. + * *Correction*: The backend API routes initially lacked checks to verify if a requester was the room host before starting or restarting a game. We added explicit validation filters on the backend to enforce host-only authority. + * *Correction*: When a user left or disconnected, the AI failed to implement host promotion. We refactored the room leave handler on the backend to ensure a new host is immediately designated if the previous host leaves. + diff --git a/speckit.discovery b/speckit.discovery index 6d8c5f9..575f33b 100644 --- a/speckit.discovery +++ b/speckit.discovery @@ -2,20 +2,20 @@ This document highlights the discovery findings, identified gaps, assumptions, affected areas, and potential risks based on inspection of the Scribble starter repository. -# Gaps -* **Gap 1**: Lobby State Polling (the lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually; automatic polling ~2s cadence is not implemented). -* **Gap 2**: Host Designation and Start Game Controls (there is no tracking of which participant is the host/creator of the room; the "Start Game" flow and host-only permissions to trigger it are not implemented). -* **Gap 3**: Drawer and Word Selection (selecting the drawer and picking the secret word deterministically from the starter list is not implemented). -* **Gap 4**: Drawing Sync and Canvas Actions (synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented). -* **Gap 5**: Guess Input, Matching, and Scoring (rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented). -* **Gap 6**: Shared Result State and Game Restart (transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game are not implemented). +## Gaps +1. **Lobby State Polling**: The lobby participant list displays only from the latest fetched snapshot when loaded or refreshed manually. Automatic polling (~2s cadence) is not implemented in the starter. +2. **Host Designation and Start Game Controls**: There is no tracking of which participant is the host/creator of the room. The "Start Game" flow and host-only permissions to trigger it are not implemented. +3. **Drawer and Word Selection**: Selecting the drawer and picking the secret word deterministically from the starter list is not implemented. +4. **Drawing Sync and Canvas Actions**: Synchronizing drawer strokes to guessers' canvases, rendering a read-only canvas for guessers, and allowing the drawer to clear the canvas are not implemented. +5. **Guess Input, Matching, and Scoring**: Rejecting empty/whitespace guesses, case-insensitive comparison against the secret word, awarding 100 points on a correct guess, and transitioning to a result state are not implemented. +6. **Shared Result State and Game Restart**: Transitioning to a result view showing final scores, the secret word, and guess logs, and letting the host restart the game are not implemented. -# Assumptions -* **Assumption 1**: HTTP Polling Cadence (polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend). -* **Assumption 2**: Deterministic State Progression (a single round progress structure is assumed; once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state). -* **Assumption 3**: No Stateful Persistency (all game state exists solely in-memory within the backend node process; any crash or restart of the backend clears all existing rooms). +## Assumptions +1. **HTTP Polling Cadence**: Polling will occur at an approximate 2-second interval, which is sufficient for syncing game state, guess logs, and canvas updates without overloading the in-memory Node.js backend. +2. **Deterministic State Progression**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. +3. **No Stateful Persistency**: All game state exists solely in-memory within the backend node process; any crash or restart of the backend clears all existing rooms. -# Relevant Files +## Relevant Files * `frontend/src/services/api.ts` * `frontend/src/state/roomStore.ts` * `frontend/src/pages/LobbyPage.tsx` @@ -24,3 +24,4 @@ This document highlights the discovery findings, identified gaps, assumptions, a * `backend/src/services/roomStore.ts` * `backend/src/api/schemas.ts` * `backend/src/api/rooms.ts` + From dc667d7703a4bcfd487a5fb31dd40771ae29ab22 Mon Sep 17 00:00:00 2001 From: Anjali Gogu Date: Sun, 31 May 2026 09:02:33 +0530 Subject: [PATCH 18/18] docs/refactor: ensure restart button is visible in active game view and enrich discovery notes --- frontend/src/pages/GamePage.tsx | 20 ++++++++++++++++++-- speckit.discovery | 26 +++++++++++++++++--------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index ac8fb5f..cbece97 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -14,6 +14,8 @@ export function GamePage() { const [searchParams] = useSearchParams(); const [isRestoring, setIsRestoring] = useState(true); + const isHost = room?.hostId === participantId; + useEffect(() => { const code = searchParams.get("code"); const pid = searchParams.get("participantId"); @@ -69,7 +71,6 @@ export function GamePage() { } if (room.status === "result") { - const isHost = room.hostId === participantId; const sortedParticipants = [...room.participants].sort((a, b) => b.score - a.score); return ( @@ -312,7 +313,7 @@ export function GamePage() { -
+
+ {isHost && ( + + )}
); diff --git a/speckit.discovery b/speckit.discovery index 575f33b..2b8ea8f 100644 --- a/speckit.discovery +++ b/speckit.discovery @@ -15,13 +15,21 @@ This document highlights the discovery findings, identified gaps, assumptions, a 2. **Deterministic State Progression**: A single round progress structure is assumed. Once a guesser matches the secret word, the round terminates, scores update, and the game moves to the result state. 3. **No Stateful Persistency**: All game state exists solely in-memory within the backend node process; any crash or restart of the backend clears all existing rooms. -## Relevant Files -* `frontend/src/services/api.ts` -* `frontend/src/state/roomStore.ts` -* `frontend/src/pages/LobbyPage.tsx` -* `frontend/src/pages/GamePage.tsx` -* `backend/src/models/game.ts` -* `backend/src/services/roomStore.ts` -* `backend/src/api/schemas.ts` -* `backend/src/api/rooms.ts` +## Relevant Frontend Files & Areas +* **`frontend/src/services/api.ts`**: Contains API model interfaces (`RoomSnapshot`, `Participant`) and raw fetch methods. These need updates to match new backend room fields (`hostId`, `drawerId`, `secretWord`, `guesses`, `drawingData`). Service methods for starting the game, sending/clearing drawings, submitting guesses, leaving rooms, and resetting rooms must be added. +* **`frontend/src/state/roomStore.ts`**: The state management layer. Update the store state management (external store class) to handle loading flags, errors, current user permissions, canvas actions, guess histories, and round status. +* **`frontend/src/pages/LobbyPage.tsx`**: Lobby view. Must incorporate the polling logic (`useEffect` interval) to refresh participants, check host status, and disable/enable the start game button. +* **`frontend/src/pages/GamePage.tsx`**: Main gameplay view. Update this screen to render separate layouts for the drawer (interactive drawing canvas, secret word indicator) and guesser (read-only canvas, guess submission form, score cards). Integrate drawing synchronization and guess log polling, and a host-only restart flow. + +## Relevant Backend Files & Areas +* **`backend/src/models/game.ts`**: Entity representations. Enhance models to track room states (`hostId`, `drawerId`, `secretWord`, `guesses`, `drawingData`, `status` updates like `"game"` and `"result"`). Add `score` tracking to `Participant`. +* **`backend/src/services/roomStore.ts`**: Business logic. Modify `createRoom()` and `joinRoom()` to assign the host and validate player inputs (trim names, reject duplicates/empty names). Implement helper services for starting games, saving drawings, clearing drawings, appending guesses, and resetting room state. +* **`backend/src/api/schemas.ts`**: Validation layer. Add Zod validation schemas for action queries/payloads (start game parameters, drawing coordinate payloads, guess submissions). +* **`backend/src/api/rooms.ts`**: Routing controllers. Setup new routing routes and handler callbacks for game lifecycle status updates (`POST /rooms/:code/start`, `POST /rooms/:code/drawing`, `POST /rooms/:code/guess`, `POST /rooms/:code/restart`). + +## Potential Risks and Unknowns +1. **Canvas Coordination Bandwidth**: Serializing and transmitting canvas coordinate points over periodic HTTP polling might introduce noticeable stroke lag or high load if drawing data grows too large. Mitigating this with concise coordinate structures or optimized strokes is essential. +2. **Lobby Polling Cadence Tuning**: Polling precisely every 2 seconds might create a race condition if players hit endpoints simultaneously. Backend services must ensure operations are synchronous and atomic in-memory to prevent state duplication. +3. **Host Leaving Mid-Session**: If the host player leaves the room in the lobby, during gameplay, or in the result state, a mechanism to promote another player to host is needed to prevent rooms from getting orphaned/stuck. +