From e757b8b113f70096a1684cd06efe9651007ed264 Mon Sep 17 00:00:00 2001 From: Swathi Manthri Date: Thu, 28 May 2026 20:38:40 +0530 Subject: [PATCH 1/6] feat(group1): add host tracking, name validation, and start-game endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Room model gains hostId (set to first participant on createRoom) - RoomStatus expanded to lobby | playing | result on backend + frontend - Name validation: trim + min(1) via Zod; Zod errors surface field message - POST /rooms/:code/start: host-only, requires ≥2 players, sets status playing - Fix starter bug: API base URL was pointing to /bug path - Add backend tests for hostId, startGame gates (non-host 403, <2 players 403) - Add spec/plan/tasks/data-model/contract artifacts for Feature Group 1 Traces to: FR-001, FR-002, FR-003, FR-004, FR-008, FR-011 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .specify/feature.json | 3 + .specify/memory/constitution.md | 129 +++++++++ CLAUDE.md | 5 + backend/src/api/rooms.ts | 28 +- backend/src/api/router.ts | 4 +- backend/src/api/schemas.ts | 8 +- backend/src/models/game.ts | 4 +- backend/src/services/roomStore.test.ts | 30 +- backend/src/services/roomStore.ts | 39 ++- frontend/src/services/api.ts | 5 +- .../checklists/requirements.md | 34 +++ .../contracts/start-room.md | 63 +++++ specs/001-room-setup-lobby/data-model.md | 44 +++ specs/001-room-setup-lobby/plan.md | 266 ++++++++++++++++++ specs/001-room-setup-lobby/spec.md | 248 ++++++++++++++++ specs/001-room-setup-lobby/tasks.md | 144 ++++++++++ 16 files changed, 1031 insertions(+), 23 deletions(-) create mode 100644 .specify/feature.json create mode 100644 .specify/memory/constitution.md create mode 100644 CLAUDE.md create mode 100644 specs/001-room-setup-lobby/checklists/requirements.md create mode 100644 specs/001-room-setup-lobby/contracts/start-room.md create mode 100644 specs/001-room-setup-lobby/data-model.md create mode 100644 specs/001-room-setup-lobby/plan.md create mode 100644 specs/001-room-setup-lobby/spec.md create mode 100644 specs/001-room-setup-lobby/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..fc29b3c --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/001-room-setup-lobby" +} 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/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..91284ac 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,10 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startRoomSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -18,7 +19,7 @@ export function createRoomsRouter() { response.status(201).json({ participantId: result.participantId, - room: toRoomSnapshot(result.room, result.participantId) + room: toRoomSnapshot(result.room) }); } catch (error) { next(error); @@ -32,12 +33,12 @@ 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({ participantId: result.participantId, - room: toRoomSnapshot(result.room, result.participantId) + room: toRoomSnapshot(result.room) }); } catch (error) { next(error); @@ -48,6 +49,7 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); + void participantId; const room = getRoom(code.toUpperCase()); if (!room) { @@ -55,7 +57,21 @@ export function createRoomsRouter() { } response.json({ - room: toRoomSnapshot(room, participantId) + room: toRoomSnapshot(room) + }); + } catch (error) { + next(error); + } + }); + + 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) }); } catch (error) { next(error); 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..53189ef 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,15 @@ 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 roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..8fbecdd 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; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77..2e1a313 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, startGame } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -11,9 +11,37 @@ describe("roomStore", () => { 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(result).toBeNull(); }); + + it("startGame sets status to playing when host starts with 2+ players", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const started = startGame(room.code, participantId); + + expect(started.status).toBe("playing"); + }); + + 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"); + }); + + 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..7e1321d 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +import { HttpError } from "../api/schemas.js"; const rooms = new Map(); @@ -29,14 +30,10 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; -} - -function createParticipant(name?: string): Participant { +function createParticipant(name: string): Participant { return { id: randomUUID(), - name: displayName(name), + name, joinedAt: now() }; } @@ -49,11 +46,12 @@ 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, participants: [participant], createdAt: now(), updatedAt: now() @@ -67,7 +65,7 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); if (!room) { @@ -96,12 +94,33 @@ 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.updatedAt = now(); + rooms.set(room.code, room); + + return cloneRoom(room); +} +export function toRoomSnapshot(room: Room): RoomSnapshot { return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..37496db 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,8 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing" | "result"; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -19,7 +20,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}`, { 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 From 5d6235105f882492629f7b369b0133838211de95 Mon Sep 17 00:00:00 2001 From: Swathi Manthri Date: Thu, 28 May 2026 20:42:49 +0530 Subject: [PATCH 2/6] feat(group1): add lobby polling, host-only start, and frontend validation - CreateRoomPage: trim + empty-check before API call (FR-004) - JoinRoomPage: trim + empty-check for name and code; uppercase normalise (FR-004, FR-005) - LobbyPage: replace manual refresh with setInterval polling every 2s (FR-006, FR-007) - LobbyPage: auto-navigate to /game when poll detects status === playing - LobbyPage: host-only Start Game button, enabled only when >=2 players (FR-009) - LobbyPage: non-hosts see waiting message instead of start button - api.ts: add startRoom() for POST /rooms/:code/start - roomStore: add startRoom() action Traces to: FR-004, FR-005, FR-006, FR-007, FR-008, FR-009 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- frontend/src/pages/CreateRoomPage.tsx | 8 ++- frontend/src/pages/JoinRoomPage.tsx | 14 +++++- frontend/src/pages/LobbyPage.tsx | 70 +++++++++++++++++++++------ frontend/src/services/api.ts | 6 +++ frontend/src/state/roomStore.ts | 12 +++++ 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee..51f9d6f 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,15 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + if (!trimmedName) { + setError("Name cannot be empty"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); diff --git a/frontend/src/pages/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/services/api.ts b/frontend/src/services/api.ts index 37496db..2efdeb3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -58,5 +58,11 @@ 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 }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd373..523b48d 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -98,6 +98,18 @@ 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; + } } const RoomStoreContext = createContext(null); From 0eea4579718a063e8a48c37ef69fa1d752ede202 Mon Sep 17 00:00:00 2001 From: Swathi Manthri Date: Thu, 28 May 2026 20:49:13 +0530 Subject: [PATCH 3/6] feat(group2): drawer assignment, deterministic word selection, role-aware visibility - Room model gains drawerId and secretWord (set on startGame) - startGame sets drawerId = hostId, secretWord = STARTER_WORDS[0] ("rocket") - toRoomSnapshot: secretWord included only when viewer === drawerId (FR-003, FR-004) - All toRoomSnapshot call sites pass participantId for role-aware responses - GamePage: shows role (Drawer/Guesser), secret word card for drawer only (FR-005, FR-006) - Backend tests: drawerId set, secretWord set, visibility per viewer role - Add spec/plan/tasks artifacts for Feature Group 2 Traces to: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .specify/feature.json | 2 +- backend/src/api/rooms.ts | 9 +- backend/src/models/game.ts | 4 + backend/src/services/roomStore.test.ts | 40 +++- backend/src/services/roomStore.ts | 13 +- frontend/src/pages/GamePage.tsx | 39 +++- frontend/src/services/api.ts | 2 + .../checklists/requirements.md | 34 ++++ specs/002-game-start-drawer-flow/plan.md | 130 +++++++++++++ specs/002-game-start-drawer-flow/spec.md | 172 ++++++++++++++++++ specs/002-game-start-drawer-flow/tasks.md | 55 ++++++ 11 files changed, 481 insertions(+), 19 deletions(-) create mode 100644 specs/002-game-start-drawer-flow/checklists/requirements.md create mode 100644 specs/002-game-start-drawer-flow/plan.md create mode 100644 specs/002-game-start-drawer-flow/spec.md create mode 100644 specs/002-game-start-drawer-flow/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index fc29b3c..33d2aab 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-room-setup-lobby" + "feature_directory": "specs/002-game-start-drawer-flow" } diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 91284ac..5240f93 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -19,7 +19,7 @@ export function createRoomsRouter() { response.status(201).json({ participantId: result.participantId, - room: toRoomSnapshot(result.room) + room: toRoomSnapshot(result.room, result.participantId) }); } catch (error) { next(error); @@ -38,7 +38,7 @@ export function createRoomsRouter() { response.json({ participantId: result.participantId, - room: toRoomSnapshot(result.room) + room: toRoomSnapshot(result.room, result.participantId) }); } catch (error) { next(error); @@ -49,7 +49,6 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); - void participantId; const room = getRoom(code.toUpperCase()); if (!room) { @@ -57,7 +56,7 @@ export function createRoomsRouter() { } response.json({ - room: toRoomSnapshot(room) + room: toRoomSnapshot(room, participantId) }); } catch (error) { next(error); @@ -71,7 +70,7 @@ export function createRoomsRouter() { const room = startGame(code.toUpperCase(), participantId); response.json({ - room: toRoomSnapshot(room) + room: toRoomSnapshot(room, participantId) }); } catch (error) { next(error); diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 8fbecdd..7788228 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -11,6 +11,8 @@ export interface Room { code: string; status: RoomStatus; hostId: string; + drawerId?: string; + secretWord?: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -20,6 +22,8 @@ export interface RoomSnapshot { code: string; status: RoomStatus; hostId: string; + drawerId?: string; + secretWord?: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 2e1a313..617da3e 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, startGame } from "./roomStore.js"; +import { createRoom, joinRoom, startGame, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -32,6 +32,44 @@ describe("roomStore", () => { expect(started.status).toBe("playing"); }); + it("startGame sets drawerId to hostId", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const started = startGame(room.code, participantId); + + expect(started.drawerId).toBe(participantId); + }); + + it("startGame sets secretWord to 'rocket'", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const started = startGame(room.code, participantId); + + expect(started.secretWord).toBe("rocket"); + }); + + it("toRoomSnapshot includes secretWord for the drawer", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + const started = startGame(room.code, participantId); + + const snapshot = toRoomSnapshot(started, participantId); + + expect(snapshot.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); + + const snapshot = toRoomSnapshot(started, join!.participantId); + + expect(snapshot.secretWord).toBeUndefined(); + }); + it("startGame throws 403 when non-host tries to start", () => { const { room } = createRoom("Alice"); const join = joinRoom(room.code, "Bob"); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 7e1321d..0440a1a 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -110,19 +110,28 @@ export function startGame(code: string, participantId: string) { } room.status = "playing"; + room.drawerId = room.hostId; + room.secretWord = STARTER_WORDS[0]; room.updatedAt = now(); rooms.set(room.code, room); return cloneRoom(room); } -export function toRoomSnapshot(room: Room): RoomSnapshot { - return { +export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, hostId: room.hostId, + drawerId: room.drawerId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] }; + + if (room.drawerId && viewerParticipantId === room.drawerId) { + snapshot.secretWord = room.secretWord; + } + + return snapshot; } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183..3269d31 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -21,14 +21,17 @@ export function GamePage() { return null; } - const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = participantId === room.drawerId; + const role = isDrawer ? "Drawer" : "Guesser"; return (
Round 1 -

Guess the Word!

+

+ {isDrawer ? "Your turn to draw!" : "Guess the Word!"} +

@@ -41,8 +44,11 @@ export function GamePage() {
-
- Waiting for drawer... +
+ {isDrawer ? "Draw here! (coming soon)" : "Waiting for drawer..."}
@@ -52,18 +58,31 @@ export function GamePage() {
Name
-
{viewer?.name ?? "Unknown player"}
+
{room.participants.find((p) => p.id === participantId)?.name ?? "Unknown"}
-
Status
-
Playing
+
Role
+
{role}
- - - + {isDrawer && room.secretWord ? ( + +

+ {room.secretWord} +

+

+ Only you can see this +

+
+ ) : null} + + {!isDrawer ? ( + + + + ) : null}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2efdeb3..de85fd6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,6 +10,8 @@ export interface RoomSnapshot { code: string; status: "lobby" | "playing" | "result"; hostId: string; + drawerId?: string; + secretWord?: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; 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 From 03f0f4684d94ab95fce38604361f83be7bfe6569 Mon Sep 17 00:00:00 2001 From: Swathi Manthri Date: Thu, 28 May 2026 20:57:08 +0530 Subject: [PATCH 4/6] feat(group3): interactive canvas, guess submission, synced history, scoring - Room model gains guesses[] and scores map (init on startGame) - POST /rooms/:code/guess: trim, empty-check (400), case-insensitive compare, correct=100 incorrect=0 scoring (FR-002, FR-003) - GET /rooms/:code returns guesses + scores in snapshot for all viewers (FR-004) - DrawingCanvas: freehand HTML5 canvas with Clear button for drawer (FR-008, FR-009) - GuessForm: wired with validation and onSubmit prop (FR-010) - Scoreboard: renders participant scores from room snapshot (FR-007) - ResultPanel: renders guess history with correct/incorrect indicators (FR-006) - GamePage: polls every 2s for synced history + scores; clears on unmount (FR-005) - Backend tests: guess stored, correct/incorrect scoring, empty rejected, case-insensitive Traces to: FR-001 through FR-010 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .specify/feature.json | 2 +- backend/src/api/rooms.ts | 17 +- backend/src/api/schemas.ts | 5 + backend/src/models/game.ts | 11 + backend/src/services/roomStore.test.ts | 75 ++++-- backend/src/services/roomStore.ts | 73 +++--- frontend/src/components/DrawingCanvas.tsx | 74 ++++++ frontend/src/components/GuessForm.tsx | 15 +- frontend/src/components/ResultPanel.tsx | 32 ++- frontend/src/components/Scoreboard.tsx | 22 +- frontend/src/pages/GamePage.tsx | 43 ++-- frontend/src/services/api.ts | 15 ++ frontend/src/state/roomStore.ts | 10 + .../checklists/requirements.md | 34 +++ specs/003-gameplay-interaction/plan.md | 127 +++++++++++ specs/003-gameplay-interaction/spec.md | 215 ++++++++++++++++++ specs/003-gameplay-interaction/tasks.md | 74 ++++++ 17 files changed, 753 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx create mode 100644 specs/003-gameplay-interaction/checklists/requirements.md create mode 100644 specs/003-gameplay-interaction/plan.md create mode 100644 specs/003-gameplay-interaction/spec.md create mode 100644 specs/003-gameplay-interaction/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index 33d2aab..80dbcfb 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/002-game-start-drawer-flow" + "feature_directory": "specs/003-gameplay-interaction" } diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 5240f93..3c70f49 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,13 +1,14 @@ import { Router } from "express"; import { createRoomSchema, + guessSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, startRoomSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -77,5 +78,19 @@ export function createRoomsRouter() { } }); + 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/schemas.ts b/backend/src/api/schemas.ts index 53189ef..20951ee 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -12,6 +12,11 @@ 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({ code: z.string() }); diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 7788228..f542fe9 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -7,12 +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; @@ -24,6 +33,8 @@ export interface RoomSnapshot { 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 617da3e..92078be 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom, startGame, toRoomSnapshot } from "./roomStore.js"; +import { createRoom, joinRoom, 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"); @@ -13,73 +12,101 @@ describe("roomStore", () => { 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(result).toBeNull(); + expect(joinRoom("ZZZZ", "Bob")).toBeNull(); }); - it("startGame sets status to playing when host starts with 2+ players", () => { + it("startGame sets status to playing", () => { const { room, participantId } = createRoom("Alice"); joinRoom(room.code, "Bob"); - - const started = startGame(room.code, participantId); - - expect(started.status).toBe("playing"); + expect(startGame(room.code, participantId).status).toBe("playing"); }); it("startGame sets drawerId to hostId", () => { const { room, participantId } = createRoom("Alice"); joinRoom(room.code, "Bob"); - - const started = startGame(room.code, participantId); - - expect(started.drawerId).toBe(participantId); + 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); + }); - expect(started.secretWord).toBe("rocket"); + 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); - - const snapshot = toRoomSnapshot(started, participantId); - - expect(snapshot.secretWord).toBe("rocket"); + 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); + }); - const snapshot = toRoomSnapshot(started, join!.participantId); + 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); + }); - expect(snapshot.secretWord).toBeUndefined(); + 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("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"); }); 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 0440a1a..50ee2dc 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; import { HttpError } from "../api/schemas.js"; @@ -12,30 +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 createParticipant(name: string): Participant { - return { - id: randomUUID(), - name, - joinedAt: now() - }; + return { id: randomUUID(), name, joinedAt: now() }; } function cloneRoom(room: Room) { @@ -52,35 +44,24 @@ export function createRoom(playerName: string) { 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) { 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) { @@ -97,21 +78,35 @@ export function saveRoom(room: Room) { 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"); - } + 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"); + + 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); @@ -124,7 +119,9 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn status: room.status, hostId: room.hostId, drawerId: room.drawerId, - participants: room.participants.map((participant) => ({ ...participant })), + guesses: room.guesses.map((g) => ({ ...g })), + scores: { ...room.scores }, + participants: room.participants.map((p) => ({ ...p })), availableWords: listWords(), roles: [...STARTER_ROLES] }; 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/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 4986ba1..f336b19 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -81,5 +81,17 @@ export const api = { 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 9449144..8ce201c 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -112,14 +112,29 @@ class RoomStore { } async submitGuess(text: string) { - if (!this.state.room || !this.state.participantId) { - return null; - } - + 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/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 From e7a5fb5c959b0eba72b17427826ebbd7bc3e3306 Mon Sep 17 00:00:00 2001 From: Swathi Manthri Date: Thu, 28 May 2026 21:03:39 +0530 Subject: [PATCH 6/6] docs: add reflection report Covers starter vs. added features, tradeoffs (polling, deterministic word, server-side visibility, restart design), and AI usage notes with commit traceability back to spec functional requirements. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- REFLECTION.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 REFLECTION.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.