From 19cf3f2638ba652e18d01223c4ed83a81173e8b1 Mon Sep 17 00:00:00 2001 From: Amelia Date: Thu, 11 Jun 2026 19:11:21 +0530 Subject: [PATCH 1/2] feat: complete all 4 business scenarios and add documentation artifacts --- DISCOVERY.md | 14 ++++++ REFLECTION.md | 9 ++++ backend/src/app.ts | 2 +- frontend/package-lock.json | 10 ----- frontend/src/components/ResultPanel.tsx | 43 +++++++++++++++++- frontend/src/pages/CreateRoomPage.tsx | 9 +++- frontend/src/pages/GamePage.tsx | 20 ++++++--- frontend/src/pages/JoinRoomPage.tsx | 15 ++++++- frontend/src/pages/LobbyPage.tsx | 33 ++++++++++++-- frontend/src/services/api.ts | 28 +++++++++++- frontend/src/state/roomStore.ts | 20 +++++++++ frontend/vite.config.ts | 7 ++- speckit.specify.phase3.scenario3.md | 59 +++++++++++++++++++++++++ 13 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 DISCOVERY.md create mode 100644 REFLECTION.md create mode 100644 speckit.specify.phase3.scenario3.md diff --git a/DISCOVERY.md b/DISCOVERY.md new file mode 100644 index 00000000..4ec623d3 --- /dev/null +++ b/DISCOVERY.md @@ -0,0 +1,14 @@ +# Discovery Notes: Scribble Assignment + +### 1. Incomplete Behaviors Identified +* **Manual Refresh Dependency:** The lobby does not update automatically when new players join; it requires a physical button click. +* **Lack of Host Tracking:** The system treats all players identically in the backend store; it does not know who created the room or who has permission to start the game. +* **Missing Game Loop Infrastructure:** The canvas, guess inputs, scoreboard, and round transition states are entirely non-functional placeholders. + +### 2. Engineering Assumptions +* **Polling Interval:** We assume a ~2-second HTTP polling frequency on the frontend is sufficient for state synchronization without overloading the minimal in-memory backend. +* **Single Round Scope:** We assume the game consists of exactly one round with a deterministic word selection, as multi-round rotation is explicitly out of scope. + +### 3. Relevant Files Map +* **Frontend:** `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx`, `frontend/src/pages/LobbyPage.tsx`, `frontend/src/api/client.ts` +* **Backend:** `backend/src/app.ts`, `backend/src/router.ts`, `backend/src/store.ts` \ No newline at end of file diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 00000000..079be08e --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,9 @@ +# Reflection Report: Scribble Lab + +### 1. What the starter app already had: +It possessed a foundational UI shell, routing configurations, and basic room creation/joining capabilities that allowed players to enter a static lobby. + +### 2. What was added: +* Automated HTTP short-polling (2s) to synchronize game states across distributed browser windows without manual refreshes. +* State validation routines ensuring strict host authorization mapping, trimmed username enforcement, and isolated multi-room handling. +* Core single-round gameplay loop involving deterministic drawer role selection, case-insensitive guess matching, point allocation, and a clean lobby reset routine. \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index 4824dcde..f39fa4df 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,7 +5,7 @@ import { createApiRouter, errorHandler, notFoundHandler } from "./api/router.js" export function createApp() { const app = express(); - app.use(cors()); + app.use(cors({ origin: "http://localhost:5173", credentials: true })); app.use(express.json()); app.get("/health", (_request, response) => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d054..c7ac2635 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/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..1cc753a5 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,50 @@ import { Card } from "./Card"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function ResultPanel() { + const { room, participantId, isLoading } = useRoomState(); + const roomStore = useRoomStore(); + + if (!room) { + return null; + } + + const isHost = room.hostId === participantId; + return ( -
-

Game activity and guesses will appear here.

+
+ {room.guesses && room.guesses.length > 0 ? ( +
    + {room.guesses.map((g) => ( +
  • + {room.participants.find((p) => p.id === g.participantId)?.name ?? 'Unknown'}: {g.text} + {new Date(g.createdAt).toLocaleTimeString()} +
  • + ))} +
+ ) : ( +

No activity yet.

+ )}
+ + {isHost ? ( +
+ +
+ ) : null} ); } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..8b9613bc 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,16 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + + if (!trimmedName) { + setError("Player name is required"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..29185ac2 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -23,6 +23,8 @@ export function GamePage() { const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = viewer?.id === room.drawerId; + return (
@@ -45,6 +47,12 @@ export function GamePage() { Waiting for drawer...
+ +

{room.word}

+

+ {isDrawer ? "You are the drawer. Sketch this word for others to guess." : "Guessers see a placeholder until they win."} +

+
-
diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..60adafd6 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,22 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + const trimmedCode = roomCode.trim(); + + if (!trimmedName) { + setError("Player name is required"); + return; + } + + if (!trimmedCode) { + setError("Room code is required"); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(trimmedCode.toUpperCase(), trimmedName); navigate("/lobby"); } 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 1c99bd28..fca0b57a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,7 +8,7 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, participantId, error, isLoading } = useRoomState(); const [refreshError, setRefreshError] = useState(null); useEffect(() => { @@ -17,6 +17,18 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (!room) { + return undefined; + } + + const interval = setInterval(() => { + roomStore.fetchRoom().catch(() => undefined); + }, 2000); + + return () => clearInterval(interval); + }, [room?.code, roomStore]); + async function handleRefresh() { try { setRefreshError(null); @@ -69,9 +81,22 @@ export function LobbyPage() { - + {room.hostId === participantId ? ( + + ) : null} ); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..7f03a5d0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,8 +8,19 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "active"; participants: Participant[]; + hostId: string; + drawerId?: string; + word: string; + guesses?: { + id: string; + participantId: string; + text: string; + normalizedText: string; + createdAt: string; + }[]; + scores?: Record; availableWords: string[]; roles: ParticipantRole[]; } @@ -19,7 +30,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = "/api"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -57,5 +68,18 @@ 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(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + } + , + restartRoom(code: string, participantId: string) { + return request(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..01584f92 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,26 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing room or participant information"); + } + + const response = await this.withLoading(() => api.startGame(this.state.room!.code, this.state.participantId!)); + this.setRoomSession(response); + return response; + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("Missing room or participant information"); + } + + const response = await this.withLoading(() => api.restartRoom(this.state.room!.code, this.state.participantId!)); + this.setRoomSession(response); + return response; + } } const RoomStoreContext = createContext(null); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5072d0be..bcdf04cf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,11 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { - port: 5173 + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, } }); diff --git a/speckit.specify.phase3.scenario3.md b/speckit.specify.phase3.scenario3.md new file mode 100644 index 00000000..b12676d9 --- /dev/null +++ b/speckit.specify.phase3.scenario3.md @@ -0,0 +1,59 @@ +**Phase 3 — Scenario 3: Gameplay Interaction** + +Goal +- Enable the drawer to interact with and clear the shared canvas; sync guesses from guessers; persist a trimmed, case-insensitive guess history; award 100 points deterministically to correct guessers. + +Acceptance Criteria +- Drawer canvas interaction + - The drawer can draw on a shared canvas component and can clear it via a Clear button. + - Clear action resets the shared canvas state for all participants within one polling tick (2s). +- Guess input handling + - Guess submissions are trimmed of leading/trailing whitespace and normalized to lower-case before processing. + - Empty guesses (after trimming) are rejected client-side and not sent to the backend. +- Synced guesses and history + - Guessers submit guesses via the existing `GuessForm` which POSTs guesses to `/api/rooms/:code/guess`. + - The server maintains a deterministic, append-only `guesses` array per room containing objects: `{ id, participantId, text, normalizedText, createdAt }`. + - Clients poll the room snapshot every 2 seconds and receive the latest `guesses` array; the array is ordered by `createdAt`. + - The frontend trims and normalizes displayed guesses to lower-case for comparison, but displays the original text as submitted. +- Scoring + - On receiving a guess, the server deterministically compares the guess `normalizedText` to the room's `secretWord` normalized to lower-case. + - If a match occurs, the server awards exactly 100 points to the guessing participant and sets the room state to indicate the round winner; points are added to a per-participant `score` integer. + - The award operation is idempotent for the winning guess (if repeated POSTs occur for the same guess ID, it does not double-award). + - After a correct guess, future guesses in the same round are accepted but do not award additional points for the same secret word. + +Implementation Notes +- Backend + - Add endpoint: `POST /api/rooms/:code/guess` that accepts `{ participantId, text }` validated with Zod; returns the updated room snapshot. + - Room model: extend `Room` with `guesses: Guess[]` and `scores: Record` where `Guess` = `{ id, participantId, text, normalizedText, createdAt }`. + - On guess POST: trim and lower-case `text` to `normalizedText`. If `normalizedText` equals lowercased `secretWord` and the room is `active` and `!roundWon`, award 100 points, set `roundWon=true`, and record winnerParticipantId; append guess to `guesses` array; save room. + - Ensure idempotency: if the incoming guess payload contains an `id` that already exists, ignore duplicate scoring and just return current snapshot. + - Ensure `toRoomSnapshot` includes `guesses` (full list) and `scores` for clients. +- Frontend + - `GuessForm` should trim and lower-case the input before sending; avoid sending empty strings. + - Use existing polling (2s) in `LobbyPage`/`GamePage` flows to refresh `room` snapshots which now include `guesses` and `scores`. + - Render `guesses` in the UI (e.g., under `ResultPanel`) showing submitter name, original text, and timestamp; highlight the winning guess when `roundWon` is true. + - `Canvas` component: expose a `clear()` action that sends `POST /api/rooms/:code/canvas/clear` or include `POST /api/rooms/:code/clear` endpoint; for simplicity use `POST /api/rooms/:code/clear-canvas` which sets `canvasState = null` and updates `updatedAt`. + +Testing & Determinism +- Use the existing deterministic `chooseWord(code)` to pick `secretWord` so scoring behavior is repeatable per room code. +- Tests should validate that: + - Trimming/case-insensitive matching awards 100 points. + - Duplicate POSTs for same `guess.id` do not double-award. + - Clearing the canvas is reflected in the polled snapshot within 2s. + +Notes for Developers +- Keep network payloads small: limit `guesses` history to last 100 entries in snapshot but persist full history in memory if needed. +- This scenario avoids websockets: short polling (2s) is acceptable for the game's scope. + +Files to Modify +- Backend: `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `backend/src/api/rooms.ts`, `backend/src/api/schemas.ts` +- Frontend: `frontend/src/components/Canvas.tsx`, `frontend/src/components/GuessForm.tsx`, `frontend/src/components/ResultPanel.tsx`, `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/GamePage.tsx` + +Acceptance Checklist +- [ ] Drawer can clear canvas and action syncs within 2s +- [ ] Guesses trimmed and normalized client-side +- [ ] Server stores normalized guesses and maintains guess history +- [ ] Correct guess awards exactly 100 points deterministically +- [ ] Idempotent scoring on duplicate submissions +- [ ] Updated snapshots include `guesses` and `scores` for UI + From 7ee36ea9f7fa25fdf5a78829e5998c275eec5fcd Mon Sep 17 00:00:00 2001 From: Amelia Date: Thu, 11 Jun 2026 19:43:29 +0530 Subject: [PATCH 2/2] fix: add structured spec folders and implement backend start/restart endpoints --- .specify/memory/constitution.md | 12 +++++ backend/src/api/rooms.ts | 44 +++++++++++++++- backend/src/api/schemas.ts | 14 +++-- backend/src/models/game.ts | 21 +++++++- backend/src/services/roomStore.ts | 85 ++++++++++++++++++++++++++++++- specs/001-lobby/plan.md | 17 +++++++ specs/001-lobby/spec.md | 29 +++++++++++ specs/001-lobby/tasks.md | 12 +++++ specs/002-game-start/plan.md | 21 ++++++++ specs/002-game-start/spec.md | 26 ++++++++++ specs/002-game-start/tasks.md | 12 +++++ specs/003-gameplay/plan.md | 23 +++++++++ specs/003-gameplay/spec.md | 32 ++++++++++++ specs/003-gameplay/tasks.md | 13 +++++ specs/004-result-restart/plan.md | 17 +++++++ specs/004-result-restart/spec.md | 28 ++++++++++ specs/004-result-restart/tasks.md | 11 ++++ 17 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 .specify/memory/constitution.md create mode 100644 specs/001-lobby/plan.md create mode 100644 specs/001-lobby/spec.md create mode 100644 specs/001-lobby/tasks.md create mode 100644 specs/002-game-start/plan.md create mode 100644 specs/002-game-start/spec.md create mode 100644 specs/002-game-start/tasks.md create mode 100644 specs/003-gameplay/plan.md create mode 100644 specs/003-gameplay/spec.md create mode 100644 specs/003-gameplay/tasks.md create mode 100644 specs/004-result-restart/plan.md create mode 100644 specs/004-result-restart/spec.md create mode 100644 specs/004-result-restart/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..3c9f1de5 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,12 @@ +# Core Development Principles + +- No WebSockets. +- Use HTTP short polling at a fixed interval of 2 seconds for synchronization. +- The game supports a single active round at a time per room. +- Player names and guess inputs must be trimmed of leading/trailing whitespace. +- All comparison logic for names and guesses is case-insensitive. +- Rooms are isolated in-memory by 4-character room code. +- The host is always the first participant created in a room. +- Room state is kept in backend memory only; frontend must poll `/api/rooms/:code` for updates. +- New games can be restarted by clearing game-specific state while preserving the connected player list. +- Deterministic behavior is required for secret-word selection and point awards. diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..99200d96 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,11 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema, + restartGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, restartGame, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -44,6 +46,44 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const result = startGame(code.toUpperCase(), participantId); + + if (!result) { + throw new HttpError(403, "Unable to start game"); + } + + response.json({ + participantId: result.participantId, + room: toRoomSnapshot(result.room, result.participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartGameSchema.parse(request.body); + const result = restartGame(code.toUpperCase(), participantId); + + if (!result) { + throw new HttpError(403, "Unable to restart game"); + } + + response.json({ + participantId: result.participantId, + room: toRoomSnapshot(result.room, result.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 bfebba08..73f326ce 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,15 +1,23 @@ import { z } from "zod"; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Player name is required" }) }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Player name is required" }) }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: z.string().trim().min(1) +}); + +export const startGameSchema = z.object({ + participantId: z.string().trim().min(1) +}); + +export const restartGameSchema = z.object({ + participantId: z.string().trim().min(1) }); export const roomViewerQuerySchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..db168799 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "active"; export interface Participant { id: string; @@ -7,10 +7,24 @@ export interface Participant { joinedAt: string; } +export interface Guess { + id: string; + participantId: string; + text: string; + normalizedText: string; + createdAt: string; +} + export interface Room { code: string; status: RoomStatus; + hostId: string; + drawerId?: string; + secretWord?: string; participants: Participant[]; + guesses: Guess[]; + scores: Record; + roundWon: boolean; createdAt: string; updatedAt: string; } @@ -19,6 +33,11 @@ export interface RoomSnapshot { code: string; status: RoomStatus; participants: Participant[]; + hostId: string; + drawerId?: string; + word: string; + guesses: Guess[]; + scores: Record; availableWords: string[]; roles: ParticipantRole[]; } diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..5fab1fb5 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -54,7 +54,11 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], + guesses: [], + scores: {}, + roundWon: false, createdAt: now(), updatedAt: now() }; @@ -96,13 +100,92 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +function hashString(value: string) { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + + return hash; +} + +function chooseWord(code: string) { + const index = hashString(code) % STARTER_WORDS.length; + return STARTER_WORDS[index]; +} + +function maskWord(word: string) { + return word.replace(/./g, "_"); +} + +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + if (room.hostId !== participantId) { + return undefined; + } + + if (room.participants.length < 2) { + return undefined; + } + + room.drawerId = room.hostId; + room.secretWord = chooseWord(room.code); + room.status = "active"; + room.roundWon = false; + room.guesses = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + room: cloneRoom(room), + participantId + }; +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + if (room.hostId !== participantId) { + return undefined; + } + + room.drawerId = undefined; + room.secretWord = undefined; + room.status = "lobby"; + room.roundWon = false; + room.guesses = []; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + room: cloneRoom(room), + participantId + }; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawer = viewerParticipantId != null && room.drawerId === viewerParticipantId; + const word = room.status === "active" && room.secretWord ? (isDrawer ? room.secretWord : maskWord(room.secretWord)) : "Waiting for game start"; return { code: room.code, status: room.status, participants: room.participants.map((participant) => ({ ...participant })), + hostId: room.hostId, + drawerId: room.drawerId, + word, + guesses: room.guesses, + scores: room.scores, availableWords: listWords(), roles: [...STARTER_ROLES] }; diff --git a/specs/001-lobby/plan.md b/specs/001-lobby/plan.md new file mode 100644 index 00000000..07acd939 --- /dev/null +++ b/specs/001-lobby/plan.md @@ -0,0 +1,17 @@ +# Scenario 1 Plan + +## Work Plan +1. Verify `backend/src/services/roomStore.ts` generates a unique room code and stores `hostId` on room creation. +2. Confirm `backend/src/api/rooms.ts` exposes `POST /api/rooms/:code/join` and `GET /api/rooms/:code`. +3. Add polling in `frontend/src/pages/LobbyPage.tsx` so `roomStore.fetchRoom()` runs every 2 seconds. +4. Ensure `frontend/src/services/api.ts` uses a base `/api` path and passes `participantId` to `fetchRoom`. +5. Update `frontend/src/state/roomStore.ts` so `fetchRoom` updates the store snapshot and error state. +6. Display current participant names and host-only `Start Game` control in `LobbyPage`. + +## APIs +- `api.fetchRoom(code, participantId)` → `{ room: RoomSnapshot }` +- `api.joinRoom(code, playerName)` → `RoomSessionResponse` + +## Verification +- Start a room, join from another browser session, then verify both sessions see the participant list update within 2s. +- Confirm host button only appears for `room.hostId === participantId`. diff --git a/specs/001-lobby/spec.md b/specs/001-lobby/spec.md new file mode 100644 index 00000000..a93cf832 --- /dev/null +++ b/specs/001-lobby/spec.md @@ -0,0 +1,29 @@ +# Scenario 1: Lobby and Host Tracking + +## Objective +Provide isolated rooms with host tracking and live participant refresh via 2-second polling. + +## Acceptance Criteria +- Each room is isolated by its unique 4-character code. +- The first participant who creates the room is assigned as the host. +- The host ID is persisted in backend room state and returned in room snapshots. +- Participants can refresh the lobby by polling the room endpoint every 2 seconds. +- The UI reflects the current participant list and host-specific controls. + +## Contracts +- Backend contract: `GET /api/rooms/:code?participantId=...` returns + - `room.code` + - `room.hostId` + - `room.participants[]` + - `room.status` +- Backend contract: `POST /api/rooms/:code/join` accepts `{ playerName }` and returns `participantId` and room snapshot. +- Frontend contract: `frontend/src/state/roomStore.ts` must support `fetchRoom()` to refresh polls. +- Timing contract: polling interval is exactly 2000ms. + +## Files +- `frontend/src/pages/LobbyPage.tsx` +- `frontend/src/state/roomStore.ts` +- `frontend/src/services/api.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/rooms.ts` +- `backend/src/api/schemas.ts` diff --git a/specs/001-lobby/tasks.md b/specs/001-lobby/tasks.md new file mode 100644 index 00000000..b210e026 --- /dev/null +++ b/specs/001-lobby/tasks.md @@ -0,0 +1,12 @@ +# Scenario 1 Tasks + +- [ ] Add `hostId` to `Room` in `backend/src/models/game.ts`. +- [ ] Persist `hostId` on room creation in `backend/src/services/roomStore.ts`. +- [ ] Implement `GET /api/rooms/:code` in `backend/src/api/rooms.ts`. +- [ ] Implement `POST /api/rooms/:code/join` in `backend/src/api/rooms.ts`. +- [ ] Add `roomViewerQuerySchema` and join request validation in `backend/src/api/schemas.ts`. +- [ ] Add `RoomSnapshot` support for hostId in `frontend/src/services/api.ts`. +- [ ] Create `fetchRoom()` in `frontend/src/state/roomStore.ts`. +- [ ] Add polling in `frontend/src/pages/LobbyPage.tsx` at a 2s interval. +- [ ] Display current participant list and host-specific start controls in `frontend/src/pages/LobbyPage.tsx`. +- [ ] Ensure refresh errors are surfaced as lobby messages. diff --git a/specs/002-game-start/plan.md b/specs/002-game-start/plan.md new file mode 100644 index 00000000..7ee2c695 --- /dev/null +++ b/specs/002-game-start/plan.md @@ -0,0 +1,21 @@ +# Scenario 2 Plan + +## Work Plan +1. Update `backend/src/api/schemas.ts` to trim `playerName` and validate `participantId` for start. +2. Confirm `backend/src/services/roomStore.ts` enforces trimmed participant names on join. +3. Implement `startGame()` in `backend/src/services/roomStore.ts` to: + - require `room.hostId === participantId` + - set `drawerId` to the host + - set `status` to `active` + - choose a deterministic secret word from `room.code` +4. Extend `backend/src/services/roomStore.ts` `toRoomSnapshot()` to return masked words for non-drawers. +5. Add `api.startGame()` to `frontend/src/services/api.ts`. +6. Ensure `frontend/src/pages/GamePage.tsx` renders the drawer word only for the drawer and hides `GuessForm` when `isDrawer`. + +## APIs +- `api.startGame(code, participantId)` → `RoomSessionResponse` +- `api.fetchRoom(code, participantId)` returns a snapshot with `word` and `drawerId` + +## Verification +- Create a room, join a second player, start the game as host, and verify drawer user sees the word while guesser sees placeholders. +- Confirm both users see trimmed names and only the host can start the game. diff --git a/specs/002-game-start/spec.md b/specs/002-game-start/spec.md new file mode 100644 index 00000000..410c4321 --- /dev/null +++ b/specs/002-game-start/spec.md @@ -0,0 +1,26 @@ +# Scenario 2: Game Start and Host Assignment + +## Objective +Make sure the host is assigned correctly, player names are trimmed, and the secret word selection is deterministic. + +## Acceptance Criteria +- Room creation and join requests trim `playerName` before storage. +- The host remains the first participant created in the room. +- The host can start the game via `POST /api/rooms/:code/start`. +- Starting the game sets `room.status` to `active` and assigns `drawerId`. +- The secret word is chosen deterministically from the room code using a fixed hash function. +- Drawer sees the actual secret word; guessers see only masked placeholders. + +## Contracts +- Backend contract: `POST /api/rooms/:code/start` accepts `{ participantId }`. +- Backend contract: `RoomSnapshot.word` returns either actual secret or masked string depending on `viewerParticipantId`. +- Frontend contract: `frontend/src/pages/GamePage.tsx` should hide the guess input for the drawer. + +## Files +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/rooms.ts` +- `backend/src/api/schemas.ts` +- `backend/src/models/game.ts` diff --git a/specs/002-game-start/tasks.md b/specs/002-game-start/tasks.md new file mode 100644 index 00000000..f5b45b0d --- /dev/null +++ b/specs/002-game-start/tasks.md @@ -0,0 +1,12 @@ +# Scenario 2 Tasks + +- [ ] Trim `playerName` on create and join requests in `backend/src/api/schemas.ts`. +- [ ] Trim stored participant names in `backend/src/services/roomStore.ts`. +- [ ] Add deterministic `chooseWord(code)` in `backend/src/services/roomStore.ts`. +- [ ] Implement `POST /api/rooms/:code/start` in `backend/src/api/rooms.ts`. +- [ ] Add `startGameSchema` validation in `backend/src/api/schemas.ts`. +- [ ] Extend `backend/src/services/roomStore.ts` to set `drawerId`, `secretWord`, and `status`. +- [ ] Update `frontend/src/services/api.ts` with `startGame()`. +- [ ] Update `frontend/src/state/roomStore.ts` with `startGame()` and session updates. +- [ ] Render drawer-specific game view in `frontend/src/pages/GamePage.tsx`. +- [ ] Confirm non-drawers see masked words and drawer sees actual secret. diff --git a/specs/003-gameplay/plan.md b/specs/003-gameplay/plan.md new file mode 100644 index 00000000..ddc1d61e --- /dev/null +++ b/specs/003-gameplay/plan.md @@ -0,0 +1,23 @@ +# Scenario 3 Plan + +## Work Plan +1. Define the guess model in `backend/src/models/game.ts` with `id`, `participantId`, `text`, `normalizedText`, and `createdAt`. +2. Extend `backend/src/services/roomStore.ts` to store `guesses[]`, `scores`, and `roundWon`. +3. Add `POST /api/rooms/:code/guess` in `backend/src/api/rooms.ts` and validate requests in `backend/src/api/schemas.ts`. +4. Normalize guesses to trimmed lower-case on the backend and compare against `secretWord.toLowerCase()`. +5. Award 100 points only once per round to the first correct guesser, update `scores[participantId]`, and preserve game state. +6. Update `backend/src/services/roomStore.ts` `toRoomSnapshot()` to include guess history and scores. +7. Add `api.guess()` and `api.restartRoom()` in `frontend/src/services/api.ts`. +8. Update `frontend/src/state/roomStore.ts` to expose `submitGuess()` and `restartGame()`. +9. Update `frontend/src/components/GuessForm.tsx` to reject empty trimmed guesses and submit normalized input. +10. Update `frontend/src/components/ResultPanel.tsx` to show the activity log and restart button for the host. + +## APIs +- `POST /api/rooms/:code/guess` → `RoomSessionResponse` +- `POST /api/rooms/:code/restart` → `RoomSessionResponse` +- `GET /api/rooms/:code` → `{ room: RoomSnapshot }` + +## Verification +- Submit guesses from a non-drawer session and verify the history updates in both sessions within 2s. +- Confirm a correct guess awards exactly 100 points and later guesses do not award again. +- Confirm `GuessForm` rejects blank or whitespace-only text. diff --git a/specs/003-gameplay/spec.md b/specs/003-gameplay/spec.md new file mode 100644 index 00000000..2f564848 --- /dev/null +++ b/specs/003-gameplay/spec.md @@ -0,0 +1,32 @@ +# Scenario 3: Gameplay Interaction + +## Objective +Enable the drawer to interact with the shared canvas and let guessers submit case-insensitive guesses that are synced and scored. + +## Acceptance Criteria +- Drawer canvas interactions are available and can be cleared. +- Guess text is trimmed and normalized to lower-case before scoring. +- Guesses are appended to a synced history array on the backend. +- Clients refresh room state every 2 seconds and receive the latest guess history. +- Correct guesses award exactly 100 points to the guessing participant. +- Only the first correct guess in a round awards points; later guesses do not re-award. + +## Contracts +- Backend contract: `POST /api/rooms/:code/guess` accepts `{ participantId, text }`. +- Backend contract: room snapshot includes `guesses[]` and `scores{}`. +- Backend contract: correct guess scoring is deterministic and case-insensitive. +- Backend contract: `POST /api/rooms/:code/restart` clears the current round state and retains connected players. +- Frontend contract: `GuessForm` trims and normalizes input before POST. +- Frontend contract: `ResultPanel` renders `room.guesses` and host restart action. + +## Files +- `frontend/src/components/GuessForm.tsx` +- `frontend/src/components/ResultPanel.tsx` +- `frontend/src/components/Canvas.tsx` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/rooms.ts` +- `backend/src/api/schemas.ts` +- `backend/src/models/game.ts` diff --git a/specs/003-gameplay/tasks.md b/specs/003-gameplay/tasks.md new file mode 100644 index 00000000..b9f5b4a5 --- /dev/null +++ b/specs/003-gameplay/tasks.md @@ -0,0 +1,13 @@ +# Scenario 3 Tasks + +- [ ] Add `Guess` type to `backend/src/models/game.ts` and extend `Room`/`RoomSnapshot`. +- [ ] Initialize `room.guesses` and `room.scores` in `backend/src/services/roomStore.ts`. +- [ ] Add `guessSchema` validation in `backend/src/api/schemas.ts`. +- [ ] Implement `POST /api/rooms/:code/guess` in `backend/src/api/rooms.ts`. +- [ ] Normalize guess text to lower-case and trim whitespace before scoring. +- [ ] Award 100 points to the first correct guesser and set `room.roundWon = true`. +- [ ] Add `api.guess()` to `frontend/src/services/api.ts`. +- [ ] Implement `submitGuess()` or equivalent in `frontend/src/state/roomStore.ts`. +- [ ] Ensure `GuessForm.tsx` trims the input and prevents empty submissions. +- [ ] Add guess history rendering to `frontend/src/components/ResultPanel.tsx`. +- [ ] Confirm the host restart action retains existing participants. diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 00000000..dff0b2f3 --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,17 @@ +# Scenario 4 Plan + +## Work Plan +1. Verify `backend/src/services/roomStore.ts` `restartGame()` clears only game-specific fields. +2. Ensure `backend/src/services/roomStore.ts` preserves `participants[]` and optionally `scores` according to the chosen restart logic. +3. Add `restartGameSchema` validation in `backend/src/api/schemas.ts`. +4. Add `POST /api/rooms/:code/restart` in `backend/src/api/rooms.ts`. +5. Add `api.restartRoom()` to `frontend/src/services/api.ts`. +6. Add `restartGame()` to `frontend/src/state/roomStore.ts`. +7. Update `frontend/src/components/ResultPanel.tsx` to show scores and display a `Restart Game` button for the host. +8. Ensure `frontend/src/pages/GamePage.tsx` and `frontend/src/pages/LobbyPage.tsx` respond to the restarted snapshot. + +## Verification +- Start a round, score at least one guess, then restart as the host. +- Confirm the game returns to `lobby` state with the same connected participants. +- Confirm the result panel and lobby UI update immediately after restart. +- Confirm the host-only action is available only when `room.hostId === participantId`. diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 00000000..d51f14a2 --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,28 @@ +# Scenario 4: Results and Restart + +## Objective +Provide a shared results screen and a host-controlled restart action that clears game state while keeping the connected player list intact. + +## Acceptance Criteria +- The results panel displays current scores and recent guesses. +- The host sees a `Restart Game` action in the shared results panel. +- Restarting the game clears `drawerId`, `secretWord`, `guesses`, and round-specific state. +- Restart preserves `participants[]` and `scores{}` if scoring carries over, or resets appropriately if only round state is cleared. +- After restart, the room returns to `status: lobby` and players remain connected. + +## Contracts +- Backend contract: `POST /api/rooms/:code/restart` accepts `{ participantId }` and returns updated room snapshot. +- Backend contract: room snapshot after restart includes player list and `status: lobby`. +- Frontend contract: `ResultPanel` triggers restart through `roomStore.restartGame()`. +- Frontend contract: `GamePage` and `LobbyPage` update according to the refreshed snapshot. + +## Files +- `frontend/src/components/ResultPanel.tsx` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/pages/LobbyPage.tsx` +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/rooms.ts` +- `backend/src/api/schemas.ts` +- `backend/src/models/game.ts` diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 00000000..7964140f --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,11 @@ +# Scenario 4 Tasks + +- [ ] Add `restartGameSchema` in `backend/src/api/schemas.ts`. +- [ ] Create `POST /api/rooms/:code/restart` in `backend/src/api/rooms.ts`. +- [ ] Implement `restartGame(code, participantId)` in `backend/src/services/roomStore.ts`. +- [ ] Ensure `restartGame` clears `drawerId`, `secretWord`, `guesses`, and `roundWon`, but retains `participants[]`. +- [ ] Update `frontend/src/services/api.ts` with `restartRoom()`. +- [ ] Add `restartGame()` to `frontend/src/state/roomStore.ts`. +- [ ] Render host restart UI in `frontend/src/components/ResultPanel.tsx`. +- [ ] Verify `GamePage` and `LobbyPage` navigate or refresh cleanly after restart. +- [ ] Add a host-only restart validation check to prevent non-hosts from restarting.