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

## Engineering Principles
1. **TypeScript First**: All new code must be fully typed. Avoid `any`; use `unknown` for dynamic types.
2. **Immutability**: Prefer pure functions and immutable data. Use `structuredClone` for state mutations.
3. **Fail fast**: Validate inputs at API boundaries with Zod. Return clear error messages.
4. **No stateful bloat**: Clean up room state on restart. Keep memory footprint minimal.
5. **No WebSockets**: All sync is via HTTP polling. No Socket.io or real-time push.
6. **No database**: All data is in-memory only.
7. **No authentication**: No accounts, sessions, JWT, or OAuth.
8. **Deterministic game rules**: Word selection must be deterministic (e.g., based on player count). No randomness in gameplay.

## AI Usage Rules
1. **Review all AI output** before committing. Do not trust generated code blindly.
2. **Trace to spec**: Every implementation change must map to a spec acceptance criterion.
3. **No speculative code**: Do not build features outside the spec or out-of-scope items.
4. **Keep the commit trail clean**: Granular, meaningful commits. Each commit should be explainable.

## Review Discipline
1. **Spec → Plan → Tasks → Implement → Validate**: Complete each phase before moving to the next.
2. **Validate with two browsers**: Test all scenarios with two browser tabs before considering a feature group complete.
3. **Build before commit**: Run `npm run build` on both backend and frontend before each commit.
4. **Test existing tests**: Run `npm test` on both sides after changes to ensure no regressions.

## Coding Standards
- File extensions: Use `.js` extension in backend imports (NodeNext resolution).
- Imports: Standard ES module imports. Relative imports within each app.
- Error handling: Use `HttpError` class for API errors. Handle errors in UI without crashing.
- State management: Use the existing `RoomStore` class pattern. Do not add new state libraries.
- Styling: Use existing CSS custom properties and BEM-like naming in `app.css`.
3 changes: 0 additions & 3 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 129 additions & 3 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { Router } from "express";
import {
clearDrawBodySchema,
createRoomSchema,
drawBodySchema,
endRoundBodySchema,
guessBodySchema,
HttpError,
joinRoomSchema,
restartBodySchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startGameBodySchema,
startGameParamsSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import {
clearDrawing,
createRoom,
endRound,
getRoom,
joinRoom,
restartGame,
saveDrawing,
startGame,
submitGuess,
toRoomSnapshot
} from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand All @@ -32,7 +50,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 +80,113 @@ export function createRoomsRouter() {
}
});

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

if (result.error) {
throw new HttpError(400, result.error);
}

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

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

if (result.error) {
throw new HttpError(400, result.error);
}

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

router.post("/:code/draw", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, drawingData } = drawBodySchema.parse(request.body);
const result = saveDrawing(code.toUpperCase(), participantId, drawingData);

if (result.error) {
throw new HttpError(400, result.error);
}

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

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

if (result.error) {
throw new HttpError(400, result.error);
}

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

router.post("/:code/end-round", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = endRoundBodySchema.parse(request.body);
const result = endRound(code.toUpperCase(), participantId);

if (result.error) {
throw new HttpError(400, result.error);
}

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

router.post("/:code/restart", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = restartBodySchema.parse(request.body);
const result = restartGame(code.toUpperCase(), participantId);

if (result.error) {
throw new HttpError(400, result.error);
}

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

return router;
}
6 changes: 4 additions & 2 deletions backend/src/api/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ZodError } from "zod";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
import { createRoomsRouter } from "./rooms.js";
Expand Down Expand Up @@ -29,8 +30,9 @@ export function errorHandler(
response: Response,
_next: NextFunction
) {
if (error.name === "ZodError") {
response.status(400).json({ message: "Invalid request payload" });
if (error instanceof ZodError) {
const firstIssue = error.issues[0];
response.status(400).json({ message: firstIssue?.message ?? "Invalid request payload" });
return;
}

Expand Down
Loading