Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
57 changes: 57 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Scribble Lab Constitution

Engineering principles and AI-assisted development rules for the Scribble brownfield enhancement lab.

## Engineering Principles

1. **Extend, don't rewrite** — build on the starter's existing structure (Express routes, React pages, roomStore pattern). Do not replace working scaffolding.
2. **TypeScript first** — all new code is fully typed. Use Zod for backend validation. Avoid `any`.
3. **Immutability** — prefer pure functions and immutable updates in the room store. Clone rooms on read/write.
4. **Fail fast** — validate inputs at the API boundary. Return clear HTTP error messages. Never silently coerce invalid data.
5. **Minimal memory footprint** — rooms live in a Map; no database. Keep room objects lean; remove inactive rooms when appropriate.
6. **Polling-only sync** — all multi-player synchronization uses HTTP polling (~2s). No WebSockets, Socket.io, or push protocols.
7. **Deterministic game rules** — word selection, scoring, and role assignment must produce the same result given the same room state. No randomness that diverges across polls.
8. **Viewer-specific responses** — `GET /rooms/:code` returns different fields based on `participantId` query param (e.g. secret word visible only to drawer).

## Out of Scope (Do Not Build)

- WebSockets, databases, authentication, deployment/CI/Docker
- Multiple rounds, drawer rotation, timers, custom word packs
- Spectator mode, moderation, room passwords
- New state-management or routing libraries
- Unjustified top-level dependencies or unrelated refactors

## AI Usage Rules

1. **Spec before code** — every feature group gets updated spec, plan, and tasks artifacts before implementation begins.
2. **One scenario at a time** — complete specify → plan → tasks → implement → validate for each phase before moving to the next.
3. **Review all AI output** — read every generated diff. Verify it matches the spec and does not introduce out-of-scope dependencies.
4. **Granular commits** — each commit maps to a single logical change (one artifact set, one backend slice, one frontend slice). Commit messages describe the "why".
5. **No AI attribution in commits** — commit messages are professional and describe the change, not the tool used to produce it.
6. **Validate with two browser tabs** — every phase must be manually verified with two tabs simulating two players before committing.

## Review Discipline

Before each commit:

- [ ] Code matches the current spec's acceptance criteria
- [ ] No forbidden technologies (WebSockets, DB, auth) in code or dependencies
- [ ] Backend and frontend build successfully (`npm run build`)
- [ ] Existing tests pass (`npm test`)
- [ ] No unrelated changes or drive-by refactors
- [ ] Types are consistent between backend model and frontend API types

## Testing Expectations

- Extend existing Vitest tests for new backend logic (roomStore, schemas)
- Extend frontend API tests for new endpoints
- Manual two-tab validation for each scenario
- Run pre-evaluation check before opening PR

## File Conventions

- Backend: `src/api/` (routes), `src/services/` (logic), `src/models/` (types)
- Frontend: `src/pages/` (screens), `src/components/` (UI), `src/state/` (store), `src/services/` (API)
- Artifacts: `.specify/memory/constitution.md`, `specs/NNN-feature-name/{spec,plan,tasks}.md`
- Discovery: `docs/discovery.md`
- Reflection: `reflection.md` at repo root
106 changes: 100 additions & 6 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import { Router } from "express";
import {
addStrokeSchema,
createRoomSchema,
HttpError,
joinRoomSchema,
participantActionSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startGameSchema,
submitGuessSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import {
addStroke,
clearDrawing,
createRoom,
getRoom,
joinRoom,
restartGame,
RoomStoreError,
startGame,
submitGuess,
toRoomSnapshot
} from "../services/roomStore.js";

function handleRoomStoreRoute(
handler: () => ReturnType<typeof getRoom>,
participantId: string | undefined,
response: import("express").Response,
next: import("express").NextFunction
) {
try {
const room = handler();

if (!room) {
throw new HttpError(404, "Room not found");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
if (error instanceof RoomStoreError) {
next(new HttpError(error.statusCode, error.message));
return;
}
next(error);
}
}

export function createRoomsRouter() {
const router = Router();
Expand All @@ -21,6 +61,10 @@ export function createRoomsRouter() {
room: toRoomSnapshot(result.room, result.participantId)
});
} catch (error) {
if (error instanceof RoomStoreError) {
next(new HttpError(error.statusCode, error.message));
return;
}
next(error);
}
});
Expand All @@ -29,10 +73,10 @@ export function createRoomsRouter() {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { playerName } = joinRoomSchema.parse(request.body);
const result = joinRoom(code.toUpperCase(), playerName);
const result = joinRoom(code, playerName);

if (!result) {
throw new HttpError(404, "Unable to join room");
throw new HttpError(404, "Room not found");
}

response.json({
Expand All @@ -48,10 +92,10 @@ export function createRoomsRouter() {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = roomViewerQuerySchema.parse(request.query);
const room = getRoom(code.toUpperCase());
const room = getRoom(code);

if (!room) {
throw new HttpError(404, "Unable to load room");
throw new HttpError(404, "Room not found");
}

response.json({
Expand All @@ -62,5 +106,55 @@ export function createRoomsRouter() {
}
});

router.post("/:code/start", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = startGameSchema.parse(request.body);
handleRoomStoreRoute(() => startGame(code, participantId), participantId, response, next);
} catch (error) {
next(error);
}
});

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

router.post("/:code/drawing/clear", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
handleRoomStoreRoute(() => clearDrawing(code, participantId), participantId, response, next);
} catch (error) {
next(error);
}
});

router.post("/:code/guesses", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, text } = submitGuessSchema.parse(request.body);
handleRoomStoreRoute(() => submitGuess(code, participantId, text), participantId, response, next);
} catch (error) {
next(error);
}
});

router.post("/:code/restart", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
handleRoomStoreRoute(() => restartGame(code, participantId), participantId, response, next);
} catch (error) {
next(error);
}
});

return router;
}
20 changes: 20 additions & 0 deletions backend/src/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,27 @@ describe("schemas", () => {
expect(result.playerName).toBe("Alice");
});

it("createRoomSchema trims playerName", () => {
const result = createRoomSchema.parse({ playerName: " Alice " });

expect(result.playerName).toBe("Alice");
});

it("createRoomSchema rejects empty playerName", () => {
expect(() => createRoomSchema.parse({ playerName: " " })).toThrow();
});

it("roomCodeParamsSchema rejects missing code", () => {
expect(() => roomCodeParamsSchema.parse({})).toThrow();
});

it("roomCodeParamsSchema rejects invalid format", () => {
expect(() => roomCodeParamsSchema.parse({ code: "AB" })).toThrow();
});

it("roomCodeParamsSchema normalizes lowercase codes", () => {
const result = roomCodeParamsSchema.parse({ code: "abcd" });

expect(result.code).toBe("ABCD");
});
});
49 changes: 46 additions & 3 deletions backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,64 @@
import { z } from "zod";

const roomCodePattern = /^[A-Z0-9]{4}$/;

export const createRoomSchema = z.object({
playerName: z.string().optional()
playerName: z
.string()
.optional()
.transform((value) => value?.trim() ?? "")
.refine((value) => value.length > 0, { message: "Player name is required" })
});

export const joinRoomSchema = z.object({
playerName: z.string().optional()
playerName: z
.string()
.optional()
.transform((value) => value?.trim() ?? "")
.refine((value) => value.length > 0, { message: "Player name is required" })
});

export const roomCodeParamsSchema = z.object({
code: z.string()
code: z
.string()
.transform((value) => value.toUpperCase())
.refine((value) => roomCodePattern.test(value), {
message: "Room code must be exactly 4 characters"
})
});

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

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

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

const drawingPointSchema = z.object({
x: z.number(),
y: z.number()
});

export const addStrokeSchema = z.object({
participantId: z.string().min(1),
stroke: z.object({
id: z.string().min(1),
color: z.string().min(1),
width: z.number().positive(),
points: z.array(drawingPointSchema).min(1)
})
});

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

export class HttpError extends Error {
statusCode: number;

Expand Down
41 changes: 39 additions & 2 deletions backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "playing" | "result";

export interface Participant {
id: string;
name: string;
joinedAt: string;
}

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

export interface ParticipantView extends Participant {
role: ParticipantRole;
score: number;
}

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
participants: Participant[];
drawerId: string | null;
secretWord: string | null;
scores: Record<string, number>;
guesses: Guess[];
drawingStrokes: DrawingStroke[];
createdAt: string;
updatedAt: string;
}

export interface DrawingPoint {
x: number;
y: number;
}

export interface DrawingStroke {
id: string;
color: string;
width: number;
points: DrawingPoint[];
}

export interface RoomSnapshot {
code: string;
status: RoomStatus;
participants: Participant[];
hostId: string;
drawerId: string | null;
secretWord?: string;
participants: ParticipantView[];
guesses: Guess[];
drawingStrokes: DrawingStroke[];
availableWords: string[];
roles: ParticipantRole[];
}
Expand Down
Loading