From cc427a8c90254740974159386c4328e7d1d5eab3 Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:11:00 +0530 Subject: [PATCH 1/9] feat: implement core game loop on backend - Extend Room model with game state (hostId, drawerId, secretWord, guesses, drawingData, score, playing/result statuses) - Add game logic: startGame, submitGuess (case-insensitive, scores 100), saveDrawing, clearDrawing, endRound, restartGame - Add POST routes: /start, /guess, /draw, /clear, /end-round, /restart - Add Zod validation schemas for all new endpoints - Reject empty/whitespace-only player names with clear message - Improve Zod error handler to surface first validation issue --- backend/src/api/rooms.ts | 132 ++++++++++++++++++- backend/src/api/router.ts | 6 +- backend/src/api/schemas.ts | 36 ++++- backend/src/models/game.ts | 21 ++- backend/src/services/roomStore.ts | 212 ++++++++++++++++++++++++++++-- 5 files changed, 389 insertions(+), 18 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c970..7b74e0130 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,30 @@ import { Router } from "express"; import { + clearDrawBodySchema, createRoomSchema, + drawBodySchema, + endRoundBodySchema, + guessBodySchema, HttpError, joinRoomSchema, + restartBodySchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameBodySchema, + startGameParamsSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + clearDrawing, + createRoom, + endRound, + getRoom, + joinRoom, + restartGame, + saveDrawing, + startGame, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -32,7 +50,7 @@ export function createRoomsRouter() { const result = joinRoom(code.toUpperCase(), playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found"); } response.json({ @@ -62,5 +80,113 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameBodySchema.parse(request.body); + const result = startGame(code.toUpperCase(), participantId); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, text } = guessBodySchema.parse(request.body); + const result = submitGuess(code.toUpperCase(), participantId, text); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/draw", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, drawingData } = drawBodySchema.parse(request.body); + const result = saveDrawing(code.toUpperCase(), participantId, drawingData); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = clearDrawBodySchema.parse(request.body); + const result = clearDrawing(code.toUpperCase(), participantId); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/end-round", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = endRoundBodySchema.parse(request.body); + const result = endRound(code.toUpperCase(), participantId); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartBodySchema.parse(request.body); + const result = restartGame(code.toUpperCase(), participantId); + + if (result.error) { + throw new HttpError(400, result.error); + } + + response.json({ + room: toRoomSnapshot(result.room!, participantId) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 127059544..84fdd7d81 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -1,3 +1,4 @@ +import { ZodError } from "zod"; import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; import { createRoomsRouter } from "./rooms.js"; @@ -29,8 +30,9 @@ export function errorHandler( response: Response, _next: NextFunction ) { - if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + if (error instanceof ZodError) { + const firstIssue = error.issues[0]; + response.status(400).json({ message: firstIssue?.message ?? "Invalid request payload" }); return; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba086..ed75a183a 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,21 +1,51 @@ 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 roomCodeParamsSchema = z.object({ - code: z.string() + code: z.string().min(1, "Room code is required") }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameParamsSchema = z.object({ + code: z.string().min(1, "Room code is required") +}); + +export const startGameBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required") +}); + +export const guessBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required"), + text: z.string().trim().min(1, "Guess cannot be empty") +}); + +export const drawBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required"), + drawingData: z.string() +}); + +export const clearDrawBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required") +}); + +export const restartBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required") +}); + +export const endRoundBodySchema = z.object({ + participantId: z.string().min(1, "Participant ID is required") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466e..8826ad460 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,26 +1,43 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; } export interface Room { code: string; + hostId: string; status: RoomStatus; participants: Participant[]; + drawerId: string | null; + secretWord: string | null; + guesses: Guess[]; + drawingData: string | null; createdAt: string; updatedAt: string; } export interface RoomSnapshot { code: string; + hostId: string; status: RoomStatus; participants: Participant[]; + drawerId: string | null; + secretWord: string | null; + guesses: Guess[]; + drawingData: string | null; availableWords: string[]; - roles: ParticipantRole[]; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a40..c24ec9581 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; -import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; +import { STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -30,14 +30,15 @@ function generateUniqueCode() { } function displayName(name?: string) { - return name || "Player"; + return name?.trim() || "Player"; } function createParticipant(name?: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + score: 0 }; } @@ -53,8 +54,13 @@ export function createRoom(playerName?: string) { const participant = createParticipant(playerName); const room: Room = { code: generateUniqueCode(), + hostId: participant.id, status: "lobby", participants: [participant], + drawerId: null, + secretWord: null, + guesses: [], + drawingData: null, createdAt: now(), updatedAt: now() }; @@ -96,14 +102,204 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +function getWordForParticipants(count: number): string { + const index = (count - 1) % STARTER_WORDS.length; + return STARTER_WORDS[index]; +} + +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.hostId !== participantId) { + return { error: "Only the host can start the game" }; + } + + if (room.participants.length < 2) { + return { error: "At least 2 players are required to start" }; + } + + if (room.status !== "lobby") { + return { error: "Game has already started" }; + } + + const drawerId = room.participants[0].id; + const secretWord = getWordForParticipants(room.participants.length); + + room.status = "playing"; + room.drawerId = drawerId; + room.secretWord = secretWord; + room.guesses = []; + room.drawingData = null; + room.participants.forEach((p) => { p.score = 0; }); + room.updatedAt = now(); + + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function submitGuess(code: string, participantId: string, text: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.status !== "playing") { + return { error: "Round is not active" }; + } + + if (room.drawerId === participantId) { + return { error: "The drawer cannot submit guesses" }; + } + + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) { + return { error: "Participant not found in room" }; + } + + const trimmedText = text.trim(); + if (trimmedText.length === 0) { + return { error: "Guess cannot be empty" }; + } + + const isCorrect = trimmedText.toLowerCase() === room.secretWord?.toLowerCase(); + + const guess: Guess = { + participantId, + participantName: participant.name, + text: trimmedText, + isCorrect + }; + + room.guesses.push(guess); + room.updatedAt = now(); + + if (isCorrect) { + participant.score += 100; + room.status = "result"; + } + + rooms.set(room.code, room); + + return { + room: cloneRoom(room), + guess + }; +} + +export function saveDrawing(code: string, participantId: string, drawingData: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.drawerId !== participantId) { + return { error: "Only the drawer can submit drawings" }; + } + + if (room.status !== "playing") { + return { error: "Round is not active" }; + } + + room.drawingData = drawingData; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function clearDrawing(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.drawerId !== participantId) { + return { error: "Only the drawer can clear the canvas" }; + } + + if (room.status !== "playing") { + return { error: "Round is not active" }; + } + + room.drawingData = null; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function endRound(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.drawerId !== participantId && room.hostId !== participantId) { + return { error: "Only the drawer or host can end the round" }; + } + + if (room.status !== "playing") { + return { error: "Round is not active" }; + } + + room.status = "result"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return { error: "Room not found" }; + } + + if (room.hostId !== participantId) { + return { error: "Only the host can restart the game" }; + } + + if (room.status !== "result") { + return { error: "Game is not in result state" }; + } + + room.status = "lobby"; + room.drawerId = null; + room.secretWord = null; + room.guesses = []; + room.drawingData = null; + room.participants.forEach((p) => { p.score = 0; }); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { room: cloneRoom(room) }; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const isDrawer = viewerParticipantId !== undefined && room.drawerId === viewerParticipantId; + const isResult = room.status === "result"; + + const secretWord = (isDrawer || isResult) ? room.secretWord : null; return { code: room.code, + hostId: room.hostId, status: room.status, - participants: room.participants.map((participant) => ({ ...participant })), - availableWords: listWords(), - roles: [...STARTER_ROLES] + participants: room.participants.map((p) => ({ ...p })), + drawerId: room.drawerId, + secretWord, + guesses: room.guesses.map((g) => ({ ...g })), + drawingData: room.drawingData, + availableWords: listWords() }; } From 3997e4ce40e79f937075265835a80d48656fc189 Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:11:04 +0530 Subject: [PATCH 2/9] feat: add frontend state management and API client for game loop - Add API methods: startGame, submitGuess, saveDrawing, clearDrawing, endRound, restartGame - Update RoomSnapshot type with hostId, drawerId, secretWord, guesses, drawingData, score, RoomStatus - Add HTTP polling to RoomStore (2s interval) with start/stopPolling - Add usePolling hook for lobby and game pages - Add store actions for all game operations --- frontend/src/services/api.ts | 58 ++++++++++++++++-- frontend/src/state/roomStore.ts | 105 ++++++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d86..fc02ed9ad 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,17 +1,29 @@ -export type ParticipantRole = "drawer" | "guesser"; - export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; } +export type RoomStatus = "lobby" | "playing" | "result"; + export interface RoomSnapshot { code: string; - status: "lobby"; + hostId: string; + status: RoomStatus; participants: Participant[]; + drawerId: string | null; + secretWord: string | null; + guesses: Guess[]; + drawingData: string | null; availableWords: string[]; - roles: ParticipantRole[]; } export interface RoomSessionResponse { @@ -19,7 +31,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -57,5 +69,41 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, text: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, text }) + }); + }, + saveDrawing(code: string, participantId: string, drawingData: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/draw`, { + method: "POST", + body: JSON.stringify({ participantId, drawingData }) + }); + }, + clearDrawing(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + endRound(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/end-round`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + restartGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd37396..36ef88962 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -1,13 +1,14 @@ import { createElement, createContext, + useCallback, useContext, useEffect, useRef, useSyncExternalStore, type PropsWithChildren } from "react"; -import { api, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; +import { api, type RoomSnapshot } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -27,6 +28,8 @@ class RoomStore { }; private listeners = new Set(); + private pollingTimer: ReturnType | null = null; + private pollingIntervalMs = 2000; subscribe = (listener: Listener) => { this.listeners.add(listener); @@ -62,7 +65,7 @@ class RoomStore { } } - setRoomSession(response: RoomSessionResponse) { + setRoomSession(response: { participantId: string; room: RoomSnapshot }) { this.setState({ participantId: response.participantId, room: response.room, @@ -98,6 +101,89 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + } + + async submitGuess(text: string) { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.submitGuess(this.state.room!.code, this.state.participantId!, text) + ); + this.setRoomSnapshot(response.room); + } + + async saveDrawing(drawingData: string) { + if (!this.state.room || !this.state.participantId) { + return; + } + + await api.saveDrawing(this.state.room.code, this.state.participantId, drawingData); + } + + async clearCanvas() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.clearDrawing(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + } + + async endRound() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.endRound(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + return; + } + + const response = await this.withLoading(() => + api.restartGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + } + + startPolling = () => { + this.stopPolling(); + this.pollingTimer = setInterval(async () => { + try { + const room = await this.fetchRoom(); + if (room) { + this.setRoomSnapshot(room); + } + } catch { + } + }, this.pollingIntervalMs); + }; + + stopPolling = () => { + if (this.pollingTimer !== null) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + }; } const RoomStoreContext = createContext(null); @@ -109,8 +195,6 @@ export function RoomStoreProvider({ children }: PropsWithChildren) { storeRef.current = new RoomStore(); } - useEffect(() => undefined, []); - return createElement(RoomStoreContext.Provider, { value: storeRef.current }, children); } @@ -128,3 +212,16 @@ export function useRoomState() { const store = useRoomStore(); return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); } + +export function usePolling(isActive: boolean) { + const store = useRoomStore(); + + useEffect(() => { + if (isActive) { + store.startPolling(); + } + return () => { + store.stopPolling(); + }; + }, [store, isActive]); +} From 04227b5d04604477eb2388b1c808b8d55b22ab1e Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:11:10 +0530 Subject: [PATCH 3/9] feat: build drawing canvas and game page UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DrawingCanvas: interactive canvas for drawer with mouse/touch support; guessers see synced drawing as image or waiting placeholder - GamePage: role-based UI — drawer sees secret word and canvas, guessers see guess form and synced drawing; result view shows word, scores, and full guess history; host restart button - LobbyPage: auto-polling, host badge, host-only start with 2-player minimum, auto-navigate to game on status change - GuessForm: wired to API with client-side empty-guess rejection - ResultPanel: displays correct word, correct guesser, all guesses - Scoreboard: shows live scores per participant - CSS: styles for canvas, guess list, result display, host badge, secret word banner, game states --- frontend/src/components/DrawingCanvas.tsx | 186 ++++++++++++++++++++++ frontend/src/components/GuessForm.tsx | 29 +++- frontend/src/components/ResultPanel.tsx | 43 ++++- frontend/src/components/Scoreboard.tsx | 23 ++- frontend/src/pages/GamePage.tsx | 135 +++++++++++++--- frontend/src/pages/LobbyPage.tsx | 57 ++++--- frontend/src/styles/app.css | 155 ++++++++++++++++++ 7 files changed, 570 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/DrawingCanvas.tsx diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 000000000..601cc7913 --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRoomState, useRoomStore } from "../state/roomStore"; + +interface Point { + x: number; + y: number; +} + +interface Stroke { + points: Point[]; + color: string; + width: number; +} + +export function DrawingCanvas() { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const strokesRef = useRef([]); + const currentStrokeRef = useRef(null); + const lastSaveRef = useRef(0); + const roomStore = useRoomStore(); + const { room, participantId } = useRoomState(); + + const isDrawer = participantId === room?.drawerId; + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const allStrokes = strokesRef.current; + for (const stroke of allStrokes) { + if (stroke.points.length < 2) continue; + ctx.beginPath(); + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.moveTo(stroke.points[0].x, stroke.points[0].y); + for (let i = 1; i < stroke.points.length; i++) { + ctx.lineTo(stroke.points[i].x, stroke.points[i].y); + } + ctx.stroke(); + } + }, []); + + const saveCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const now = Date.now(); + if (now - lastSaveRef.current < 500) return; + lastSaveRef.current = now; + + const dataUrl = canvas.toDataURL(); + roomStore.saveDrawing(dataUrl); + }, [roomStore]); + + useEffect(() => { + if (!room || !isDrawer) return; + + strokesRef.current = []; + currentStrokeRef.current = null; + redraw(); + }, [room?.status, isDrawer, redraw]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const parent = canvas.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + canvas.width = rect.width - 4; + canvas.height = 400; + redraw(); + }; + + resize(); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [redraw]); + + function getCanvasPoint(e: React.MouseEvent | React.TouchEvent): Point | null { + const canvas = canvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + + if ("touches" in e) { + const touch = e.touches[0]; + if (!touch) return null; + return { + x: touch.clientX - rect.left, + y: touch.clientY - rect.top + }; + } + + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + + function handlePointerDown(e: React.MouseEvent | React.TouchEvent) { + if (!isDrawer) return; + e.preventDefault(); + const point = getCanvasPoint(e); + if (!point) return; + + setIsDrawing(true); + currentStrokeRef.current = { + points: [point], + color: "#1f2937", + width: 3 + }; + } + + function handlePointerMove(e: React.MouseEvent | React.TouchEvent) { + if (!isDrawing || !currentStrokeRef.current) return; + e.preventDefault(); + const point = getCanvasPoint(e); + if (!point) return; + + currentStrokeRef.current.points.push(point); + redraw(); + } + + function handlePointerUp(e: React.MouseEvent | React.TouchEvent) { + e.preventDefault(); + if (!isDrawing || !currentStrokeRef.current) return; + + if (currentStrokeRef.current.points.length > 0) { + strokesRef.current.push(currentStrokeRef.current); + } + + currentStrokeRef.current = null; + setIsDrawing(false); + saveCanvas(); + } + + async function handleClear() { + strokesRef.current = []; + currentStrokeRef.current = null; + redraw(); + await roomStore.clearCanvas(); + } + + return ( +
+ {isDrawer ? ( + <> + +
+ +
+ + ) : room?.drawingData ? ( + Current drawing + ) : ( +
+

Waiting for the drawer to draw...

+
+ )} +
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474a..8cf37ad2c 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,27 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; -interface GuessFormProps { - disabled?: boolean; -} - -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm() { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const roomStore = useRoomStore(); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + setError(null); + + const trimmed = guessText.trim(); + if (trimmed.length === 0) { + setError("Guess cannot be empty"); + return; + } + + try { + await roomStore.submitGuess(trimmed); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to submit guess"); + } } return ( @@ -19,11 +32,11 @@ export function GuessForm({ disabled = false }: GuessFormProps) { value={guessText} onChange={(event) => setGuessText(event.target.value)} placeholder="Type your guess here..." - disabled={disabled} /> + {error ?

{error}

: null}
-
diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e0..bdb16a520 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,10 +1,45 @@ +import type { Guess } from "../services/api"; import { Card } from "./Card"; -export function ResultPanel() { +interface ResultPanelProps { + guesses: Guess[]; + secretWord: string | null; +} + +export function ResultPanel({ guesses, secretWord }: ResultPanelProps) { + const correctGuess = guesses.find((g) => g.isCorrect); + const allGuesses = guesses; + return ( - -
-

Game activity and guesses will appear here.

+ +
+
+ The word was: + {secretWord ?? "???"} +
+ + {correctGuess ? ( +
+ {correctGuess.participantName} guessed it correctly! +
+ ) : ( +

No one guessed the word correctly.

+ )} + + {allGuesses.length > 0 ? ( +
+

All Guesses

+
    + {allGuesses.map((guess, index) => ( +
  • + {guess.participantName}: + {guess.text} + {guess.isCorrect ? Correct! : null} +
  • + ))} +
+
+ ) : null}
); diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f4..d3bf318e1 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,25 @@ +import type { Participant } from "../services/api"; import { Card } from "./Card"; -export function Scoreboard() { +interface ScoreboardProps { + participants: Participant[]; +} + +export function Scoreboard({ participants }: ScoreboardProps) { return ( -
-
- Waiting for players... - 0 + {participants.length === 0 ? ( +

Waiting for players...

+ ) : ( +
+ {participants.map((p) => ( +
+ {p.name} + {p.score} +
+ ))}
-
+ )} ); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e3..07b819b09 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,20 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; +import { DrawingCanvas } from "../components/DrawingCanvas"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { usePolling, useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); + const prevStatusRef = useRef(room?.status); + + usePolling(true); useEffect(() => { if (!room) { @@ -17,34 +22,102 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room && room.status === "lobby" && prevStatusRef.current !== "lobby") { + navigate("/lobby", { replace: true }); + } + prevStatusRef.current = room?.status; + }, [navigate, room]); + if (!room) { return null; } - const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const viewer = room.participants.find((p) => p.id === participantId) ?? null; + const isDrawer = participantId === room.drawerId; + const isHost = participantId === room.hostId; + const isResult = room.status === "result"; + const isPlaying = room.status === "playing"; + + async function handleRestart() { + try { + await roomStore.restartGame(); + } catch { + } + } + + async function handleEndRound() { + try { + await roomStore.endRound(); + } catch { + } + } return (
Round 1 -

Guess the Word!

+

+ {isResult ? "Round Over!" : "Guess the Word!"} +

- -
- Waiting for drawer... -
+ p.id === room.drawerId)?.name ?? "Unknown"}`}> + {isPlaying ? ( + <> + {isDrawer ? ( +
+ Your word: {room.secretWord} +
+ ) : ( +
+ Waiting for the drawer to draw... +
+ )} + + + ) : null} + + {isResult ? ( +
+ {room.drawingData ? ( + Final drawing + ) : ( +

No drawing was made.

+ )} +
+ ) : null}
+ + {isPlaying ? ( + + {room.guesses.length === 0 ? ( +

No guesses yet.

+ ) : ( +
    + {room.guesses.map((guess, index) => ( +
  • + {guess.participantName}: + {guess.text} + {guess.isCorrect ? Correct! : null} +
  • + ))} +
+ )} +
+ ) : null}
-
Status
-
Playing
+
Role
+
{isDrawer ? "Drawing" : "Guessing"}
+ {isDrawer && isPlaying ? ( +
+
Secret Word
+
{room.secretWord}
+
+ ) : null} - - - + {isPlaying && !isDrawer ? ( + + + + ) : null} + + {isResult ? ( + + {isHost ? ( + + ) : ( +

Waiting for host to restart...

+ )} +
+ ) : null}
-
- -
+ {isPlaying && isDrawer ? ( +
+ +
+ ) : null} ); } diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd284..049fa28af 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,15 +1,16 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; -import { useRoomState, useRoomStore } from "../state/roomStore"; +import { usePolling, 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, error, isLoading, participantId } = useRoomState(); + + usePolling(true); useEffect(() => { if (!room) { @@ -17,19 +18,26 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { - try { - setRefreshError(null); - await roomStore.fetchRoom(); - } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + useEffect(() => { + if (room && room.status === "playing") { + navigate("/game", { replace: true }); } - } + }, [navigate, room]); if (!room) { return null; } + const isHost = participantId === room.hostId; + const canStart = isHost && room.participants.length >= 2 && room.status === "lobby"; + + async function handleStartGame() { + try { + await roomStore.startGame(); + } catch { + } + } + return (
@@ -49,7 +57,12 @@ export function LobbyPage() {
    {room.participants.map((participant) => (
  • - {participant.name} + + {participant.name} + {participant.id === room.hostId ? ( + HOST + ) : null} + joined
  • ))} @@ -59,19 +72,23 @@ export function LobbyPage() {

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

    -

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

    +

    {error ?? (isHost ? "You are the host. Start the game when at least 2 players have joined." : "Waiting for the host to start the game.")}

    + {room.participants.length < 2 ? ( +

    At least 2 players are required to start.

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

Waiting for host to start...

+ )}
); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6ddf..df902cb78 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -566,3 +566,158 @@ input { justify-content: stretch; } } + +.host-badge { + color: var(--brand); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +.drawing-canvas-container { + display: grid; + gap: 12px; +} + +.drawing-canvas { + width: 100%; + height: 400px; + border: 2px solid var(--line); + border-radius: 8px; + cursor: crosshair; + touch-action: none; + background: #ffffff; +} + +.drawing-canvas-toolbar { + display: flex; + gap: 8px; +} + +.drawing-canvas--viewer { + object-fit: contain; + pointer-events: none; +} + +.drawing-canvas-placeholder { + width: 100%; + height: 400px; + border: 2px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); + display: grid; + place-items: center; + color: var(--ink-soft); + font-style: italic; +} + +.secret-word-banner { + padding: 16px 20px; + border-radius: 8px; + background: #dbeafe; + color: var(--brand-strong); + font-size: 1.25rem; + font-weight: 600; + text-align: center; + margin-bottom: 12px; +} + +.secret-word-banner--hidden { + background: var(--surface-muted); + color: var(--ink-soft); +} + +.secret-word { + color: var(--brand-strong); + font-size: 1.25rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +.guess-list { + display: grid; + gap: 8px; + padding: 0; + margin: 0; + list-style: none; +} + +.guess-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-strong); + font-size: 0.95rem; +} + +.guess-list__item--correct { + background: #d1fae5; + border-color: #6ee7b7; +} + +.guess-list__author { + font-weight: 600; + color: var(--ink); +} + +.guess-list__text { + color: var(--ink-soft); +} + +.guess-list__correct { + margin-left: auto; + color: #059669; + font-weight: 700; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.result-content { + display: grid; + gap: 16px; +} + +.result-word { + display: grid; + gap: 4px; + padding: 16px; + border: 2px solid var(--brand); + border-radius: 12px; + background: #eff6ff; + text-align: center; +} + +.result-word__label { + color: var(--ink-soft); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.result-word__value { + color: var(--brand-strong); + font-size: 1.75rem; + font-weight: 800; + letter-spacing: 0.05em; +} + +.result-correct { + padding: 12px 16px; + border-radius: 8px; + background: #d1fae5; + color: #065f46; + font-size: 1rem; + text-align: center; +} + +.result-guesses h3 { + margin: 0 0 8px; + font-size: 0.95rem; + font-weight: 600; + color: var(--ink); +} From 94c30def5a4f22a7d458760f02b206a9f418ce4e Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:11:13 +0530 Subject: [PATCH 4/9] chore: deduplicate peer dependencies in lock files Remove unnecessary 'peer: true' markers hoisted during npm install. --- backend/package-lock.json | 3 --- frontend/package-lock.json | 10 ---------- 2 files changed, 13 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 38f3d3c86..9839cda98 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1879,7 +1879,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2298,7 +2297,6 @@ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2379,7 +2377,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d054e..c7ac26350 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -414,7 +413,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -438,7 +436,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1333,7 +1330,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1538,7 +1534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1900,7 +1895,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2083,7 +2077,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2135,7 +2128,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2148,7 +2140,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2506,7 +2497,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 88ba8a23b68a246f0936590140c1b5f58afb9484 Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:11:18 +0530 Subject: [PATCH 5/9] docs: add spec kit artifacts and reflection report - discovery-notes.md: 8 incomplete behaviors, 4 assumptions, file list - speckit.constitution: engineering principles, AI rules, review discipline - speckit.specify: 4 feature groups with acceptance criteria and edge cases - speckit.plan: state model, data flow diagram, file-level implementation plan - speckit.tasks: ordered task list with dependencies across 10 phases (all complete) - reflection.md: comprehensive report on starter state, additions, and tradeoffs --- discovery-notes.md | 53 +++++++++++++++++ reflection.md | 73 +++++++++++++++++++++++ speckit.constitution | 30 ++++++++++ speckit.plan | 139 +++++++++++++++++++++++++++++++++++++++++++ speckit.specify | 75 +++++++++++++++++++++++ speckit.tasks | 78 ++++++++++++++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 discovery-notes.md create mode 100644 reflection.md create mode 100644 speckit.constitution create mode 100644 speckit.plan create mode 100644 speckit.specify create mode 100644 speckit.tasks diff --git a/discovery-notes.md b/discovery-notes.md new file mode 100644 index 000000000..94071b687 --- /dev/null +++ b/discovery-notes.md @@ -0,0 +1,53 @@ +# Discovery Notes + +## Incomplete Behaviors (≥3) + +1. **Host tracking & host-only actions**: The starter has no concept of a "host" — `createRoom` returns a participant but does not mark them as host. There is no mechanism to restrict game-starting to the host. Relevant files: `backend/src/services/roomStore.ts`, `backend/src/models/game.ts`, `frontend/src/pages/LobbyPage.tsx`. + +2. **Game state transitions**: The starter only supports `lobby` status. There is no `playing` or `result` status, no drawer assignment, no secret word selection, no guess/scoring logic, and no restart flow. The entire gameplay lifecycle is missing. Relevant files: `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `frontend/src/pages/GamePage.tsx`. + +3. **Name validation & error feedback**: Player names are accepted as-is with no trimming or empty-string rejection. Join codes are uppercased but not validated for emptiness or existence with clear user-facing messages. Relevant files: `backend/src/api/schemas.ts`, `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx`. + +4. **Drawing & canvas interaction**: The canvas is a placeholder div with no drawing functionality. There is no mechanism to store or sync drawing data to other players via HTTP polling. Relevant files: `frontend/src/pages/GamePage.tsx`, `backend/src/services/roomStore.ts`. + +5. **Guess submission & history**: The `GuessForm` component exists but does not submit guesses to the backend. There is no guess history stored or synced to players. Relevant files: `frontend/src/components/GuessForm.tsx`, `backend/src/services/roomStore.ts`, `backend/src/api/rooms.ts`. + +6. **Lobby polling**: The lobby page has a manual "Refresh Room" button but no automatic polling. Players must click the button to see updated participant lists. Relevant files: `frontend/src/pages/LobbyPage.tsx`. + +7. **Scoring**: Scores are not tracked per participant. The `Scoreboard` component shows a hardcoded placeholder. Relevant files: `frontend/src/components/Scoreboard.tsx`, `backend/src/models/game.ts`. + +8. **Result state & restart**: The `ResultPanel` shows a placeholder. There is no result state where all players see the correct word, final scores, and guess history. There is no restart mechanism to return to lobby with players preserved. Relevant files: `frontend/src/components/ResultPanel.tsx`, `frontend/src/pages/GamePage.tsx`, `backend/src/services/roomStore.ts`. + +## Assumptions (≥2) + +1. **Drawing sync via polling**: Since WebSockets are out of scope, drawing data will be serialized (e.g., as a base64 data URL or stroke array) and stored in the room state on the server. Guessers will poll this data via `GET /rooms/:code` every ~2s. This means drawing updates may have up to 2s latency for other players. + +2. **Round ends on correct guess**: With no timer mechanism (out of scope), the round ends when a guesser submits the correct word. All players then see the result state showing the word, scores, and guess history. The host can then restart from the result screen. + +3. **Single round per game session**: Since "multiple rounds" and "drawer rotation" are explicitly out of scope, each game consists of exactly one round. After restart, players return to the lobby and the host can start a new single round. + +4. **First player (host) is the drawer**: The drawer is the first player in the participants array (which is the room creator/host). This avoids any round-robin or rotation logic. + +## Relevant Files + +### Backend +- `backend/src/models/game.ts` — Data type definitions +- `backend/src/services/roomStore.ts` — In-memory store & game logic +- `backend/src/services/roomStore.test.ts` — Store tests +- `backend/src/api/rooms.ts` — API route handlers +- `backend/src/api/schemas.ts` — Zod validation schemas +- `backend/src/api/schemas.test.ts` — Schema tests +- `backend/src/seed/starterData.ts` — Seed word list & roles + +### Frontend +- `frontend/src/services/api.ts` — API client +- `frontend/src/services/api.test.ts` — API client tests +- `frontend/src/state/roomStore.ts` — Client-side state store +- `frontend/src/pages/LobbyPage.tsx` — Lobby UI +- `frontend/src/pages/GamePage.tsx` — Game UI +- `frontend/src/pages/CreateRoomPage.tsx` — Create room UI +- `frontend/src/pages/JoinRoomPage.tsx` — Join room UI +- `frontend/src/components/GuessForm.tsx` — Guess input component +- `frontend/src/components/Scoreboard.tsx` — Score display component +- `frontend/src/components/ResultPanel.tsx` — Result display component +- `frontend/src/styles/app.css` — Styles diff --git a/reflection.md b/reflection.md new file mode 100644 index 000000000..d3375f750 --- /dev/null +++ b/reflection.md @@ -0,0 +1,73 @@ +# Reflection Report + +## What did the starter app already have? + +The starter app provided: +- A working Vite + React + TypeScript frontend with routing (Start, Create Room, Join Room, Lobby, Game pages) +- An Express + TypeScript backend with in-memory room storage +- Room CRUD endpoints (create, join, fetch) +- Basic UI components (Card, PageHeader, AppShell, RoomCodeBadge) +- Placeholder components for GuessForm, Scoreboard, ResultPanel +- A canvas placeholder div +- Starter word list (rocket, pizza, castle, guitar, sunflower) +- Zod schemas for request validation +- Vitest test setup with a few passing tests +- CSS styling system with custom properties + +## What did you add? + +### Group 1 — Room Setup & Lobby +- **Host tracking**: Added `hostId` field to the Room model; `createRoom` assigns the creator's participant ID as host +- **Host-only start**: Backend `startGame` validates the requester is the host; frontend shows "Start Game" only to host +- **2-player minimum**: Backend rejects start with < 2 players; frontend shows "At least 2 players required" message +- **Auto-polling**: Added `startPolling`/`stopPolling` to RoomStore with 2-second interval; LobbyPage uses `usePolling` hook +- **Join validation**: Backend returns 404 "Room not found" for invalid codes; frontend displays error +- **Name validation**: Player names are trimmed; empty/whitespace-only names default to "Player" + +### Group 2 — Game Start & Drawer Flow +- **Drawer assignment**: When game starts, the first participant (host) becomes the drawer +- **Secret word selection**: Deterministic word selection based on participant count (`(count-1) % words.length`) +- **Word visibility**: `toRoomSnapshot` only includes `secretWord` if viewer is the drawer or status is "result" +- **Drawer identification**: Game page shows "Drawer: Name" and "Your word: secret" banner for drawer + +### Group 3 — Gameplay Interaction +- **DrawingCanvas component**: HTML5 canvas with mouse and touch support; strokes stored and serialized to data URL +- **Clear canvas**: Button to clear all strokes, syncs to server +- **Drawing sync**: Canvas data URL sent to server via POST /draw; all players receive it via polling +- **Guess submission**: GuessForm wired to API with client-side and server-side validation +- **Case-insensitive comparison**: `toLowerCase()` comparison on trimmed input +- **Empty guess rejection**: Zod schema `.trim().min(1)` + client check +- **Guess history**: Stored in room state, synced to all players via polling snapshot +- **Scoring**: Correct guess scores 100 points; incorrect adds 0 +- **Round end on correct guess**: Room status transitions to "result" on correct guess +- **End Round button**: Drawer can manually end the round + +### Group 4 — Result, Restart & Final Validation +- **Result view**: All players see the secret word, final scores, guess history when status = "result" +- **Final drawing display**: Canvas data URL rendered as image in result view +- **Host-only restart**: Backend validates host; frontend shows restart button only for host +- **Clean restart**: All round state cleared (drawing, guesses, scores, secretWord, drawerId); participants and host preserved; status returns to "lobby" +- **Auto-navigation**: Lobby auto-navigates to game when status changes to "playing"; game auto-navigates to lobby on restart + +### Backend API Endpoints Added +- `POST /rooms/:code/start` — Start game (host-only, min 2 players) +- `POST /rooms/:code/guess` — Submit a guess +- `POST /rooms/:code/draw` — Save drawing data +- `POST /rooms/:code/clear` — Clear canvas +- `POST /rooms/:code/end-round` — End the current round +- `POST /rooms/:code/restart` — Restart game (host-only) + +### Spec Kit Artifacts +- `discovery-notes.md` — 8 incomplete behaviors, 4 assumptions, 20 relevant files +- `speckit.constitution` — Engineering principles, AI rules, review discipline +- `speckit.specify` — 4 feature groups with acceptance criteria and edge cases +- `speckit.plan` — State model, data flow diagram, file-level plan +- `speckit.tasks` — Ordered task list with dependencies across 10 phases + +## Key Decisions & Tradeoffs +- Used base64 data URL for drawing sync over HTTP polling (simplest approach; ~2s delay acceptable) +- Single round per game (out of scope for multiple rounds) +- Round ends on correct guess OR drawer-initiated "End Round" button +- No drawer rotation — host is always drawer (out of scope) +- Strokes stored client-side in ref array; only data URL sent to server +- No database — all in-memory as required diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 000000000..9bae09eef --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,30 @@ +# Speckit Constitution + +## Engineering Principles +1. **TypeScript First**: All new code must be fully typed. Avoid `any`; use `unknown` for dynamic types. +2. **Immutability**: Prefer pure functions and immutable data. Use `structuredClone` for state mutations. +3. **Fail fast**: Validate inputs at API boundaries with Zod. Return clear error messages. +4. **No stateful bloat**: Clean up room state on restart. Keep memory footprint minimal. +5. **No WebSockets**: All sync is via HTTP polling. No Socket.io or real-time push. +6. **No database**: All data is in-memory only. +7. **No authentication**: No accounts, sessions, JWT, or OAuth. +8. **Deterministic game rules**: Word selection must be deterministic (e.g., based on player count). No randomness in gameplay. + +## AI Usage Rules +1. **Review all AI output** before committing. Do not trust generated code blindly. +2. **Trace to spec**: Every implementation change must map to a spec acceptance criterion. +3. **No speculative code**: Do not build features outside the spec or out-of-scope items. +4. **Keep the commit trail clean**: Granular, meaningful commits. Each commit should be explainable. + +## Review Discipline +1. **Spec → Plan → Tasks → Implement → Validate**: Complete each phase before moving to the next. +2. **Validate with two browsers**: Test all scenarios with two browser tabs before considering a feature group complete. +3. **Build before commit**: Run `npm run build` on both backend and frontend before each commit. +4. **Test existing tests**: Run `npm test` on both sides after changes to ensure no regressions. + +## Coding Standards +- File extensions: Use `.js` extension in backend imports (NodeNext resolution). +- Imports: Standard ES module imports. Relative imports within each app. +- Error handling: Use `HttpError` class for API errors. Handle errors in UI without crashing. +- State management: Use the existing `RoomStore` class pattern. Do not add new state libraries. +- Styling: Use existing CSS custom properties and BEM-like naming in `app.css`. diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 000000000..bc0fc806a --- /dev/null +++ b/speckit.plan @@ -0,0 +1,139 @@ +# Plan + +## Findings +- Starter has minimal room CRUD (create, join, view) but no game lifecycle. +- No host tracking, no drawer assignment, no word visibility rules. +- No drawing, guessing, scoring, or result state. +- Frontend placeholders for canvas, guess form, scoreboard, result panel. +- Manual lobby refresh instead of auto-polling. +- No validation for empty/whitespace names or codes. + +## State Model + +### RoomStatus: "lobby" | "playing" | "result" + +### Participant (extended) +```ts +interface Participant { + id: string; + name: string; + joinedAt: string; + score: number; +} +``` + +### Guess +```ts +interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; +} +``` + +### Room (extended) +```ts +interface Room { + code: string; + hostId: string; + status: RoomStatus; + participants: Participant[]; + drawerId: string | null; + secretWord: string | null; + guesses: Guess[]; + drawingData: string | null; // base64 data URL of canvas + createdAt: string; + updatedAt: string; +} +``` + +### RoomSnapshot (extended) +```ts +interface RoomSnapshot { + code: string; + hostId: string; + status: RoomStatus; + participants: Participant[]; + drawerId: string | null; + secretWord: string | null; // only revealed if viewer is drawer or status is "result" + guesses: Guess[]; + drawingData: string | null; + availableWords: string[]; +} +``` + +## Data Flow + +### Lobby Polling +1. Frontend polls `GET /rooms/:code?participantId=X` every ~2000ms via `setInterval`. +2. Backend returns full room snapshot (participants, hostId, status). +3. If status transitions to "playing" (triggered by host on another tab), frontend auto-navigates to game. +4. If status transitions to "result", frontend shows result view. + +### Drawing Sync +1. Drawer draws on HTML canvas. +2. On each stroke (throttled), drawer serializes canvas to base64 data URL. +3. `POST /rooms/:code/draw` sends `{ drawingData: "data:image/png;base64,..." }`. +4. All players poll `GET /rooms/:code` and receive `drawingData`. +5. Guessers render the data URL in an `` tag. + +### Guess Flow +1. Guesser submits guess via `POST /rooms/:code/guess`. +2. Backend trims, case-insensitively compares to secret word. +3. If correct, score += 100, round status → "result". +4. Guess is added to room's guess history. +5. All players see updated guess history on next poll. + +### Result & Restart +1. When status = "result", `secretWord` is visible to all in snapshot. +2. `POST /rooms/:code/restart` (host-only) clears drawing, guesses, scores, secretWord, drawerId. +3. Status returns to "lobby". Participants and hostId preserved. + +## Data Flow Diagram +``` +[Drawer Browser] [Server] [Guesser Browsers] + | | | + |-- POST /draw (canvas) ---->| | + | |-- stores drawingData | + |<-- 200 OK -----------------| | + | | | + | |<-- GET /rooms/:code -----| (poll ~2s) + | |----- drawingData ------->| + | | | + | |<-- POST /guess ----------| + | |-- validates, scores | + |<-- GET /rooms/:code -------|----- guess history ----->| + | (poll, sees guess) | | + | | | +``` + +## File-Level Implementation Plan + +### Backend Changes +1. `src/models/game.ts` — Add `hostId`, `score` to Participant, `Guess`, `drawerId`, `secretWord`, `guesses`, `drawingData`, `RoomStatus` values +2. `src/api/schemas.ts` — Add schemas for start, guess, draw, restart, clear endpoints; add name validation +3. `src/services/roomStore.ts` — Add `startGame`, `submitGuess`, `saveDrawing`, `clearDrawing`, `restartGame`; update `createRoom` to set hostId; update `toRoomSnapshot` to respect word visibility rules +4. `src/api/rooms.ts` — Add POST /start, POST /guess, POST /draw, POST /clear, POST /restart routes with auth checks +5. `src/seed/starterData.ts` — Keep as-is (already has word list) + +### Frontend Changes +1. `src/services/api.ts` — Add `startGame`, `submitGuess`, `saveDrawing`, `clearDrawing`, `restartGame` methods; update `RoomSnapshot` type +2. `src/state/roomStore.ts` — Add actions for new API methods; add `pollingIntervalId` for auto-polling; add `startPolling`/`stopPolling` +3. `src/pages/LobbyPage.tsx` — Add auto-poll on mount; add host badge; add start game button (host-only, min 2 players); navigate to game when status changes +4. `src/pages/GamePage.tsx` — Split drawer/guesser views; show secret word for drawer; auto-poll for game state; show result view when status=result; restart button for host +5. `src/components/GuessForm.tsx` — Wire up submit to API; add validation feedback; disable when not guesser or round over +6. `src/components/Scoreboard.tsx` — Display real scores from room snapshot +7. `src/components/ResultPanel.tsx` — Display guess history +8. `src/components/DrawingCanvas.tsx` — New component: HTML5 canvas with drawing, clear button +9. `src/styles/app.css` — Add styles for canvas, host badge, result screen, game states + +## Testing Strategy +- Backend unit tests for new game logic (start, guess validation, scoring). +- Frontend component rendering tests where feasible. +- Manual two-browser validation for each scenario. + +## Risks +- Drawing data URL size may grow large; throttle saves to every 500ms. +- Polling at 2s means up to 2s delay for drawing/guess sync — acceptable for lab. +- Multiple tabs with same participantId could cause state confusion — participants are identified by unique ID. diff --git a/speckit.specify b/speckit.specify new file mode 100644 index 000000000..658a732b5 --- /dev/null +++ b/speckit.specify @@ -0,0 +1,75 @@ +# Specification + +## Group 1 — Room Setup & Lobby (Scenario 1) + +### Acceptance Criteria +1.1. Room creator is automatically the host (stored as `hostId` on the room). +1.2. Invalid/empty room codes are rejected when joining, with clear error feedback. +1.3. Rooms are fully isolated — players in one room cannot see data from another room. +1.4. Lobby auto-refreshes via polling every ~2 seconds (not manual button). +1.5. Only the host can start the game; the "Start Game" button is disabled for non-host players. +1.6. At least 2 players must be present before the game can start; "Start Game" is disabled when < 2 players. + +### Edge Cases +- Empty room code in join form → inline error message. +- Non-existent room code → "Unable to join room" error displayed. +- Player name is trimmed; empty/whitespace-only names → default to "Player". +- Host leaves the room — host remains the original host (no transfer). +- Multiple rooms with same participant names — fully isolated by room code. + +--- + +## Group 2 — Game Start & Drawer Flow (Scenario 2) + +### Acceptance Criteria +2.1. Player names are trimmed on submission; empty or whitespace-only names are rejected with a message. +2.2. When the game starts, the host (first player in participants list) is assigned as the drawer. +2.3. The secret word is deterministically selected from the starter list (based on participant count index). +2.4. The secret word is visible only to the drawer — guessers see "???" or nothing. +2.5. All players see who the drawer is (clearly identified in the UI). + +### Edge Cases +- Player name is all whitespace → reject with "Name cannot be empty". +- Player name has leading/trailing spaces → trimmed before saving. +- 2+ players present → game starts; drawer is the first participant (host). +- Room code provided for start must exist; error if room not found. + +--- + +## Group 3 — Gameplay Interaction (Scenario 3) + +### Acceptance Criteria +3.1. The drawer can draw on a canvas (mouse/touch drawing). +3.2. The drawer can clear the canvas (single button clears all strokes). +3.3. The drawing is synced to all players via polling (stored as image data URL on server). +3.4. Guessers can submit guesses; guesses are trimmed before processing. +3.5. Guess comparison is case-insensitive. +3.6. Empty guesses are rejected with feedback. +3.7. Guess history is synced to all players via polling. +3.8. Correct guesses score 100 points; incorrect guesses add 0. +3.9. When a correct guess is made, the round ends (status → "result"). + +### Edge Cases +- Empty guess → "Guess cannot be empty" message. +- Case variation: "Pizza", "pizza", "PIZZA" all match the word "pizza". +- Leading/trailing whitespace in guess → trimmed. +- Guesser submits after correct guess already made → rejected (round over). +- Drawer cannot submit guesses (UI hides the guess form for drawer). +- Canvas cleared → all strokes removed. + +--- + +## Group 4 — Result, Restart & Final Validation (Scenario 4) + +### Acceptance Criteria +4.1. All players see the correct secret word on the result screen. +4.2. All players see final scores for all participants. +4.3. All players see the complete guess history for the round. +4.4. Only the host can restart the game. +4.5. On restart, all players return to the lobby with participants preserved. +4.6. On restart, all round state (drawing, guesses, scores, secret word) is cleared. + +### Edge Cases +- Non-host tries to restart → button disabled / error. +- Restart clears game state but keeps participants and host intact. +- Result screen is visible to all players simultaneously (shared state via polling). diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 000000000..37f9efc61 --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,78 @@ +# Tasks + +## Phase 1: Discovery & Artifacts +- [x] 1.1 Read and document starter code gaps (discovery notes) +- [x] 1.2 Create `/speckit.constitution` +- [x] 1.3 Create `/speckit.specify` with all 4 groups +- [x] 1.4 Create `/speckit.plan` with state model and data flow +- [x] 1.5 Create `/speckit.tasks` + +## Phase 2: Feature Group 1 — Room Setup & Lobby (Backend) +- [x] 2.1 Update `models/game.ts`: add `hostId` to Room, `score` to Participant +- [x] 2.2 Update `schemas.ts`: add name validation (trim, reject empty) +- [x] 2.3 Update `roomStore.ts`: set hostId on createRoom, add start game validation +- [x] 2.4 Update `rooms.ts`: add POST /:code/start endpoint (host-only, min 2 players) +- [x] 2.5 Update `toRoomSnapshot`: include hostId in snapshot + +## Phase 3: Feature Group 1 — Room Setup & Lobby (Frontend) +- [x] 3.1 Update `api.ts`: add startGame method, update RoomSnapshot type +- [x] 3.2 Update `roomStore.ts`: add startGame action, auto-polling logic +- [x] 3.3 Update `LobbyPage.tsx`: auto-poll, host badge, start game button + +## Phase 4: Feature Group 2 — Game Start & Drawer Flow (Backend) +- [x] 4.1 Update `models/game.ts`: add RoomStatus "playing", drawerId, secretWord +- [x] 4.2 Update `roomStore.ts`: implement startGame logic (assign drawer, select word) +- [x] 4.3 Update `toRoomSnapshot`: word visibility rules (only drawer sees word) + +## Phase 5: Feature Group 2 — Game Start & Drawer Flow (Frontend) +- [x] 5.1 Update `GamePage.tsx`: show drawer indicator, secret word for drawer +- [x] 5.2 Navigate from lobby to game when status = "playing" +- [x] 5.3 Show "You are the drawer!" / "Waiting for drawer..." based on role + +## Phase 6: Feature Group 3 — Gameplay Interaction (Backend) +- [x] 6.1 Update `models/game.ts`: add Guess type, drawingData, guesses array +- [x] 6.2 Update `schemas.ts`: add guess schema (trim, reject empty), draw schema +- [x] 6.3 Update `roomStore.ts`: submitGuess (case-insensitive compare, score 100), saveDrawing, clearDrawing +- [x] 6.4 Update `rooms.ts`: add POST /:code/guess, POST /:code/draw, POST /:code/clear + +## Phase 7: Feature Group 3 — Gameplay Interaction (Frontend) +- [x] 7.1 Create `DrawingCanvas.tsx`: HTML5 canvas with drawing and clear +- [x] 7.2 Update `GuessForm.tsx`: wire to API, validation, disable for drawer +- [x] 7.3 Update `Scoreboard.tsx`: show real scores +- [x] 7.4 Update `ResultPanel.tsx`: show guess history +- [x] 7.5 Update `GamePage.tsx`: integrate canvas, auto-poll game state, sync drawing + +## Phase 8: Feature Group 4 — Result, Restart & Final Validation (Backend) +- [x] 8.1 Update `models/game.ts`: add RoomStatus "result" +- [x] 8.2 Update `roomStore.ts`: implement restartGame (clear round state, preserve participants) +- [x] 8.3 Update `rooms.ts`: add POST /:code/restart endpoint +- [x] 8.4 Update `toRoomSnapshot`: reveal secret word to all when status = "result" + +## Phase 9: Feature Group 4 — Result, Restart & Final Validation (Frontend) +- [x] 9.1 Update `GamePage.tsx`: show result view (word, scores, guess history) when status = "result" +- [x] 9.2 Add restart button for host on result screen +- [x] 9.3 Navigate to lobby on restart +- [x] 9.4 Verify two-browser flow: join → start → draw → guess → result → restart + +## Phase 10: Validation & Polish +- [x] 10.1 Run backend build (`cd backend && npm run build`) +- [x] 10.2 Run frontend build (`cd frontend && npm run build`) +- [x] 10.3 Run backend tests (`cd backend && npm test`) +- [x] 10.4 Two-browser manual validation of all 4 scenarios +- [x] 10.5 Write reflection report + +## Dependencies +- Phase 1 → Phase 2 → Phase 3 (sequential within each feature group) +- Phase 2,3 → Phase 4,5 → Phase 6,7 → Phase 8,9 (feature groups in order) +- Phase 10 requires all prior phases complete + +## Task Dependencies +- Tasks 2.x depend on 1.x +- Tasks 3.x depend on 2.x +- Tasks 4.x depend on 3.x +- Tasks 5.x depend on 4.x +- Tasks 6.x depend on 5.x +- Tasks 7.x depend on 6.x +- Tasks 8.x depend on 7.x +- Tasks 9.x depend on 8.x +- Tasks 10.x depend on all prior From 5738fdcd7d4537b74e365831c4bf2951df5f08d2 Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 11 Jun 2026 19:43:33 +0530 Subject: [PATCH 6/9] docs: restructure spec kit artifacts for evaluator format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move artifacts to paths expected by pre-evaluation check: - constitution → .specify/memory/constitution.md - Group 1 spec/plan/tasks → specs/001-room-setup/ - Group 2 spec/plan/tasks → specs/002-game-start/ - Group 3 spec/plan/tasks → specs/003-gameplay/ - Group 4 spec/plan/tasks → specs/004-result-restart/ Remove old speckit.* root files. --- .../memory/constitution.md | 0 speckit.plan | 139 ------------------ speckit.specify | 75 ---------- speckit.tasks | 78 ---------- specs/001-room-setup/plan.md | 24 +++ specs/001-room-setup/spec.md | 16 ++ specs/001-room-setup/tasks.md | 18 +++ specs/002-game-start/plan.md | 28 ++++ specs/002-game-start/spec.md | 14 ++ specs/002-game-start/tasks.md | 11 ++ specs/003-gameplay/plan.md | 34 +++++ specs/003-gameplay/spec.md | 20 +++ specs/003-gameplay/tasks.md | 14 ++ specs/004-result-restart/plan.md | 26 ++++ specs/004-result-restart/spec.md | 14 ++ specs/004-result-restart/tasks.md | 19 +++ 16 files changed, 238 insertions(+), 292 deletions(-) rename speckit.constitution => .specify/memory/constitution.md (100%) delete mode 100644 speckit.plan delete mode 100644 speckit.specify delete mode 100644 speckit.tasks create mode 100644 specs/001-room-setup/plan.md create mode 100644 specs/001-room-setup/spec.md create mode 100644 specs/001-room-setup/tasks.md create mode 100644 specs/002-game-start/plan.md create mode 100644 specs/002-game-start/spec.md create mode 100644 specs/002-game-start/tasks.md create mode 100644 specs/003-gameplay/plan.md create mode 100644 specs/003-gameplay/spec.md create mode 100644 specs/003-gameplay/tasks.md create mode 100644 specs/004-result-restart/plan.md create mode 100644 specs/004-result-restart/spec.md create mode 100644 specs/004-result-restart/tasks.md diff --git a/speckit.constitution b/.specify/memory/constitution.md similarity index 100% rename from speckit.constitution rename to .specify/memory/constitution.md diff --git a/speckit.plan b/speckit.plan deleted file mode 100644 index bc0fc806a..000000000 --- a/speckit.plan +++ /dev/null @@ -1,139 +0,0 @@ -# Plan - -## Findings -- Starter has minimal room CRUD (create, join, view) but no game lifecycle. -- No host tracking, no drawer assignment, no word visibility rules. -- No drawing, guessing, scoring, or result state. -- Frontend placeholders for canvas, guess form, scoreboard, result panel. -- Manual lobby refresh instead of auto-polling. -- No validation for empty/whitespace names or codes. - -## State Model - -### RoomStatus: "lobby" | "playing" | "result" - -### Participant (extended) -```ts -interface Participant { - id: string; - name: string; - joinedAt: string; - score: number; -} -``` - -### Guess -```ts -interface Guess { - participantId: string; - participantName: string; - text: string; - isCorrect: boolean; -} -``` - -### Room (extended) -```ts -interface Room { - code: string; - hostId: string; - status: RoomStatus; - participants: Participant[]; - drawerId: string | null; - secretWord: string | null; - guesses: Guess[]; - drawingData: string | null; // base64 data URL of canvas - createdAt: string; - updatedAt: string; -} -``` - -### RoomSnapshot (extended) -```ts -interface RoomSnapshot { - code: string; - hostId: string; - status: RoomStatus; - participants: Participant[]; - drawerId: string | null; - secretWord: string | null; // only revealed if viewer is drawer or status is "result" - guesses: Guess[]; - drawingData: string | null; - availableWords: string[]; -} -``` - -## Data Flow - -### Lobby Polling -1. Frontend polls `GET /rooms/:code?participantId=X` every ~2000ms via `setInterval`. -2. Backend returns full room snapshot (participants, hostId, status). -3. If status transitions to "playing" (triggered by host on another tab), frontend auto-navigates to game. -4. If status transitions to "result", frontend shows result view. - -### Drawing Sync -1. Drawer draws on HTML canvas. -2. On each stroke (throttled), drawer serializes canvas to base64 data URL. -3. `POST /rooms/:code/draw` sends `{ drawingData: "data:image/png;base64,..." }`. -4. All players poll `GET /rooms/:code` and receive `drawingData`. -5. Guessers render the data URL in an `` tag. - -### Guess Flow -1. Guesser submits guess via `POST /rooms/:code/guess`. -2. Backend trims, case-insensitively compares to secret word. -3. If correct, score += 100, round status → "result". -4. Guess is added to room's guess history. -5. All players see updated guess history on next poll. - -### Result & Restart -1. When status = "result", `secretWord` is visible to all in snapshot. -2. `POST /rooms/:code/restart` (host-only) clears drawing, guesses, scores, secretWord, drawerId. -3. Status returns to "lobby". Participants and hostId preserved. - -## Data Flow Diagram -``` -[Drawer Browser] [Server] [Guesser Browsers] - | | | - |-- POST /draw (canvas) ---->| | - | |-- stores drawingData | - |<-- 200 OK -----------------| | - | | | - | |<-- GET /rooms/:code -----| (poll ~2s) - | |----- drawingData ------->| - | | | - | |<-- POST /guess ----------| - | |-- validates, scores | - |<-- GET /rooms/:code -------|----- guess history ----->| - | (poll, sees guess) | | - | | | -``` - -## File-Level Implementation Plan - -### Backend Changes -1. `src/models/game.ts` — Add `hostId`, `score` to Participant, `Guess`, `drawerId`, `secretWord`, `guesses`, `drawingData`, `RoomStatus` values -2. `src/api/schemas.ts` — Add schemas for start, guess, draw, restart, clear endpoints; add name validation -3. `src/services/roomStore.ts` — Add `startGame`, `submitGuess`, `saveDrawing`, `clearDrawing`, `restartGame`; update `createRoom` to set hostId; update `toRoomSnapshot` to respect word visibility rules -4. `src/api/rooms.ts` — Add POST /start, POST /guess, POST /draw, POST /clear, POST /restart routes with auth checks -5. `src/seed/starterData.ts` — Keep as-is (already has word list) - -### Frontend Changes -1. `src/services/api.ts` — Add `startGame`, `submitGuess`, `saveDrawing`, `clearDrawing`, `restartGame` methods; update `RoomSnapshot` type -2. `src/state/roomStore.ts` — Add actions for new API methods; add `pollingIntervalId` for auto-polling; add `startPolling`/`stopPolling` -3. `src/pages/LobbyPage.tsx` — Add auto-poll on mount; add host badge; add start game button (host-only, min 2 players); navigate to game when status changes -4. `src/pages/GamePage.tsx` — Split drawer/guesser views; show secret word for drawer; auto-poll for game state; show result view when status=result; restart button for host -5. `src/components/GuessForm.tsx` — Wire up submit to API; add validation feedback; disable when not guesser or round over -6. `src/components/Scoreboard.tsx` — Display real scores from room snapshot -7. `src/components/ResultPanel.tsx` — Display guess history -8. `src/components/DrawingCanvas.tsx` — New component: HTML5 canvas with drawing, clear button -9. `src/styles/app.css` — Add styles for canvas, host badge, result screen, game states - -## Testing Strategy -- Backend unit tests for new game logic (start, guess validation, scoring). -- Frontend component rendering tests where feasible. -- Manual two-browser validation for each scenario. - -## Risks -- Drawing data URL size may grow large; throttle saves to every 500ms. -- Polling at 2s means up to 2s delay for drawing/guess sync — acceptable for lab. -- Multiple tabs with same participantId could cause state confusion — participants are identified by unique ID. diff --git a/speckit.specify b/speckit.specify deleted file mode 100644 index 658a732b5..000000000 --- a/speckit.specify +++ /dev/null @@ -1,75 +0,0 @@ -# Specification - -## Group 1 — Room Setup & Lobby (Scenario 1) - -### Acceptance Criteria -1.1. Room creator is automatically the host (stored as `hostId` on the room). -1.2. Invalid/empty room codes are rejected when joining, with clear error feedback. -1.3. Rooms are fully isolated — players in one room cannot see data from another room. -1.4. Lobby auto-refreshes via polling every ~2 seconds (not manual button). -1.5. Only the host can start the game; the "Start Game" button is disabled for non-host players. -1.6. At least 2 players must be present before the game can start; "Start Game" is disabled when < 2 players. - -### Edge Cases -- Empty room code in join form → inline error message. -- Non-existent room code → "Unable to join room" error displayed. -- Player name is trimmed; empty/whitespace-only names → default to "Player". -- Host leaves the room — host remains the original host (no transfer). -- Multiple rooms with same participant names — fully isolated by room code. - ---- - -## Group 2 — Game Start & Drawer Flow (Scenario 2) - -### Acceptance Criteria -2.1. Player names are trimmed on submission; empty or whitespace-only names are rejected with a message. -2.2. When the game starts, the host (first player in participants list) is assigned as the drawer. -2.3. The secret word is deterministically selected from the starter list (based on participant count index). -2.4. The secret word is visible only to the drawer — guessers see "???" or nothing. -2.5. All players see who the drawer is (clearly identified in the UI). - -### Edge Cases -- Player name is all whitespace → reject with "Name cannot be empty". -- Player name has leading/trailing spaces → trimmed before saving. -- 2+ players present → game starts; drawer is the first participant (host). -- Room code provided for start must exist; error if room not found. - ---- - -## Group 3 — Gameplay Interaction (Scenario 3) - -### Acceptance Criteria -3.1. The drawer can draw on a canvas (mouse/touch drawing). -3.2. The drawer can clear the canvas (single button clears all strokes). -3.3. The drawing is synced to all players via polling (stored as image data URL on server). -3.4. Guessers can submit guesses; guesses are trimmed before processing. -3.5. Guess comparison is case-insensitive. -3.6. Empty guesses are rejected with feedback. -3.7. Guess history is synced to all players via polling. -3.8. Correct guesses score 100 points; incorrect guesses add 0. -3.9. When a correct guess is made, the round ends (status → "result"). - -### Edge Cases -- Empty guess → "Guess cannot be empty" message. -- Case variation: "Pizza", "pizza", "PIZZA" all match the word "pizza". -- Leading/trailing whitespace in guess → trimmed. -- Guesser submits after correct guess already made → rejected (round over). -- Drawer cannot submit guesses (UI hides the guess form for drawer). -- Canvas cleared → all strokes removed. - ---- - -## Group 4 — Result, Restart & Final Validation (Scenario 4) - -### Acceptance Criteria -4.1. All players see the correct secret word on the result screen. -4.2. All players see final scores for all participants. -4.3. All players see the complete guess history for the round. -4.4. Only the host can restart the game. -4.5. On restart, all players return to the lobby with participants preserved. -4.6. On restart, all round state (drawing, guesses, scores, secret word) is cleared. - -### Edge Cases -- Non-host tries to restart → button disabled / error. -- Restart clears game state but keeps participants and host intact. -- Result screen is visible to all players simultaneously (shared state via polling). diff --git a/speckit.tasks b/speckit.tasks deleted file mode 100644 index 37f9efc61..000000000 --- a/speckit.tasks +++ /dev/null @@ -1,78 +0,0 @@ -# Tasks - -## Phase 1: Discovery & Artifacts -- [x] 1.1 Read and document starter code gaps (discovery notes) -- [x] 1.2 Create `/speckit.constitution` -- [x] 1.3 Create `/speckit.specify` with all 4 groups -- [x] 1.4 Create `/speckit.plan` with state model and data flow -- [x] 1.5 Create `/speckit.tasks` - -## Phase 2: Feature Group 1 — Room Setup & Lobby (Backend) -- [x] 2.1 Update `models/game.ts`: add `hostId` to Room, `score` to Participant -- [x] 2.2 Update `schemas.ts`: add name validation (trim, reject empty) -- [x] 2.3 Update `roomStore.ts`: set hostId on createRoom, add start game validation -- [x] 2.4 Update `rooms.ts`: add POST /:code/start endpoint (host-only, min 2 players) -- [x] 2.5 Update `toRoomSnapshot`: include hostId in snapshot - -## Phase 3: Feature Group 1 — Room Setup & Lobby (Frontend) -- [x] 3.1 Update `api.ts`: add startGame method, update RoomSnapshot type -- [x] 3.2 Update `roomStore.ts`: add startGame action, auto-polling logic -- [x] 3.3 Update `LobbyPage.tsx`: auto-poll, host badge, start game button - -## Phase 4: Feature Group 2 — Game Start & Drawer Flow (Backend) -- [x] 4.1 Update `models/game.ts`: add RoomStatus "playing", drawerId, secretWord -- [x] 4.2 Update `roomStore.ts`: implement startGame logic (assign drawer, select word) -- [x] 4.3 Update `toRoomSnapshot`: word visibility rules (only drawer sees word) - -## Phase 5: Feature Group 2 — Game Start & Drawer Flow (Frontend) -- [x] 5.1 Update `GamePage.tsx`: show drawer indicator, secret word for drawer -- [x] 5.2 Navigate from lobby to game when status = "playing" -- [x] 5.3 Show "You are the drawer!" / "Waiting for drawer..." based on role - -## Phase 6: Feature Group 3 — Gameplay Interaction (Backend) -- [x] 6.1 Update `models/game.ts`: add Guess type, drawingData, guesses array -- [x] 6.2 Update `schemas.ts`: add guess schema (trim, reject empty), draw schema -- [x] 6.3 Update `roomStore.ts`: submitGuess (case-insensitive compare, score 100), saveDrawing, clearDrawing -- [x] 6.4 Update `rooms.ts`: add POST /:code/guess, POST /:code/draw, POST /:code/clear - -## Phase 7: Feature Group 3 — Gameplay Interaction (Frontend) -- [x] 7.1 Create `DrawingCanvas.tsx`: HTML5 canvas with drawing and clear -- [x] 7.2 Update `GuessForm.tsx`: wire to API, validation, disable for drawer -- [x] 7.3 Update `Scoreboard.tsx`: show real scores -- [x] 7.4 Update `ResultPanel.tsx`: show guess history -- [x] 7.5 Update `GamePage.tsx`: integrate canvas, auto-poll game state, sync drawing - -## Phase 8: Feature Group 4 — Result, Restart & Final Validation (Backend) -- [x] 8.1 Update `models/game.ts`: add RoomStatus "result" -- [x] 8.2 Update `roomStore.ts`: implement restartGame (clear round state, preserve participants) -- [x] 8.3 Update `rooms.ts`: add POST /:code/restart endpoint -- [x] 8.4 Update `toRoomSnapshot`: reveal secret word to all when status = "result" - -## Phase 9: Feature Group 4 — Result, Restart & Final Validation (Frontend) -- [x] 9.1 Update `GamePage.tsx`: show result view (word, scores, guess history) when status = "result" -- [x] 9.2 Add restart button for host on result screen -- [x] 9.3 Navigate to lobby on restart -- [x] 9.4 Verify two-browser flow: join → start → draw → guess → result → restart - -## Phase 10: Validation & Polish -- [x] 10.1 Run backend build (`cd backend && npm run build`) -- [x] 10.2 Run frontend build (`cd frontend && npm run build`) -- [x] 10.3 Run backend tests (`cd backend && npm test`) -- [x] 10.4 Two-browser manual validation of all 4 scenarios -- [x] 10.5 Write reflection report - -## Dependencies -- Phase 1 → Phase 2 → Phase 3 (sequential within each feature group) -- Phase 2,3 → Phase 4,5 → Phase 6,7 → Phase 8,9 (feature groups in order) -- Phase 10 requires all prior phases complete - -## Task Dependencies -- Tasks 2.x depend on 1.x -- Tasks 3.x depend on 2.x -- Tasks 4.x depend on 3.x -- Tasks 5.x depend on 4.x -- Tasks 6.x depend on 5.x -- Tasks 7.x depend on 6.x -- Tasks 8.x depend on 7.x -- Tasks 9.x depend on 8.x -- Tasks 10.x depend on all prior diff --git a/specs/001-room-setup/plan.md b/specs/001-room-setup/plan.md new file mode 100644 index 000000000..83cac29ad --- /dev/null +++ b/specs/001-room-setup/plan.md @@ -0,0 +1,24 @@ +# Group 1 — Room Setup & Lobby Plan + +## State Model Additions +- Room: add `hostId: string` +- Participant: add `score: number` + +## Data Flow (Lobby Polling) +1. Frontend polls `GET /rooms/:code?participantId=X` every ~2000ms via `setInterval`. +2. Backend returns full room snapshot (participants, hostId, status). +3. Frontend updates room state on each poll response. + +## File-Level Changes + +### Backend +1. `src/models/game.ts` — Add `hostId` to Room, `score` to Participant +2. `src/api/schemas.ts` — Add name validation (trim, reject empty) +3. `src/services/roomStore.ts` — Set hostId on createRoom, add start game validation +4. `src/api/rooms.ts` — Add POST /:code/start endpoint (host-only, min 2 players) +5. Update `toRoomSnapshot`: include hostId in snapshot + +### Frontend +1. `src/services/api.ts` — Add startGame method, update RoomSnapshot type +2. `src/state/roomStore.ts` — Add startGame action, auto-polling logic +3. `src/pages/LobbyPage.tsx` — Auto-poll, host badge, start game button diff --git a/specs/001-room-setup/spec.md b/specs/001-room-setup/spec.md new file mode 100644 index 000000000..5fe179a25 --- /dev/null +++ b/specs/001-room-setup/spec.md @@ -0,0 +1,16 @@ +# Group 1 — Room Setup & Lobby (Scenario 1) + +## Acceptance Criteria +1.1. Room creator is automatically the host (stored as `hostId` on the room). +1.2. Invalid/empty room codes are rejected when joining, with clear error feedback. +1.3. Rooms are fully isolated — players in one room cannot see data from another room. +1.4. Lobby auto-refreshes via polling every ~2 seconds (not manual button). +1.5. Only the host can start the game; the "Start Game" button is disabled for non-host players. +1.6. At least 2 players must be present before the game can start; "Start Game" is disabled when < 2 players. + +## Edge Cases +- Empty room code in join form → inline error message. +- Non-existent room code → "Unable to join room" error displayed. +- Player name is trimmed; empty/whitespace-only names → default to "Player". +- Host leaves the room — host remains the original host (no transfer). +- Multiple rooms with same participant names — fully isolated by room code. diff --git a/specs/001-room-setup/tasks.md b/specs/001-room-setup/tasks.md new file mode 100644 index 000000000..827f28328 --- /dev/null +++ b/specs/001-room-setup/tasks.md @@ -0,0 +1,18 @@ +# Group 1 — Room Setup & Lobby Tasks + +## Phase 1: Discovery & Artifacts +- [x] 1.1 Read and document starter code gaps (discovery notes) +- [x] 1.2 Create constitution +- [x] 1.3 Create spec, plan, tasks for Group 1 + +## Phase 2: Feature Group 1 — Room Setup & Lobby (Backend) +- [x] 2.1 Update `models/game.ts`: add `hostId` to Room, `score` to Participant +- [x] 2.2 Update `schemas.ts`: add name validation (trim, reject empty) +- [x] 2.3 Update `roomStore.ts`: set hostId on createRoom, add start game validation +- [x] 2.4 Update `rooms.ts`: add POST /:code/start endpoint (host-only, min 2 players) +- [x] 2.5 Update `toRoomSnapshot`: include hostId in snapshot + +## Phase 3: Feature Group 1 — Room Setup & Lobby (Frontend) +- [x] 3.1 Update `api.ts`: add startGame method, update RoomSnapshot type +- [x] 3.2 Update `roomStore.ts`: add startGame action, auto-polling logic +- [x] 3.3 Update `LobbyPage.tsx`: auto-poll, host badge, start game button diff --git a/specs/002-game-start/plan.md b/specs/002-game-start/plan.md new file mode 100644 index 000000000..2498e0b3e --- /dev/null +++ b/specs/002-game-start/plan.md @@ -0,0 +1,28 @@ +# Group 2 — Game Start & Drawer Flow Plan + +## State Model Additions +- RoomStatus: add `"playing"` +- Room: add `drawerId: string | null`, `secretWord: string | null` + +## Data Flow +1. Host clicks "Start Game" on lobby page. +2. Frontend sends `POST /rooms/:code/start` with participantId. +3. Backend validates: host-only, min 2 players, status must be "lobby". +4. Backend assigns drawer = first participant, selects word deterministically `(count-1) % words.length`. +5. Room status → "playing". All polling clients see the update and navigate to game page. + +## Word Visibility Rule +- In `toRoomSnapshot`: if viewer's participantId === drawerId, include `secretWord`. +- Otherwise, `secretWord` is `null`. + +## File-Level Changes + +### Backend +1. `src/models/game.ts` — Add RoomStatus "playing", drawerId, secretWord fields +2. `src/services/roomStore.ts` — Implement startGame logic (assign drawer, select word) +3. Update `toRoomSnapshot`: word visibility rules + +### Frontend +1. `src/pages/GamePage.tsx` — Show drawer indicator, secret word for drawer +2. Navigate from lobby to game when status = "playing" +3. Show "You are the drawer!" / "Waiting for drawer..." based on role diff --git a/specs/002-game-start/spec.md b/specs/002-game-start/spec.md new file mode 100644 index 000000000..5939998da --- /dev/null +++ b/specs/002-game-start/spec.md @@ -0,0 +1,14 @@ +# Group 2 — Game Start & Drawer Flow (Scenario 2) + +## Acceptance Criteria +2.1. Player names are trimmed on submission; empty or whitespace-only names are rejected with a message. +2.2. When the game starts, the host (first player in participants list) is assigned as the drawer. +2.3. The secret word is deterministically selected from the starter list (based on participant count index). +2.4. The secret word is visible only to the drawer — guessers see "???" or nothing. +2.5. All players see who the drawer is (clearly identified in the UI). + +## Edge Cases +- Player name is all whitespace → reject with "Name cannot be empty". +- Player name has leading/trailing spaces → trimmed before saving. +- 2+ players present → game starts; drawer is the first participant (host). +- Room code provided for start must exist; error if room not found. diff --git a/specs/002-game-start/tasks.md b/specs/002-game-start/tasks.md new file mode 100644 index 000000000..31ae95ae5 --- /dev/null +++ b/specs/002-game-start/tasks.md @@ -0,0 +1,11 @@ +# Group 2 — Game Start & Drawer Flow Tasks + +## Phase 4: Feature Group 2 — Game Start & Drawer Flow (Backend) +- [x] 4.1 Update `models/game.ts`: add RoomStatus "playing", drawerId, secretWord +- [x] 4.2 Update `roomStore.ts`: implement startGame logic (assign drawer, select word) +- [x] 4.3 Update `toRoomSnapshot`: word visibility rules (only drawer sees word) + +## Phase 5: Feature Group 2 — Game Start & Drawer Flow (Frontend) +- [x] 5.1 Update `GamePage.tsx`: show drawer indicator, secret word for drawer +- [x] 5.2 Navigate from lobby to game when status = "playing" +- [x] 5.3 Show "You are the drawer!" / "Waiting for drawer..." based on role diff --git a/specs/003-gameplay/plan.md b/specs/003-gameplay/plan.md new file mode 100644 index 000000000..87f70a2f3 --- /dev/null +++ b/specs/003-gameplay/plan.md @@ -0,0 +1,34 @@ +# Group 3 — Gameplay Interaction Plan + +## State Model Additions +- `Guess` type: `{ participantId, participantName, text, isCorrect }` +- Room: add `guesses: Guess[]`, `drawingData: string | null` + +## Data Flow (Drawing Sync) +1. Drawer draws on HTML5 canvas. +2. On each stroke (throttled to 500ms), drawer serializes canvas to base64 data URL. +3. `POST /rooms/:code/draw` sends `{ drawingData: "data:image/png;base64,..." }`. +4. All players poll `GET /rooms/:code` and receive `drawingData`. +5. Guessers render the data URL in an `` tag. + +## Data Flow (Guess Flow) +1. Guesser submits guess via `POST /rooms/:code/guess`. +2. Backend trims, case-insensitively compares to secret word. +3. If correct: score += 100, round status → "result". +4. Guess is added to room's guess history. +5. All players see updated guess history on next poll. + +## File-Level Changes + +### Backend +1. `src/models/game.ts` — Add Guess type, drawingData, guesses array +2. `src/api/schemas.ts` — Add guess schema (trim, reject empty), draw schema +3. `src/services/roomStore.ts` — submitGuess, saveDrawing, clearDrawing, endRound +4. `src/api/rooms.ts` — Add POST /:code/guess, POST /:code/draw, POST /:code/clear, POST /:code/end-round + +### Frontend +1. `src/components/DrawingCanvas.tsx` — New: HTML5 canvas with mouse/touch, clear button; guessers see image +2. `src/components/GuessForm.tsx` — Wire to API, validation, disable for drawer +3. `src/components/Scoreboard.tsx` — Show real scores +4. `src/components/ResultPanel.tsx` — Show guess history +5. `src/pages/GamePage.tsx` — Integrate canvas, auto-poll, sync drawing & guesses diff --git a/specs/003-gameplay/spec.md b/specs/003-gameplay/spec.md new file mode 100644 index 000000000..1128b845e --- /dev/null +++ b/specs/003-gameplay/spec.md @@ -0,0 +1,20 @@ +# Group 3 — Gameplay Interaction (Scenario 3) + +## Acceptance Criteria +3.1. The drawer can draw on a canvas (mouse/touch drawing). +3.2. The drawer can clear the canvas (single button clears all strokes). +3.3. The drawing is synced to all players via polling (stored as image data URL on server). +3.4. Guessers can submit guesses; guesses are trimmed before processing. +3.5. Guess comparison is case-insensitive. +3.6. Empty guesses are rejected with feedback. +3.7. Guess history is synced to all players via polling. +3.8. Correct guesses score 100 points; incorrect guesses add 0. +3.9. When a correct guess is made, the round ends (status → "result"). + +## Edge Cases +- Empty guess → "Guess cannot be empty" message. +- Case variation: "Pizza", "pizza", "PIZZA" all match the word "pizza". +- Leading/trailing whitespace in guess → trimmed. +- Guesser submits after correct guess already made → rejected (round over). +- Drawer cannot submit guesses (UI hides the guess form for drawer). +- Canvas cleared → all strokes removed. diff --git a/specs/003-gameplay/tasks.md b/specs/003-gameplay/tasks.md new file mode 100644 index 000000000..9c1c3e92d --- /dev/null +++ b/specs/003-gameplay/tasks.md @@ -0,0 +1,14 @@ +# Group 3 — Gameplay Interaction Tasks + +## Phase 6: Feature Group 3 — Gameplay Interaction (Backend) +- [x] 6.1 Update `models/game.ts`: add Guess type, drawingData, guesses array +- [x] 6.2 Update `schemas.ts`: add guess schema (trim, reject empty), draw schema +- [x] 6.3 Update `roomStore.ts`: submitGuess (case-insensitive compare, score 100), saveDrawing, clearDrawing, endRound +- [x] 6.4 Update `rooms.ts`: add POST /:code/guess, POST /:code/draw, POST /:code/clear, POST /:code/end-round + +## Phase 7: Feature Group 3 — Gameplay Interaction (Frontend) +- [x] 7.1 Create `DrawingCanvas.tsx`: HTML5 canvas with drawing and clear; guessers see image +- [x] 7.2 Update `GuessForm.tsx`: wire to API, validation, disable for drawer +- [x] 7.3 Update `Scoreboard.tsx`: show real scores +- [x] 7.4 Update `ResultPanel.tsx`: show guess history +- [x] 7.5 Update `GamePage.tsx`: integrate canvas, auto-poll game state, sync drawing diff --git a/specs/004-result-restart/plan.md b/specs/004-result-restart/plan.md new file mode 100644 index 000000000..6a7ac9914 --- /dev/null +++ b/specs/004-result-restart/plan.md @@ -0,0 +1,26 @@ +# Group 4 — Result, Restart & Final Validation Plan + +## State Model Additions +- RoomStatus: add `"result"` + +## Data Flow (Result & Restart) +1. When status = "result", `secretWord` is visible to all in snapshot (drawer and guessers). +2. `POST /rooms/:code/restart` (host-only) clears drawing, guesses, scores, secretWord, drawerId. +3. Status returns to "lobby". Participants and hostId preserved. +4. All polling clients see the status transition and auto-navigate to lobby. + +## Word Visibility Rule (Result) +- In `toRoomSnapshot`: if room.status === "result", include `secretWord` for all viewers. + +## File-Level Changes + +### Backend +1. `src/models/game.ts` — Add RoomStatus "result" +2. `src/services/roomStore.ts` — Implement restartGame (clear round state, preserve participants) +3. `src/api/rooms.ts` — Add POST /:code/restart endpoint +4. Update `toRoomSnapshot`: reveal secret word to all when status = "result" + +### Frontend +1. `src/pages/GamePage.tsx` — Show result view (word, scores, guess history) when status = "result" +2. Add restart button for host on result screen +3. Navigate to lobby on restart diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 000000000..450fcc855 --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,14 @@ +# Group 4 — Result, Restart & Final Validation (Scenario 4) + +## Acceptance Criteria +4.1. All players see the correct secret word on the result screen. +4.2. All players see final scores for all participants. +4.3. All players see the complete guess history for the round. +4.4. Only the host can restart the game. +4.5. On restart, all players return to the lobby with participants preserved. +4.6. On restart, all round state (drawing, guesses, scores, secret word) is cleared. + +## Edge Cases +- Non-host tries to restart → button disabled / error. +- Restart clears game state but keeps participants and host intact. +- Result screen is visible to all players simultaneously (shared state via polling). diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 000000000..9d993c467 --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,19 @@ +# Group 4 — Result, Restart & Final Validation Tasks + +## Phase 8: Feature Group 4 — Result, Restart & Final Validation (Backend) +- [x] 8.1 Update `models/game.ts`: add RoomStatus "result" +- [x] 8.2 Update `roomStore.ts`: implement restartGame (clear round state, preserve participants) +- [x] 8.3 Update `rooms.ts`: add POST /:code/restart endpoint +- [x] 8.4 Update `toRoomSnapshot`: reveal secret word to all when status = "result" + +## Phase 9: Feature Group 4 — Result, Restart & Final Validation (Frontend) +- [x] 9.1 Update `GamePage.tsx`: show result view (word, scores, guess history) when status = "result" +- [x] 9.2 Add restart button for host on result screen +- [x] 9.3 Navigate to lobby on restart + +## Phase 10: Validation & Polish +- [x] 10.1 Run backend build +- [x] 10.2 Run frontend build +- [x] 10.3 Run backend tests +- [x] 10.4 Two-browser manual validation of all 4 scenarios +- [x] 10.5 Write reflection report From ff88de70ef3926b23607484ad7ea08b54d210a22 Mon Sep 17 00:00:00 2001 From: Suhail Date: Wed, 17 Jun 2026 20:43:22 +0530 Subject: [PATCH 7/9] test: expand schema validation tests to cover all 10 Zod schemas with edge cases --- backend/src/api/schemas.test.ts | 186 +++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 5 deletions(-) diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea35..f59051e85 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,14 +1,190 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { + createRoomSchema, + joinRoomSchema, + roomCodeParamsSchema, + roomViewerQuerySchema, + startGameBodySchema, + guessBodySchema, + drawBodySchema, + clearDrawBodySchema, + restartBodySchema, + endRoundBodySchema +} from "./schemas.js"; -describe("schemas", () => { - it("createRoomSchema accepts a valid body with playerName", () => { +describe("createRoomSchema", () => { + it("accepts a valid playerName", () => { const result = createRoomSchema.parse({ playerName: "Alice" }); - expect(result.playerName).toBe("Alice"); }); - it("roomCodeParamsSchema rejects missing code", () => { + it("accepts playerName with whitespace (trims it)", () => { + const result = createRoomSchema.parse({ playerName: " Bob " }); + expect(result.playerName).toBe("Bob"); + }); + + it("rejects empty playerName", () => { + expect(() => createRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("rejects whitespace-only playerName after trim", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow(); + }); + + it("rejects missing playerName", () => { + expect(() => createRoomSchema.parse({})).toThrow(); + }); +}); + +describe("joinRoomSchema", () => { + it("accepts a valid playerName", () => { + const result = joinRoomSchema.parse({ playerName: "Charlie" }); + expect(result.playerName).toBe("Charlie"); + }); + + it("trims playerName", () => { + const result = joinRoomSchema.parse({ playerName: " Dave " }); + expect(result.playerName).toBe("Dave"); + }); + + it("rejects empty playerName", () => { + expect(() => joinRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("rejects whitespace-only playerName", () => { + expect(() => joinRoomSchema.parse({ playerName: " " })).toThrow(); + }); +}); + +describe("roomCodeParamsSchema", () => { + it("accepts a valid room code", () => { + const result = roomCodeParamsSchema.parse({ code: "ABCD" }); + expect(result.code).toBe("ABCD"); + }); + + it("rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); + + it("rejects empty code", () => { + expect(() => roomCodeParamsSchema.parse({ code: "" })).toThrow(); + }); +}); + +describe("roomViewerQuerySchema", () => { + it("accepts optional participantId", () => { + const result = roomViewerQuerySchema.parse({ participantId: "p1" }); + expect(result.participantId).toBe("p1"); + }); + + it("accepts missing participantId", () => { + const result = roomViewerQuerySchema.parse({}); + expect(result.participantId).toBeUndefined(); + }); +}); + +describe("startGameBodySchema", () => { + it("accepts a valid participantId", () => { + const result = startGameBodySchema.parse({ participantId: "p1" }); + expect(result.participantId).toBe("p1"); + }); + + it("rejects empty participantId", () => { + expect(() => startGameBodySchema.parse({ participantId: "" })).toThrow(); + }); + + it("rejects missing participantId", () => { + expect(() => startGameBodySchema.parse({})).toThrow(); + }); +}); + +describe("guessBodySchema", () => { + it("accepts valid participantId and text", () => { + const result = guessBodySchema.parse({ participantId: "p1", text: "pizza" }); + expect(result.text).toBe("pizza"); + }); + + it("trims whitespace from guess text", () => { + const result = guessBodySchema.parse({ participantId: "p1", text: " pizza " }); + expect(result.text).toBe("pizza"); + }); + + it("rejects empty guess", () => { + expect(() => guessBodySchema.parse({ participantId: "p1", text: "" })).toThrow(); + }); + + it("rejects whitespace-only guess", () => { + expect(() => guessBodySchema.parse({ participantId: "p1", text: " " })).toThrow(); + }); + + it("rejects missing participantId", () => { + expect(() => guessBodySchema.parse({ text: "pizza" })).toThrow(); + }); + + it("rejects missing text", () => { + expect(() => guessBodySchema.parse({ participantId: "p1" })).toThrow(); + }); +}); + +describe("drawBodySchema", () => { + it("accepts valid participantId and drawingData", () => { + const result = drawBodySchema.parse({ + participantId: "p1", + drawingData: "data:image/png;base64,abc" + }); + expect(result.drawingData).toBe("data:image/png;base64,abc"); + }); + + it("rejects missing participantId", () => { + expect(() => drawBodySchema.parse({ drawingData: "abc" })).toThrow(); + }); + + it("rejects missing drawingData", () => { + expect(() => drawBodySchema.parse({ participantId: "p1" })).toThrow(); + }); +}); + +describe("clearDrawBodySchema", () => { + it("accepts valid participantId", () => { + const result = clearDrawBodySchema.parse({ participantId: "p1" }); + expect(result.participantId).toBe("p1"); + }); + + it("rejects empty participantId", () => { + expect(() => clearDrawBodySchema.parse({ participantId: "" })).toThrow(); + }); + + it("rejects missing participantId", () => { + expect(() => clearDrawBodySchema.parse({})).toThrow(); + }); +}); + +describe("restartBodySchema", () => { + it("accepts valid participantId", () => { + const result = restartBodySchema.parse({ participantId: "p1" }); + expect(result.participantId).toBe("p1"); + }); + + it("rejects empty participantId", () => { + expect(() => restartBodySchema.parse({ participantId: "" })).toThrow(); + }); + + it("rejects missing participantId", () => { + expect(() => restartBodySchema.parse({})).toThrow(); + }); +}); + +describe("endRoundBodySchema", () => { + it("accepts valid participantId", () => { + const result = endRoundBodySchema.parse({ participantId: "p1" }); + expect(result.participantId).toBe("p1"); + }); + + it("rejects empty participantId", () => { + expect(() => endRoundBodySchema.parse({ participantId: "" })).toThrow(); + }); + + it("rejects missing participantId", () => { + expect(() => endRoundBodySchema.parse({})).toThrow(); + }); }); From 2750c9d23002132c059e845a0f31d99e7d8033c5 Mon Sep 17 00:00:00 2001 From: Suhail Date: Wed, 17 Jun 2026 20:44:33 +0530 Subject: [PATCH 8/9] test: add comprehensive roomStore tests for all game logic with validation edge cases --- backend/src/services/roomStore.test.ts | 361 ++++++++++++++++++++++++- 1 file changed, 353 insertions(+), 8 deletions(-) diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b9..7bfaa38cb 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,364 @@ -import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { describe, expect, it, beforeEach } from "vitest"; +import { + createRoom, + joinRoom, + getRoom, + startGame, + submitGuess, + saveDrawing, + clearDrawing, + endRound, + restartGame, + toRoomSnapshot +} from "./roomStore.js"; -describe("roomStore", () => { - it("createRoom returns a room with a 4-character uppercase code", () => { - const result = createRoom("Alice"); +function createTestRoom() { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + return { host, guest: guest!, code: host.room.code, hostId: host.participantId }; +} +describe("createRoom", () => { + it("returns a room with 4-character uppercase code and hostId", () => { + const result = createRoom("Alice"); expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); + expect(result.room.hostId).toBe(result.participantId); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); - expect(result.participantId).toBeDefined(); + expect(result.room.status).toBe("lobby"); }); - it("joinRoom returns null for an unknown room code", () => { - const result = joinRoom("ZZZZ", "Bob"); + it("defaults empty name to Player", () => { + const result = createRoom(""); + expect(result.room.participants[0].name).toBe("Player"); + }); + + it("defaults whitespace name to Player", () => { + const result = createRoom(" "); + expect(result.room.participants[0].name).toBe("Player"); + }); + + it("trims leading/trailing whitespace from name", () => { + const result = createRoom(" Alice "); + expect(result.room.participants[0].name).toBe("Alice"); + }); + + it("initializes score to 0", () => { + const result = createRoom("Alice"); + expect(result.room.participants[0].score).toBe(0); + }); + + it("initializes game fields as null/empty", () => { + const result = createRoom("Alice"); + expect(result.room.drawerId).toBeNull(); + expect(result.room.secretWord).toBeNull(); + expect(result.room.guesses).toEqual([]); + expect(result.room.drawingData).toBeNull(); + }); +}); +describe("joinRoom", () => { + it("returns null for unknown room code", () => { + const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + + it("adds participant to existing room", () => { + const host = createRoom("Alice"); + const result = joinRoom(host.room.code, "Bob"); + expect(result).not.toBeNull(); + expect(result!.room.participants).toHaveLength(2); + }); +}); + +describe("startGame", () => { + it("returns error for non-existent room", () => { + const result = startGame("ZZZZ", "p1"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when non-host tries to start", () => { + const { code } = createTestRoom(); + const result = startGame(code, "non-host-id"); + expect(result.error).toBe("Only the host can start the game"); + }); + + it("returns error with fewer than 2 players", () => { + const host = createRoom("Alice"); + const result = startGame(host.room.code, host.participantId); + expect(result.error).toBe("At least 2 players are required to start"); + }); + + it("returns error when game already started", () => { + const { code, hostId } = createTestRoom(); + startGame(code, hostId); + const result = startGame(code, hostId); + expect(result.error).toBe("Game has already started"); + }); + + it("assigns drawer as first participant (host)", () => { + const { code, hostId } = createTestRoom(); + const result = startGame(code, hostId); + expect(result.error).toBeUndefined(); + expect(result.room!.drawerId).toBe(result.room!.participants[0].id); + expect(result.room!.status).toBe("playing"); + }); + + it("selects a secret word", () => { + const { code, hostId } = createTestRoom(); + const result = startGame(code, hostId); + expect(result.room!.secretWord).toBeTruthy(); + }); + + it("resets scores on start", () => { + const { code, hostId } = createTestRoom(); + const room = getRoom(code)!; + room.participants[0].score = 50; + startGame(code, hostId); + const updated = getRoom(code)!; + updated.participants.forEach((p) => expect(p.score).toBe(0)); + }); +}); + +describe("submitGuess", () => { + function startGameAndGetIds() { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + const room = getRoom(code)!; + return { code, hostId, guestId: guest!.participantId, secretWord: room.secretWord! }; + } + + it("returns error for non-existent room", () => { + const result = submitGuess("ZZZZ", "p1", "test"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when round is not active", () => { + const { code, hostId } = createTestRoom(); + const result = submitGuess(code, hostId, "test"); + expect(result.error).toBe("Round is not active"); + }); + + it("returns error when drawer tries to guess", () => { + const { code, hostId } = startGameAndGetIds(); + const result = submitGuess(code, hostId, "test"); + expect(result.error).toBe("The drawer cannot submit guesses"); + }); + + it("returns error for empty guess", () => { + const { code, guestId } = startGameAndGetIds(); + const result = submitGuess(code, guestId, ""); + expect(result.error).toBe("Guess cannot be empty"); + }); + + it("returns error for whitespace-only guess", () => { + const { code, guestId } = startGameAndGetIds(); + const result = submitGuess(code, guestId, " "); + expect(result.error).toBe("Guess cannot be empty"); + }); + + it("accepts correct guess, awards 100 points, and ends round", () => { + const { code, guestId, secretWord } = startGameAndGetIds(); + const result = submitGuess(code, guestId, secretWord); + expect(result.error).toBeUndefined(); + expect(result.guess!.isCorrect).toBe(true); + const room = getRoom(code)!; + const guesser = room.participants.find((p) => p.id === guestId); + expect(guesser!.score).toBe(100); + expect(room.status).toBe("result"); + }); + + it("accepts case-insensitive correct guess", () => { + const { code, guestId, secretWord } = startGameAndGetIds(); + const result = submitGuess(code, guestId, secretWord.toUpperCase()); + expect(result.guess!.isCorrect).toBe(true); + }); + + it("accepts trimmed correct guess", () => { + const { code, guestId, secretWord } = startGameAndGetIds(); + const result = submitGuess(code, guestId, ` ${secretWord} `); + expect(result.guess!.isCorrect).toBe(true); + }); + + it("rejects incorrect guess without scoring", () => { + const { code, guestId } = startGameAndGetIds(); + const result = submitGuess(code, guestId, "wrongguess"); + expect(result.guess!.isCorrect).toBe(false); + expect(result.error).toBeUndefined(); + const room = getRoom(code)!; + const guesser = room.participants.find((p) => p.id === guestId); + expect(guesser!.score).toBe(0); + }); + + it("rejects guess after round ended", () => { + const { code, guestId, secretWord } = startGameAndGetIds(); + submitGuess(code, guestId, secretWord); + const result = submitGuess(code, guestId, "something"); + expect(result.error).toBe("Round is not active"); + }); +}); + +describe("saveDrawing", () => { + it("returns error for non-existent room", () => { + const result = saveDrawing("ZZZZ", "p1", "data:image/png;base64,abc"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when non-drawer tries to save", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + const result = saveDrawing(code, guest!.participantId, "data:image/png;base64,abc"); + expect(result.error).toBe("Only the drawer can submit drawings"); + }); + + it("returns error when no drawer assigned (before game start)", () => { + const { code, hostId } = createTestRoom(); + const result = saveDrawing(code, hostId, "data:image/png;base64,abc"); + expect(result.error).toBe("Only the drawer can submit drawings"); + }); + + it("saves drawing data for drawer", () => { + const { code, hostId } = createTestRoom(); + startGame(code, hostId); + const result = saveDrawing(code, hostId, "data:image/png;base64,abc"); + expect(result.error).toBeUndefined(); + expect(result.room!.drawingData).toBe("data:image/png;base64,abc"); + }); +}); + +describe("clearDrawing", () => { + it("returns error for non-existent room", () => { + const result = clearDrawing("ZZZZ", "p1"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when non-drawer tries to clear", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + const result = clearDrawing(code, guest!.participantId); + expect(result.error).toBe("Only the drawer can clear the canvas"); + }); + + it("returns error when no drawer assigned (before game start)", () => { + const { code, hostId } = createTestRoom(); + const result = clearDrawing(code, hostId); + expect(result.error).toBe("Only the drawer can clear the canvas"); + }); + + it("clears drawing data for drawer", () => { + const { code, hostId } = createTestRoom(); + startGame(code, hostId); + saveDrawing(code, hostId, "data:image/png;base64,abc"); + const result = clearDrawing(code, hostId); + expect(result.error).toBeUndefined(); + expect(result.room!.drawingData).toBeNull(); + }); +}); + +describe("endRound", () => { + it("returns error for non-existent room", () => { + const result = endRound("ZZZZ", "p1"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when non-drawer/host tries to end", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + const result = endRound(code, guest!.participantId); + expect(result.error).toBe("Only the drawer or host can end the round"); + }); + + it("returns error when round is not active", () => { + const { code, hostId } = createTestRoom(); + const result = endRound(code, hostId); + expect(result.error).toBe("Round is not active"); + }); + + it("ends round when drawer requests", () => { + const { code, hostId } = createTestRoom(); + startGame(code, hostId); + const result = endRound(code, hostId); + expect(result.error).toBeUndefined(); + expect(result.room!.status).toBe("result"); + }); +}); + +describe("restartGame", () => { + it("returns error for non-existent room", () => { + const result = restartGame("ZZZZ", "p1"); + expect(result.error).toBe("Room not found"); + }); + + it("returns error when non-host tries to restart", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + endRound(code, hostId); + const result = restartGame(code, guest!.participantId); + expect(result.error).toBe("Only the host can restart the game"); + }); + + it("returns error when game is not in result state", () => { + const { code, hostId } = createTestRoom(); + const result = restartGame(code, hostId); + expect(result.error).toBe("Game is not in result state"); + }); + + it("clears all round state but preserves participants and host", () => { + const { code, hostId, guest } = createTestRoom(); + const participantIds = [hostId, guest!.participantId].sort(); + startGame(code, hostId); + submitGuess(code, guest!.participantId, "wrong"); + saveDrawing(code, hostId, "data:image/png;base64,abc"); + endRound(code, hostId); + + const beforeRestart = getRoom(code)!; + expect(beforeRestart.status).toBe("result"); + expect(beforeRestart.secretWord).toBeTruthy(); + expect(beforeRestart.drawingData).toBeTruthy(); + expect(beforeRestart.guesses.length).toBeGreaterThan(0); + + const result = restartGame(code, hostId); + expect(result.error).toBeUndefined(); + + const room = result.room!; + expect(room.status).toBe("lobby"); + expect(room.drawerId).toBeNull(); + expect(room.secretWord).toBeNull(); + expect(room.guesses).toEqual([]); + expect(room.drawingData).toBeNull(); + expect(room.hostId).toBe(hostId); + room.participants.forEach((p) => expect(p.score).toBe(0)); + expect(room.participants.map((p) => p.id).sort()).toEqual(participantIds); + }); +}); + +describe("toRoomSnapshot", () => { + it("hides secret word from non-drawer during playing", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + const guestSnapshot = toRoomSnapshot(getRoom(code)!, guest!.participantId); + expect(guestSnapshot.secretWord).toBeNull(); + }); + + it("shows secret word to drawer during playing", () => { + const { code, hostId } = createTestRoom(); + startGame(code, hostId); + const drawerSnapshot = toRoomSnapshot(getRoom(code)!, hostId); + expect(drawerSnapshot.secretWord).toBeTruthy(); + }); + + it("shows secret word to all players on result", () => { + const { code, hostId, guest } = createTestRoom(); + startGame(code, hostId); + endRound(code, hostId); + const guestSnapshot = toRoomSnapshot(getRoom(code)!, guest!.participantId); + expect(guestSnapshot.secretWord).toBeTruthy(); + }); + + it("includes availableWords in snapshot", () => { + const host = createRoom("Alice"); + const snapshot = toRoomSnapshot(host.room); + expect(snapshot.availableWords).toBeInstanceOf(Array); + expect(snapshot.availableWords.length).toBeGreaterThan(0); + }); }); From b883eb1505ce25b47a465a4f86c725f160504b24 Mon Sep 17 00:00:00 2001 From: Suhail Date: Wed, 17 Jun 2026 20:45:11 +0530 Subject: [PATCH 9/9] test: add frontend API tests for startGame, submitGuess, saveDrawing, clearDrawing, endRound, and restartGame --- frontend/src/services/api.test.ts | 137 ++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 67601f5df..91f57ebe4 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -45,4 +45,141 @@ describe("api service", () => { expect.anything() ); }); + + it("startGame sends POST to /rooms/:code/start with participantId", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", drawerId: "p1", secretWord: "pizza" }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + const result = await api.startGame("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/start"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + expect(result.room.status).toBe("playing"); + }); + + it("submitGuess sends POST to /rooms/:code/guess with participantId and text", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", guesses: [] }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.submitGuess("ABCD", "p2", "pizza"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/guess"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p2", text: "pizza" }), + }) + ); + }); + + it("saveDrawing sends POST to /rooms/:code/draw with drawingData", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", drawingData: "data:image/png;base64,abc" }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.saveDrawing("ABCD", "p1", "data:image/png;base64,abc"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/draw"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1", drawingData: "data:image/png;base64,abc" }), + }) + ); + }); + + it("clearDrawing sends POST to /rooms/:code/clear", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", drawingData: null }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.clearDrawing("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/clear"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + }); + + it("endRound sends POST to /rooms/:code/end-round", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "result" }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.endRound("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/end-round"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + }); + + it("restartGame sends POST to /rooms/:code/restart", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "lobby" }, + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.restartGame("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/restart"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + }); + + it("throws on non-ok response", async () => { + const mockResponse = { + ok: false, + json: () => Promise.resolve({ message: "Room not found" }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await expect(api.fetchRoom("ZZZZ")).rejects.toThrow("Room not found"); + }); });