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
74 changes: 74 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Scribble Game Engineering Constitution

This constitution defines the long-term engineering principles and standards for the multiplayer Scribble drawing game. These principles constrain both backend and frontend development and MUST be followed for every feature implementation.

## 1. Code Quality & Maintainability
- **TypeScript First**: All code written for the backend and frontend MUST be fully typed. The use of `any` is strictly prohibited. If a type is truly dynamic, `unknown` MUST be used alongside appropriate type guards or assertions.
- **ES Modules**: Standard relative and absolute ES module imports MUST be used across both environments. File extensions MUST be omitted or handled via `.js` standards where required.
- **Immutability**: Developers SHOULD prefer immutable data structures. Pure functions MUST be used for calculations, state transformations, and game rule evaluations to avoid side effects.
- **React Patterns**: The frontend MUST use functional components and strict React hooks (`useState`, `useEffect`, etc.).
- **Code Style Consistency**: Code style MUST remain consistent with the existing patterns in the project. Linting and formatting rules SHOULD be run before committing changes.

## 2. Simplicity & Modular Design
- **Single Responsibility Principle**: Each module, function, and component MUST have a single, clear responsibility.
- **Backend Architecture & Structure**:
- `src/api` MUST only handle routing, payload validation, and HTTP responses.
- `src/services` MUST contain the core business logic (e.g., room lifecycle, gameplay rules).
- `src/models` MUST define the data structures and TypeScript interfaces.
- **Memory Management**: The backend MUST keep the memory footprint for active game rooms minimal and explicitly remove inactive rooms to avoid stateful bloat.
- **Frontend Architecture & Styling**:
- Components SHOULD be presentational and visually clean.
- Complex state management and logic MUST be encapsulated in custom hooks or state stores under `src/state` (following the established patterns in `roomStore.ts` using Zustand or Context API).
- **Styling Rules**: Classes MUST reside in `app.css` or CSS modules. Components MUST be kept structurally clean.
- **No Over-Engineering**: Features MUST be implemented using the simplest viable logic. Premature optimizations or adding unnecessary abstraction layers SHOULD be avoided.

## 3. Performance & Responsiveness
- **Polling Efficiency**: Because WebSockets are not permitted, client-server sync MUST use HTTP polling.
- The polling frequency MUST be balanced (e.g., ~2 seconds for lobby/gameplay history).
- Backend endpoints serving polling requests MUST keep responses small and fast, avoiding heavy computations.
- **Frontend Render Optimization**: Components MUST use appropriate React hooks (`useMemo`, `useCallback`) to prevent unnecessary re-renders during active canvas updates or polling cycles.
- **Network Payload Minimization**: API responses SHOULD only return fields that are necessary for the current UI state.

## 4. Security & Server-Side Validation
- **Zero Trust Client**: The backend MUST NEVER trust input from the client. All gameplay state, validation of turns, drawer checks, and scoring calculations MUST be performed and stored on the server.
- **Request Validation**: All request payloads (body, parameters, query string) and responses MUST be validated on the backend using `Zod` schemas before any business logic executes.
- **Input Sanitization & Trimming**: Player names and guess inputs MUST be trimmed of leading/trailing whitespace. Whitespace-only or empty strings MUST be rejected with clear error messages.
- **Multi-room Isolation**: All room data MUST be strictly isolated. A client in Room A MUST NEVER be able to query or affect the state of Room B.

## 5. Error Handling & Recovery
- **Fail Fast and Gracefully**: Code SHOULD detect invalid state or conditions early.
- **Backend Recovery**:
- Express routes MUST catch exceptions and forward them to a centralized error handler.
- In-memory data store operations MUST fail gracefully without crashing the Node.js process.
- **Frontend Resilience**:
- The frontend UI MUST NEVER crash due to API errors or unexpected server payloads.
- Polling mechanisms MUST handle transient network failures gracefully, using exponential backoff or simple retries, and showing a fallback/reconnecting indicator to the user.
- Errors from user actions (e.g. entering an invalid room code) MUST be displayed clearly as inline/toast UI messages.

## 6. Testing Expectations
- **Manual Verification**: Features MUST be verified manually using at least two independent browser sessions (tabs or windows) to simulate multiplayer flows.
- **State Transition Testing**: Transitions (e.g., Lobby -> Game -> Result -> Restart) MUST be tested for clean state teardown and setup.
- **Edge Cases**: Empty strings, special characters, case-insensitive guesses, and simultaneous request polling MUST be actively verified.

## 7. Documentation Standards
- **Self-Documenting Code**: Variable, function, and component names MUST be highly descriptive.
- **Inline Documentation**: Complex business logic, state transitions, or math/rendering logic on the canvas MUST be documented with clear inline comments.
- **API Spec Preservation**: Any changes or additions to REST endpoints MUST be documented in the corresponding specification file.

## 8. Accessibility & User Experience
- **Semantic HTML**: Frontend pages MUST use proper HTML5 semantic elements (`<main>`, `<section>`, `<nav>`, `<header>`).
- **Interactive States**: Buttons, links, inputs, and drawing tools MUST have clear hover, focus, and active visual states.
- **Error/Loading States**: Loading indicators MUST be displayed during api requests, and clear validation feedback MUST be rendered for user mistakes.

## 9. AI Collaboration Rules
- **Code Review**: All code generated by AI assistants MUST be reviewed line-by-line for readability, security, and conformity to this constitution before commitment.
- **Step-by-step Refactoring**: Large-scale refactoring SHOULD be broken into smaller, reviewable commits rather than single large edits.
- **Constraint Enforcement**: AI assistants MUST be explicitly instructed on constraints (e.g., no WebSockets, no database) prior to generation.

## 10. Boundaries & Out-of-Scope Technologies
The following technologies and practices are strictly out-of-scope. No code referring to or implementing these MUST be added:
- **No WebSockets**: Do not use Socket.io or native HTML5 WebSockets. Sync MUST be done via HTTP polling.
- **No Databases**: Do not use databases or persistent storage (SQL, MongoDB, Redis, SQLite). All data stores MUST be in-memory only.
- **No Authentication**: Do not use accounts, logins, JWT, OAuth, or sessions.
- **No Deployment/Infrastructure**: Do not configure CI/CD pipelines, Docker, or Kubernetes.
- **No State/Routing Libraries**: Do not introduce new state management or routing libraries beyond what is already present in the starter (React Router v6).
92 changes: 88 additions & 4 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 {
createRoomSchema,
drawingUpdateSchema,
HttpError,
joinRoomSchema,
participantActionSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
submitGuessSchema,
startGameSchema
} 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();
Expand All @@ -32,7 +46,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 All @@ -44,14 +58,84 @@ export function createRoomsRouter() {
}
});

router.post("/:code/start", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = startGameSchema.parse(request.body);
const room = startGame(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 } = submitGuessSchema.parse(request.body);
const room = submitGuess(code.toUpperCase(), participantId, text);

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

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

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

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

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

router.get("/:code", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = roomViewerQuerySchema.parse(request.query);
const room = getRoom(code.toUpperCase());

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

response.json({
Expand Down
23 changes: 22 additions & 1 deletion backend/src/api/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js";
import {
createRoomSchema,
drawingUpdateSchema,
roomCodeParamsSchema,
submitGuessSchema
} from "./schemas.js";

describe("schemas", () => {
it("createRoomSchema accepts a valid body with playerName", () => {
Expand All @@ -11,4 +16,20 @@ describe("schemas", () => {
it("roomCodeParamsSchema rejects missing code", () => {
expect(() => roomCodeParamsSchema.parse({})).toThrow();
});

it("submitGuessSchema accepts participantId and text", () => {
const result = submitGuessSchema.parse({ participantId: "p1", text: "pizza" });
expect(result.participantId).toBe("p1");
expect(result.text).toBe("pizza");
});

it("drawingUpdateSchema accepts strokes payload", () => {
const result = drawingUpdateSchema.parse({
participantId: "p1",
strokes: [{ points: [{ x: 10, y: 20 }, { x: 12, y: 22 }] }]
});

expect(result.strokes).toHaveLength(1);
expect(result.strokes[0].points).toHaveLength(2);
});
});
33 changes: 30 additions & 3 deletions backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,48 @@
import { z } from "zod";

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

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

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

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

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

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

export const drawingUpdateSchema = z.object({
participantId: z.string().min(1, "Participant ID is required"),
strokes: z.array(
z.object({
points: z.array(
z.object({
x: z.number(),
y: z.number()
})
)
})
)
});

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

export class HttpError extends Error {
statusCode: number;

Expand Down
34 changes: 33 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "active" | "results";

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

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

export interface DrawingStroke {
points: Point[];
}

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

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

export interface RoomSnapshot {
code: string;
status: RoomStatus;
hostId: string;
participants: Participant[];
availableWords: string[];
roles: ParticipantRole[];
drawerId?: string;
secretWord?: string;
guesses: Guess[];
scores: Record<string, number>;
drawing: DrawingStroke[];
correctGuessers: string[];
}

export interface RoomSessionResponse {
Expand Down
Loading