diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..4001df4c --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,154 @@ +# Spec Kit Constitution — Scribble Lab + +## Purpose + +This constitution constrains how we specify, plan, implement, and validate changes in this repo so the delivered behavior matches the README business scenarios and remains easy to review. + +## Non-negotiable boundaries (must match README) + +- **No WebSockets / real-time push**: all synchronization is HTTP + polling only. +- **No database / persistence**: backend state is in-memory only; restarts wipe rooms. +- **No authentication/accounts/sessions**. +- **No scope creep**: do not add multi-rounds, timers, bonuses, spectator mode, moderation, passwords/invites, custom word packs, new routing/state libraries, or unrelated refactors. +- **No “rewrite the starter”**: enhance incrementally; prefer minimal, local changes. + +## Engineering principles + +- **TypeScript-first**: avoid `any`; use `unknown` and narrow when needed. +- **Deterministic game rules**: outcomes must be repeatable across runs and tabs (no random word selection or random scoring behavior). +- **Backwards-compatible increments**: each commit should keep the app runnable. +- **Keep state minimal**: room state should store only what is required for the scenarios; remove/clear round state on restart. +- **Fail fast with clear errors**: + - Backend returns consistent JSON errors with useful messages. + - Frontend shows clear, user-facing validation feedback (not stack traces). + +## AI usage rules + +- **AI is allowed for**: exploration help, drafting specs/plans, proposing code changes, writing tests, and suggesting refactors that are directly justified by scenarios. +- **AI is not allowed to decide** ambiguous product rules silently: + - If a rule is unclear, record an assumption explicitly in `/speckit.specify` (and/or discovery notes) before implementing. +- **No blind copy/paste**: every AI-proposed change must be read, understood, and matched against acceptance criteria before committing. + +## Spec → Plan → Tasks → Code traceability + +- Every scenario must have: + - **Specification**: acceptance criteria + edge cases. + - **Plan**: state model + endpoints + file-level change list. + - **Tasks**: ordered, testable slices with dependencies. +- Implementation must remain consistent with artifacts; if implementation deviates, update the spec/plan/tasks in the same feature group. +- Complete **at least 4 specify iterations** (one per scenario group minimum). + +## Validation expectations (manual) + +All scenarios must be validated with **two browser tabs** (or two browsers) to simulate multiplayer. + +- **Polling**: observable refresh roughly every ~2 seconds where required (Lobby and Game sync). +- **Input validation**: + - Names and guesses are **trimmed**; empty/whitespace-only is rejected with a clear message. + - Guess matching is **case-insensitive**. + - Room code invalid/empty is rejected with clear feedback. +- **Isolation**: actions in one room must not affect another room. +- **Determinism**: word selection and scoring behave predictably and repeatably. + +## Commit discipline + +- Keep commits **granular and meaningful**: + - Prefer one behavioral slice per commit (backend + frontend together if needed for a slice). + - Include artifact updates in their own commits (docs-only) when possible. +- Before committing: + - Verify the slice manually against its acceptance criteria. + - Ensure no unrelated files (like lockfiles) are included unless intentionally changed. + +## Build verification (required before final handoff) + +- `cd backend && npm run build` +- `cd frontend && npm run build` + + + +## Discovery summary + +## What the starter already provides + +- Frontend routes/screens for Start, Create Room, Join Room, Lobby, and Game. +- Minimal REST backend with in-memory room store: + - `GET /health` + - `POST /rooms` + - `POST /rooms/:code/join` + - `GET /rooms/:code` +- Room snapshot includes participant list plus starter seed lists: + - words: `rocket`, `pizza`, `castle`, `guitar`, `sunflower` + - roles: `drawer`, `guesser` + +## Key incomplete behaviors observed (gaps vs README scenarios) + +- **No host concept / permissions** + - Room creation does not record a host participant id. + - Lobby “Start Game” is just a navigation button; there is no backend “start game” action, no host-only gating, and no 2-player minimum enforcement. + +- **No automatic polling** + - Lobby state updates only via a manual “Refresh Room” button; no ~2s polling loop. + - There is no game-state polling at all (guesses, results, etc.). + +- **Gameplay state model is missing** + - `RoomStatus` is only `"lobby"`; there is no `"playing"` or `"results"` state. + - No drawer assignment, no secret word selection/storage, and no “drawer-only visibility”. + - No guess submission endpoint/state, no guess history, no scoring, no result state, no restart flow. + +- **Validation is permissive / not aligned to scenarios** + - `playerName` is optional on create/join; backend coerces missing to `"Player"` and does not trim or reject whitespace-only names. + - `roomCode` param schema is `z.string()` (no length/format validation), so “invalid/empty codes rejected with clear feedback” is not implemented. + +- **Viewer-specific snapshot logic is not implemented** + - Backend `toRoomSnapshot(room, viewerParticipantId)` currently ignores `viewerParticipantId`. + - This will matter for “secret word visible only to drawer”. + +- **Potential API base URL bug in frontend** + - `frontend/src/services/api.ts` defaults `VITE_API_URL` to `http://localhost:3001/bug` (appending `/bug`). + - Without a proxy, this would call `/bug/rooms` which the backend does not serve. The README “Quick Verification” implies the starter works, so this may rely on an environment override or be an intentional scaffold flaw. + +## Assumptions (to resolve ambiguity while staying deterministic) + +- **A1 — Host definition** + - The creator of the room is the host. + - Store `hostParticipantId` on the room and expose it in snapshots so the frontend can gate host-only actions. + +- **A2 — Polling strategy** + - Implement client polling with `setInterval` (about every 2000ms) in screens that must stay fresh (Lobby and Game). + - Polling will call `GET /rooms/:code?participantId=...` and replace the room snapshot in the store. + +- **A3 — Deterministic word selection** + - Select the secret word deterministically from the starter word list using a stable input (e.g., room code) so it is repeatable and testable without randomness. + +- **A4 — Drawer assignment** + - Drawer for the single-round game is the host (or, if host is missing for any reason, the first participant in the room). + +## Relevant files inspected (likely to be touched later) + +### Backend +- Routes and validation: + - `backend/src/api/rooms.ts` + - `backend/src/api/schemas.ts` + - `backend/src/api/router.ts` +- In-memory room store + snapshot: + - `backend/src/services/roomStore.ts` +- Types + state model: + - `backend/src/models/game.ts` +- Seed data: + - `backend/src/seed/starterData.ts` + +### Frontend +- Room client + types: + - `frontend/src/services/api.ts` + - `frontend/src/state/roomStore.ts` +- Screens: + - `frontend/src/pages/CreateRoomPage.tsx` + - `frontend/src/pages/JoinRoomPage.tsx` + - `frontend/src/pages/LobbyPage.tsx` + - `frontend/src/pages/GamePage.tsx` + +## Notes / risks to track + +- Ensure all “sync” remains HTTP polling (explicitly no websockets). +- Backend is in-memory only; restarting backend wipes all rooms (expected). +- If the API base URL default really is wrong, we will need to fix it early (otherwise later work can’t be verified in two tabs). diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 00000000..62146416 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,79 @@ +# Reflection Report — Scribble Lab + +## What the starter app already had + +The starter was a runnable scaffold with: + +- React routes for Start, Create Room, Join Room, Lobby, and Game +- A minimal Express API: `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code`, `GET /health` +- In-memory room storage with participant lists +- Placeholder game UI (canvas, guess form, scoreboard, results) without real gameplay logic +- Manual lobby refresh only (no polling) +- No host permissions, drawer assignment, secret word rules, scoring, or restart flow + +## What I added + +Work followed the Spec Kit loop: discovery → constitution → specify/plan/tasks per scenario → incremental implementation → validation. + +### Artifacts + +- `speckit.discovery.md` — gaps, assumptions, relevant files +- `speckit.constitution` — scope boundaries, deterministic rules, AI/review discipline +- `speckit.specify`, `speckit.plan`, `speckit.tasks` — four iterations aligned to Scenarios 1–4 + +### Scenario 1 — Room setup & lobby + +- Host tracking on room creation +- Join validation with clear 400/404 errors +- Lobby polling (~2s) +- Host-only start with 2-player minimum +- Commits: `feat(rooms)`, `feat(join)`, `feat(lobby)`, `feat(game)` + +### Scenario 2 — Game start & drawer flow + +- Player name trim + non-empty validation +- Deterministic drawer assignment (host) and secret word selection (from room code) +- Drawer-only word visibility during `playing` +- Commits: `feat(players)`, `feat(game)` + +### Scenario 3 — Gameplay interaction + +- Drawer-only canvas draw/clear with synced strokes via polling +- Guess submission with trim, case-insensitive match, shared history, and scoring (+100 first correct, +0 incorrect) +- Commits: `feat(canvas)`, `feat(guesses)`, `feat(sync)`, `feat(scoring)` + +### Scenario 4 — Result & restart + +- Transition to `results` on first correct guess; word/scores/history visible to all +- Host-only restart clears round state and returns everyone to lobby via polling +- Commits: `feat(results)`, `feat(restart)` + +## Decisions and tradeoffs + +- **Polling over push**: kept HTTP polling (~2s) everywhere to match lab constraints; simple and sufficient for two-tab validation, but not real-time. +- **Deterministic word pick**: used room-code checksum modulo starter word list for repeatable behavior and easier manual testing. +- **First correct guess ends round**: single-round flow; avoids timers/multi-round complexity called out as out of scope. +- **Score once per player**: first correct guess awards 100; later correct guesses do not stack, keeping scoring predictable. +- **Minimal canvas model**: stored stroke paths rather than pixel buffers; easier to sync over REST but not optimized for high-frequency drawing. +- **Incremental commits**: each scenario split into small backend/frontend slices so changes stay traceable to spec acceptance criteria. + +## AI usage + +AI assisted with: + +- Drafting and refining Spec Kit artifacts from README scenarios +- Exploring the starter codebase and proposing file-level plans +- Implementing slices incrementally with typed backend/frontend changes +- Smoke-testing API flows and build checks between commits + +I reviewed AI output against `speckit.specify` acceptance criteria before committing, especially for permission rules (host/drawer/guesser), validation messages, and snapshot visibility (drawer-only word during play, all players in results). + +## Traceability + +Each implementation commit maps to a scenario slice in `speckit.tasks` and acceptance criteria in `speckit.specify`. Out-of-scope items from the README (WebSockets, databases, auth, multi-round rotation) were intentionally not implemented. + +## Manual validation performed + +- Two-tab flows for lobby sync, game start, drawing, guessing, results, and restart +- Backend and frontend `npm run build` after major slices +- API smoke checks for validation codes, role gating, and scoring/restart behavior diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..d5891010 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,27 @@ import { Router } from "express"; import { + addCanvasStrokeSchema, + clearCanvasSchema, createRoomSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + restartGameSchema, + startGameSchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + addCanvasStroke, + clearCanvas, + createRoom, + getRoom, + joinRoom, + restartGame, + startGame, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -29,10 +44,10 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { playerName } = joinRoomSchema.parse(request.body); - const result = joinRoom(code.toUpperCase(), playerName); + const result = joinRoom(code, playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found. Check the code and try again."); } response.json({ @@ -48,10 +63,10 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); - const room = getRoom(code.toUpperCase()); + const room = getRoom(code); if (!room) { - throw new HttpError(404, "Unable to load room"); + throw new HttpError(404, "Room not found."); } response.json({ @@ -62,5 +77,163 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const result = startGame(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_host") { + throw new HttpError(403, "Only the host can start the game."); + } + + if (result.reason === "not_in_lobby") { + throw new HttpError(409, "The game can only be started from the lobby."); + } + + throw new HttpError(409, "At least 2 players are required to start the game."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/canvas/strokes", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, stroke } = addCanvasStrokeSchema.parse(request.body); + const result = addCanvasStroke(code, participantId, stroke); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Drawing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + throw new HttpError(403, "Only the drawer can draw on the canvas."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/canvas/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = clearCanvasSchema.parse(request.body); + const result = clearCanvas(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Drawing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + throw new HttpError(403, "Only the drawer can clear the canvas."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guesses", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const result = submitGuess(code, participantId, guess); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "not_playing") { + throw new HttpError(409, "Guessing is only available during an active game."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + if (result.reason === "drawer_cannot_guess") { + throw new HttpError(403, "The drawer cannot submit guesses."); + } + + if (result.reason === "empty_guess") { + throw new HttpError(400, "Enter a guess."); + } + + throw new HttpError(400, "Unable to submit guess."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartGameSchema.parse(request.body); + const result = restartGame(code, participantId); + + if (!result.ok) { + if (result.reason === "not_found") { + throw new HttpError(404, "Room not found."); + } + + if (result.reason === "unknown_participant") { + throw new HttpError(404, "Participant not found in this room."); + } + + if (result.reason === "not_host") { + throw new HttpError(403, "Only the host can restart the game."); + } + + throw new HttpError(409, "The game can only be restarted from results."); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 12705954..5514c277 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; +import { ZodError } from "zod"; import { createRoomsRouter } from "./rooms.js"; export function createApiRouter() { @@ -29,8 +30,10 @@ export function errorHandler( response: Response, _next: NextFunction ) { - if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + if (error instanceof ZodError) { + response.status(400).json({ + message: error.issues[0]?.message ?? "Invalid request" + }); return; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..f4c3d4dd 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,21 +1,65 @@ import { z } from "zod"; +const roomCodeSchema = z + .string() + .trim() + .min(1, { message: "Enter a room code." }) + .length(4, { message: "Room code must be 4 characters." }) + .regex(/^[a-z0-9]{4}$/i, { message: "Room code must use only letters and numbers." }) + .transform((value) => value.toUpperCase()); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Enter a player name." }) }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, { message: "Enter a player name." }) }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: roomCodeSchema }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + +export const restartGameSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + +const canvasPointSchema = z.object({ + x: z.number(), + y: z.number() +}); + +export const canvasStrokeSchema = z.object({ + id: z.string().min(1), + points: z.array(canvasPointSchema).min(1, { message: "Stroke must include at least one point." }), + color: z.string().min(1), + width: z.number().positive(), + createdBy: z.string().min(1), + createdAt: z.string().min(1) +}); + +export const addCanvasStrokeSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }), + stroke: canvasStrokeSchema +}); + +export const clearCanvasSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }) +}); + +export const submitGuessSchema = z.object({ + participantId: z.string().min(1, { message: "Missing participant id." }), + guess: z.string().trim().min(1, { message: "Enter a guess." }) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..03e05b9c 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,28 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "results"; + +export interface CanvasPoint { + x: number; + y: number; +} + +export interface CanvasStroke { + id: string; + points: CanvasPoint[]; + color: string; + width: number; + createdBy: string; + createdAt: string; +} + +export interface GuessEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + isCorrect: boolean; + createdAt: string; +} export interface Participant { id: string; @@ -10,6 +33,12 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostParticipantId: string; + drawerParticipantId: string | null; + secretWord: string | null; + canvasStrokes: CanvasStroke[]; + guesses: GuessEntry[]; + scores: Record; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +47,13 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostParticipantId: string; + drawerParticipantId: string | null; + viewerRole: ParticipantRole; + secretWord?: string; + canvasStrokes: CanvasStroke[]; + guesses: GuessEntry[]; + scores: Record; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..14cc2cd8 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { CanvasStroke, GuessEntry, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -29,11 +29,11 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +function displayName(name: string) { + return name.trim(); } -function createParticipant(name?: string): Participant { +function createParticipant(name: string): Participant { return { id: randomUUID(), name: displayName(name), @@ -45,15 +45,39 @@ function cloneRoom(room: Room) { return structuredClone(room); } +function emptyCanvasStrokes(): Room["canvasStrokes"] { + return []; +} + +function emptyGuesses(): Room["guesses"] { + return []; +} + +function initialScores(participants: Participant[]): Record { + return Object.fromEntries(participants.map((participant) => [participant.id, 0])); +} + +function pickDeterministicWord(roomCode: string) { + const score = Array.from(roomCode).reduce((sum, char) => sum + char.charCodeAt(0), 0); + const index = score % STARTER_WORDS.length; + return STARTER_WORDS[index]; +} + export function listWords() { return [...STARTER_WORDS]; } -export function createRoom(playerName?: string) { +export function createRoom(playerName: string) { const participant = createParticipant(playerName); const room: Room = { code: generateUniqueCode(), status: "lobby", + hostParticipantId: participant.id, + drawerParticipantId: null, + secretWord: null, + canvasStrokes: emptyCanvasStrokes(), + guesses: emptyGuesses(), + scores: initialScores([participant]), participants: [participant], createdAt: now(), updatedAt: now() @@ -67,7 +91,7 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); if (!room) { @@ -76,6 +100,7 @@ export function joinRoom(code: string, playerName?: string) { const participant = createParticipant(playerName); room.participants.push(participant); + room.scores[participant.id] = 0; room.updatedAt = now(); rooms.set(room.code, room); @@ -90,6 +115,194 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } +export function startGame(code: string, requesterParticipantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (requesterParticipantId !== room.hostParticipantId) { + return { ok: false as const, reason: "not_host" as const }; + } + + if (room.participants.length < 2) { + return { ok: false as const, reason: "not_enough_players" as const }; + } + + if (room.status !== "lobby") { + return { ok: false as const, reason: "not_in_lobby" as const }; + } + + const fallbackDrawerId = room.participants[0]?.id ?? null; + room.drawerParticipantId = room.hostParticipantId || fallbackDrawerId; + room.secretWord = pickDeterministicWord(room.code); + room.canvasStrokes = emptyCanvasStrokes(); + room.guesses = emptyGuesses(); + room.scores = initialScores(room.participants); + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { ok: true as const, room: cloneRoom(room) }; +} + +function getPlayingRoomForParticipant(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "playing") { + return { ok: false as const, reason: "not_playing" as const }; + } + + const participant = room.participants.find((entry) => entry.id === participantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + if (participantId !== room.drawerParticipantId) { + return { ok: false as const, reason: "not_drawer" as const }; + } + + return { ok: true as const, room, participant }; +} + +export function addCanvasStroke(code: string, participantId: string, stroke: CanvasStroke) { + const access = getPlayingRoomForParticipant(code, participantId); + + if (!access.ok) { + return access; + } + + access.room.canvasStrokes.push(stroke); + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room) }; +} + +export function clearCanvas(code: string, participantId: string) { + const access = getPlayingRoomForParticipant(code, participantId); + + if (!access.ok) { + return access; + } + + access.room.canvasStrokes = emptyCanvasStrokes(); + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room) }; +} + +function getGuesserRoom(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "playing") { + return { ok: false as const, reason: "not_playing" as const }; + } + + const participant = room.participants.find((entry) => entry.id === participantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + if (participantId === room.drawerParticipantId) { + return { ok: false as const, reason: "drawer_cannot_guess" as const }; + } + + return { ok: true as const, room, participant }; +} + +export function submitGuess(code: string, participantId: string, guess: string) { + const access = getGuesserRoom(code, participantId); + + if (!access.ok) { + return access; + } + + const normalizedGuess = guess.trim(); + + if (!normalizedGuess) { + return { ok: false as const, reason: "empty_guess" as const }; + } + + if (!access.room.secretWord) { + return { ok: false as const, reason: "not_playing" as const }; + } + + const isCorrect = normalizedGuess.toLowerCase() === access.room.secretWord.toLowerCase(); + const alreadyScoredCorrectly = access.room.guesses.some( + (existingGuess) => existingGuess.participantId === participantId && existingGuess.isCorrect + ); + + const entry: GuessEntry = { + id: randomUUID(), + participantId: access.participant.id, + participantName: access.participant.name, + guess: normalizedGuess, + isCorrect, + createdAt: now() + }; + + access.room.guesses.push(entry); + + if (isCorrect && !alreadyScoredCorrectly) { + access.room.scores[access.participant.id] = (access.room.scores[access.participant.id] ?? 0) + 100; + } + + if (isCorrect) { + access.room.status = "results"; + } + + access.room.updatedAt = now(); + rooms.set(access.room.code, access.room); + + return { ok: true as const, room: cloneRoom(access.room), entry }; +} + +export function restartGame(code: string, requesterParticipantId: string) { + const room = rooms.get(code); + + if (!room) { + return { ok: false as const, reason: "not_found" as const }; + } + + if (room.status !== "results") { + return { ok: false as const, reason: "not_in_results" as const }; + } + + if (requesterParticipantId !== room.hostParticipantId) { + return { ok: false as const, reason: "not_host" as const }; + } + + const participant = room.participants.find((entry) => entry.id === requesterParticipantId); + + if (!participant) { + return { ok: false as const, reason: "unknown_participant" as const }; + } + + room.status = "lobby"; + room.drawerParticipantId = null; + room.secretWord = null; + room.canvasStrokes = emptyCanvasStrokes(); + room.guesses = emptyGuesses(); + room.scores = initialScores(room.participants); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { ok: true as const, room: cloneRoom(room) }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -97,13 +310,27 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawerViewer = Boolean( + viewerParticipantId && room.drawerParticipantId && viewerParticipantId === room.drawerParticipantId + ); - return { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, + hostParticipantId: room.hostParticipantId, + drawerParticipantId: room.drawerParticipantId, + viewerRole: isDrawerViewer ? "drawer" : "guesser", + canvasStrokes: room.canvasStrokes.map((stroke) => ({ ...stroke, points: [...stroke.points] })), + guesses: room.guesses.map((guess) => ({ ...guess })), + scores: { ...room.scores }, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] }; + + if (room.secretWord && (room.status === "results" || isDrawerViewer)) { + snapshot.secretWord = room.secretWord; + } + + return snapshot; } diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 00000000..8a24f13a --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import type { CanvasPoint, CanvasStroke } from "../services/api"; + +interface DrawingCanvasProps { + strokes: CanvasStroke[]; + canDraw: boolean; + onStrokeComplete: (stroke: CanvasStroke) => Promise; + onClear: () => Promise; +} + +const STROKE_COLOR = "#111827"; +const STROKE_WIDTH = 3; + +function drawStroke(context: CanvasRenderingContext2D, stroke: CanvasStroke) { + if (stroke.points.length === 0) { + return; + } + + context.strokeStyle = stroke.color; + context.lineWidth = stroke.width; + context.lineCap = "round"; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(stroke.points[0].x, stroke.points[0].y); + + for (let index = 1; index < stroke.points.length; index += 1) { + context.lineTo(stroke.points[index].x, stroke.points[index].y); + } + + context.stroke(); +} + +export function DrawingCanvas({ strokes, canDraw, onStrokeComplete, onClear }: DrawingCanvasProps) { + const canvasRef = useRef(null); + const [activePoints, setActivePoints] = useState([]); + const [isDrawing, setIsDrawing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + strokes.forEach((stroke) => drawStroke(context, stroke)); + + if (activePoints.length > 0) { + drawStroke(context, { + id: "preview", + points: activePoints, + color: STROKE_COLOR, + width: STROKE_WIDTH, + createdBy: "", + createdAt: "" + }); + } + }, [activePoints, strokes]); + + function getCanvasPoint(event: React.PointerEvent): CanvasPoint | null { + const canvas = canvasRef.current; + if (!canvas) { + return null; + } + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY + }; + } + + function handlePointerDown(event: React.PointerEvent) { + if (!canDraw || isSubmitting) { + return; + } + + const point = getCanvasPoint(event); + if (!point) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setIsDrawing(true); + setActivePoints([point]); + } + + function handlePointerMove(event: React.PointerEvent) { + if (!isDrawing || !canDraw) { + return; + } + + const point = getCanvasPoint(event); + if (!point) { + return; + } + + setActivePoints((current) => [...current, point]); + } + + async function handlePointerUp(event: React.PointerEvent) { + if (!isDrawing || !canDraw) { + return; + } + + event.currentTarget.releasePointerCapture(event.pointerId); + setIsDrawing(false); + + const completedPoints = activePoints; + setActivePoints([]); + + if (completedPoints.length === 0) { + return; + } + + const stroke: CanvasStroke = { + id: crypto.randomUUID(), + points: completedPoints, + color: STROKE_COLOR, + width: STROKE_WIDTH, + createdBy: "", + createdAt: new Date().toISOString() + }; + + try { + setIsSubmitting(true); + await onStrokeComplete(stroke); + } finally { + setIsSubmitting(false); + } + } + + async function handleClear() { + if (!canDraw || isSubmitting) { + return; + } + + try { + setIsSubmitting(true); + await onClear(); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + {canDraw ? ( +
+ +
+ ) : ( +

Watch the drawer's sketch update here.

+ )} +
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..9ae83426 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -2,13 +2,34 @@ import { useState } from "react"; interface GuessFormProps { disabled?: boolean; + onSubmitGuess: (guess: string) => Promise; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ disabled = false, onSubmitGuess }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + const normalizedGuess = guessText.trim(); + + if (!normalizedGuess) { + setError("Enter a guess."); + return; + } + + try { + setError(null); + setIsSubmitting(true); + await onSubmitGuess(normalizedGuess); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } finally { + setIsSubmitting(false); + } } return ( @@ -19,11 +40,12 @@ export function GuessForm({ disabled = false }: GuessFormProps) { value={guessText} onChange={(event) => setGuessText(event.target.value)} placeholder="Type your guess here..." - disabled={disabled} + disabled={disabled || isSubmitting} /> + {error ?

{error}

: null}
-
diff --git a/frontend/src/components/GuessHistory.tsx b/frontend/src/components/GuessHistory.tsx new file mode 100644 index 00000000..c0a78e24 --- /dev/null +++ b/frontend/src/components/GuessHistory.tsx @@ -0,0 +1,28 @@ +import { Card } from "./Card"; +import type { GuessEntry } from "../services/api"; + +interface GuessHistoryProps { + guesses: GuessEntry[]; +} + +export function GuessHistory({ guesses }: GuessHistoryProps) { + return ( + + {guesses.length === 0 ? ( +

No guesses yet.

+ ) : ( +
    + {guesses.map((entry) => ( +
  • + {entry.participantName} + {entry.guess} + + {entry.isCorrect ? "Correct" : "Incorrect"} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e..b5d0b5c7 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,58 @@ import { Card } from "./Card"; +import type { GuessEntry, Participant } from "../services/api"; + +interface ResultPanelProps { + status: "lobby" | "playing" | "results"; + secretWord?: string; + participants: Participant[]; + scores: Record; + guesses: GuessEntry[]; +} + +export function ResultPanel({ status, secretWord, participants, scores, guesses }: ResultPanelProps) { + if (status !== "results") { + return ( + +

Results will appear here when the round ends.

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

Game activity and guesses will appear here.

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

Final scores

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

Guess history

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

No guesses were submitted.

+ ) : ( +
    + {guesses.map((entry) => ( +
  • + {entry.participantName} + {entry.guess} + + {entry.isCorrect ? "Correct" : "Incorrect"} + +
  • + ))} +
+ )}
); } diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..1b45f3d0 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,32 @@ import { Card } from "./Card"; +import type { Participant } from "../services/api"; + +interface ScoreboardProps { + participants: Participant[]; + scores: Record; +} + +export function Scoreboard({ participants, scores }: ScoreboardProps) { + const rows = participants.map((participant) => ({ + id: participant.id, + name: participant.name, + score: scores[participant.id] ?? 0 + })); -export function Scoreboard() { return ( -
-
- Waiting for players... - 0 + {rows.length === 0 ? ( +

Waiting for players...

+ ) : ( +
+ {rows.map((row) => ( +
+ {row.name} + {row.score} +
+ ))}
-
+ )} ); } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..211e1114 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -14,7 +14,14 @@ export function CreateRoomPage() { try { setError(null); - await roomStore.createRoom(playerName); + const normalizedName = playerName.trim(); + + if (!normalizedName) { + setError("Enter a player name."); + return; + } + + await roomStore.createRoom(normalizedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..b6ad49a2 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,14 +1,17 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; +import { DrawingCanvas } from "../components/DrawingCanvas"; import { GuessForm } from "../components/GuessForm"; +import { GuessHistory } from "../components/GuessHistory"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); useEffect(() => { @@ -17,11 +20,45 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + if (!room?.code || (room.status !== "playing" && room.status !== "results")) { + return; + } + + const intervalId = window.setInterval(() => { + void roomStore.fetchRoom(); + }, 2000); + + return () => { + window.clearInterval(intervalId); + }; + }, [room?.code, room?.status, roomStore]); + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.viewerRole === "drawer"; + const isPlaying = room.status === "playing"; + const isResults = room.status === "results"; + const isHost = participantId === room.hostParticipantId; + const roleLabel = + room.status === "results" + ? "Round complete" + : isDrawer + ? "You are the drawer" + : "You are guessing"; + + async function handleRestart() { + await roomStore.restartGame(); + } return (
@@ -35,15 +72,29 @@ export function GamePage() {
-
- Waiting for drawer... -
+ { + await roomStore.addCanvasStroke(stroke); + }} + onClear={async () => { + await roomStore.clearCanvas(); + }} + />
@@ -56,18 +107,38 @@ export function GamePage() {
Status
-
Playing
+
{roleLabel}
+ {isPlaying && isDrawer && room.secretWord ? ( +
+
Secret word
+
{room.secretWord}
+
+ ) : null} - + { + await roomStore.submitGuess(guess); + }} + />
+ {isResults ? ( + isHost ? ( + + ) : ( +

Waiting for the host to restart...

+ ) + ) : null} diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..d39b8fe3 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -15,7 +15,31 @@ export function JoinRoomPage() { try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + const normalizedName = playerName.trim(); + + if (!normalizedName) { + setError("Enter a player name."); + return; + } + + const normalizedCode = roomCode.trim().toUpperCase(); + + if (!normalizedCode) { + setError("Enter a room code."); + return; + } + + if (normalizedCode.length !== 4) { + setError("Room code must be 4 characters."); + return; + } + + if (!/^[A-Z0-9]{4}$/.test(normalizedCode)) { + setError("Room code must use only letters and numbers."); + return; + } + + await roomStore.joinRoom(normalizedCode, normalizedName); 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..41560df1 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,51 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "playing") { + navigate("/game", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + if (!room?.code) { + return; + } + + let isActive = true; + + async function tick() { + try { + await roomStore.fetchRoom(); + if (isActive) { + setRefreshError(null); + } + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : "Unable to refresh room"; + if (!isActive) { + return; + } + + setRefreshError(message); + + if (message.toLowerCase().includes("not found")) { + navigate("/", { replace: true }); + } + } + } + + void tick(); + + const intervalId = window.setInterval(() => { + void tick(); + }, 2000); + + return () => { + isActive = false; + window.clearInterval(intervalId); + }; + }, [navigate, room?.code, roomStore]); + async function handleRefresh() { try { setRefreshError(null); @@ -30,6 +75,26 @@ export function LobbyPage() { return null; } + const isHost = participantId === room.hostParticipantId; + const canStart = isHost && room.participants.length >= 2; + const statusMessage = + error ?? + refreshError ?? + (isHost + ? room.participants.length >= 2 + ? "You can start the game when you're ready." + : "Waiting for at least one more player to join." + : "Waiting for the host to start the game."); + + async function handleStartGame() { + try { + setRefreshError(null); + await roomStore.startGame(); + } catch (caughtError) { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + } + return (
@@ -50,7 +115,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostParticipantId ? "host" : "joined"} +
  • ))} @@ -61,7 +128,7 @@ export function LobbyPage() {

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

    -

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

    +

    {statusMessage}

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