diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..7d853443 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,25 @@ import { Router } from "express"; import { createRoomSchema, + drawingSchema, + guessSchema, HttpError, joinRoomSchema, + participantActionSchema, roomCodeParamsSchema, roomViewerQuerySchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + clearDrawing, + createRoom, + getRoom, + joinRoom, + restartGame, + startGame, + submitGuess, + toRoomSnapshot, + updateDrawing +} from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -44,6 +57,96 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = startGame(code.toUpperCase(), participantId); + + if (!room) { + throw new HttpError(404, "Unable to start game"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/drawing", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, drawingDataUrl } = drawingSchema.parse(request.body); + const room = updateDrawing(code.toUpperCase(), participantId, drawingDataUrl); + + if (!room) { + throw new HttpError(404, "Unable to update drawing"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = clearDrawing(code.toUpperCase(), participantId); + + if (!room) { + throw new HttpError(404, "Unable to clear drawing"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guesses", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = guessSchema.parse(request.body); + const room = submitGuess(code.toUpperCase(), participantId, guess); + + if (!room) { + throw new HttpError(404, "Unable to submit guess"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = participantActionSchema.parse(request.body); + const room = restartGame(code.toUpperCase(), participantId); + + if (!room) { + throw new HttpError(404, "Unable to restart game"); + } + + response.json({ + room: toRoomSnapshot(room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 12705954..673d6232 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; +import { ZodError } from "zod"; import { createRoomsRouter } from "./rooms.js"; export function createApiRouter() { @@ -29,8 +30,8 @@ export function errorHandler( response: Response, _next: NextFunction ) { - if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + if (error instanceof ZodError) { + response.status(400).json({ message: error.issues[0]?.message ?? "Invalid request payload" }); return; } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea3..a5b5b94d 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,14 +1,30 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { createRoomSchema, guessSchema, roomCodeParamsSchema } from "./schemas.js"; describe("schemas", () => { it("createRoomSchema accepts a valid body with playerName", () => { - const result = createRoomSchema.parse({ playerName: "Alice" }); + const result = createRoomSchema.parse({ playerName: " Alice " }); expect(result.playerName).toBe("Alice"); }); + it("createRoomSchema rejects empty player names", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow("Player name is required"); + }); + it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); + + it("roomCodeParamsSchema rejects malformed room codes", () => { + expect(() => roomCodeParamsSchema.parse({ code: "A" })).toThrow( + "Room code must be 4 letters or numbers" + ); + }); + + it("guessSchema trims valid guesses", () => { + const result = guessSchema.parse({ participantId: "p1", guess: " Rocket " }); + + expect(result.guess).toBe("Rocket"); + }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..f9ac834c 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,21 +1,53 @@ import { z } from "zod"; +const requiredTrimmedString = (message: string) => + z + .string({ + required_error: message, + invalid_type_error: message + }) + .trim() + .min(1, message); + +const participantIdSchema = requiredTrimmedString("Participant id is required"); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: requiredTrimmedString("Player name is required") }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: requiredTrimmedString("Player name is required") }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: requiredTrimmedString("Room code is required").regex( + /^[A-Za-z0-9]{4}$/, + "Room code must be 4 letters or numbers" + ) }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const participantActionSchema = z.object({ + participantId: participantIdSchema +}); + +export const drawingSchema = participantActionSchema.extend({ + drawingDataUrl: z + .string({ + invalid_type_error: "Drawing data must be a data URL" + }) + .startsWith("data:image/png;base64,", "Drawing data must be a PNG data URL") + .max(1_000_000, "Drawing data is too large") + .nullable() +}); + +export const guessSchema = participantActionSchema.extend({ + guess: requiredTrimmedString("Guess is required") +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..36b04b77 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,26 +1,69 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; name: string; + isHost: boolean; joinedAt: string; } +export interface Guess { + id: string; + participantId: string; + playerName: string; + text: string; + correct: boolean; + scoreDelta: number; + createdAt: string; +} + +export interface Round { + drawerId: string; + word: string; + drawingDataUrl: string | null; + guesses: Guess[]; + startedAt: string; + endedAt: string | null; +} + export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; + scores: Record; + round: Round | null; createdAt: string; updatedAt: string; } +export interface ScoreEntry { + participantId: string; + playerName: string; + score: number; +} + +export interface RoomResult { + word: string; + endedAt: string; +} + export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + drawerId: string | null; + drawerName: string | null; + secretWord: string | null; + wordLength: number | null; + drawingDataUrl: string | null; + guesses: Guess[]; + scores: ScoreEntry[]; + result: RoomResult | null; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b..7c4b3c36 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,13 +1,29 @@ -import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearDrawing, + createRoom, + joinRoom, + resetRoomsForTests, + restartGame, + startGame, + submitGuess, + toRoomSnapshot, + updateDrawing +} from "./roomStore.js"; describe("roomStore", () => { + beforeEach(() => { + resetRoomsForTests(); + }); + it("createRoom returns a room with a 4-character uppercase code", () => { - const result = createRoom("Alice"); + const result = createRoom(" Alice "); expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); + expect(result.room.participants[0].isHost).toBe(true); + expect(result.room.hostId).toBe(result.participantId); expect(result.participantId).toBeDefined(); }); @@ -16,4 +32,99 @@ describe("roomStore", () => { expect(result).toBeNull(); }); + + it("starts a game only when the host has at least 2 players", () => { + const host = createRoom("Alice"); + const soloStart = () => startGame(host.room.code, host.participantId); + + expect(soloStart).toThrow("At least 2 players are required to start"); + + joinRoom(host.room.code, "Bob"); + const started = startGame(host.room.code, host.participantId); + + expect(started?.status).toBe("playing"); + expect(started?.round?.drawerId).toBe(host.participantId); + expect(started?.round?.word).toBe("rocket"); + expect(started?.scores).toEqual({ + [host.participantId]: 0, + [started?.participants[1].id ?? ""]: 0 + }); + }); + + it("rejects non-host start attempts", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(() => startGame(host.room.code, guest?.participantId ?? "")).toThrow( + "Only the host can perform this action" + ); + }); + + it("hides the secret word from guessers until result", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + const playing = startGame(host.room.code, host.participantId); + + expect(playing).not.toBeNull(); + + const drawerSnapshot = toRoomSnapshot(playing!, host.participantId); + const guesserSnapshot = toRoomSnapshot(playing!, guest?.participantId); + + expect(drawerSnapshot.secretWord).toBe("rocket"); + expect(guesserSnapshot.secretWord).toBeNull(); + expect(guesserSnapshot.wordLength).toBe(6); + }); + + it("updates and clears drawing only for the drawer", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + startGame(host.room.code, host.participantId); + + expect(() => + updateDrawing(host.room.code, guest?.participantId ?? "", "data:image/png;base64,abc") + ).toThrow("Only the drawer can update the drawing"); + + const drawn = updateDrawing(host.room.code, host.participantId, "data:image/png;base64,abc"); + expect(drawn?.round?.drawingDataUrl).toBe("data:image/png;base64,abc"); + + const cleared = clearDrawing(host.room.code, host.participantId); + expect(cleared?.round?.drawingDataUrl).toBeNull(); + }); + + it("records guesses, scores correct guesses, and exposes result", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + startGame(host.room.code, host.participantId); + + const incorrect = submitGuess(host.room.code, guest?.participantId ?? "", " pizza "); + expect(incorrect?.status).toBe("playing"); + expect(incorrect?.round?.guesses[0]).toMatchObject({ + text: "pizza", + correct: false, + scoreDelta: 0 + }); + + const result = submitGuess(host.room.code, guest?.participantId ?? "", "ROCKET"); + const resultSnapshot = toRoomSnapshot(result!, guest?.participantId); + + expect(result?.status).toBe("result"); + expect(result?.scores[guest?.participantId ?? ""]).toBe(100); + expect(resultSnapshot.secretWord).toBe("rocket"); + expect(resultSnapshot.result?.word).toBe("rocket"); + expect(resultSnapshot.guesses).toHaveLength(2); + }); + + it("restarts from result with players preserved and round state cleared", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + startGame(host.room.code, host.participantId); + submitGuess(host.room.code, guest?.participantId ?? "", "rocket"); + + const restarted = restartGame(host.room.code, host.participantId); + + expect(restarted?.status).toBe("lobby"); + expect(restarted?.participants.map((participant) => participant.name)).toEqual(["Alice", "Bob"]); + expect(restarted?.round).toBeNull(); + expect(restarted?.scores).toEqual({}); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..ea7b575b 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,9 +1,18 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); +export class RoomStoreError extends Error { + statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + function now() { return new Date().toISOString(); } @@ -29,14 +38,21 @@ function generateUniqueCode() { return code; } -function displayName(name?: string) { - return name || "Player"; +function normalisePlayerName(name: string) { + const trimmedName = name.trim(); + + if (!trimmedName) { + throw new RoomStoreError(400, "Player name is required"); + } + + return trimmedName; } -function createParticipant(name?: string): Participant { +function createParticipant(name: string, isHost = false): Participant { return { id: randomUUID(), - name: displayName(name), + name: normalisePlayerName(name), + isHost, joinedAt: now() }; } @@ -49,12 +65,51 @@ export function listWords() { return [...STARTER_WORDS]; } -export function createRoom(playerName?: string) { - const participant = createParticipant(playerName); +function getParticipant(room: Room, participantId: string) { + return room.participants.find((participant) => participant.id === participantId) ?? null; +} + +function requireParticipant(room: Room, participantId: string) { + const participant = getParticipant(room, participantId); + + if (!participant) { + throw new RoomStoreError(403, "Participant is not in this room"); + } + + return participant; +} + +function requireHost(room: Room, participantId: string) { + requireParticipant(room, participantId); + + if (room.hostId !== participantId) { + throw new RoomStoreError(403, "Only the host can perform this action"); + } +} + +function requireActiveRound(room: Room) { + if (room.status !== "playing" || !room.round) { + throw new RoomStoreError(409, "Round is not active"); + } + + return room.round; +} + +function saveLiveRoom(room: Room) { + room.updatedAt = now(); + rooms.set(room.code, room); + return cloneRoom(room); +} + +export function createRoom(playerName: string) { + const participant = createParticipant(playerName, true); const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], + scores: {}, + round: null, createdAt: now(), updatedAt: now() }; @@ -67,20 +122,22 @@ export function createRoom(playerName?: string) { }; } -export function joinRoom(code: string, playerName?: string) { +export function joinRoom(code: string, playerName: string) { const room = rooms.get(code); if (!room) { return null; } + if (room.status !== "lobby") { + throw new RoomStoreError(409, "Game already started"); + } + const participant = createParticipant(playerName); room.participants.push(participant); - room.updatedAt = now(); - rooms.set(room.code, room); return { - room: cloneRoom(room), + room: saveLiveRoom(room), participantId: participant.id }; } @@ -96,14 +153,155 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +export function startGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + requireHost(room, participantId); + + if (room.status !== "lobby") { + throw new RoomStoreError(409, "Game has already started"); + } + + if (room.participants.length < 2) { + throw new RoomStoreError(400, "At least 2 players are required to start"); + } + + const drawer = getParticipant(room, room.hostId) ?? room.participants[0]; + room.status = "playing"; + room.scores = Object.fromEntries(room.participants.map((participant) => [participant.id, 0])); + room.round = { + drawerId: drawer.id, + word: STARTER_WORDS[0], + drawingDataUrl: null, + guesses: [], + startedAt: now(), + endedAt: null + }; + + return saveLiveRoom(room); +} + +export function updateDrawing(code: string, participantId: string, drawingDataUrl: string | null) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + requireParticipant(room, participantId); + const round = requireActiveRound(room); + + if (round.drawerId !== participantId) { + throw new RoomStoreError(403, "Only the drawer can update the drawing"); + } + + round.drawingDataUrl = drawingDataUrl; + return saveLiveRoom(room); +} + +export function clearDrawing(code: string, participantId: string) { + return updateDrawing(code, participantId, null); +} + +export function submitGuess(code: string, participantId: string, guessText: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + const participant = requireParticipant(room, participantId); + const round = requireActiveRound(room); + const text = guessText.trim(); + + if (!text) { + throw new RoomStoreError(400, "Guess is required"); + } + + if (round.drawerId === participantId) { + throw new RoomStoreError(403, "Drawer cannot submit guesses"); + } + + const correct = text.toLowerCase() === round.word.toLowerCase(); + const scoreDelta = correct ? 100 : 0; + const guess: Guess = { + id: randomUUID(), + participantId, + playerName: participant.name, + text, + correct, + scoreDelta, + createdAt: now() + }; + + round.guesses.push(guess); + room.scores[participantId] = (room.scores[participantId] ?? 0) + scoreDelta; + + if (correct) { + room.status = "result"; + round.endedAt = now(); + } + + return saveLiveRoom(room); +} + +export function restartGame(code: string, participantId: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + requireHost(room, participantId); + + if (room.status !== "result") { + throw new RoomStoreError(409, "Round must be complete before restarting"); + } + + room.status = "lobby"; + room.scores = {}; + room.round = null; + + return saveLiveRoom(room); +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const round = room.round; + const drawer = round ? getParticipant(room, round.drawerId) : null; + const canSeeWord = Boolean(round && (room.status === "result" || viewerParticipantId === round.drawerId)); return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), - roles: [...STARTER_ROLES] + roles: [...STARTER_ROLES], + drawerId: round?.drawerId ?? null, + drawerName: drawer?.name ?? null, + secretWord: canSeeWord && round ? round.word : null, + wordLength: round?.word.length ?? null, + drawingDataUrl: round?.drawingDataUrl ?? null, + guesses: round?.guesses.map((guess) => ({ ...guess })) ?? [], + scores: room.participants.map((participant) => ({ + participantId: participant.id, + playerName: participant.name, + score: room.scores[participant.id] ?? 0 + })), + result: + room.status === "result" && round + ? { + word: round.word, + endedAt: round.endedAt ?? room.updatedAt + } + : null }; } + +export function resetRoomsForTests() { + rooms.clear(); +} diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 00000000..59340209 --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState, type PointerEvent } from "react"; + +const CANVAS_WIDTH = 900; +const CANVAS_HEIGHT = 540; + +interface DrawingCanvasProps { + drawingDataUrl: string | null; + disabled: boolean; + hint: string; + onDrawingChange: (drawingDataUrl: string) => void; +} + +function prepareCanvas(canvas: HTMLCanvasElement) { + const context = canvas.getContext("2d"); + + if (!context) { + return null; + } + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = 5; + context.strokeStyle = "#111827"; + + return context; +} + +function getCanvasPoint(canvas: HTMLCanvasElement, event: PointerEvent) { + const bounds = canvas.getBoundingClientRect(); + + return { + x: ((event.clientX - bounds.left) / bounds.width) * CANVAS_WIDTH, + y: ((event.clientY - bounds.top) / bounds.height) * CANVAS_HEIGHT + }; +} + +export function DrawingCanvas({ drawingDataUrl, disabled, hint, onDrawingChange }: DrawingCanvasProps) { + const canvasRef = useRef(null); + const isDrawingRef = useRef(false); + const [hasDrawing, setHasDrawing] = useState(Boolean(drawingDataUrl)); + + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + + const context = prepareCanvas(canvas); + + setHasDrawing(Boolean(drawingDataUrl)); + + if (!context || !drawingDataUrl) { + return; + } + + const image = new Image(); + image.onload = () => { + context.drawImage(image, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + }; + image.src = drawingDataUrl; + }, [drawingDataUrl]); + + function startDrawing(event: PointerEvent) { + if (disabled || !canvasRef.current) { + return; + } + + const context = canvasRef.current.getContext("2d"); + + if (!context) { + return; + } + + const point = getCanvasPoint(canvasRef.current, event); + isDrawingRef.current = true; + setHasDrawing(true); + canvasRef.current.setPointerCapture(event.pointerId); + context.beginPath(); + context.moveTo(point.x, point.y); + } + + function draw(event: PointerEvent) { + if (disabled || !isDrawingRef.current || !canvasRef.current) { + return; + } + + const context = canvasRef.current.getContext("2d"); + + if (!context) { + return; + } + + const point = getCanvasPoint(canvasRef.current, event); + context.lineTo(point.x, point.y); + context.stroke(); + } + + function finishDrawing(event: PointerEvent) { + if (disabled || !isDrawingRef.current || !canvasRef.current) { + return; + } + + isDrawingRef.current = false; + canvasRef.current.releasePointerCapture(event.pointerId); + onDrawingChange(canvasRef.current.toDataURL("image/png")); + } + + const showHint = !hasDrawing; + + return ( +
+ + {showHint ?

{hint}

: null} +
+ ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..d5892dbf 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,14 +1,31 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; interface GuessFormProps { disabled?: boolean; + disabledReason?: string; + onSubmit: (guess: string) => Promise; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ disabled = false, disabledReason, onSubmit }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault(); + const trimmedGuess = guessText.trim(); + + if (!trimmedGuess) { + setError("Guess is required"); + return; + } + + try { + setError(null); + await onSubmit(trimmedGuess); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } } return ( @@ -22,6 +39,8 @@ export function GuessForm({ disabled = false }: GuessFormProps) { disabled={disabled} /> + {disabledReason ?

{disabledReason}

: null} + {error ?

{error}

: null}
+ ) : null} ); } diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..d35d6291 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,29 @@ import { Card } from "./Card"; +import type { ScoreEntry } from "../services/api"; -export function Scoreboard() { +interface ScoreboardProps { + scores: ScoreEntry[]; + drawerId: string | null; +} + +export function Scoreboard({ scores, drawerId }: ScoreboardProps) { return ( -
-
- Waiting for players... - 0 -
-
+ {scores.length === 0 ? ( +

No scores yet.

+ ) : ( +
    + {scores.map((score) => ( +
  1. + + {score.playerName} + {drawerId === score.participantId ? Drawer : null} + + {score.score} +
  2. + ))} +
+ )}
); } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..403e2d92 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { PageHeader } from "../components/PageHeader"; import { useRoomStore } from "../state/roomStore"; @@ -9,12 +9,18 @@ export function CreateRoomPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - async function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + + if (!trimmedName) { + setError("Player name is required"); + return; + } try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); @@ -36,6 +42,7 @@ export function CreateRoomPage() { value={playerName} onChange={(event) => setPlayerName(event.target.value)} placeholder="Sketch captain" + autoComplete="nickname" /> {error ?

{error}

: null} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..b81a8101 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,18 @@ -import { useEffect } from "react"; +import { useEffect, useState } 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 { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); - const { room, participantId } = useRoomState(); + const roomStore = useRoomStore(); + const { room, participantId, error, isLoading } = useRoomState(); + const [actionError, setActionError] = useState(null); useEffect(() => { if (!room) { @@ -17,33 +20,121 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + if (!room) { + return; + } + + const intervalId = window.setInterval(() => { + roomStore.fetchRoom().catch(() => undefined); + }, 2000); + + return () => window.clearInterval(intervalId); + }, [room?.code, roomStore]); + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.drawerId === participantId; + const isHost = room.hostId === participantId; + const isRoundActive = room.status === "playing"; + const drawerName = room.drawerName ?? "Unknown drawer"; + const displayedError = actionError ?? error; + const canvasHint = isRoundActive + ? isDrawer + ? "Draw the secret word here." + : "Waiting for the drawer to sketch." + : "The round is complete."; + const guessDisabledReason = isRoundActive + ? isDrawer + ? "You are drawing this round." + : undefined + : "Guessing is closed."; + + async function handleDrawingChange(drawingDataUrl: string) { + try { + setActionError(null); + await roomStore.updateDrawing(drawingDataUrl); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to save drawing"); + } + } + + async function handleClearDrawing() { + try { + setActionError(null); + await roomStore.clearDrawing(); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to clear drawing"); + } + } + + async function handleGuess(guess: string) { + setActionError(null); + await roomStore.submitGuess(guess); + } + + async function handleRestart() { + try { + setActionError(null); + await roomStore.restartGame(); + navigate("/lobby"); + } catch (caughtError) { + setActionError(caughtError instanceof Error ? caughtError.message : "Unable to restart game"); + } + } return (
Round 1 -

Guess the Word!

+

+ {room.status === "result" ? "Round Results" : `${drawerName} is drawing`} +

+

+ {room.secretWord + ? `Secret word: ${room.secretWord}` + : room.wordLength + ? `Secret word: ${room.wordLength} letters` + : "Waiting for the round to start."} +

- -
- Waiting for drawer... -
+ + Clear Canvas + + ) : null + } + > + + {displayedError ?

{displayedError}

: null}
@@ -56,13 +147,21 @@ export function GamePage() {
Status
-
Playing
+
{isDrawer ? "Drawer" : "Guesser"}
+
+
+
Host
+
{isHost ? "Yes" : "No"}
- +
diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f5304..f9bc33e0 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { PageHeader } from "../components/PageHeader"; import { useRoomStore } from "../state/roomStore"; @@ -10,12 +10,24 @@ export function JoinRoomPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - async function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault(); + const trimmedName = playerName.trim(); + const trimmedRoomCode = roomCode.trim().toUpperCase(); + + if (!trimmedName) { + setError("Player name is required"); + return; + } + + if (!/^[A-Z0-9]{4}$/.test(trimmedRoomCode)) { + setError("Room code must be 4 letters or numbers"); + return; + } try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(trimmedRoomCode, trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); @@ -37,6 +49,7 @@ export function JoinRoomPage() { value={playerName} onChange={(event) => setPlayerName(event.target.value)} placeholder="Second pencil" + autoComplete="nickname" /> @@ -47,6 +60,7 @@ export function JoinRoomPage() { value={roomCode} onChange={(event) => setRoomCode(event.target.value.toUpperCase())} placeholder="ABCD" + maxLength={4} /> {error ?

{error}

: null} diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd28..35744b1a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,7 +8,7 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, participantId, error, isLoading } = useRoomState(); const [refreshError, setRefreshError] = useState(null); useEffect(() => { @@ -17,6 +17,28 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (!room) { + return; + } + + if (room.status !== "lobby") { + navigate("/game", { replace: true }); + } + }, [navigate, room]); + + useEffect(() => { + if (!room) { + return; + } + + const intervalId = window.setInterval(() => { + roomStore.fetchRoom().catch(() => undefined); + }, 2000); + + return () => window.clearInterval(intervalId); + }, [room?.code, roomStore]); + async function handleRefresh() { try { setRefreshError(null); @@ -26,10 +48,28 @@ export function LobbyPage() { } } + async function handleStartGame() { + try { + setRefreshError(null); + await roomStore.startGame(); + navigate("/game"); + } catch (caughtError) { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + } + if (!room) { return null; } + const viewerIsHost = room.hostId === participantId; + const canStart = viewerIsHost && room.participants.length >= 2 && !isLoading; + const startMessage = viewerIsHost + ? room.participants.length >= 2 + ? "You can start the game now." + : "At least 2 players are required to start." + : "Waiting for the host to start the game."; + return (
@@ -50,7 +90,7 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + {participant.isHost ? "host" : "joined"}
  • ))} @@ -58,10 +98,8 @@ export function LobbyPage() { -

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

    -

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

    +

    {isLoading ? "Working..." : "Polling every 2 seconds"}

    +

    {error ?? refreshError ?? startMessage}

    @@ -69,7 +107,7 @@ export function LobbyPage() { - diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 67601f5d..936dbc3c 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -12,7 +12,7 @@ describe("api service", () => { json: () => Promise.resolve({ participantId: "p1", - room: { code: "ABCD", status: "lobby", participants: [] }, + room: { code: "ABCD", status: "lobby", participants: [] } }), }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -20,7 +20,7 @@ describe("api service", () => { await api.createRoom("Alice"); expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/rooms"), + "http://localhost:3001/rooms", expect.objectContaining({ method: "POST", body: JSON.stringify({ playerName: "Alice" }), @@ -33,7 +33,7 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: { code: "XYZW", status: "lobby", participants: [] }, + room: { code: "XYZW", status: "lobby", participants: [] } }), }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -45,4 +45,46 @@ describe("api service", () => { expect.anything() ); }); + + it("startGame sends participantId to /rooms/:code/start", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", participants: [] } + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.startGame("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/start"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }) + }) + ); + }); + + it("submitGuess sends trimmed guess text to /rooms/:code/guesses", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { code: "ABCD", status: "playing", participants: [] } + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.submitGuess("ABCD", "p2", "rocket"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/guesses"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p2", guess: "rocket" }) + }) + ); + }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..0440eff7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,17 +1,49 @@ export type ParticipantRole = "drawer" | "guesser"; +export type RoomStatus = "lobby" | "playing" | "result"; export interface Participant { id: string; name: string; + isHost: boolean; joinedAt: string; } +export interface Guess { + id: string; + participantId: string; + playerName: string; + text: string; + correct: boolean; + scoreDelta: number; + createdAt: string; +} + +export interface ScoreEntry { + participantId: string; + playerName: string; + score: number; +} + +export interface RoomResult { + word: string; + endedAt: string; +} + export interface RoomSnapshot { code: string; - status: "lobby"; + status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + drawerId: string | null; + drawerName: string | null; + secretWord: string | null; + wordLength: number | null; + drawingDataUrl: string | null; + guesses: Guess[]; + scores: ScoreEntry[]; + result: RoomResult | null; } export interface RoomSessionResponse { @@ -19,7 +51,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 +89,35 @@ 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 }) + }); + }, + updateDrawing(code: string, participantId: string, drawingDataUrl: string | null) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing`, { + method: "POST", + body: JSON.stringify({ participantId, drawingDataUrl }) + }); + }, + clearDrawing(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guesses`, { + method: "POST", + body: JSON.stringify({ participantId, guess }) + }); + }, + restartGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..d51635d5 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -94,7 +94,59 @@ class RoomStore { return null; } - const response = await api.fetchRoom(this.state.room.code, this.state.participantId ?? undefined); + try { + const response = await api.fetchRoom(this.state.room.code, this.state.participantId ?? undefined); + this.setRoomSnapshot(response.room); + return response.room; + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to refresh room"; + this.setState({ error: message }); + throw error; + } + } + + private requireActiveSession() { + if (!this.state.room || !this.state.participantId) { + throw new Error("Room session is missing"); + } + + return { + code: this.state.room.code, + participantId: this.state.participantId + }; + } + + async startGame() { + const { code, participantId } = this.requireActiveSession(); + const response = await this.withLoading(() => api.startGame(code, participantId)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async updateDrawing(drawingDataUrl: string | null) { + const { code, participantId } = this.requireActiveSession(); + const response = await api.updateDrawing(code, participantId, drawingDataUrl); + this.setRoomSnapshot(response.room); + return response.room; + } + + async clearDrawing() { + const { code, participantId } = this.requireActiveSession(); + const response = await this.withLoading(() => api.clearDrawing(code, participantId)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(guess: string) { + const { code, participantId } = this.requireActiveSession(); + const response = await this.withLoading(() => api.submitGuess(code, participantId, guess)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async restartGame() { + const { code, participantId } = this.requireActiveSession(); + const response = await this.withLoading(() => api.restartGame(code, participantId)); this.setRoomSnapshot(response.room); return response.room; } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6dd..ea2b0612 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -377,6 +377,12 @@ input { font-weight: 500; } +.form__hint { + margin: 0; + color: var(--ink-soft); + font-size: 0.9rem; +} + .player-list { display: grid; gap: 12px; @@ -416,6 +422,87 @@ input { font-weight: 600; } +.status-line--success { + background: #dcfce7; + color: #166534; +} + +.score-list, +.guess-list { + display: grid; + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} + +.score-list li, +.guess-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-strong); +} + +.score-list span { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ink); + font-weight: 600; +} + +.score-list em { + padding: 2px 8px; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 0.75rem; + font-style: normal; + font-weight: 700; +} + +.score-list strong { + color: var(--ink); + font-size: 1.15rem; +} + +.guess-list li { + align-items: flex-start; +} + +.guess-list span { + color: var(--ink-soft); +} + +.guess-list em { + color: var(--ink-soft); + font-style: normal; + font-weight: 700; +} + +.guess-list__item--correct { + border-color: #86efac; + background: #f0fdf4; +} + +.guess-list__item--correct em { + color: #166534; +} + +.result-summary { + display: grid; + gap: 12px; +} + +.result-summary strong { + color: var(--ink); +} + .placeholder-block { display: grid; gap: 16px; @@ -486,6 +573,42 @@ input { letter-spacing: -0.025em; } +.game-page__subtitle { + margin: 0; + color: var(--ink-soft); + font-size: 1.1rem; +} + +.drawing-board { + position: relative; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + aspect-ratio: 5 / 3; +} + +.drawing-board__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +.drawing-board__hint { + position: absolute; + inset: 0; + display: grid; + place-items: center; + margin: 0; + padding: 24px; + color: var(--ink-soft); + font-size: 1.05rem; + font-weight: 600; + pointer-events: none; + text-align: center; +} + /* Responsive grid: 3-column layout */ .game-page__layout { display: grid; diff --git a/speckit.constitution b/speckit.constitution new file mode 100644 index 00000000..05fce533 --- /dev/null +++ b/speckit.constitution @@ -0,0 +1,22 @@ +# Scribble Constitution + +## Engineering Principles + +1. Preserve the starter architecture: Express backend, React frontend, TypeScript, REST endpoints, and in-memory room state. +2. Keep game rules deterministic. Room setup, drawer assignment, word selection, scoring, and restart behavior must be predictable and testable. +3. Validate at the boundary. Backend request bodies and params must reject empty names, malformed room codes, missing participant ids, and empty guesses with clear messages. +4. Sync through HTTP polling only. WebSockets, databases, authentication, sessions, and unrelated infrastructure are out of scope. +5. Keep room data isolated by room code. Actions for one room must not affect another room. + +## AI Usage Rules + +1. Inspect the existing code before changing behavior. +2. Tie every generated artifact to real files and implemented behavior. +3. Prefer small, reviewable edits over framework rewrites. +4. Run tests and builds after implementation, and record any validation gaps honestly. + +## Review Discipline + +1. Review host-only permissions, player validation, room isolation, word visibility, scoring, and restart reset before submission. +2. Check the UI with two participants in separate browser tabs. +3. Keep implementation and Spec Kit artifacts consistent. If behavior changes, update the relevant artifact in the same branch. diff --git a/speckit.discovery.md b/speckit.discovery.md new file mode 100644 index 00000000..835b60f8 --- /dev/null +++ b/speckit.discovery.md @@ -0,0 +1,31 @@ +# Discovery Notes + +## Incomplete Behaviors Found + +1. Room ownership was not represented. `backend/src/models/game.ts` and `backend/src/services/roomStore.ts` had participants but no host identity, so the UI could not enforce host-only start or restart actions. +2. Game lifecycle was lobby-only. `RoomStatus` only allowed `lobby`, and `frontend/src/pages/GamePage.tsx` rendered placeholders instead of a playing/result state. +3. Lobby synchronization was manual. `frontend/src/pages/LobbyPage.tsx` exposed a refresh button but did not poll room snapshots every 2 seconds. +4. Gameplay commands were absent. `backend/src/api/rooms.ts` only supported create, join, and fetch; there were no start, drawing, clear, guess, or restart endpoints. +5. Input validation was too permissive. Empty player names defaulted to `Player`, room codes were not constrained, and frontend API requests pointed at `http://localhost:3001/bug`. + +## Assumptions + +1. The first round uses the host as drawer because the scenarios say the host or first player should draw first. +2. The deterministic starter word for the first round is the first word in `STARTER_WORDS`, currently `rocket`. +3. A correct guess ends the single required round and moves the room to result state. This gives the lab a clear result path without adding timers or multiple rounds. +4. Drawing sync can use HTTP polling and a saved PNG data URL. No WebSockets or push transport are introduced. + +## Relevant Files + +- `backend/src/models/game.ts` +- `backend/src/services/roomStore.ts` +- `backend/src/api/schemas.ts` +- `backend/src/api/rooms.ts` +- `frontend/src/services/api.ts` +- `frontend/src/state/roomStore.ts` +- `frontend/src/pages/LobbyPage.tsx` +- `frontend/src/pages/GamePage.tsx` +- `frontend/src/components/DrawingCanvas.tsx` +- `frontend/src/components/GuessForm.tsx` +- `frontend/src/components/ResultPanel.tsx` +- `frontend/src/components/Scoreboard.tsx` diff --git a/speckit.plan b/speckit.plan new file mode 100644 index 00000000..0d3e896d --- /dev/null +++ b/speckit.plan @@ -0,0 +1,44 @@ +# Implementation Plan + +## State Model + +`Room` now contains `status`, `hostId`, `participants`, `scores`, and optional `round` state. `Round` contains `drawerId`, deterministic `word`, optional `drawingDataUrl`, ordered `guesses`, `startedAt`, and optional `endedAt`. + +Snapshots are viewer-aware. Drawer and result viewers receive `secretWord`; guessers receive only `wordLength` while playing. Scores are returned in participant order for stable display. + +## Backend Flow + +1. `POST /rooms` creates a host participant and lobby room. +2. `POST /rooms/:code/join` validates code/name and adds a participant only while lobby is open. +3. `POST /rooms/:code/start` requires host and at least 2 players, then creates the first round. +4. `POST /rooms/:code/drawing` and `/clear` require the drawer. +5. `POST /rooms/:code/guesses` requires a guesser, records guesses, scores correct guesses, and moves to result. +6. `POST /rooms/:code/restart` requires the host in result state and clears round state. + +## Frontend Flow + +1. Create and join pages trim inputs and surface backend validation messages. +2. Lobby polls `fetchRoom` every 2 seconds and redirects to game after start. +3. Game polls `fetchRoom` every 2 seconds for drawing, guesses, scores, result, and restart changes. +4. Drawer uses a pointer-event canvas; stroke completion saves a PNG data URL. +5. Guessers submit guesses through the store and see the synced history/result. +6. Host restart returns all players to the lobby state on the next snapshot. + +## Files Changed + +- Backend model, store, schemas, routes, and tests. +- Frontend API service, room store, create/join/lobby/game pages, game components, and CSS. +- Spec Kit artifacts in the repository root. + +## Validation Strategy + +1. Backend unit tests for game rules and schema validation. +2. Backend TypeScript build. +3. Frontend API tests. +4. Frontend TypeScript and Vite production build. +5. Manual two-browser smoke test with backend and frontend dev servers. + +## Risks + +1. Drawing sync uses data URLs and polling, so it is sufficient for the lab but not optimized for high-frequency real-time drawing. +2. The assignment requires one round only; drawer rotation, timers, and multi-round scoring remain intentionally out of scope. diff --git a/speckit.reflection.md b/speckit.reflection.md new file mode 100644 index 00000000..e497c3a1 --- /dev/null +++ b/speckit.reflection.md @@ -0,0 +1,7 @@ +# Reflection + +The implementation kept the starter architecture intact and added only the state and endpoints needed for the four scenarios. The main tradeoff was drawing synchronization: the app stores a canvas PNG data URL after each completed stroke and relies on polling snapshots. That is intentionally simpler than real-time streaming and stays inside the no-WebSockets constraint. + +AI assistance was useful for mapping the scaffold to the scenario gaps and generating consistent tests/artifacts, but the important checks were still code-level: the store now owns the game rules, schemas reject invalid inputs, and frontend controls reflect backend permissions. + +The most important self-review points were host-only permissions, secret-word visibility, empty input handling, and restart cleanup. Automated validation covers those behaviors in backend tests plus frontend API tests and builds. A final manual smoke test should use two browser tabs to confirm the create/join/start/draw/guess/result/restart flow end to end. diff --git a/speckit.specify/01-room-setup-lobby.md b/speckit.specify/01-room-setup-lobby.md new file mode 100644 index 00000000..02114e25 --- /dev/null +++ b/speckit.specify/01-room-setup-lobby.md @@ -0,0 +1,22 @@ +# Specify Iteration 1: Room Setup and Lobby + +## Requirement + +Players can create or join isolated rooms by code. The creator is the host. Lobby membership updates through polling about every 2 seconds. Only the host can start once at least 2 players are present. + +## Acceptance Criteria + +1. Creating a room trims the player name, rejects empty names, creates a 4-character uppercase room code, and marks the creator as host. +2. Joining trims the player name, rejects empty names, rejects empty or malformed room codes, and returns a clear error for unknown rooms. +3. Rooms are isolated by code; participants and game state remain scoped to their own room. +4. Lobby snapshots poll every 2 seconds while the viewer remains in the lobby. +5. Start is disabled for non-hosts and for hosts with fewer than 2 players. +6. Backend start attempts from non-host participants fail with a 403 response. + +## Edge Cases + +- Whitespace-only player name. +- Room code shorter or longer than 4 characters. +- Unknown room code. +- Guest attempting to start the game. +- Host attempting to start alone. diff --git a/speckit.specify/02-game-start-drawer-flow.md b/speckit.specify/02-game-start-drawer-flow.md new file mode 100644 index 00000000..e9022ca6 --- /dev/null +++ b/speckit.specify/02-game-start-drawer-flow.md @@ -0,0 +1,20 @@ +# Specify Iteration 2: Game Start and Drawer Flow + +## Requirement + +When the host starts a valid lobby, the first round begins. The host is assigned as drawer, all players receive zero scores, and the secret word is visible only to the drawer. + +## Acceptance Criteria + +1. Starting from lobby changes room status from `lobby` to `playing`. +2. The drawer id is the host participant id. +3. The deterministic first word is `rocket`, selected from the starter word list. +4. Drawer snapshots include `secretWord`. +5. Guesser snapshots hide `secretWord` and expose only the word length. +6. Score entries exist for all participants and start at 0. + +## Edge Cases + +- Fetching the same room as drawer and guesser returns different word visibility. +- Starting an already-started room fails. +- Joining after the game has started fails with a clear message. diff --git a/speckit.specify/03-gameplay-interaction.md b/speckit.specify/03-gameplay-interaction.md new file mode 100644 index 00000000..07e4a39a --- /dev/null +++ b/speckit.specify/03-gameplay-interaction.md @@ -0,0 +1,22 @@ +# Specify Iteration 3: Gameplay Interaction + +## Requirement + +During an active round, the drawer can draw or clear the canvas. Guessers can submit guesses. Guesses are trimmed, empty guesses are rejected, matching is case-insensitive, and guess history is synchronized through polling. + +## Acceptance Criteria + +1. Only the drawer can update or clear drawing data. +2. Drawing updates are stored as a PNG data URL and included in room snapshots. +3. Guessers can submit non-empty guesses. +4. Drawer guess attempts fail. +5. Incorrect guesses are recorded with `correct: false` and `scoreDelta: 0`. +6. Correct guesses are recorded with `correct: true`, add 100 points, and end the round. +7. Guess history appears for all participants through room polling. + +## Edge Cases + +- Guess with leading or trailing whitespace. +- Guess with different letter casing. +- Empty or whitespace-only guess. +- Non-drawer attempting to modify drawing. diff --git a/speckit.specify/04-result-restart-final-validation.md b/speckit.specify/04-result-restart-final-validation.md new file mode 100644 index 00000000..1fc3869a --- /dev/null +++ b/speckit.specify/04-result-restart-final-validation.md @@ -0,0 +1,21 @@ +# Specify Iteration 4: Result, Restart, and Final Validation + +## Requirement + +After a correct guess ends the round, all participants see the result state with the correct word, final scores, and complete guess history. The host can restart to lobby while preserving players and clearing round state. + +## Acceptance Criteria + +1. A correct guess changes room status to `result`. +2. Result snapshots expose the correct word to every participant. +3. Result UI shows final scores and full guess history. +4. Only the host sees and can use restart. +5. Restart returns the room to `lobby`. +6. Restart preserves participants and host identity. +7. Restart clears drawer id, secret word, drawing data, guesses, result data, and score state. + +## Edge Cases + +- Guest attempting to restart. +- Restart before result state. +- Polling clients returning from result to lobby after restart. diff --git a/speckit.tasks b/speckit.tasks new file mode 100644 index 00000000..ddeee0de --- /dev/null +++ b/speckit.tasks @@ -0,0 +1,34 @@ +# Tasks + +## Discovery and Artifacts + +- [x] Inspect backend room model, route surface, and frontend placeholders. +- [x] Document incomplete behaviors, assumptions, and relevant files. +- [x] Write constitution, four specify iterations, plan, tasks, and reflection. + +## Backend + +- [x] Extend room state with host id, statuses, scores, round, guesses, and result. +- [x] Validate player names, room codes, participant ids, drawing data, and guesses. +- [x] Add start, drawing, clear, guess, and restart endpoints. +- [x] Enforce host-only start/restart and drawer-only drawing. +- [x] Hide secret word from guessers while playing. +- [x] Add backend tests for the required scenarios. + +## Frontend + +- [x] Fix API base URL and add typed command calls. +- [x] Add store actions for start, drawing, clear, guess, and restart. +- [x] Add 2-second polling in lobby and game views. +- [x] Add host-only lobby start state. +- [x] Implement drawing canvas and clear action. +- [x] Implement live scoreboard, guess form, activity history, result panel, and restart action. + +## Validation + +- [x] Run backend tests. +- [x] Run backend build. +- [x] Run frontend tests. +- [x] Run frontend build. +- [x] Run live frontend-load and two-player backend smoke test against local dev servers. +- [ ] Run a final manual two-tab visual check in a real browser before submission.