Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Scribble Lab Constitution

## Engineering Principles

1. **Brownfield first** — Extend the starter's patterns (Express routes, Zod validation, custom RoomStore with `useSyncExternalStore`). Do not rewrite or replace working scaffolding.
2. **HTTP polling only** — All multiplayer sync uses periodic `GET /rooms/:code` requests (~2s). No WebSockets, Socket.io, or push protocols.
3. **In-memory state** — All room data lives in the backend `Map`. No databases, files, or external persistence.
4. **Deterministic game rules** — Word selection, scoring (+100 correct, 0 incorrect), and role assignment must produce the same outcome for the same inputs. Document the algorithm in spec and plan.
5. **Fail fast with clear errors** — Invalid inputs return 400 with human-readable messages. Unknown rooms return 404. Unauthorized actions (non-host start, drawer guessing) return 403.
6. **TypeScript strictness** — No `any`. Shared concepts typed in backend models and mirrored in frontend API types.

## AI Usage Rules

1. **Spec before code** — Each feature group gets spec → plan → tasks before implementation commits.
2. **Review all AI output** — Verify generated code against acceptance criteria in two browser tabs before committing.
3. **No scope creep** — Do not add timers, multi-round rotation, auth, databases, or new dependencies unless the spec justifies them.
4. **Traceable commits** — Each commit maps to a task in `tasks.md`. Commit messages use conventional prefixes (`feat`, `fix`, `docs`, `test`).
5. **Artifacts stay aligned** — If implementation deviates from spec, update the spec and document the reason.

## Review Discipline

1. **Two-tab validation** — Every scenario is verified with two browser tabs simulating host and joiner.
2. **Edge cases required** — Empty names, invalid room codes, case-insensitive guesses, multi-room isolation.
3. **Build must pass** — Run `npm run build` and `npm test` in both backend and frontend before PR.
4. **Pre-evaluation check** — Ensure constitution (≥800 chars), 4 feature folders with spec/plan/tasks, and reflection (≥500 chars) exist.
5. **Self-review checklist** — Before each commit: Does behavior match spec? Are forbidden technologies absent? Is viewer-specific data filtered on the backend?

## Testing Expectations

- Extend existing Vitest tests for roomStore and API schemas.
- Test name validation, host assignment, start preconditions, guess scoring, and restart reset in backend unit tests.
- Manual two-tab testing is the primary integration validation method for this lab.
34 changes: 34 additions & 0 deletions .specify/memory/discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Discovery Notes — Scribble Starter

## Incomplete Behaviors (≥3)

1. **No host tracking** — Room creator is not marked as host; any participant can click "Start Game" and navigate to `/game` without a backend call.
2. **No automatic lobby polling** — Participants only appear after manual "Refresh Room"; the assignment requires ~2s HTTP polling.
3. **Game screen is placeholder-only** — Canvas is a static div, guess form is a no-op, scoreboard and result panel show hardcoded placeholder text.
4. **No viewer-specific API responses** — `toRoomSnapshot` ignores `viewerParticipantId`; secret word cannot be hidden from guessers.
5. **Player name validation missing** — Empty or whitespace-only names silently become `"Player"` on both backend and frontend.
6. **Broken default API URL** — Frontend defaults to `http://localhost:3001/bug` instead of `http://localhost:3001`.

## Assumptions (≥2)

1. **Single round per game session** — The lab scope is one round: start → draw/guess → result → restart to lobby. No drawer rotation or multi-round logic.
2. **Round ends on first correct guess** — When a guesser submits a case-insensitive match, status transitions to `result` and scoring is final.
3. **Host is always the drawer** — On game start, the room creator (host) becomes the drawer for the single round.
4. **Deterministic word selection** — Secret word is chosen by hashing the room code against the starter word list (same word for the same room code every time).
5. **Polling is the sole sync mechanism** — All state (participants, strokes, guesses, scores, status) syncs via `GET /rooms/:code?participantId=...` at ~2s intervals.

## Relevant Files

### Backend
- `backend/src/models/game.ts` — Room and participant types
- `backend/src/services/roomStore.ts` — In-memory store and snapshot builder
- `backend/src/api/rooms.ts` — HTTP route handlers
- `backend/src/api/schemas.ts` — Zod request validation
- `backend/src/seed/starterData.ts` — Word list and roles

### Frontend
- `frontend/src/services/api.ts` — REST client and types
- `frontend/src/state/roomStore.ts` — Global room state
- `frontend/src/pages/LobbyPage.tsx` — Lobby UI (manual refresh only)
- `frontend/src/pages/GamePage.tsx` — Placeholder game UI
- `frontend/src/components/GuessForm.tsx`, `Scoreboard.tsx`, `ResultPanel.tsx` — Non-functional placeholders
94 changes: 89 additions & 5 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { Router } from "express";
import {
addStrokeSchema,
createRoomSchema,
HttpError,
joinRoomSchema,
mapStoreError,
participantActionSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
submitGuessSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import {
addStroke,
clearCanvas,
createRoom,
getRoom,
joinRoom,
restartGame,
startGame,
submitGuess,
toRoomSnapshot
} from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand All @@ -21,7 +35,7 @@ export function createRoomsRouter() {
room: toRoomSnapshot(result.room, result.participantId)
});
} catch (error) {
next(error);
next(mapStoreError(error));
}
});

Expand All @@ -40,7 +54,7 @@ export function createRoomsRouter() {
room: toRoomSnapshot(result.room, result.participantId)
});
} catch (error) {
next(error);
next(error instanceof HttpError ? error : mapStoreError(error));
}
});

Expand All @@ -58,7 +72,77 @@ export function createRoomsRouter() {
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
next(error instanceof HttpError ? error : mapStoreError(error));
}
});

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);

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(mapStoreError(error));
}
});

router.post("/:code/strokes", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, stroke } = addStrokeSchema.parse(request.body);
const room = addStroke(code.toUpperCase(), participantId, stroke);

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(mapStoreError(error));
}
});

router.post("/:code/clear", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
const room = clearCanvas(code.toUpperCase(), participantId);

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(mapStoreError(error));
}
});

router.post("/:code/guess", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, guess } = submitGuessSchema.parse(request.body);
const room = submitGuess(code.toUpperCase(), participantId, guess);

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(mapStoreError(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);

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(mapStoreError(error));
}
});

Expand Down
68 changes: 67 additions & 1 deletion backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,39 @@ export const joinRoomSchema = z.object({
});

export const roomCodeParamsSchema = z.object({
code: z.string()
code: z
.string()
.trim()
.min(1, "Room code is required")
});

export const roomViewerQuerySchema = z.object({
participantId: z.string().optional()
});

export const participantActionSchema = z.object({
participantId: z.string().min(1, "Participant ID is required")
});

export const strokePointSchema = z.object({
x: z.number().min(0).max(1),
y: z.number().min(0).max(1)
});

export const strokeSchema = z.object({
points: z.array(strokePointSchema).min(1)
});

export const addStrokeSchema = z.object({
participantId: z.string().min(1),
stroke: strokeSchema
});

export const submitGuessSchema = z.object({
participantId: z.string().min(1),
guess: z.string()
});

export class HttpError extends Error {
statusCode: number;

Expand All @@ -24,3 +50,43 @@ export class HttpError extends Error {
this.statusCode = statusCode;
}
}

export function mapStoreError(error: unknown): HttpError {
if (error instanceof HttpError) {
return error;
}

const message = error instanceof Error ? error.message : "Unexpected error";

if (message === "Player name is required" || message === "Guess is required") {
return new HttpError(400, message);
}

if (
message === "Only the host can start the game" ||
message === "Only the host can restart the game" ||
message === "Only the drawer can draw" ||
message === "Only the drawer can clear the canvas" ||
message === "Drawer cannot guess"
) {
return new HttpError(403, message);
}

if (
message === "Unable to load room" ||
message === "Participant not found"
) {
return new HttpError(404, message);
}

if (
message === "Game already started" ||
message === "At least 2 players are required to start" ||
message === "Game is not in progress" ||
message === "Game is not in result state"
) {
return new HttpError(400, message);
}

return new HttpError(500, message);
}
32 changes: 31 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
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 StrokePoint {
x: number;
y: number;
}

export interface Stroke {
points: StrokePoint[];
}

export interface Guess {
id: string;
participantId: string;
participantName: string;
text: string;
correct: boolean;
createdAt: string;
}

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
drawerId?: string;
secretWord?: string;
participants: Participant[];
strokes: Stroke[];
guesses: Guess[];
createdAt: string;
updatedAt: string;
}

export interface RoomSnapshot {
code: string;
status: RoomStatus;
hostId: string;
isHost: boolean;
drawerId?: string;
secretWord?: string;
participants: Participant[];
strokes: Stroke[];
guesses: Guess[];
availableWords: string[];
roles: ParticipantRole[];
}
Expand Down
Loading