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
3 changes: 3 additions & 0 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/004-result-restart"
}
129 changes: 129 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<!--
SYNC IMPACT REPORT
==================
Version change: (template) → 1.0.0
Modified principles: All placeholders replaced with concrete project rules
Added sections: N/A (template structure preserved)
Removed sections: N/A
Templates reviewed:
- .specify/templates/plan-template.md ✅ no structural changes required
- .specify/templates/spec-template.md ✅ no structural changes required
- .specify/templates/tasks-template.md ✅ no structural changes required
Follow-up TODOs: None — all placeholders resolved
-->

# Scribble Constitution

## Core Principles

### I. Brownfield-First

This project is a brownfield enhancement of an existing starter application.
All work MUST extend the existing codebase — rewriting the starter from scratch
is prohibited. Before adding any file or function, confirm the starter does not
already provide it. Prefer minimal additions over sweeping restructuring.
Existing conventions (naming, file structure, import style) MUST be followed
unless the spec explicitly introduces a new pattern with justification.

### II. Spec-Driven Development

Every code change MUST be traceable to an artifact (spec, plan, or task).
Implementation decisions that deviate from the spec MUST be documented in
the spec before committing. No code is written before the relevant acceptance
criterion exists in `speckit.specify`. No task is started before it appears in
`speckit.tasks`.

Artifacts are updated incrementally — one feature group at a time — in this
strict order: specify → clarify → plan → tasks → checklist → implement → validate.

### III. Deterministic Game Rules

All game-rule outcomes MUST be deterministic and reproducible given the same
inputs. Specifically:

- Secret word: selected by `STARTER_WORDS[(round - 1) % word count]` — index 0,
so the first (and only) round always uses `"rocket"`.
- Drawer assignment: the room creator (host, first participant) is always the
drawer for this single-round game.
- Scoring: correct guess = 100 points; incorrect guess = 0 points. No bonuses,
no partial credit, no time modifiers.

Any deviation from these rules MUST be documented as a spec amendment before
implementation.

### IV. Strict Scope Discipline

The following are permanently out of scope and MUST NOT appear in any artifact
or code:

- WebSockets or real-time sync (polling only, ~2 s cadence)
- Databases or persistent storage (in-memory only)
- Authentication, accounts, or sessions
- Multiple rounds, drawer rotation, timers, countdowns, or bonuses
- Custom word packs, spectator mode, room moderation, passwords, or invite links
- Deployment, CI/CD, or Docker configuration
- New top-level npm dependencies not justified by a spec requirement
- Refactors of unrelated starter code

When an edge case is ambiguous, the simpler interpretation within scope MUST be
chosen and recorded as an assumption.

### V. Incremental Validation

Work proceeds in four feature groups, each gated by a validation checkpoint.
A group is complete only when its acceptance criteria pass in two browser tabs.
The next group MUST NOT begin implementation until the current group passes.

| Group | Gate |
|-------|------|
| 1. Room Setup & Lobby | Host tracking, polling, host-only start, 2-player minimum |
| 2. Game Start & Drawer Flow | Name validation, drawer assignment, drawer-only word |
| 3. Gameplay Interaction | Canvas, guess submit, synced history, scoring |
| 4. Result, Restart & Final Validation | Result state, clean restart, preserved players |

### VI. AI-Assisted, Human-Reviewed

AI output (code, specs, plans, tasks) MUST be reviewed and understood before
committing. The developer is responsible for every line in the diff, regardless
of whether AI generated it. AI suggestions that introduce out-of-scope features
or deviate from the spec MUST be rejected.

All AI usage decisions and tradeoffs MUST be recorded in `REFLECTION.md`.

## Coding Standards

- TypeScript strict mode required on both frontend and backend.
- No `any` except where the existing starter already uses it.
- No comments explaining what code does — only comments for non-obvious WHY
(hidden constraints, spec-specific invariants).
- Validation occurs at system boundaries only (HTTP request body, user form
input). Internal functions trust their callers.
- User-facing error messages MUST be human-readable (e.g., "Name cannot be
empty", not "Validation error").
- Polling MUST use `setInterval` / `clearInterval` inside `useEffect` with
proper cleanup. No `setTimeout` chains.

## Review Discipline

Before committing any implementation:

1. Verify the changed behavior matches at least one acceptance criterion in the
current feature group's spec.
2. Run `npm run build` in both `backend/` and `frontend/` — zero TypeScript
errors required.
3. Manually test the happy path and at least one error path in the browser.
4. Confirm no out-of-scope code was introduced.

## Governance

This constitution supersedes all other informal conventions for the duration of
this lab. Amendments require a version bump following semantic versioning:

- MAJOR: removal or redefinition of an existing principle.
- MINOR: new principle or materially expanded guidance.
- PATCH: clarification, wording, or typo fix.

`LAST_AMENDED_DATE` is updated on every change. All PRs must verify compliance
before approval.

**Version**: 1.0.0 | **Ratified**: 2026-05-28 | **Last Amended**: 2026-05-28
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at:
specs/001-room-setup-lobby/plan.md
<!-- SPECKIT END -->
86 changes: 86 additions & 0 deletions REFLECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Reflection Report

## What the Starter App Already Had

- App shell with routing between Start, Create Room, Join Room, Lobby, and Game screens
- Branded Scribble landing page and basic UI styling
- Create Room flow: generates a unique 4-char room code, adds creator as participant, navigates to lobby
- Join Room flow: accepts a room code, adds the player, navigates to lobby
- Lobby: displays room code and participant list with a manual Refresh button
- In-memory room store on the backend (`Map<string, Room>`)
- `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code` endpoints
- Seed data: words (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) and roles (`drawer`, `guesser`)
- Placeholder Game screen with non-functional canvas, guess form, scoreboard, and result panel
- A deliberate bug: API base URL pointed to `http://localhost:3001/bug` instead of `http://localhost:3001`

## What Was Added

### Spec Kit Artifacts

Produced and committed four sets of Spec Kit artifacts — one per feature group — each following the full loop: specify → clarify → plan → tasks → implement → commit.

- **Constitution** (`/.specify/memory/constitution.md`): six principles covering brownfield-first development, spec-driven workflow, deterministic game rules, strict scope discipline, incremental validation, and AI-assisted human-reviewed development
- **4 spec files** with user stories, acceptance criteria, edge cases, functional requirements, success criteria, and assumptions
- **4 plan files** with constitution checks, data model changes, API contracts, data flow, and implementation sequences
- **4 task files** with phase-ordered, dependency-aware task lists mapped to user stories
- **Clarification session** on Group 1 resolving two ambiguities: host-navigates-immediately vs. non-hosts-via-polling, and auto-navigate vs. manual action on status change

### Feature Group 1 — Room Setup & Lobby

- `hostId` added to `Room` and `RoomSnapshot` (first participant = host)
- Name validation: trim + `min(1)` via Zod on backend; client-side trim + empty-check on frontend
- Room code normalised to uppercase on join
- `POST /rooms/:code/start`: host-only, requires ≥2 players, transitions to `"playing"`
- Lobby auto-polling every 2s via `setInterval` / `clearInterval` in `useEffect`
- Non-hosts auto-navigate to `/game` when poll detects `status === "playing"`
- Host-only Start Game button (enabled only when `isHost && participants.length >= 2`)
- Fixed starter bug: `/bug` suffix in API base URL removed

### Feature Group 2 — Game Start & Drawer Flow

- `drawerId` and `secretWord` set on `startGame()`: drawer = host, word = `STARTER_WORDS[0]` ("rocket")
- `toRoomSnapshot()` made viewer-aware: `secretWord` included only when viewer === drawer
- Game screen shows role label (Drawer / Guesser) and secret word card for drawer only

### Feature Group 3 — Gameplay Interaction

- `guesses[]` and `scores` map added to `Room`; initialised on `startGame()`
- `POST /rooms/:code/guess`: trim, empty-check (400), case-insensitive comparison, correct = 100 / incorrect = 0
- `GET /rooms/:code` returns guesses and scores in snapshot for all viewers
- `DrawingCanvas` component: freehand HTML5 canvas with Clear button (drawer only)
- `GuessForm` wired with validation and `onSubmit` prop
- `Scoreboard` and `ResultPanel` (guess history) rendered from live snapshot
- Game screen polls every 2s to sync history and scores

### Feature Group 4 — Result, Restart & Final Validation

- `POST /rooms/:code/end`: host-only, `playing → result`
- `POST /rooms/:code/restart`: host-only, `result → lobby`; clears guesses, scores, drawerId, secretWord; preserves participants
- `secretWord` revealed to all viewers when `status === "result"` (round over)
- `ResultPage`: shows correct word, final scores, full guess history; host-only Restart button; polls every 2s; auto-navigates to `/lobby` on restart
- Game screen poll auto-navigates to `/result` on status change
- Host-only End Round button on game screen
- `/result` route added

## Tradeoffs and Decisions

**Polling over WebSockets**: The lab mandated polling (~2s). The approach is simple and sufficient — a fixed `setInterval` with `clearInterval` cleanup on unmount prevents stale background requests.

**Deterministic word selection**: `STARTER_WORDS[0]` ("rocket") is used for the single round rather than any index formula, keeping the implementation as minimal as the spec requires.

**Server-side `secretWord` visibility**: Enforced in `toRoomSnapshot()` rather than on the client, so a guesser cannot see the word by inspecting the API response. This is the correct defence layer.

**Restart preserves participants**: A deliberate design choice per spec — participants are not cleared on restart, only round state (guesses, scores, drawer, word). This allows the same group to play again without rejoining.

**Canvas is local-only**: Drawing is not synced to guessers (WebSockets are out of scope). The guesser sees a static placeholder. This is explicitly documented in the spec assumptions.

## AI Usage Notes

Claude Code generated all spec, plan, task, and implementation artifacts. Each artifact was reviewed for spec alignment and scope before committing. The key human decisions were:

- Confirming the recommended clarification answers (host navigates immediately; non-hosts via polling; auto-navigate on status change)
- Approving each phase of implementation after visual testing in the browser before committing
- Catching that the Zod error handler needed to surface the field-level message rather than a generic string
- Deciding to reveal `secretWord` to all viewers in `result` status (not just the drawer) — a natural extension of the spec's requirement that "all players see the correct word"

Every commit is traceable to a specific set of functional requirements in the corresponding spec file.
58 changes: 55 additions & 3 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Router } from "express";
import {
createRoomSchema,
guessSchema,
HttpError,
joinRoomSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startRoomSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import { createRoom, endGame, getRoom, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand All @@ -32,7 +34,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({
Expand Down Expand Up @@ -62,5 +64,55 @@ export function createRoomsRouter() {
}
});

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

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

router.post("/:code/end", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = startRoomSchema.parse(request.body);
const room = endGame(code.toUpperCase(), participantId);
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 } = startRoomSchema.parse(request.body);
const room = restartGame(code.toUpperCase(), participantId);
response.json({ room: toRoomSnapshot(room, participantId) });
} catch (error) {
next(error);
}
});

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

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

return router;
}
4 changes: 3 additions & 1 deletion backend/src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export function errorHandler(
_next: NextFunction
) {
if (error.name === "ZodError") {
response.status(400).json({ message: "Invalid request payload" });
const zodError = error as { errors?: Array<{ message: string }> };
const message = zodError.errors?.[0]?.message ?? "Invalid request payload";
response.status(400).json({ message });
return;
}

Expand Down
13 changes: 11 additions & 2 deletions backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { z } from "zod";

export const createRoomSchema = z.object({
playerName: z.string().optional()
playerName: z.string().trim().min(1, "Name cannot be empty")
});

export const joinRoomSchema = z.object({
playerName: z.string().optional()
playerName: z.string().trim().min(1, "Name cannot be empty")
});

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

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

export const roomCodeParamsSchema = z.object({
Expand Down
19 changes: 18 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
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 {
participantId: string;
text: string;
correct: boolean;
submittedAt: string;
}

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
drawerId?: string;
secretWord?: string;
guesses: Guess[];
scores: Record<string, number>;
participants: Participant[];
createdAt: string;
updatedAt: string;
Expand All @@ -18,6 +30,11 @@ export interface Room {
export interface RoomSnapshot {
code: string;
status: RoomStatus;
hostId: string;
drawerId?: string;
secretWord?: string;
guesses: Guess[];
scores: Record<string, number>;
participants: Participant[];
availableWords: string[];
roles: ParticipantRole[];
Expand Down
Loading
Loading