diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..5b3f4b1 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/004-result-restart" +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..8e65efd --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,129 @@ + + +# Scribble Constitution + +## Core Principles + +### I. Brownfield-First + +This project is a brownfield enhancement of an existing starter application. +All work MUST extend the existing codebase — rewriting the starter from scratch +is prohibited. Before adding any file or function, confirm the starter does not +already provide it. Prefer minimal additions over sweeping restructuring. +Existing conventions (naming, file structure, import style) MUST be followed +unless the spec explicitly introduces a new pattern with justification. + +### II. Spec-Driven Development + +Every code change MUST be traceable to an artifact (spec, plan, or task). +Implementation decisions that deviate from the spec MUST be documented in +the spec before committing. No code is written before the relevant acceptance +criterion exists in `speckit.specify`. No task is started before it appears in +`speckit.tasks`. + +Artifacts are updated incrementally — one feature group at a time — in this +strict order: specify → clarify → plan → tasks → checklist → implement → validate. + +### III. Deterministic Game Rules + +All game-rule outcomes MUST be deterministic and reproducible given the same +inputs. Specifically: + +- Secret word: selected by `STARTER_WORDS[(round - 1) % word count]` — index 0, + so the first (and only) round always uses `"rocket"`. +- Drawer assignment: the room creator (host, first participant) is always the + drawer for this single-round game. +- Scoring: correct guess = 100 points; incorrect guess = 0 points. No bonuses, + no partial credit, no time modifiers. + +Any deviation from these rules MUST be documented as a spec amendment before +implementation. + +### IV. Strict Scope Discipline + +The following are permanently out of scope and MUST NOT appear in any artifact +or code: + +- WebSockets or real-time sync (polling only, ~2 s cadence) +- Databases or persistent storage (in-memory only) +- Authentication, accounts, or sessions +- Multiple rounds, drawer rotation, timers, countdowns, or bonuses +- Custom word packs, spectator mode, room moderation, passwords, or invite links +- Deployment, CI/CD, or Docker configuration +- New top-level npm dependencies not justified by a spec requirement +- Refactors of unrelated starter code + +When an edge case is ambiguous, the simpler interpretation within scope MUST be +chosen and recorded as an assumption. + +### V. Incremental Validation + +Work proceeds in four feature groups, each gated by a validation checkpoint. +A group is complete only when its acceptance criteria pass in two browser tabs. +The next group MUST NOT begin implementation until the current group passes. + +| Group | Gate | +|-------|------| +| 1. Room Setup & Lobby | Host tracking, polling, host-only start, 2-player minimum | +| 2. Game Start & Drawer Flow | Name validation, drawer assignment, drawer-only word | +| 3. Gameplay Interaction | Canvas, guess submit, synced history, scoring | +| 4. Result, Restart & Final Validation | Result state, clean restart, preserved players | + +### VI. AI-Assisted, Human-Reviewed + +AI output (code, specs, plans, tasks) MUST be reviewed and understood before +committing. The developer is responsible for every line in the diff, regardless +of whether AI generated it. AI suggestions that introduce out-of-scope features +or deviate from the spec MUST be rejected. + +All AI usage decisions and tradeoffs MUST be recorded in `REFLECTION.md`. + +## Coding Standards + +- TypeScript strict mode required on both frontend and backend. +- No `any` except where the existing starter already uses it. +- No comments explaining what code does — only comments for non-obvious WHY + (hidden constraints, spec-specific invariants). +- Validation occurs at system boundaries only (HTTP request body, user form + input). Internal functions trust their callers. +- User-facing error messages MUST be human-readable (e.g., "Name cannot be + empty", not "Validation error"). +- Polling MUST use `setInterval` / `clearInterval` inside `useEffect` with + proper cleanup. No `setTimeout` chains. + +## Review Discipline + +Before committing any implementation: + +1. Verify the changed behavior matches at least one acceptance criterion in the + current feature group's spec. +2. Run `npm run build` in both `backend/` and `frontend/` — zero TypeScript + errors required. +3. Manually test the happy path and at least one error path in the browser. +4. Confirm no out-of-scope code was introduced. + +## Governance + +This constitution supersedes all other informal conventions for the duration of +this lab. Amendments require a version bump following semantic versioning: + +- MAJOR: removal or redefinition of an existing principle. +- MINOR: new principle or materially expanded guidance. +- PATCH: clarification, wording, or typo fix. + +`LAST_AMENDED_DATE` is updated on every change. All PRs must verify compliance +before approval. + +**Version**: 1.0.0 | **Ratified**: 2026-05-28 | **Last Amended**: 2026-05-28 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..48079b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan at: +specs/001-room-setup-lobby/plan.md + diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 0000000..b9d8785 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,86 @@ +# Reflection Report + +## What the Starter App Already Had + +- App shell with routing between Start, Create Room, Join Room, Lobby, and Game screens +- Branded Scribble landing page and basic UI styling +- Create Room flow: generates a unique 4-char room code, adds creator as participant, navigates to lobby +- Join Room flow: accepts a room code, adds the player, navigates to lobby +- Lobby: displays room code and participant list with a manual Refresh button +- In-memory room store on the backend (`Map`) +- `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code` endpoints +- Seed data: words (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) and roles (`drawer`, `guesser`) +- Placeholder Game screen with non-functional canvas, guess form, scoreboard, and result panel +- A deliberate bug: API base URL pointed to `http://localhost:3001/bug` instead of `http://localhost:3001` + +## What Was Added + +### Spec Kit Artifacts + +Produced and committed four sets of Spec Kit artifacts — one per feature group — each following the full loop: specify → clarify → plan → tasks → implement → commit. + +- **Constitution** (`/.specify/memory/constitution.md`): six principles covering brownfield-first development, spec-driven workflow, deterministic game rules, strict scope discipline, incremental validation, and AI-assisted human-reviewed development +- **4 spec files** with user stories, acceptance criteria, edge cases, functional requirements, success criteria, and assumptions +- **4 plan files** with constitution checks, data model changes, API contracts, data flow, and implementation sequences +- **4 task files** with phase-ordered, dependency-aware task lists mapped to user stories +- **Clarification session** on Group 1 resolving two ambiguities: host-navigates-immediately vs. non-hosts-via-polling, and auto-navigate vs. manual action on status change + +### Feature Group 1 — Room Setup & Lobby + +- `hostId` added to `Room` and `RoomSnapshot` (first participant = host) +- Name validation: trim + `min(1)` via Zod on backend; client-side trim + empty-check on frontend +- Room code normalised to uppercase on join +- `POST /rooms/:code/start`: host-only, requires ≥2 players, transitions to `"playing"` +- Lobby auto-polling every 2s via `setInterval` / `clearInterval` in `useEffect` +- Non-hosts auto-navigate to `/game` when poll detects `status === "playing"` +- Host-only Start Game button (enabled only when `isHost && participants.length >= 2`) +- Fixed starter bug: `/bug` suffix in API base URL removed + +### Feature Group 2 — Game Start & Drawer Flow + +- `drawerId` and `secretWord` set on `startGame()`: drawer = host, word = `STARTER_WORDS[0]` ("rocket") +- `toRoomSnapshot()` made viewer-aware: `secretWord` included only when viewer === drawer +- Game screen shows role label (Drawer / Guesser) and secret word card for drawer only + +### Feature Group 3 — Gameplay Interaction + +- `guesses[]` and `scores` map added to `Room`; initialised on `startGame()` +- `POST /rooms/:code/guess`: trim, empty-check (400), case-insensitive comparison, correct = 100 / incorrect = 0 +- `GET /rooms/:code` returns guesses and scores in snapshot for all viewers +- `DrawingCanvas` component: freehand HTML5 canvas with Clear button (drawer only) +- `GuessForm` wired with validation and `onSubmit` prop +- `Scoreboard` and `ResultPanel` (guess history) rendered from live snapshot +- Game screen polls every 2s to sync history and scores + +### Feature Group 4 — Result, Restart & Final Validation + +- `POST /rooms/:code/end`: host-only, `playing → result` +- `POST /rooms/:code/restart`: host-only, `result → lobby`; clears guesses, scores, drawerId, secretWord; preserves participants +- `secretWord` revealed to all viewers when `status === "result"` (round over) +- `ResultPage`: shows correct word, final scores, full guess history; host-only Restart button; polls every 2s; auto-navigates to `/lobby` on restart +- Game screen poll auto-navigates to `/result` on status change +- Host-only End Round button on game screen +- `/result` route added + +## Tradeoffs and Decisions + +**Polling over WebSockets**: The lab mandated polling (~2s). The approach is simple and sufficient — a fixed `setInterval` with `clearInterval` cleanup on unmount prevents stale background requests. + +**Deterministic word selection**: `STARTER_WORDS[0]` ("rocket") is used for the single round rather than any index formula, keeping the implementation as minimal as the spec requires. + +**Server-side `secretWord` visibility**: Enforced in `toRoomSnapshot()` rather than on the client, so a guesser cannot see the word by inspecting the API response. This is the correct defence layer. + +**Restart preserves participants**: A deliberate design choice per spec — participants are not cleared on restart, only round state (guesses, scores, drawer, word). This allows the same group to play again without rejoining. + +**Canvas is local-only**: Drawing is not synced to guessers (WebSockets are out of scope). The guesser sees a static placeholder. This is explicitly documented in the spec assumptions. + +## AI Usage Notes + +Claude Code generated all spec, plan, task, and implementation artifacts. Each artifact was reviewed for spec alignment and scope before committing. The key human decisions were: + +- Confirming the recommended clarification answers (host navigates immediately; non-hosts via polling; auto-navigate on status change) +- Approving each phase of implementation after visual testing in the browser before committing +- Catching that the Zod error handler needed to surface the field-level message rather than a generic string +- Deciding to reveal `secretWord` to all viewers in `result` status (not just the drawer) — a natural extension of the spec's requirement that "all players see the correct word" + +Every commit is traceable to a specific set of functional requirements in the corresponding spec file. diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..c26ed4a 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,14 @@ import { Router } from "express"; import { createRoomSchema, + guessSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startRoomSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, endGame, getRoom, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -32,7 +34,7 @@ export function createRoomsRouter() { const result = joinRoom(code.toUpperCase(), playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found"); } response.json({ @@ -62,5 +64,55 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startRoomSchema.parse(request.body); + const room = startGame(code.toUpperCase(), participantId); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/end", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startRoomSchema.parse(request.body); + const room = endGame(code.toUpperCase(), participantId); + response.json({ room: toRoomSnapshot(room, participantId) }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startRoomSchema.parse(request.body); + const room = restartGame(code.toUpperCase(), participantId); + response.json({ room: toRoomSnapshot(room, participantId) }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, text } = guessSchema.parse(request.body); + const room = submitGuess(code.toUpperCase(), participantId, text); + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 1270595..032bdda 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -30,7 +30,9 @@ export function errorHandler( _next: NextFunction ) { if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + const zodError = error as { errors?: Array<{ message: string }> }; + const message = zodError.errors?.[0]?.message ?? "Invalid request payload"; + response.status(400).json({ message }); return; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba0..20951ee 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,20 @@ import { z } from "zod"; export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Name cannot be empty") }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: z.string().trim().min(1, "Name cannot be empty") +}); + +export const startRoomSchema = z.object({ + participantId: z.string().min(1) +}); + +export const guessSchema = z.object({ + participantId: z.string().min(1), + text: z.string() }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..f542fe9 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; @@ -7,9 +7,21 @@ export interface Participant { joinedAt: string; } +export interface Guess { + participantId: string; + text: string; + correct: boolean; + submittedAt: string; +} + export interface Room { code: string; status: RoomStatus; + hostId: string; + drawerId?: string; + secretWord?: string; + guesses: Guess[]; + scores: Record; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +30,11 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; + drawerId?: string; + secretWord?: string; + guesses: Guess[]; + scores: Record; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77..031c282 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,162 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, endGame, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { const result = createRoom("Alice"); - expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); expect(result.participantId).toBeDefined(); }); + it("createRoom sets hostId to the first participant id", () => { + const result = createRoom("Alice"); + expect(result.room.hostId).toBe(result.participantId); + }); + it("joinRoom returns null for an unknown room code", () => { - const result = joinRoom("ZZZZ", "Bob"); + expect(joinRoom("ZZZZ", "Bob")).toBeNull(); + }); + + it("startGame sets status to playing", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(startGame(room.code, participantId).status).toBe("playing"); + }); + + it("startGame sets drawerId to hostId", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(startGame(room.code, participantId).drawerId).toBe(participantId); + }); + + it("startGame sets secretWord to 'rocket'", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(startGame(room.code, participantId).secretWord).toBe("rocket"); + }); + + it("startGame initialises scores to 0 for all participants", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + const started = startGame(room.code, participantId); + expect(started.scores[participantId]).toBe(0); + expect(started.scores[join!.participantId]).toBe(0); + }); + + it("startGame initialises guesses to empty array", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(startGame(room.code, participantId).guesses).toEqual([]); + }); + + it("toRoomSnapshot includes secretWord for the drawer", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + const started = startGame(room.code, participantId); + expect(toRoomSnapshot(started, participantId).secretWord).toBe("rocket"); + }); + + it("toRoomSnapshot omits secretWord for a guesser", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + const started = startGame(room.code, participantId); + expect(toRoomSnapshot(started, join!.participantId).secretWord).toBeUndefined(); + }); + + it("submitGuess stores a correct guess and adds 100 to score", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + const updated = submitGuess(room.code, join!.participantId, "rocket"); + expect(updated.guesses).toHaveLength(1); + expect(updated.guesses[0].correct).toBe(true); + expect(updated.scores[join!.participantId]).toBe(100); + }); + + it("submitGuess stores an incorrect guess and adds 0 to score", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + const updated = submitGuess(room.code, join!.participantId, "pizza"); + expect(updated.guesses[0].correct).toBe(false); + expect(updated.scores[join!.participantId]).toBe(0); + }); + + it("submitGuess is case-insensitive", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + const updated = submitGuess(room.code, join!.participantId, "ROCKET"); + expect(updated.guesses[0].correct).toBe(true); + }); + + it("submitGuess throws 400 for empty text", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + expect(() => submitGuess(room.code, join!.participantId, " ")).toThrow("Guess cannot be empty"); + }); + + it("endGame sets status to result", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + expect(endGame(room.code, participantId).status).toBe("result"); + }); + + it("endGame throws 403 for non-host", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + expect(() => endGame(room.code, join!.participantId)).toThrow("Only the host can end the game"); + }); + + it("endGame throws 400 when not in playing state", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(() => endGame(room.code, participantId)).toThrow("Game is not in playing state"); + }); + + it("restartGame resets to lobby and clears round state", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + endGame(room.code, participantId); + const restarted = restartGame(room.code, participantId); + expect(restarted.status).toBe("lobby"); + expect(restarted.guesses).toEqual([]); + expect(restarted.scores).toEqual({}); + expect(restarted.drawerId).toBeUndefined(); + expect(restarted.secretWord).toBeUndefined(); + }); + + it("restartGame preserves participants", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + endGame(room.code, participantId); + const restarted = restartGame(room.code, participantId); + expect(restarted.participants).toHaveLength(2); + }); + + it("restartGame throws 403 for non-host", () => { + const { room, participantId } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + startGame(room.code, participantId); + endGame(room.code, participantId); + expect(() => restartGame(room.code, join!.participantId)).toThrow("Only the host can restart the game"); + }); + + it("startGame throws 403 when non-host tries to start", () => { + const { room } = createRoom("Alice"); + const join = joinRoom(room.code, "Bob"); + expect(() => startGame(room.code, join!.participantId)).toThrow("Only the host can start the game"); + }); - expect(result).toBeNull(); + it("startGame throws 403 when fewer than 2 players are present", () => { + const { room, participantId } = createRoom("Alice"); + expect(() => startGame(room.code, participantId)).toThrow("Need at least 2 players to start"); }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a..b31d0f2 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +import { HttpError } from "../api/schemas.js"; const rooms = new Map(); @@ -11,34 +12,22 @@ function now() { function generateCode() { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let code = ""; - for (let index = 0; index < 4; index += 1) { code += alphabet[Math.floor(Math.random() * alphabet.length)]; } - return code; } function generateUniqueCode() { let code = generateCode(); - while (rooms.has(code)) { code = generateCode(); } - return code; } -function displayName(name?: string) { - return name || "Player"; -} - -function createParticipant(name?: string): Participant { - return { - id: randomUUID(), - name: displayName(name), - joinedAt: now() - }; +function createParticipant(name: string): Participant { + return { id: randomUUID(), name, joinedAt: now() }; } function cloneRoom(room: Room) { @@ -49,40 +38,30 @@ 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", + hostId: participant.id, + guesses: [], + scores: {}, participants: [participant], createdAt: now(), updatedAt: now() }; - rooms.set(room.code, room); - - return { - room: cloneRoom(room), - participantId: participant.id - }; + return { room: cloneRoom(room), participantId: participant.id }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); - - if (!room) { - return null; - } - + if (!room) return null; const participant = createParticipant(playerName); room.participants.push(participant); room.updatedAt = now(); rooms.set(room.code, room); - - return { - room: cloneRoom(room), - participantId: participant.id - }; + return { room: cloneRoom(room), participantId: participant.id }; } export function getRoom(code: string) { @@ -96,14 +75,96 @@ export function saveRoom(room: Room) { return getRoom(room.code); } -export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) throw new HttpError(404, "Unable to load room"); + if (room.hostId !== participantId) throw new HttpError(403, "Only the host can start the game"); + if (room.participants.length < 2) throw new HttpError(403, "Need at least 2 players to start"); + + room.status = "playing"; + room.drawerId = room.hostId; + room.secretWord = STARTER_WORDS[0]; + room.guesses = []; + room.scores = Object.fromEntries(room.participants.map((p) => [p.id, 0])); + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function submitGuess(code: string, participantId: string, text: string) { + const room = rooms.get(code); + + if (!room) throw new HttpError(404, "Unable to load room"); + + const trimmed = text.trim(); + if (!trimmed) throw new HttpError(400, "Guess cannot be empty"); - return { + const correct = trimmed.toLowerCase() === (room.secretWord ?? "").toLowerCase(); + const guess: Guess = { participantId, text: trimmed, correct, submittedAt: now() }; + + room.guesses.push(guess); + if (room.scores[participantId] === undefined) room.scores[participantId] = 0; + room.scores[participantId] += correct ? 100 : 0; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function endGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) throw new HttpError(404, "Unable to load room"); + if (room.hostId !== participantId) throw new HttpError(403, "Only the host can end the game"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in playing state"); + + room.status = "result"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) throw new HttpError(404, "Unable to load room"); + if (room.hostId !== participantId) throw new HttpError(403, "Only the host can restart the game"); + if (room.status !== "result") throw new HttpError(400, "Game is not in result state"); + + room.status = "lobby"; + room.guesses = []; + room.scores = {}; + room.drawerId = undefined; + room.secretWord = undefined; + room.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} + +export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, - participants: room.participants.map((participant) => ({ ...participant })), + hostId: room.hostId, + drawerId: room.drawerId, + guesses: room.guesses.map((g) => ({ ...g })), + scores: { ...room.scores }, + participants: room.participants.map((p) => ({ ...p })), availableWords: listWords(), roles: [...STARTER_ROLES] }; + + // During playing: only the drawer sees the secret word + // During result: everyone sees the correct word (round is over) + if (room.secretWord) { + if (room.status === "result" || viewerParticipantId === room.drawerId) { + 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 0000000..aa24ad9 --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; + +export function DrawingCanvas() { + const canvasRef = useRef(null); + const isDrawing = useRef(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "#1e293b"; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + }, []); + + function getPos(e: React.MouseEvent) { + const rect = canvasRef.current!.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function handleMouseDown(e: React.MouseEvent) { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + isDrawing.current = true; + const { x, y } = getPos(e); + ctx.beginPath(); + ctx.moveTo(x, y); + } + + function handleMouseMove(e: React.MouseEvent) { + if (!isDrawing.current) return; + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const { x, y } = getPos(e); + ctx.lineTo(x, y); + ctx.stroke(); + } + + function handleMouseUp() { + isDrawing.current = false; + } + + function handleClear() { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec47..32af5bf 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,24 @@ import { useState } from "react"; interface GuessFormProps { + onSubmit: (text: string) => Promise; disabled?: boolean; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ onSubmit, disabled = false }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmed = guessText.trim(); + if (!trimmed) { + setError("Guess cannot be empty"); + return; + } + setError(null); + await onSubmit(trimmed); + setGuessText(""); } return ( @@ -22,6 +32,7 @@ export function GuessForm({ disabled = false }: GuessFormProps) { disabled={disabled} /> + {error ?

{error}

: null}
+ ) : null} diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f530..fa7ae43 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,21 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + if (!trimmedName) { + setError("Name cannot be empty"); + return; + } + + const trimmedCode = roomCode.trim().toUpperCase(); + if (!trimmedCode) { + setError("Room code cannot be empty"); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(trimmedCode, trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd2..fc9bb26 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,8 +8,8 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); + const { room, participantId, isLoading } = useRoomState(); + const [startError, setStartError] = useState(null); useEffect(() => { if (!room) { @@ -17,12 +17,30 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { + useEffect(() => { + if (!room) return; + + const interval = setInterval(async () => { + try { + const updated = await roomStore.fetchRoom(); + if (updated?.status === "playing") { + navigate("/game"); + } + } catch { + // poll errors are non-fatal; retain last known state + } + }, 2000); + + return () => clearInterval(interval); + }, [room?.code]); + + async function handleStartGame() { try { - setRefreshError(null); - await roomStore.fetchRoom(); + setStartError(null); + await roomStore.startRoom(); + navigate("/game"); } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + setStartError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); } } @@ -30,6 +48,9 @@ export function LobbyPage() { return null; } + const isHost = room.hostId === participantId; + const canStart = isHost && room.participants.length >= 2; + return (
@@ -49,7 +70,10 @@ export function LobbyPage() {
    {room.participants.map((participant) => (
  • - {participant.name} + + {participant.name} + {participant.id === room.hostId ? " (host)" : ""} + joined
  • ))} @@ -58,20 +82,34 @@ export function LobbyPage() { -

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

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

    -

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

    + {isHost ? ( +

    + {room.participants.length < 2 + ? "Waiting for more players to join before you can start." + : "You can start the game when ready."} +

    + ) : ( +

    Waiting for host to start the game.

    + )} + {startError ?

    {startError}

    : null}
- - + {isHost ? ( + + ) : ( +

Waiting for host to start the game.

+ )}
); diff --git a/frontend/src/pages/ResultPage.tsx b/frontend/src/pages/ResultPage.tsx new file mode 100644 index 0000000..5e2e256 --- /dev/null +++ b/frontend/src/pages/ResultPage.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Card } from "../components/Card"; +import { PageHeader } from "../components/PageHeader"; +import { ResultPanel } from "../components/ResultPanel"; +import { RoomCodeBadge } from "../components/RoomCodeBadge"; +import { Scoreboard } from "../components/Scoreboard"; +import { useRoomState, useRoomStore } from "../state/roomStore"; + +export function ResultPage() { + const navigate = useNavigate(); + const roomStore = useRoomStore(); + const { room, participantId, isLoading } = useRoomState(); + const [restartError, setRestartError] = useState(null); + + useEffect(() => { + if (!room) { + navigate("/", { replace: true }); + } + }, [navigate, room]); + + useEffect(() => { + if (!room) return; + const interval = setInterval(async () => { + try { + const updated = await roomStore.fetchRoom(); + if (updated?.status === "lobby") { + navigate("/lobby"); + } + } catch { + // non-fatal poll error + } + }, 2000); + return () => clearInterval(interval); + }, [room?.code]); + + if (!room) return null; + + const isHost = room.hostId === participantId; + + async function handleRestart() { + try { + setRestartError(null); + await roomStore.restartRoom(); + navigate("/lobby"); + } catch (err) { + setRestartError(err instanceof Error ? err.message : "Unable to restart"); + } + } + + return ( +
+
+ + +
+ + {room.secretWord ? ( + +

+ {room.secretWord} +

+
+ ) : null} + +
+ + +
+ + {restartError ?

{restartError}

: null} + +
+ {isHost ? ( + + ) : ( +

Waiting for host to restart...

+ )} +
+
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 1d15a3f..59c4c4c 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -4,6 +4,7 @@ import { CreateRoomPage } from "../pages/CreateRoomPage"; import { GamePage } from "../pages/GamePage"; import { JoinRoomPage } from "../pages/JoinRoomPage"; import { LobbyPage } from "../pages/LobbyPage"; +import { ResultPage } from "../pages/ResultPage"; import { StartPage } from "../pages/StartPage"; export function AppRoutes() { @@ -16,6 +17,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..f336b19 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -6,9 +6,21 @@ export interface Participant { joinedAt: string; } +export interface Guess { + participantId: string; + text: string; + correct: boolean; + submittedAt: string; +} + export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing" | "result"; + hostId: string; + drawerId?: string; + secretWord?: string; + guesses: Guess[]; + scores: Record; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -19,7 +31,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}`, { @@ -57,5 +69,29 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, text }) + }); + }, + endRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/end`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + restartRoom(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 aefd373..8ce201c 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,43 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startRoom() { + if (!this.state.room || !this.state.participantId) { + return null; + } + + const response = await this.withLoading(() => + api.startRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) return null; + const response = await api.submitGuess(this.state.room.code, this.state.participantId, text); + this.setRoomSnapshot(response.room); + return response.room; + } + + async endRoom() { + if (!this.state.room || !this.state.participantId) return null; + const response = await this.withLoading(() => + api.endRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async restartRoom() { + if (!this.state.room || !this.state.participantId) return null; + const response = await this.withLoading(() => + api.restartRoom(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } } const RoomStoreContext = createContext(null); diff --git a/specs/001-room-setup-lobby/checklists/requirements.md b/specs/001-room-setup-lobby/checklists/requirements.md new file mode 100644 index 0000000..072a9dc --- /dev/null +++ b/specs/001-room-setup-lobby/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Room Setup & Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Spec is ready for `/speckit-clarify` or `/speckit-plan`. diff --git a/specs/001-room-setup-lobby/contracts/start-room.md b/specs/001-room-setup-lobby/contracts/start-room.md new file mode 100644 index 0000000..5433867 --- /dev/null +++ b/specs/001-room-setup-lobby/contracts/start-room.md @@ -0,0 +1,63 @@ +# Contract: POST /rooms/:code/start + +## Purpose + +Transitions a room from `lobby` to `playing`. Only callable by the host when +≥2 participants are present. + +## Request + +``` +POST /rooms/:code/start +Content-Type: application/json + +Path params: + code — 4-char uppercase room code + +Body: + { "participantId": "" } +``` + +## Responses + +### 200 OK — game started + +```json +{ + "room": { + "code": "ABCD", + "status": "playing", + "hostId": "", + "participants": [ + { "id": "", "name": "Alice", "joinedAt": "" }, + { "id": "", "name": "Bob", "joinedAt": "" } + ], + "availableWords": ["rocket","pizza","castle","guitar","sunflower"], + "roles": ["drawer","guesser"] + } +} +``` + +### 403 Forbidden — not the host + +```json +{ "message": "Only the host can start the game" } +``` + +### 403 Forbidden — not enough players + +```json +{ "message": "Need at least 2 players to start" } +``` + +### 404 Not Found — room does not exist + +```json +{ "message": "Unable to load room" } +``` + +## Side Effects + +- `room.status` is set to `"playing"` in the in-memory store +- `room.updatedAt` is updated to current timestamp +- No other fields are mutated by this endpoint diff --git a/specs/001-room-setup-lobby/data-model.md b/specs/001-room-setup-lobby/data-model.md new file mode 100644 index 0000000..c9c33f5 --- /dev/null +++ b/specs/001-room-setup-lobby/data-model.md @@ -0,0 +1,44 @@ +# Data Model: Room Setup & Lobby + +## Room + +| Field | Type | Description | +|-------|------|-------------| +| code | string (4-char uppercase) | Unique room identifier | +| status | "lobby" \| "playing" \| "result" | Lifecycle state | +| hostId | string (UUID) | participant.id of the room creator; set on createRoom, never changes | +| participants | Participant[] | Ordered array; index 0 is always the host | +| createdAt | ISO8601 string | Set once at creation | +| updatedAt | ISO8601 string | Updated on any mutation | + +**State transitions**: +``` +lobby → playing (via POST /rooms/:code/start, host only, ≥2 participants) +playing → result (Group 4 — not in scope for this group) +result → lobby (Group 4 — not in scope for this group) +``` + +## Participant + +| Field | Type | Description | +|-------|------|-------------| +| id | string (UUID) | Stable identity for the session | +| name | string (trimmed, min 1 char) | Display name | +| joinedAt | ISO8601 string | When they joined | + +## RoomSnapshot (API response shape) + +| Field | Type | Notes | +|-------|------|-------| +| code | string | Room code | +| status | "lobby" \| "playing" \| "result" | Current state | +| hostId | string | Identifies the host for frontend role detection | +| participants | Participant[] | Full list | +| availableWords | string[] | Seed word list (unchanged for this group) | +| roles | ParticipantRole[] | Seed roles (unchanged for this group) | + +## Validation Rules + +- `playerName`: required; trimmed before check; min 1 char after trim; max not enforced +- `code` (join): required; trimmed; normalised to uppercase; must match existing room +- `participantId` (start): required in body; must equal room.hostId diff --git a/specs/001-room-setup-lobby/plan.md b/specs/001-room-setup-lobby/plan.md new file mode 100644 index 0000000..f67a68d --- /dev/null +++ b/specs/001-room-setup-lobby/plan.md @@ -0,0 +1,266 @@ +# Implementation Plan: Room Setup & Lobby + +**Branch**: `assignment` | **Date**: 2026-05-28 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-room-setup-lobby/spec.md` + +--- + +## Summary + +Add host tracking to room creation, validate player names and room codes, replace +the manual refresh button with automatic ~2s polling, and add a host-only Start +Game endpoint that gates on ≥2 participants. Non-hosts auto-navigate when polling +detects `status === "playing"`. + +--- + +## Technical Context + +**Language/Version**: TypeScript 5 (backend: Node 18 + Express; frontend: React 18 + Vite) + +**Primary Dependencies**: Express, Zod (validation), React hooks (`useEffect` for polling) + +**Storage**: In-memory `Map` in `backend/src/services/roomStore.ts` + +**Testing**: Vitest — extend existing `roomStore.test.ts` and `schemas.test.ts` + +**Target Platform**: localhost dev server (backend :3001, frontend :5173) + +**Project Type**: Brownfield web application enhancement + +**Performance Goals**: Polling latency ≤ 2s; no other performance targets + +**Constraints**: No WebSockets, no DB, no auth. In-memory only. Polling cadence ~2s fixed. + +**Scale/Scope**: 2–6 players per room; single active round per room + +--- + +## Constitution Check + +*GATE: Must pass before implementation begins. Re-checked after design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | Extending existing models/routes; no rewrites | +| II. Spec-Driven | ✅ Pass | Every change traces to FR-001–FR-011 in spec | +| III. Deterministic Rules | ✅ Pass | No game rules in this group; host = first participant | +| IV. Strict Scope | ✅ Pass | No WebSockets, DB, auth, or new npm dependencies | +| V. Incremental Validation | ✅ Pass | Checkpoint: 2-tab lobby test before starting Group 2 | +| VI. AI-Assisted, Human-Reviewed | ✅ Pass | All output reviewed before commit | + +--- + +## Research Findings + +### Host Identification + +**Decision**: Store `hostId` as the `id` of the first participant added during +`createRoom`. Set once, never changes. + +**Rationale**: Simplest possible approach — one extra field on the existing Room +model. No new concept required. + +**Alternatives considered**: Separate `isHost` boolean on Participant — rejected +(more fields, same outcome, more mutation surface). + +### Polling Strategy + +**Decision**: `setInterval` (2000ms) inside a `useEffect` in `LobbyPage`, with +`clearInterval` in the cleanup return. + +**Rationale**: Matches starter hook patterns, zero new dependencies, cleanup on +unmount automatically stops background requests. + +**Alternatives considered**: Recursive `setTimeout` — unnecessary complexity for +a fixed interval. + +### Start Game Endpoint + +**Decision**: New `POST /rooms/:code/start` accepting `participantId` in the +request body. Validates host identity and ≥2 participants, sets +`room.status = "playing"`, returns updated `RoomSnapshot`. + +**Rationale**: Follows existing `POST /rooms` and `POST /rooms/:code/join` +patterns exactly. + +**Alternatives considered**: Query-param `participantId` on POST — rejected +(non-standard for state-mutating operations). + +### Name Validation + +**Decision**: Trim + empty-check on frontend (before API call, inline error +message) and in Zod schema on backend (400 on empty after trim). + +**Rationale**: Frontend avoids unnecessary round-trips; backend guards against +direct API calls. + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-room-setup-lobby/ +├── plan.md ← this file +├── spec.md +├── research.md ← findings above (condensed inline) +├── data-model.md +├── contracts/ +│ └── start-room.md +└── checklists/ + └── requirements.md +``` + +### Source Code + +```text +backend/ +├── src/ +│ ├── models/game.ts ← add hostId to Room + RoomSnapshot; expand status type +│ ├── services/roomStore.ts ← set hostId in createRoom(); add startGame() +│ ├── api/ +│ │ ├── rooms.ts ← add POST /:code/start handler +│ │ └── schemas.ts ← add startRoomSchema; tighten name validation (trim+min) +│ └── services/roomStore.test.ts ← extend: hostId set, startGame gates +│ +frontend/ +├── src/ +│ ├── services/api.ts ← add hostId to RoomSnapshot; add startRoom() +│ ├── state/roomStore.ts ← add startRoom() action +│ ├── pages/LobbyPage.tsx ← replace manual refresh with polling; host-only start; auto-nav +│ ├── pages/CreateRoomPage.tsx ← add name trim + empty-check +│ └── pages/JoinRoomPage.tsx ← add name trim + empty-check; code empty-check +``` + +**Structure Decision**: Web application (Option 2) — existing `backend/` + `frontend/` layout preserved. + +--- + +## Data Model Changes + +### Backend `Room` (game.ts) + +``` +Before: code, status: "lobby", participants[], createdAt, updatedAt +After: code, status: "lobby" | "playing" | "result", hostId, participants[], createdAt, updatedAt +``` + +### Backend `RoomSnapshot` (game.ts) + +``` +Before: code, status, participants[], availableWords, roles +After: code, status, hostId, participants[], availableWords, roles +``` + +### Frontend `RoomSnapshot` (api.ts) + +``` +Before: code, status: "lobby", participants[], availableWords, roles +After: code, status: "lobby" | "playing" | "result", hostId, participants[], availableWords, roles +``` + +--- + +## API Contracts + +### Updated responses (existing endpoints) + +`POST /rooms` — `room` in response now includes `hostId` + +`GET /rooms/:code` — `room` in response now includes `hostId` + +### New endpoint + +``` +POST /rooms/:code/start +Body: { "participantId": "" } + +200 OK: + { "room": { "code": "ABCD", "status": "playing", "hostId": "...", "participants": [...], ... } } + +403 Forbidden: + { "message": "Only the host can start the game" } + { "message": "Need at least 2 players to start" } + +404 Not Found: + { "message": "Unable to load room" } +``` + +--- + +## Data Flow + +### Create Room (updated) + +``` +CreateRoomPage validates name (trim, non-empty) + → POST /rooms { playerName } + → roomStore.createRoom() sets hostId = participant.id + → returns { participantId, room: { ...hostId } } + → RoomStore.setRoomSession(); navigate("/lobby") +``` + +### Join Room (updated) + +``` +JoinRoomPage validates name (trim, non-empty) + code (trim, non-empty, uppercase) + → POST /rooms/:code/join { playerName } + → returns { participantId, room: { ...hostId } } + → RoomStore.setRoomSession(); navigate("/lobby") +``` + +### Lobby Polling (new) + +``` +LobbyPage mounts + → useEffect: setInterval(2000, fetchRoom) + → each tick: GET /rooms/:code?participantId= + → roomStore.setRoomSnapshot(room) + → if room.status === "playing": navigate("/game") + → cleanup: clearInterval on unmount / navigation +``` + +### Start Game (new) + +``` +Host: Start Game button enabled when isHost && participants.length >= 2 + → POST /rooms/:code/start { participantId } + → server: hostId === participantId && length >= 2 → status = "playing" + → host navigates immediately to "/game" + → non-hosts: next poll detects status "playing" → auto-navigate to "/game" +``` + +--- + +## Implementation Sequence + +1. Backend: extend `Room` + `RoomSnapshot` types with `hostId` and updated `status` +2. Backend: `createRoom()` sets `hostId`; `toRoomSnapshot()` includes it +3. Backend: Zod schemas — trim + `min(1)` on `playerName`; add `startRoomSchema` +4. Backend: `startGame()` in roomStore; `POST /:code/start` route in rooms.ts +5. Frontend: update `RoomSnapshot` type; add `startRoom()` to api.ts +6. Frontend: add `startRoom()` action to RoomStore +7. Frontend: name validation in `CreateRoomPage` + `JoinRoomPage` +8. Frontend: `LobbyPage` — polling with auto-nav; host-only Start Game button + +--- + +## Testing Strategy + +- Extend `roomStore.test.ts`: `hostId` set on `createRoom`; `startGame` 403 for non-host; `startGame` 403 for <2 players; `startGame` success sets status to "playing". +- Manual two-tab validation against all acceptance criteria in spec (SC-001–SC-005). +- No new test files — extend existing only. + +--- + +## Risks + +| Risk | Mitigation | +|------|-----------| +| Polling fires after component unmounts | `clearInterval` in `useEffect` cleanup | +| Non-host calls `/start` directly | Server-side 403: `hostId !== participantId` | +| Name whitespace passes validation | Both frontend trim-check and Zod `min(1)` after trim | +| Old frontend code breaks on missing `hostId` | TypeScript strict mode catches missing field at compile time | diff --git a/specs/001-room-setup-lobby/spec.md b/specs/001-room-setup-lobby/spec.md new file mode 100644 index 0000000..94bb5e9 --- /dev/null +++ b/specs/001-room-setup-lobby/spec.md @@ -0,0 +1,248 @@ +# Feature Specification: Room Setup & Lobby + +**Feature Branch**: `assignment` + +**Created**: 2026-05-28 + +**Status**: Draft + +**Feature Directory**: `specs/001-room-setup-lobby` + +--- + +## Overview + +Enable players to create or join a drawing-game room using a unique code, with +the creator automatically designated as host. The lobby refreshes automatically +via polling so all participants see an up-to-date player list, and only the host +can start the game once at least two players are present. + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Create a Room as Host (Priority: P1) + +A player opens the app, enters their name, and creates a room. They are +immediately recognised as the host and land on the lobby with the room code +displayed prominently so they can share it. + +**Why this priority**: All other scenarios depend on a room existing and a host +being identified. Nothing else works without this foundation. + +**Independent Test**: Open one browser tab, create a room with a valid name, and +confirm the lobby shows the room code, the player's name, and a disabled or +unavailable "Start Game" button (only one player present). + +**Acceptance Scenarios**: + +1. **Given** a player is on the Start screen, **When** they enter a non-empty + name (after trimming) and click Create Room, **Then** a room is created, the + player is recorded as the host (first participant), and the app navigates to + the Lobby showing the room code. + +2. **Given** a player tries to create a room with an empty or whitespace-only + name, **When** they submit the form, **Then** the form displays the error + message "Name cannot be empty" and no room is created. + +3. **Given** a room is created, **When** any GET /rooms/:code request is made, + **Then** the response includes a `hostId` field identifying the creator. + +--- + +### User Story 2 — Join a Room as Participant (Priority: P1) + +A second player opens the app, enters their name and the room code they received +from the host, and joins the existing room. Both the host and joiner now see each +other in the lobby. + +**Why this priority**: Multi-player gameplay requires at least two participants; +join flow is equally foundational to room creation. + +**Independent Test**: Tab 1 creates a room; Tab 2 joins using the code. After +the lobby auto-refreshes, both tabs show two participants in the list. + +**Acceptance Scenarios**: + +1. **Given** a valid room code and a non-empty player name, **When** the player + submits the join form, **Then** they are added to the room and the lobby + displays their name alongside existing participants. + +2. **Given** a player enters an empty or whitespace-only name, **When** they + submit the join form, **Then** the form displays "Name cannot be empty" and + the player is not added to any room. + +3. **Given** a player enters a room code that does not exist, **When** they + submit the join form, **Then** the app displays "Room not found" and remains + on the join screen. + +4. **Given** a player enters an empty room code, **When** they submit the join + form, **Then** the app displays "Room code cannot be empty" and does not + attempt a network request. + +5. **Given** two separate rooms exist (Room A and Room B), **When** a player + joins Room B, **Then** Room A's participant list is unaffected (rooms are + fully isolated). + +--- + +### User Story 3 — Lobby Auto-Polling (Priority: P2) + +The lobby page polls the backend automatically every ~2 seconds so that when a +new player joins, all participants see the updated list without manually clicking +Refresh. + +**Why this priority**: Without polling the lobby feels broken — players cannot +see each other join. Required before the host can make a meaningful start +decision. + +**Independent Test**: Tab 1 is on the Lobby. Tab 2 joins the room. Within +3 seconds Tab 1's participant list updates without any manual interaction. + +**Acceptance Scenarios**: + +1. **Given** a player is on the Lobby screen, **When** another player joins the + room, **Then** the first player's lobby participant list updates automatically + within approximately 2 seconds without any manual action. + +2. **Given** the lobby is polling, **When** the player navigates away from the + Lobby, **Then** polling stops and no further background requests are made. + +3. **Given** a poll request fails due to a network error, **When** the next + interval fires, **Then** polling continues (errors are not fatal) and the + displayed list retains its last known state. + +4. **Given** the lobby is polling and the room status changes to "playing", + **When** a non-host participant's poll detects this change, **Then** they are + automatically navigated to the game screen without any manual action. + +--- + +### User Story 4 — Host-Only Start Game (Priority: P2) + +The Start Game button is only available to the host and only when at least two +players are present. Non-host participants see a "Waiting for host to start" +message instead of the button. + +**Why this priority**: Prevents a lone player or a non-host from starting a +game prematurely, which would break drawer assignment and the game flow. + +**Independent Test**: Tab 1 (host) and Tab 2 (joiner) are both in the lobby. +Only Tab 1 shows an enabled Start Game button. Clicking it on Tab 1 navigates +the host to the game screen. + +**Acceptance Scenarios**: + +1. **Given** the host is in the lobby with fewer than 2 participants total, + **When** the host views the lobby, **Then** the Start Game button is disabled + with a label or tooltip indicating more players are needed. + +2. **Given** the host is in the lobby with 2 or more participants, **When** the + host views the lobby, **Then** the Start Game button is enabled. + +3. **Given** a non-host participant is in the lobby, **When** they view the + lobby, **Then** they do not see an enabled Start Game button; they see a + "Waiting for host to start" message instead. + +4. **Given** the host clicks the enabled Start Game button, **When** the action + completes, **Then** the host navigates immediately to the game screen (POST + /rooms/:code/start succeeds and returns the updated room in "playing" status); + non-host participants detect the status change via their next poll and + auto-navigate to the game screen without any manual action. + +--- + +### Edge Cases + +- Empty room code (whitespace only) on join: rejected client-side before any + network request, message "Room code cannot be empty". +- Room code with mixed case (e.g. "abcd"): normalised to uppercase before lookup. +- Player name with leading/trailing whitespace: trimmed before submission; if + result is empty, rejected with "Name cannot be empty". +- Polling while offline: errors are swallowed silently; list retains last known + state; polling resumes when connectivity returns. +- Start Game while only 1 player present: button disabled; no API call made. +- Non-host attempting to start: Start Game button not rendered for non-hosts; + even if API is called directly, server rejects with 403. + +--- + +## Clarifications + +### Session 2026-05-28 + +- Q: When host clicks Start Game, do non-hosts navigate immediately or via polling? → A: Host navigates immediately; non-hosts detect the `status === "playing"` transition via their next poll (~2s lag) and auto-navigate then. +- Q: When poll detects room status is "playing", should non-hosts auto-navigate or wait for manual action? → A: Auto-navigate to the game screen when poll detects `status === "playing"`. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST record the first participant of a room as the host + (`hostId` on the Room model). +- **FR-002**: The `POST /rooms` endpoint MUST return `hostId` in the room + snapshot response. +- **FR-003**: The `GET /rooms/:code` endpoint MUST return `hostId` in the room + snapshot so the frontend can determine whether the current viewer is the host. +- **FR-004**: Player names MUST be trimmed of leading/trailing whitespace before + any storage or comparison; empty/whitespace-only names MUST be rejected with a + human-readable error message. +- **FR-005**: Room codes MUST be normalised to uppercase before lookup on join. +- **FR-006**: The lobby MUST poll `GET /rooms/:code` approximately every 2 seconds + and update the participant list without a full page reload. +- **FR-007**: Polling MUST stop when the user navigates away from the Lobby. +- **FR-008**: Only the host MAY trigger game start; the `POST /rooms/:code/start` + endpoint MUST validate that the requesting participant is the host and that at + least 2 participants are present, returning 403 otherwise. +- **FR-009**: The Start Game button MUST be visible and enabled only to the host + when ≥2 participants are present. +- **FR-010**: Rooms MUST be fully isolated; joining or modifying one room MUST + NOT affect any other room. +- **FR-011**: An invalid or non-existent room code on join MUST return a 404 and + display "Room not found" to the user. + +### Key Entities + +- **Room**: code (4-char uppercase), status ("lobby" | "playing" | "result"), + hostId (participant id of creator), participants (array), createdAt, updatedAt. +- **Participant**: id (UUID), name (trimmed string), joinedAt. +- **RoomSnapshot** (API response): code, status, hostId, participants, + availableWords, roles — returned to all viewers; used by frontend to derive + host status. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: A player can create a room, share the code, and a second player can + join — both see each other in the lobby within 3 seconds of joining, without + any manual refresh action. +- **SC-002**: All name and code validation errors present a human-readable + message; no silent failures or generic "error" messages are shown. +- **SC-003**: The Start Game button appears only for the host and only when ≥2 + players are in the lobby; non-hosts see a waiting message. +- **SC-004**: Two separate rooms with players in each remain completely isolated — + joining Room B does not change Room A's participant list. +- **SC-005**: Navigating away from the lobby stops all background polling — no + lingering network requests after leaving the page. + +--- + +## Assumptions + +- A single room supports a small number of players (2–6); no capacity limits are + enforced for this lab. +- The host is always the first participant; there is no host-transfer mechanism. +- Room codes are 4 uppercase alphanumeric characters as generated by the starter. +- The `participantId` returned at room creation/join is stored in frontend state + for the lifetime of the browser session; there is no persistence across page + reloads. +- The polling interval of ~2 seconds is implemented as a fixed `setInterval`; no + back-off or jitter is required. +- The `POST /rooms/:code/start` endpoint is new — it does not exist in the + starter and must be added. +- "Host-only" enforcement on the server uses the `participantId` passed as a + query parameter (consistent with existing `GET /rooms/:code` pattern); no + session or token auth is required. diff --git a/specs/001-room-setup-lobby/tasks.md b/specs/001-room-setup-lobby/tasks.md new file mode 100644 index 0000000..1d0035f --- /dev/null +++ b/specs/001-room-setup-lobby/tasks.md @@ -0,0 +1,144 @@ +--- +description: "Task list for Feature Group 1 — Room Setup & Lobby" +--- + +# Tasks: Room Setup & Lobby + +**Input**: Design documents from `specs/001-room-setup-lobby/` + +**Prerequisites**: plan.md ✅ spec.md ✅ data-model.md ✅ contracts/ ✅ + +**Tests**: Extend existing test files only (no new test files). Test tasks included where existing files have coverage gaps. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Extend existing type definitions that all stories depend on. + +- [ ] T001 Extend `Room` model in `backend/src/models/game.ts` — add `hostId: string` field and expand `RoomStatus` to `"lobby" | "playing" | "result"` +- [ ] T002 Extend `RoomSnapshot` interface in `backend/src/models/game.ts` — add `hostId: string` field +- [ ] T003 Update `RoomSnapshot` type in `frontend/src/services/api.ts` — add `hostId: string` and expand `status` union to `"lobby" | "playing" | "result"` + +**Checkpoint**: TypeScript compiles (`npm run build`) in both `backend/` and `frontend/` with the new types. + +--- + +## Phase 2: Foundational (Backend Core — blocks all stories) + +**Purpose**: Backend store and validation changes that all user stories depend on. + +- [ ] T004 Update `createRoom()` in `backend/src/services/roomStore.ts` — set `room.hostId = participant.id` before storing +- [ ] T005 Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` — include `hostId` in the returned snapshot +- [ ] T006 [P] Tighten `createRoomSchema` in `backend/src/api/schemas.ts` — `playerName`: `z.string().trim().min(1, "Name cannot be empty")` +- [ ] T007 [P] Tighten `joinRoomSchema` in `backend/src/api/schemas.ts` — same trim + min(1) rule +- [ ] T008 Add `startGame()` to `backend/src/services/roomStore.ts` — validates `hostId === participantId` (403) and `participants.length >= 2` (403), sets `status = "playing"`, returns updated room +- [ ] T009 Add `POST /:code/start` route in `backend/src/api/rooms.ts` — parse `startRoomSchema` from body, call `startGame()`, return `{ room: toRoomSnapshot(...) }`; propagate `HttpError` for 403/404 +- [ ] T010 Add `startRoomSchema` in `backend/src/api/schemas.ts` — `z.object({ participantId: z.string().min(1) })` +- [ ] T011 Extend `backend/src/services/roomStore.test.ts` — add tests: `hostId` is set on `createRoom`; `startGame` returns 403 for non-host; `startGame` returns 403 for <2 players; `startGame` sets status to "playing" and returns snapshot + +**Checkpoint**: `npm run test` in `backend/` passes. `POST /rooms` and `GET /rooms/:code` responses include `hostId`. + +--- + +## Phase 3: User Story 1 — Create a Room as Host (P1) + +**Goal**: Host tracking visible on room creation; name validation enforced. + +**Independent Test**: Create a room with a valid name — lobby shows room code and player name. Try empty name — inline error shown, no room created. Verify response body includes `hostId`. + +- [ ] T012 [US1] Add client-side name validation in `frontend/src/pages/CreateRoomPage.tsx` — trim name, show inline error "Name cannot be empty" if empty, prevent API call; no other changes to existing form logic +- [ ] T013 [US1] Add `startRoom()` to `frontend/src/services/api.ts` — `POST /rooms/:code/start` with `{ participantId }`, returns `{ room: RoomSnapshot }` + +**Checkpoint**: Creating a room with a valid name works; empty name shows error. Network response includes `hostId`. + +--- + +## Phase 4: User Story 2 — Join a Room as Participant (P1) + +**Goal**: Join validation enforced for name and code; room isolation verified. + +**Independent Test**: Join with valid name + valid code — success. Join with empty name — error "Name cannot be empty". Join with empty code — error "Room code cannot be empty" (no network call). Join with non-existent code — error "Room not found". + +- [ ] T014 [US2] Add client-side validation in `frontend/src/pages/JoinRoomPage.tsx` — trim name (error "Name cannot be empty" if empty); trim code (error "Room code cannot be empty" if empty, no API call); no other changes to existing join logic +- [ ] T015 [US2] Normalise code to uppercase in `frontend/src/pages/JoinRoomPage.tsx` before passing to `api.joinRoom()` (already uppercase-normalised on backend; belt-and-suspenders on frontend) + +**Checkpoint**: All four join validation scenarios in spec pass in the browser. + +--- + +## Phase 5: User Story 3 — Lobby Auto-Polling (P2) + +**Goal**: Lobby refreshes participant list automatically every ~2 seconds without manual action. Non-hosts auto-navigate when status becomes "playing". + +**Independent Test**: Tab 1 on Lobby. Tab 2 joins. Within 3 seconds Tab 1 shows both players — no manual click. Navigate Tab 1 away, confirm no further requests in DevTools Network tab. + +- [ ] T016 [US3] Replace manual refresh with `useEffect` polling in `frontend/src/pages/LobbyPage.tsx` — `setInterval(fetchRoom, 2000)` on mount; `clearInterval` in cleanup; remove or keep the manual Refresh button as secondary action +- [ ] T017 [US3] Add auto-navigate to game in `frontend/src/pages/LobbyPage.tsx` — inside the poll callback, if `room.status === "playing"` call `navigate("/game")` +- [ ] T018 [US3] Add `startRoom()` action to `frontend/src/state/roomStore.ts` — calls `api.startRoom(code, participantId)`, calls `setRoomSnapshot(room)` on success + +**Checkpoint**: Auto-polling works in two tabs; navigating away stops polling (verified via Network DevTools). + +--- + +## Phase 6: User Story 4 — Host-Only Start Game (P2) + +**Goal**: Start Game button visible/enabled only to host with ≥2 players; non-hosts see waiting message. + +**Independent Test**: Tab 1 (host, 2 players present) — enabled Start Game button. Tab 2 (non-host) — no Start Game button, sees "Waiting for host to start". Tab 1 clicks Start Game — Tab 1 navigates to `/game`; Tab 2 auto-navigates via next poll. + +- [ ] T019 [US4] Update `frontend/src/pages/LobbyPage.tsx` — derive `isHost = room.hostId === participantId`; render Start Game button (enabled) only when `isHost && participants.length >= 2`; render disabled button when `isHost && participants.length < 2`; render "Waiting for host to start" for non-hosts +- [ ] T020 [US4] Wire Start Game click in `frontend/src/pages/LobbyPage.tsx` — on click call `roomStore.startRoom(room.code, participantId)`; on success `navigate("/game")`; show inline error on failure + +**Checkpoint**: Host/non-host button rendering correct in two tabs. Host can start, both tabs end up on `/game`. + +--- + +## Phase 7: Polish & Cross-Cutting + +- [ ] T021 [P] Run `npm run build` in `backend/` — confirm zero TypeScript errors +- [ ] T022 [P] Run `npm run build` in `frontend/` — confirm zero TypeScript errors +- [ ] T023 Two-tab manual validation against SC-001 through SC-005 in spec — document any deviations + +--- + +## Dependencies & Execution Order + +- **Phase 1** (T001–T003): No dependencies — start immediately +- **Phase 2** (T004–T011): Depends on Phase 1 (types must exist before store/route changes) +- **Phase 3** (T012–T013): Depends on Phase 2 (backend must return `hostId`) +- **Phase 4** (T014–T015): Depends on Phase 2; can run in parallel with Phase 3 +- **Phase 5** (T016–T018): Depends on Phase 2 + T013 (needs `startRoom` in api.ts) +- **Phase 6** (T019–T020): Depends on Phase 5 (polling must exist before start-game wiring) +- **Phase 7** (T021–T023): Depends on all prior phases + +### Within-Phase Parallel Opportunities + +- T006 and T007 can run in parallel (different schemas, same file — coordinate) +- T012 and T014 can run in parallel (different page files) +- T021 and T022 can run in parallel (different directories) + +--- + +## Implementation Strategy + +### MVP (P1 Stories Only) + +1. Complete Phase 1 (types) +2. Complete Phase 2 (backend) +3. Complete Phase 3 (create room validation) +4. Complete Phase 4 (join room validation) +5. **Validate**: One tab creates, one tab joins — both see each other in lobby + +### Full Delivery (all stories) + +Continue with Phase 5 (polling) → Phase 6 (start game) → Phase 7 (build + validate) + +--- + +## Notes + +- `[P]` = parallelisable (different files, no incomplete dependencies) +- `[USn]` maps each task to its user story for traceability +- Extend existing test files only — no new test files +- Commit after Phase 2 checkpoint and again after Phase 6 checkpoint diff --git a/specs/002-game-start-drawer-flow/checklists/requirements.md b/specs/002-game-start-drawer-flow/checklists/requirements.md new file mode 100644 index 0000000..2c5ac3f --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Spec is ready for planning. diff --git a/specs/002-game-start-drawer-flow/plan.md b/specs/002-game-start-drawer-flow/plan.md new file mode 100644 index 0000000..969c20a --- /dev/null +++ b/specs/002-game-start-drawer-flow/plan.md @@ -0,0 +1,130 @@ +# Implementation Plan: Game Start & Drawer Flow + +**Branch**: `assignment` | **Date**: 2026-05-28 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/002-game-start-drawer-flow/spec.md` + +--- + +## Summary + +When `POST /rooms/:code/start` fires, set `drawerId = hostId` and +`secretWord = STARTER_WORDS[0]` on the room. `toRoomSnapshot()` conditionally +includes `secretWord` only when the viewer is the drawer. The game screen derives +each player's role and shows/hides the word accordingly. + +--- + +## Technical Context + +**Language/Version**: TypeScript 5 (backend Node 18 + Express; frontend React 18 + Vite) + +**Primary Dependencies**: Existing — no new dependencies + +**Storage**: In-memory `Map` — extend existing Room model + +**Testing**: Vitest — extend `roomStore.test.ts` + +**Constraints**: Deterministic — word always `STARTER_WORDS[0]`; drawer always `hostId` + +--- + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | Extending Room model and snapshot only | +| II. Spec-Driven | ✅ Pass | Traces to FR-001–FR-007 | +| III. Deterministic Rules | ✅ Pass | word = STARTER_WORDS[0], drawer = hostId | +| IV. Strict Scope | ✅ Pass | No new libraries, no rotation, no timer | +| V. Incremental Validation | ✅ Pass | Gate: drawer sees word, guesser does not | +| VI. AI-Assisted, Human-Reviewed | ✅ Pass | Reviewed before commit | + +--- + +## Data Model Changes + +### Backend `Room` (game.ts) + +``` +Before: code, status, hostId, participants[], createdAt, updatedAt +After: code, status, hostId, drawerId, secretWord, participants[], createdAt, updatedAt + drawerId and secretWord are set when status transitions to "playing" + Both are optional (undefined) while in "lobby" or "result" +``` + +### Backend `RoomSnapshot` (game.ts) + +``` +Before: code, status, hostId, participants[], availableWords, roles +After: code, status, hostId, drawerId, secretWord (optional), participants[], + availableWords, roles + secretWord present only when viewer === drawer +``` + +### Frontend `RoomSnapshot` (api.ts) + +``` +Before: code, status, hostId, participants[], availableWords, roles +After: code, status, hostId, drawerId, secretWord (optional), participants[], + availableWords, roles +``` + +--- + +## File-Level Changes + +``` +backend/ +├── src/models/game.ts ← add drawerId?: string, secretWord?: string to Room + RoomSnapshot +├── src/services/roomStore.ts ← startGame() sets drawerId + secretWord; toRoomSnapshot() conditionally includes secretWord +└── src/services/roomStore.test.ts ← add tests: drawerId set, secretWord set, visibility rules + +frontend/ +├── src/services/api.ts ← add drawerId, secretWord? to RoomSnapshot type +└── src/pages/GamePage.tsx ← show role (Drawer/Guesser), show secretWord to drawer only +``` + +--- + +## Data Flow + +### Start Game (extended from Group 1) + +``` +POST /rooms/:code/start { participantId } + → startGame(): + room.drawerId = room.hostId + room.secretWord = STARTER_WORDS[0] // "rocket" + room.status = "playing" + → toRoomSnapshot(room, viewerParticipantId): + if viewerParticipantId === room.drawerId → include secretWord + else → omit secretWord (undefined) +``` + +### Game Screen Rendering + +``` +GamePage loads room from RoomStore + → isDrawer = participantId === room.drawerId + → role label: isDrawer ? "Drawer" : "Guesser" + → secret word panel: isDrawer && room.secretWord ? show word : hide +``` + +--- + +## Implementation Sequence + +1. Backend: add `drawerId?` and `secretWord?` to `Room` and `RoomSnapshot` in `game.ts` +2. Backend: update `startGame()` to set both fields; update `toRoomSnapshot()` to accept `viewerParticipantId` and conditionally include `secretWord` +3. Backend: update all `toRoomSnapshot()` call sites in `rooms.ts` to pass `participantId` +4. Backend: extend `roomStore.test.ts` — drawer set, word set, visibility per viewer +5. Frontend: add `drawerId` and `secretWord?` to `RoomSnapshot` type in `api.ts` +6. Frontend: update `GamePage.tsx` — role label + secret word display + +--- + +## Testing Strategy + +- Extend `roomStore.test.ts`: `startGame` sets `drawerId === hostId`; `startGame` sets `secretWord === "rocket"`; `toRoomSnapshot` includes `secretWord` for drawer; omits it for guesser. +- Manual two-screen validation: drawer sees word, guesser does not, network tab confirms. diff --git a/specs/002-game-start-drawer-flow/spec.md b/specs/002-game-start-drawer-flow/spec.md new file mode 100644 index 0000000..eb08b38 --- /dev/null +++ b/specs/002-game-start-drawer-flow/spec.md @@ -0,0 +1,172 @@ +# Feature Specification: Game Start & Drawer Flow + +**Feature Branch**: `assignment` + +**Created**: 2026-05-28 + +**Status**: Draft + +**Feature Directory**: `specs/002-game-start-drawer-flow` + +--- + +## Overview + +When the host starts the game, the first round begins. The host is automatically +assigned as the drawer. A secret word is deterministically selected from the +starter word list. The drawer sees the secret word; all other participants +(guessers) do not. + +This group builds on Feature Group 1 (room setup and lobby) — the game can only +start after `POST /rooms/:code/start` transitions the room to `"playing"` status. + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Drawer Assignment (Priority: P1) + +When a game starts, the room creator (host) becomes the drawer for the round. +The drawer's identity is clearly communicated to all participants. + +**Why this priority**: Drawer assignment is the foundation of every round — +nothing else in the game works without knowing who is drawing. + +**Independent Test**: Host starts the game. On the host's game screen, confirm +they are identified as the drawer. On a second player's screen, confirm they are +identified as a guesser. + +**Acceptance Scenarios**: + +1. **Given** a room is in `"playing"` status, **When** any player views the game + screen, **Then** the room snapshot identifies the host (`hostId`) as the + drawer for this round. + +2. **Given** the game screen loads for the host (drawer), **When** the player + info area is rendered, **Then** the drawer's role is shown as "Drawer". + +3. **Given** the game screen loads for a non-host (guesser), **When** the player + info area is rendered, **Then** the guesser's role is shown as "Guesser". + +--- + +### User Story 2 — Deterministic Secret Word Selection (Priority: P1) + +The secret word for the round is selected deterministically from the starter word +list. For round 1 (the only round in this lab), the word is always the first word +in the list: `"rocket"`. + +**Why this priority**: The word must be consistent across all clients — everyone +must agree on what the correct answer is for scoring to work correctly. + +**Independent Test**: Start a game. Verify the drawer sees "rocket" as the secret +word. Verify that restarting a fresh game also produces "rocket" (deterministic). + +**Acceptance Scenarios**: + +1. **Given** the game starts for the first (and only) round, **When** the secret + word is selected, **Then** it is always `"rocket"` — the first word in + `STARTER_WORDS`. + +2. **Given** the room snapshot is returned to any player, **When** the selection + formula is applied (`STARTER_WORDS[0]`), **Then** the result is always + `"rocket"` regardless of which player requests it or when. + +--- + +### User Story 3 — Drawer-Only Word Visibility (Priority: P1) + +The secret word is visible only to the drawer. Guessers see a placeholder (e.g. +`"_ _ _ _ _ _"`) or no word at all — they must not be able to see the word in +the API response or the UI. + +**Why this priority**: If guessers can see the secret word, the game is broken. +This is the primary privacy requirement for the game. + +**Independent Test**: Start a game. On the drawer's screen, the word "rocket" +is visible. On a guesser's screen, the word is not shown. Inspect the network +response for each player — the guesser's response must not contain the secret +word. + +**Acceptance Scenarios**: + +1. **Given** the game is in `"playing"` status, **When** `GET /rooms/:code` is + called with the drawer's `participantId`, **Then** the response includes + `secretWord: "rocket"`. + +2. **Given** the game is in `"playing"` status, **When** `GET /rooms/:code` is + called with a guesser's `participantId` (or no `participantId`), **Then** the + response does NOT include `secretWord` (field is absent or `null`). + +3. **Given** the drawer is on the game screen, **When** the UI renders, **Then** + the secret word `"rocket"` is displayed prominently to the drawer. + +4. **Given** a guesser is on the game screen, **When** the UI renders, **Then** + no secret word is displayed to the guesser. + +--- + +### Edge Cases + +- Game screen loaded without a room in state (e.g. page refresh): redirect to `/`. +- Drawer role derived from `hostId === participantId` — consistent with Group 1. +- `secretWord` absent from guesser's snapshot must not cause a UI crash. +- Word selection formula is `STARTER_WORDS[0]` — hardcoded for the single round; + no index arithmetic needed beyond the first element. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The room model MUST store `drawerId` when status transitions to + `"playing"`; `drawerId` MUST equal `hostId`. +- **FR-002**: The room model MUST store `secretWord` when status transitions to + `"playing"`; `secretWord` MUST be `STARTER_WORDS[0]` (`"rocket"`). +- **FR-003**: `GET /rooms/:code` MUST return `secretWord` in the snapshot when + the requesting `participantId` equals `drawerId`. +- **FR-004**: `GET /rooms/:code` MUST NOT return `secretWord` (or return `null`) + when the requesting `participantId` does not equal `drawerId`. +- **FR-005**: The game screen MUST display the player's role ("Drawer" or + "Guesser") based on whether `participantId === room.drawerId`. +- **FR-006**: The game screen MUST display `secretWord` to the drawer and MUST + NOT display it to guessers. +- **FR-007**: `POST /rooms/:code/start` MUST set `drawerId = hostId` and + `secretWord = STARTER_WORDS[0]` when transitioning to `"playing"`. + +### Key Entities + +- **Room** (extended): adds `drawerId: string` and `secretWord: string` fields, + set on transition to `"playing"`. +- **RoomSnapshot** (role-aware): `drawerId` always present; `secretWord` present + only when viewer is the drawer, absent (or `null`) otherwise. +- **ViewerRole**: derived client-side as `"drawer"` if `participantId === drawerId`, + else `"guesser"`. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: On the drawer's game screen, the secret word `"rocket"` is visible + and the role label reads "Drawer". +- **SC-002**: On a guesser's game screen, no secret word is visible and the role + label reads "Guesser". +- **SC-003**: Inspecting the network response for a guesser's `GET /rooms/:code` + call confirms `secretWord` is absent or `null` — the word is not leaked. +- **SC-004**: Starting two separate games always produces `"rocket"` as the secret + word (deterministic, not random). + +--- + +## Assumptions + +- There is exactly one round per game session in this lab; word index is always 0. +- The drawer is always the host (`drawerId === hostId`); no rotation occurs. +- `secretWord` visibility is enforced server-side by conditionally including it + in `toRoomSnapshot()` based on `viewerParticipantId`. +- The existing `viewerParticipantId` parameter in `toRoomSnapshot()` (currently + unused in the starter) is the hook for this role-aware response. +- Guessers seeing `secretWord: null` vs. field absent are equivalent — the UI + treats both as "no word to display". +- `STARTER_WORDS[0]` is `"rocket"` — this is a constant, not a lookup. diff --git a/specs/002-game-start-drawer-flow/tasks.md b/specs/002-game-start-drawer-flow/tasks.md new file mode 100644 index 0000000..db67062 --- /dev/null +++ b/specs/002-game-start-drawer-flow/tasks.md @@ -0,0 +1,55 @@ +--- +description: "Task list for Feature Group 2 — Game Start & Drawer Flow" +--- + +# Tasks: Game Start & Drawer Flow + +**Input**: Design documents from `specs/002-game-start-drawer-flow/` + +**Prerequisites**: plan.md ✅ spec.md ✅ + +--- + +## Phase 1: Type Extensions + +- [ ] T001 Add `drawerId?: string` and `secretWord?: string` to `Room` interface in `backend/src/models/game.ts` +- [ ] T002 Add `drawerId: string` and `secretWord?: string` to `RoomSnapshot` interface in `backend/src/models/game.ts` +- [ ] T003 Add `drawerId: string` and `secretWord?: string` to `RoomSnapshot` interface in `frontend/src/services/api.ts` + +**Checkpoint**: TypeScript builds clean in both backend and frontend. + +--- + +## Phase 2: Backend Logic + +- [ ] T004 Update `startGame()` in `backend/src/services/roomStore.ts` — set `room.drawerId = room.hostId` and `room.secretWord = STARTER_WORDS[0]` before saving +- [ ] T005 Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` — accept `viewerParticipantId?: string`; include `secretWord` only when `viewerParticipantId === room.drawerId` +- [ ] T006 Update all `toRoomSnapshot()` call sites in `backend/src/api/rooms.ts` — pass `participantId` from request body/query to each call +- [ ] T007 Extend `backend/src/services/roomStore.test.ts` — add: `startGame` sets `drawerId === hostId`; `startGame` sets `secretWord === "rocket"`; `toRoomSnapshot` includes `secretWord` for drawer; `toRoomSnapshot` omits `secretWord` for guesser + +**Checkpoint**: All backend tests pass. + +--- + +## Phase 3: Frontend Game Screen + +- [ ] T008 [US1] Update `frontend/src/pages/GamePage.tsx` — derive `isDrawer = participantId === room.drawerId`; update Player Info card to show role as "Drawer" or "Guesser" +- [ ] T009 [US3] Update `frontend/src/pages/GamePage.tsx` — add secret word display for drawer: show `room.secretWord` when `isDrawer && room.secretWord`; show nothing for guessers + +**Checkpoint**: Drawer sees "rocket" and role "Drawer"; guesser sees role "Guesser" and no word. + +--- + +## Phase 4: Build Validation + +- [ ] T010 [P] Run `npm run build` in `backend/` — zero TypeScript errors +- [ ] T011 [P] Run `npm run build` in `frontend/` — zero TypeScript errors + +--- + +## Dependencies + +- Phase 1 first (types needed by backend + frontend) +- Phase 2 depends on Phase 1 +- Phase 3 depends on Phase 1 (frontend types) +- T010/T011 run in parallel after all phases complete diff --git a/specs/003-gameplay-interaction/checklists/requirements.md b/specs/003-gameplay-interaction/checklists/requirements.md new file mode 100644 index 0000000..e7a6a61 --- /dev/null +++ b/specs/003-gameplay-interaction/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Gameplay Interaction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Spec is ready for planning. diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 0000000..608f5bd --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,127 @@ +# Implementation Plan: Gameplay Interaction + +**Branch**: `assignment` | **Date**: 2026-05-28 | **Spec**: [spec.md](./spec.md) + +--- + +## Summary + +Add `guesses[]` and `scores` to the Room model. New `POST /rooms/:code/guess` +endpoint validates, stores, and scores each guess. `GET /rooms/:code` returns +guesses + scores in the snapshot. Game screen polls every ~2s for synced history +and scoreboard. Drawer gets an interactive HTML5 canvas with a clear button. + +--- + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | Extending Room model, adding one endpoint | +| II. Spec-Driven | ✅ Pass | Traces to FR-001–FR-010 | +| III. Deterministic Rules | ✅ Pass | correct=100, incorrect=0; case-insensitive compare | +| IV. Strict Scope | ✅ Pass | No canvas sync, no WebSockets, no new libraries | +| V. Incremental Validation | ✅ Pass | Gate: guess synced in 2 tabs, score updates | +| VI. AI-Assisted, Human-Reviewed | ✅ Pass | Reviewed before commit | + +--- + +## Data Model Changes + +### New type `Guess` (game.ts) +``` +participantId: string +text: string (trimmed) +correct: boolean +submittedAt: string (ISO timestamp) +``` + +### Backend `Room` (game.ts) +``` +Added: guesses: Guess[] — empty array, initialised on startGame + scores: Record — participantId→score, all 0 on startGame +``` + +### Backend + Frontend `RoomSnapshot` +``` +Added: guesses: Guess[], scores: Record +Visible to all viewers — no role filtering +``` + +--- + +## File-Level Changes + +``` +backend/ +├── src/models/game.ts ← add Guess; add guesses/scores to Room + RoomSnapshot +├── src/services/roomStore.ts ← startGame() inits guesses/scores; add submitGuess() +├── src/api/rooms.ts ← add POST /:code/guess route +├── src/api/schemas.ts ← add guessSchema +└── src/services/roomStore.test.ts ← extend tests + +frontend/ +├── src/services/api.ts ← add Guess type; add fields to RoomSnapshot; add submitGuess() +├── src/state/roomStore.ts ← add submitGuess() action +├── src/components/DrawingCanvas.tsx ← new: interactive canvas + clear button (drawer only) +├── src/components/GuessForm.tsx ← wire validation + submitGuess call +├── src/components/Scoreboard.tsx ← render room.scores +├── src/components/ResultPanel.tsx ← render room.guesses as history +└── src/pages/GamePage.tsx ← add polling; wire DrawingCanvas; layout updates +``` + +--- + +## API Contract + +``` +POST /rooms/:code/guess +Body: { "participantId": "", "text": "" } + +200 OK: { "room": { ...RoomSnapshot with updated guesses + scores } } +400: { "message": "Guess cannot be empty" } +404: { "message": "Unable to load room" } +``` + +--- + +## Data Flow + +### Submit Guess +``` +GuessForm validates (trim, non-empty client-side) + → POST /rooms/:code/guess { participantId, text } + → submitGuess(): + trim; reject empty (400) + correct = text.trim().toLowerCase() === secretWord.toLowerCase() + push to guesses[]; scores[participantId] += correct ? 100 : 0 + → return updated RoomSnapshot → setRoomSnapshot(room) +``` + +### Game Screen Polling +``` +GamePage mounts → useEffect: setInterval(2000, fetchRoom) + → setRoomSnapshot(room) each tick + → cleanup: clearInterval on unmount +``` + +### Canvas (drawer only) +``` +DrawingCanvas: + mousedown → beginPath + moveTo + mousemove (while pressed) → lineTo + stroke + mouseup/mouseleave → end path + Clear button → ctx.clearRect(full) +``` + +--- + +## Implementation Sequence + +1. Backend models: add `Guess` type; extend `Room` + `RoomSnapshot` +2. Backend store: `startGame()` inits `guesses`/`scores`; `toRoomSnapshot()` includes them; add `submitGuess()` +3. Backend schemas + route: `guessSchema`; `POST /:code/guess` +4. Backend tests: guess stored, correct/incorrect scoring, empty rejected (400) +5. Frontend types: add `Guess` + fields to `api.ts`; add `submitGuess()` to api + roomStore +6. Frontend canvas: new `DrawingCanvas` component +7. Frontend wiring: `GuessForm`, `Scoreboard`, `ResultPanel`, `GamePage` polling diff --git a/specs/003-gameplay-interaction/spec.md b/specs/003-gameplay-interaction/spec.md new file mode 100644 index 0000000..2e90f9d --- /dev/null +++ b/specs/003-gameplay-interaction/spec.md @@ -0,0 +1,215 @@ +# Feature Specification: Gameplay Interaction + +**Feature Branch**: `assignment` + +**Created**: 2026-05-28 + +**Status**: Draft + +**Feature Directory**: `specs/003-gameplay-interaction` + +--- + +## Overview + +Once a round is active, the drawer uses an interactive canvas to draw the secret +word. Guessers submit text guesses that are trimmed, case-insensitively compared +to the secret word, and validated (empty guesses rejected). All guesses are stored +on the room and synced to every player via polling. Correct guesses score 100 +points; incorrect guesses score 0. All scores start at 0. + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Drawing Canvas (Priority: P1) + +The drawer can draw freehand on an interactive canvas. A Clear button wipes the +canvas back to blank. Guessers see the canvas area (drawing sync is out of scope +— guessers see a static placeholder, drawer draws locally). + +**Why this priority**: The canvas is the core of the game for the drawer. Without +it the drawing experience is broken. + +**Independent Test**: Start a game as host (drawer). Confirm the canvas is +interactive — mouse/touch draws lines. Click Clear — canvas returns to blank. + +**Acceptance Scenarios**: + +1. **Given** the game is in `"playing"` status and the viewer is the drawer, + **When** the game screen loads, **Then** an interactive canvas element is + displayed (not a placeholder div). + +2. **Given** the drawer has drawn some lines on the canvas, **When** they click + the Clear Canvas button, **Then** the canvas is wiped back to a blank white + surface. + +3. **Given** the viewer is a guesser, **When** the game screen loads, **Then** + a non-interactive canvas placeholder is shown (drawing sync is out of scope). + +--- + +### User Story 2 — Guess Submission (Priority: P1) + +Guessers type a guess and submit it. The guess is trimmed and validated — empty +guesses are rejected with a message. The guess is compared case-insensitively to +the secret word and stored on the room with a correct/incorrect result. + +**Why this priority**: Guess submission is the core interaction for guessers and +the trigger for scoring. Nothing in Group 4 works without it. + +**Independent Test**: As a guesser, submit "ROCKET" → should be marked correct. +Submit "pizza" → marked incorrect. Submit empty/spaces → error "Guess cannot be +empty", not submitted. + +**Acceptance Scenarios**: + +1. **Given** a guesser is on the game screen, **When** they submit a non-empty + guess, **Then** the guess is stored on the room with the guesser's participant + id, the text (trimmed), a timestamp, and a `correct` boolean. + +2. **Given** a guesser submits "ROCKET" (case-insensitive match to "rocket"), + **When** the guess is evaluated, **Then** `correct` is `true` and the + guesser's score increases by 100. + +3. **Given** a guesser submits "pizza" (no match), **When** the guess is + evaluated, **Then** `correct` is `false` and the guesser's score increases + by 0. + +4. **Given** a guesser submits an empty or whitespace-only guess, **When** they + click Submit Guess, **Then** the form shows "Guess cannot be empty" and no + API call is made. + +5. **Given** a guesser has already submitted a correct guess, **When** they + submit another guess, **Then** the additional guess is still accepted and + stored (no lock-out required for this lab). + +--- + +### User Story 3 — Synced Guess History (Priority: P1) + +All players (drawer and guessers) see the full guess history via polling. The +list updates automatically every ~2 seconds. Each entry shows the guesser's name, +their guess text, and whether it was correct. + +**Why this priority**: Without synced history, neither the drawer nor other +guessers can see what has been tried. Scoring also depends on history being +consistent across clients. + +**Independent Test**: Tab 1 (drawer) and Tab 2 (guesser). Tab 2 submits a guess. +Within 3 seconds, Tab 1's guess history shows the new entry with the guesser's +name and result. + +**Acceptance Scenarios**: + +1. **Given** a guesser submits a guess, **When** all players' next poll fires, + **Then** the new guess appears in the guess history panel for all players + within approximately 2 seconds. + +2. **Given** multiple guesses have been submitted, **When** any player views + the guess history, **Then** all guesses are shown in submission order with + guesser name, guess text, and correct/incorrect indicator. + +3. **Given** a player is on the game screen, **When** they navigate away, + **Then** game-screen polling stops. + +--- + +### User Story 4 — Scoreboard (Priority: P2) + +All players see a live scoreboard showing each participant's current score. Scores +update via the same polling cycle as guess history. All scores start at 0. + +**Why this priority**: Scores provide feedback on progress and are required for +the result screen in Group 4. + +**Independent Test**: Start game. Scoreboard shows all players at 0. Guesser +submits a correct guess. Within 3 seconds scoreboard shows guesser at 100. + +**Acceptance Scenarios**: + +1. **Given** the game starts, **When** any player views the scoreboard, + **Then** all participants are listed with score 0. + +2. **Given** a guesser submits a correct guess (100 points), **When** the next + poll fires, **Then** the scoreboard reflects the updated score for that + guesser. + +--- + +### Edge Cases + +- Empty/whitespace-only guess: rejected client-side with "Guess cannot be empty"; no API call. +- Case-insensitive comparison: "ROCKET", "Rocket", "rocket" all match. +- Drawer submitting a guess: the guess form is not shown to the drawer (from Group 2); drawer cannot submit. +- Multiple correct guesses from same player: all stored, score accumulates (no cap for this lab). +- Canvas interaction only available to drawer; guesser sees static placeholder. +- Poll errors on game screen are non-fatal; history retains last known state. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The room model MUST store a `guesses` array and a `scores` map + (participantId → number, initialised to 0 for all participants on game start). +- **FR-002**: `POST /rooms/:code/guess` MUST accept `{ participantId, text }`, + trim the text, reject empty with 400, compare case-insensitively to + `secretWord`, store the guess with `correct` boolean, and update the score. +- **FR-003**: A correct guess MUST add exactly 100 to the guesser's score; an + incorrect guess MUST add 0. +- **FR-004**: `GET /rooms/:code` MUST return `guesses[]` and `scores` in the + room snapshot for all viewers. +- **FR-005**: The game screen MUST poll `GET /rooms/:code` every ~2 seconds to + sync guess history and scores (reusing the same polling mechanism as the lobby). +- **FR-006**: The guess history panel MUST display each guess with guesser name, + guess text, and a correct/incorrect indicator for all players. +- **FR-007**: The scoreboard MUST display all participants with their current + score, updated each poll cycle. +- **FR-008**: The drawer's game screen MUST include an interactive HTML canvas + element with freehand drawing support (mouse events). +- **FR-009**: A Clear Canvas button MUST wipe the canvas back to blank white. +- **FR-010**: The guess form MUST NOT be shown to the drawer; the canvas MUST + NOT be interactive for guessers. + +### Key Entities + +- **Guess**: participantId, text (trimmed), correct (boolean), submittedAt (ISO timestamp). +- **Room** (extended): adds `guesses: Guess[]` (empty on start) and + `scores: Record` (all 0 on start). +- **RoomSnapshot** (extended): adds `guesses` and `scores` fields, visible to + all players. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: The drawer can draw freehand and clear the canvas without page + reload or errors. +- **SC-002**: A guesser submitting "ROCKET" sees a correct result; submitting + "pizza" sees an incorrect result; submitting empty text sees "Guess cannot be + empty" with no submission. +- **SC-003**: Both players see the same guess history within 3 seconds of any + guess being submitted, without any manual refresh. +- **SC-004**: The scoreboard shows 0 for all players at game start; after a + correct guess it shows 100 for the correct guesser within 3 seconds. +- **SC-005**: Navigating away from the game screen stops all polling. + +--- + +## Assumptions + +- Drawing is local-only for the drawer — no canvas sync to guessers (WebSockets + are out of scope). Guessers see a static placeholder. +- Canvas uses HTML5 Canvas API with mouse events (mousedown, mousemove, mouseup). + Touch events are out of scope. +- The guess endpoint is `POST /rooms/:code/guess` — a new endpoint not in the + starter. +- Scores are stored in a `Record` on the Room, keyed by + `participantId`, initialised to 0 for all participants when `startGame` fires. +- There is no lock-out after a correct guess — guessers can keep submitting. +- The drawer cannot submit guesses (UI-enforced from Group 2: guess form hidden + for drawer). +- Guess history and scores are included in every `GET /rooms/:code` response + regardless of viewer role. diff --git a/specs/003-gameplay-interaction/tasks.md b/specs/003-gameplay-interaction/tasks.md new file mode 100644 index 0000000..cd892e1 --- /dev/null +++ b/specs/003-gameplay-interaction/tasks.md @@ -0,0 +1,74 @@ +--- +description: "Task list for Feature Group 3 — Gameplay Interaction" +--- + +# Tasks: Gameplay Interaction + +**Input**: Design documents from `specs/003-gameplay-interaction/` + +--- + +## Phase 1: Type Extensions + +- [ ] T001 Add `Guess` interface to `backend/src/models/game.ts` — fields: participantId, text, correct, submittedAt +- [ ] T002 Add `guesses: Guess[]` and `scores: Record` to `Room` in `backend/src/models/game.ts` +- [ ] T003 Add `guesses: Guess[]` and `scores: Record` to `RoomSnapshot` in `backend/src/models/game.ts` +- [ ] T004 Add `Guess` interface and extend `RoomSnapshot` with `guesses` + `scores` in `frontend/src/services/api.ts` + +**Checkpoint**: Both builds compile clean. + +--- + +## Phase 2: Backend Logic + +- [ ] T005 Update `startGame()` in `backend/src/services/roomStore.ts` — initialise `room.guesses = []` and `room.scores` as `Record` for all participants +- [ ] T006 Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` — include `guesses` and `scores` in returned snapshot +- [ ] T007 Add `guessSchema` to `backend/src/api/schemas.ts` — `z.object({ participantId: z.string().min(1), text: z.string() })` +- [ ] T008 Add `submitGuess()` to `backend/src/services/roomStore.ts` — trim text, throw 400 if empty, compare case-insensitively to `secretWord`, push to `guesses[]`, update `scores` +- [ ] T009 Add `POST /:code/guess` route to `backend/src/api/rooms.ts` — parse `guessSchema`, call `submitGuess()`, return updated snapshot +- [ ] T010 Extend `backend/src/services/roomStore.test.ts` — add: guess stored with correct fields; correct guess adds 100; incorrect adds 0; empty text throws 400 + +**Checkpoint**: All backend tests pass. + +--- + +## Phase 3: Frontend Services + +- [ ] T011 Add `submitGuess()` to `frontend/src/services/api.ts` — POST /rooms/:code/guess with { participantId, text } +- [ ] T012 Add `submitGuess()` action to `frontend/src/state/roomStore.ts` — calls api.submitGuess, setRoomSnapshot on success + +--- + +## Phase 4: Frontend Components + +- [ ] T013 Create `frontend/src/components/DrawingCanvas.tsx` — interactive HTML5 canvas with mousedown/mousemove/mouseup handlers for freehand drawing; Clear button calls ctx.clearRect +- [ ] T014 Update `frontend/src/components/GuessForm.tsx` — accept `onSubmit` prop; trim + empty check ("Guess cannot be empty"); call prop on valid submit; clear input after submission +- [ ] T015 Update `frontend/src/components/Scoreboard.tsx` — accept `scores: Record` and `participants` props; render each participant name + score +- [ ] T016 Update `frontend/src/components/ResultPanel.tsx` — accept `guesses: Guess[]` and `participants` props; render each guess with guesser name, text, correct/incorrect badge + +--- + +## Phase 5: GamePage Wiring + +- [ ] T017 Add polling to `frontend/src/pages/GamePage.tsx` — `useEffect` with `setInterval(fetchRoom, 2000)`; `clearInterval` on cleanup +- [ ] T018 Wire `DrawingCanvas` into `frontend/src/pages/GamePage.tsx` — render only when `isDrawer`; replace canvas placeholder +- [ ] T019 Wire `GuessForm` in `frontend/src/pages/GamePage.tsx` — pass `onSubmit` that calls `roomStore.submitGuess(text)`; only shown to guessers (already gated from Group 2) +- [ ] T020 Wire `Scoreboard` and `ResultPanel` (guess history) in `frontend/src/pages/GamePage.tsx` — pass `room.scores`, `room.guesses`, `room.participants` + +--- + +## Phase 6: Build Validation + +- [ ] T021 [P] Run `npm run build` in `backend/` — zero TypeScript errors +- [ ] T022 [P] Run `npm run build` in `frontend/` — zero TypeScript errors + +--- + +## Dependencies + +- Phase 1 first (types needed everywhere) +- Phase 2 depends on Phase 1 +- Phase 3 depends on Phase 1 + 2 +- Phase 4 depends on Phase 1 (types for props) +- Phase 5 depends on Phase 3 + 4 +- Phase 6 after all phases diff --git a/specs/004-result-restart/checklists/requirements.md b/specs/004-result-restart/checklists/requirements.md new file mode 100644 index 0000000..3c0abba --- /dev/null +++ b/specs/004-result-restart/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Result, Restart & Final Validation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Spec ready for planning. diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 0000000..01e187f --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,99 @@ +# Implementation Plan: Result, Restart & Final Validation + +**Branch**: `assignment` | **Date**: 2026-05-28 | **Spec**: [spec.md](./spec.md) + +--- + +## Summary + +Add `POST /rooms/:code/end` (host-only, playing→result) and +`POST /rooms/:code/restart` (host-only, result→lobby with round state cleared). +New `/result` route shows correct word, scores, and guess history to all players. +Game screen polling auto-navigates to `/result`; result screen polling +auto-navigates back to `/lobby` on restart. + +--- + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Brownfield-First | ✅ Pass | Two new endpoints, one new page, no rewrites | +| II. Spec-Driven | ✅ Pass | Traces to FR-001–FR-007 | +| III. Deterministic Rules | ✅ Pass | State transitions are deterministic | +| IV. Strict Scope | ✅ Pass | No new libraries, no multi-round, no timer | +| V. Incremental Validation | ✅ Pass | Gate: full lobby→game→result→lobby loop in 2 tabs | +| VI. AI-Assisted, Human-Reviewed | ✅ Pass | Reviewed before commit | + +--- + +## File-Level Changes + +``` +backend/ +├── src/services/roomStore.ts ← add endGame(), restartGame() +├── src/api/rooms.ts ← add POST /:code/end and POST /:code/restart +└── src/api/schemas.ts ← reuse startRoomSchema (participantId only) + +frontend/ +├── src/services/api.ts ← add endRoom(), restartRoom() +├── src/state/roomStore.ts ← add endRoom(), restartRoom() actions +├── src/pages/ResultPage.tsx ← new: result screen with word, scores, history, restart +├── src/routes/index.tsx ← add /result route +└── src/pages/GamePage.tsx ← add auto-nav to /result when status === "result" +``` + +--- + +## API Contracts + +``` +POST /rooms/:code/end +Body: { "participantId": "" } +200: { "room": { ...snapshot, status: "result" } } +400: { "message": "Game is not in playing state" } +403: { "message": "Only the host can end the game" } + +POST /rooms/:code/restart +Body: { "participantId": "" } +200: { "room": { ...snapshot, status: "lobby", guesses:[], scores:{} } } +400: { "message": "Game is not in result state" } +403: { "message": "Only the host can restart the game" } +``` + +--- + +## Data Flow + +### End Round +``` +Host clicks End Round on GamePage + → POST /rooms/:code/end { participantId } + → endGame(): validate host + playing status → status = "result" + → host navigates to /result immediately + → non-hosts: next game-screen poll detects "result" → navigate /result +``` + +### Restart +``` +Host clicks Restart on ResultPage + → POST /rooms/:code/restart { participantId } + → restartGame(): validate host + result status + → status = "lobby"; guesses=[]; scores={}; drawerId=undefined; secretWord=undefined + → participants unchanged + → host navigates to /lobby immediately + → non-hosts: result-screen poll detects "lobby" → navigate /lobby +``` + +--- + +## Implementation Sequence + +1. Backend: add `endGame()` and `restartGame()` to `roomStore.ts` +2. Backend: add `POST /:code/end` and `POST /:code/restart` to `rooms.ts` +3. Backend: extend tests for both new functions +4. Frontend: add `endRoom()` and `restartRoom()` to `api.ts` and `roomStore.ts` +5. Frontend: create `ResultPage.tsx` with polling + host restart button +6. Frontend: add `/result` route in `routes/index.tsx` +7. Frontend: update `GamePage.tsx` polling to auto-navigate on `status === "result"` +8. Frontend: add End Round button to `GamePage.tsx` for host diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 0000000..90b4021 --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,157 @@ +# Feature Specification: Result, Restart & Final Validation + +**Feature Branch**: `assignment` + +**Created**: 2026-05-28 + +**Status**: Draft + +**Feature Directory**: `specs/004-result-restart` + +--- + +## Overview + +After a round ends, all players see a shared result screen showing the correct +word, final scores, and the full guess history. The host can restart the game, +which returns all players to the lobby with the same participant list but all +round state cleared (no guesses, no scores, no drawer, no secret word, status +back to "lobby"). + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — End Round & Show Result Screen (Priority: P1) + +The host ends the round by clicking "End Round". The room transitions to +`"result"` status. All players are automatically navigated to a result screen +(via polling) showing the correct word, final scores, and full guess history. + +**Why this priority**: The result screen is the natural conclusion of every round +and the gate before restart. Nothing in this group works without the transition. + +**Independent Test**: In a live game, host clicks End Round. Both tabs navigate +to the result screen showing "rocket", the scoreboard, and all guesses within +~2 seconds. + +**Acceptance Scenarios**: + +1. **Given** the game is in `"playing"` status and the host clicks End Round, + **When** `POST /rooms/:code/end` is called with the host's `participantId`, + **Then** the room status transitions to `"result"` and returns the updated + snapshot. + +2. **Given** the room status becomes `"result"`, **When** any player's poll + detects this change, **Then** they are automatically navigated to the result + screen. + +3. **Given** the result screen is shown, **When** any player views it, **Then** + they see: the correct word (`"rocket"`), all participants with their final + scores, and the complete guess history in submission order. + +4. **Given** a non-host player is on the result screen, **When** they view it, + **Then** they see the result data but do not see a Restart button. + +--- + +### User Story 2 — Host Restarts to Lobby (Priority: P1) + +The host clicks Restart on the result screen. The room transitions back to +`"lobby"` status with all participants preserved but all round state cleared. +All players are navigated back to the lobby. + +**Why this priority**: Without restart, the game is single-use. Restart completes +the full game loop. + +**Independent Test**: After the result screen appears, host clicks Restart. Both +tabs return to the lobby showing the same player list but scores at 0, no guess +history, and no secret word. + +**Acceptance Scenarios**: + +1. **Given** the room is in `"result"` status and the host clicks Restart, + **When** `POST /rooms/:code/restart` is called with the host's `participantId`, + **Then** the room status transitions to `"lobby"` and all round state is + cleared: `guesses = []`, `scores = {}`, `drawerId = undefined`, + `secretWord = undefined`. + +2. **Given** the restart completes, **When** any player's poll detects + `status === "lobby"`, **Then** they are automatically navigated back to the + lobby. + +3. **Given** the lobby is shown after restart, **When** any player views it, + **Then** the full participant list is preserved (no one was removed) and the + Start Game button reflects the correct host/player-count state. + +4. **Given** a non-host player is on the result screen, **When** they view it, + **Then** no Restart button is shown to them. + +--- + +### Edge Cases + +- Non-host calling `POST /rooms/:code/end` or `/restart`: server returns 403. +- Calling `/end` when status is not `"playing"`: server returns 400. +- Calling `/restart` when status is not `"result"`: server returns 400. +- Player navigating away from result screen manually: allowed; they may miss + the auto-navigate on restart. +- Game screen poll detecting `status === "result"`: auto-navigates to result + screen (wires Group 3 polling to this transition). + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: `POST /rooms/:code/end` MUST accept `{ participantId }`, validate + the caller is the host (403 otherwise), validate status is `"playing"` (400 + otherwise), set status to `"result"`, and return the updated snapshot. +- **FR-002**: `POST /rooms/:code/restart` MUST accept `{ participantId }`, + validate the caller is the host (403 otherwise), validate status is `"result"` + (400 otherwise), reset status to `"lobby"`, clear `guesses`, `scores`, + `drawerId`, and `secretWord`, and return the updated snapshot. +- **FR-003**: The result screen MUST be accessible at `/result` and display: + the correct word, all participants with final scores, and full guess history. +- **FR-004**: The result screen MUST show a Restart button only to the host. +- **FR-005**: The game screen polling (Group 3) MUST auto-navigate to `/result` + when it detects `status === "result"`. +- **FR-006**: The result screen MUST poll `GET /rooms/:code` every ~2 seconds; + when `status === "lobby"` is detected, all players auto-navigate to `/lobby`. +- **FR-007**: After restart, participants array MUST be unchanged — all players + who were in the room before restart remain in the room. + +### Key Entities + +- **Room state after `end`**: status = `"result"`; guesses, scores, drawerId, + secretWord all preserved for display. +- **Room state after `restart`**: status = `"lobby"`; guesses = `[]`; + scores = `{}`; drawerId = `undefined`; secretWord = `undefined`; + participants unchanged. + +--- + +## Success Criteria *(mandatory)* + +- **SC-001**: Both players see the result screen with correct word, final scores, + and full guess history within ~2 seconds of the host clicking End Round. +- **SC-002**: Non-hosts do not see the Restart button on the result screen. +- **SC-003**: After the host clicks Restart, both players return to the lobby + within ~2 seconds with the same participant list and all round state gone. +- **SC-004**: The full game loop — lobby → game → result → lobby — works end-to-end + in two browser tabs without any manual page refresh. + +--- + +## Assumptions + +- The "End Round" trigger is a host-only button on the game screen (not automatic). +- There is no timer or automatic end — the host decides when the round is over. +- The result screen is a new route `/result` with its own page component. +- Polling on the result screen reuses the same `fetchRoom` pattern as lobby/game. +- `POST /rooms/:code/restart` does not reset participants — only round state. +- After restart, non-host players' lobby poll detects `status === "lobby"` and + auto-navigates (same pattern as the playing transition in Group 1). +- The host navigates to `/lobby` immediately on restart success (same pattern as + start game navigating host to `/game`). diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 0000000..fc8c84d --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,63 @@ +--- +description: "Task list for Feature Group 4 — Result, Restart & Final Validation" +--- + +# Tasks: Result, Restart & Final Validation + +--- + +## Phase 1: Backend + +- [ ] T001 Add `endGame()` to `backend/src/services/roomStore.ts` — validate host (403) + playing status (400); set status="result"; return cloned room +- [ ] T002 Add `restartGame()` to `backend/src/services/roomStore.ts` — validate host (403) + result status (400); reset status="lobby", guesses=[], scores={}, drawerId=undefined, secretWord=undefined; participants unchanged +- [ ] T003 Add `POST /:code/end` route to `backend/src/api/rooms.ts` — parse startRoomSchema (participantId), call endGame(), return snapshot +- [ ] T004 Add `POST /:code/restart` route to `backend/src/api/rooms.ts` — parse startRoomSchema, call restartGame(), return snapshot +- [ ] T005 Extend `backend/src/services/roomStore.test.ts` — endGame sets status "result"; endGame 403 for non-host; endGame 400 for non-playing; restartGame clears round state; restartGame preserves participants; restartGame 403 for non-host + +**Checkpoint**: All backend tests pass. + +--- + +## Phase 2: Frontend Services + +- [ ] T006 [P] Add `endRoom()` to `frontend/src/services/api.ts` — POST /rooms/:code/end with { participantId } +- [ ] T007 [P] Add `restartRoom()` to `frontend/src/services/api.ts` — POST /rooms/:code/restart with { participantId } +- [ ] T008 Add `endRoom()` and `restartRoom()` actions to `frontend/src/state/roomStore.ts` + +--- + +## Phase 3: Result Page & Routing + +- [ ] T009 Create `frontend/src/pages/ResultPage.tsx` — show correct word (room.secretWord from snapshot — visible to all in result state), scoreboard, full guess history; host-only Restart button; poll every 2s; auto-navigate to /lobby on status="lobby" +- [ ] T010 Add `/result` route to `frontend/src/routes/index.tsx` + +--- + +## Phase 4: Game Screen Updates + +- [ ] T011 Update `GamePage.tsx` polling — auto-navigate to `/result` when `room.status === "result"` +- [ ] T012 Add End Round button to `frontend/src/pages/GamePage.tsx` — host-only; calls `roomStore.endRoom()`; navigates to `/result` on success + +--- + +## Phase 5: Result Screen Secret Word Visibility + +- [ ] T013 Update `backend/src/services/roomStore.ts` `toRoomSnapshot()` — when status is "result", include `secretWord` for ALL viewers (round is over, word is revealed) + +--- + +## Phase 6: Build Validation + +- [ ] T014 [P] Run `npm run build` in `backend/` — zero TypeScript errors +- [ ] T015 [P] Run `npm run build` in `frontend/` — zero TypeScript errors + +--- + +## Dependencies + +- Phase 1 first (backend must exist before frontend calls it) +- Phase 2 depends on Phase 1 +- Phase 3 depends on Phase 2 +- Phase 4 depends on Phase 2 +- Phase 5 is a standalone backend tweak, can run with Phase 1 +- Phase 6 last