Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7c346f9
fix API base URL for room endpoints
shailajanimmagari7 May 29, 2026
85a818f
add discovery notes
shailajanimmagari7 May 29, 2026
1a581c7
add project constitution
shailajanimmagari7 May 29, 2026
b9e3cda
add spec scenario 1 requirements
shailajanimmagari7 May 30, 2026
59d5381
add scenario 1 implementation plan
shailajanimmagari7 May 30, 2026
a86aa33
add scenario 1 tasks breakdown
shailajanimmagari7 May 30, 2026
b410961
add room host tracking
shailajanimmagari7 May 30, 2026
884faf5
add room code validation
shailajanimmagari7 May 30, 2026
c77aef4
add automatic lobby polling
shailajanimmagari7 May 30, 2026
e0558ad
enforce host-only game start
shailajanimmagari7 May 30, 2026
08bc6f3
validate scenario 1 acceptance criteria
shailajanimmagari7 May 30, 2026
6c3fe7e
add scenario 2 game start requirements
shailajanimmagari7 May 30, 2026
12720a2
add scenario 2 implementation plan
shailajanimmagari7 May 30, 2026
0f8b73a
add scenario 2 work breakdown
shailajanimmagari7 May 30, 2026
bea9b69
complete scenario 2 game start and drawer flow
shailajanimmagari7 May 30, 2026
99ea6e9
add scenario 3 gameplay requirements
shailajanimmagari7 May 30, 2026
f286c94
add scenario 3 implementation plan
shailajanimmagari7 May 30, 2026
48e59d9
add scenario 3 work breakdown
shailajanimmagari7 May 30, 2026
26ed4c5
complete scenario 3 gameplay interaction
shailajanimmagari7 May 30, 2026
4de6882
add scenario 4 result and restart requirements
shailajanimmagari7 May 30, 2026
f3cb028
add scenario 4 implementation plan
shailajanimmagari7 May 30, 2026
d766115
add scenario 4 work breakdown
shailajanimmagari7 May 30, 2026
b910c53
complete scenario 4 result and restart flow
shailajanimmagari7 May 30, 2026
3733a4b
add assignment reflection report
shailajanimmagari7 May 30, 2026
d139e2c
add required spec kit artifacts
shailajanimmagari7 May 30, 2026
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
35 changes: 35 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Constitution

Rules that govern every decision made during this assignment.
Deviating from these rules requires an explicit note in the commit message.

---

## 1. No Extra Features

Build only what the current scenario requires.
Do not add fields, endpoints, components, or behavior that are not demanded by the acceptance criteria being worked on.
Out-of-scope items listed in the README (WebSockets, timers, multiple rounds, auth, etc.) are permanently off the table.
If something seems useful but is not in the spec, write it down as a future note — do not build it.

## 2. Test Before Commit

Every commit must leave the test suite passing.
Run `npm test` in both `backend/` and `frontend/` before staging.
Run `npm run build` in both directories to confirm there are no type errors.
If a change breaks an existing test, fix the test or fix the code — do not skip or delete the test.
New logic that can be unit tested (validation, scoring, snapshot filtering) must have a test before the code is committed.

## 3. Small Commits

Each commit should cover exactly one logical change: a model update, a single endpoint, a single component, a state action, or a test.
A commit that touches both backend and frontend is a signal to split it.
Commit messages must state what changed and why in plain language.
Large "everything works now" commits are not acceptable.

## 4. Review AI-Generated Code

Never commit AI-generated code without reading it line by line first.
Verify that the code matches the spec, not just that it looks reasonable.
Check for: invented fields or endpoints not in the plan, silent error suppression, logic that differs from acceptance criteria, and unnecessary abstractions.
If the AI output requires more than minor edits to be correct, treat the output as a draft and rewrite the relevant parts before committing.
101 changes: 98 additions & 3 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Router } from "express";
import {
createRoomSchema,
endRoundSchema,
HttpError,
joinRoomSchema,
restartGameSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startGameSchema,
submitGuessSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import { createRoom, endRound, getRoom, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand All @@ -32,7 +36,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,6 +48,97 @@ export function createRoomsRouter() {
}
});

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

if (result === null) {
throw new HttpError(404, "Room not found");
}
if (result === "not-host") {
throw new HttpError(403, "Only the host can start the game");
}
if (result === "not-enough-players") {
throw new HttpError(422, "Need at least 2 players to start");
}

response.json({ room: result });
} 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 result = submitGuess(code.toUpperCase(), participantId, text);

if (result === "room-not-found") {
throw new HttpError(404, "Room not found");
}
if (result === "not-playing") {
throw new HttpError(422, "Game is not in progress");
}
if (result === "participant-not-found") {
throw new HttpError(404, "Participant not found");
}
if (result === "is-drawer") {
throw new HttpError(403, "Drawer cannot submit a guess");
}

response.json({ room: result });
} catch (error) {
next(error);
}
});

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

if (result === null) {
throw new HttpError(404, "Room not found");
}
if (result === "not-host") {
throw new HttpError(403, "Only the host can end the round");
}
if (result === "not-playing") {
throw new HttpError(422, "Game is not in progress");
}

response.json({ room: result });
} catch (error) {
next(error);
}
});

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

if (result === null) {
throw new HttpError(404, "Room not found");
}
if (result === "not-host") {
throw new HttpError(403, "Only the host can restart the game");
}
if (result === "not-finished") {
throw new HttpError(422, "Round has not finished yet");
}

response.json({ room: result });
} catch (error) {
next(error);
}
});

router.get("/:code", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
Expand Down
21 changes: 19 additions & 2 deletions backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { z } from "zod";

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

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

export const roomCodeParamsSchema = z.object({
Expand All @@ -16,6 +16,23 @@ export const roomViewerQuerySchema = z.object({
participantId: z.string().optional()
});

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

export const submitGuessSchema = z.object({
participantId: z.string().min(1),
text: z.string().trim().min(1, "Guess cannot be empty")
});

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

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

export class HttpError extends Error {
statusCode: number;

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,28 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "playing" | "finished";

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

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

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
drawerId: string | null;
currentWord: string | null;
guesses: Guess[];
participants: Participant[];
createdAt: string;
updatedAt: string;
Expand All @@ -18,6 +31,10 @@ export interface Room {
export interface RoomSnapshot {
code: string;
status: RoomStatus;
hostId: string;
drawerId: string | null;
currentWord: string | null;
guesses: Guess[];
participants: Participant[];
availableWords: string[];
roles: ParticipantRole[];
Expand Down
100 changes: 96 additions & 4 deletions backend/src/services/roomStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import type { Participant, Room, RoomSnapshot } from "../models/game.js";
import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js";
import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js";

const rooms = new Map<string, Room>();
Expand Down Expand Up @@ -37,7 +37,8 @@ function createParticipant(name?: string): Participant {
return {
id: randomUUID(),
name: displayName(name),
joinedAt: now()
joinedAt: now(),
score: 0
};
}

Expand All @@ -54,6 +55,10 @@ export function createRoom(playerName?: string) {
const room: Room = {
code: generateUniqueCode(),
status: "lobby",
hostId: participant.id,
drawerId: null,
currentWord: null,
guesses: [],
participants: [participant],
createdAt: now(),
updatedAt: now()
Expand Down Expand Up @@ -96,12 +101,99 @@ export function saveRoom(room: Room) {
return getRoom(room.code);
}

export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot {
void viewerParticipantId;
function selectWord(code: string): string {
const index = Array.from(code).reduce((sum, char) => sum + char.charCodeAt(0), 0) % STARTER_WORDS.length;
return STARTER_WORDS[index];
}

export function startGame(code: string, participantId: string) {
const room = rooms.get(code);

if (!room) return null;
if (room.hostId !== participantId) return "not-host" as const;
if (room.participants.length < 2) return "not-enough-players" as const;

room.status = "playing";
room.drawerId = room.hostId;
room.currentWord = selectWord(room.code);
room.updatedAt = now();
rooms.set(room.code, room);

return toRoomSnapshot(cloneRoom(room), participantId);
}

export function submitGuess(code: string, participantId: string, text: string) {
const room = rooms.get(code);

if (!room) return "room-not-found" as const;
if (room.status !== "playing") return "not-playing" as const;

const participant = room.participants.find((p) => p.id === participantId);
if (!participant) return "participant-not-found" as const;
if (participantId === room.drawerId) return "is-drawer" as const;

const trimmed = text.trim().toLowerCase();
const isCorrect = trimmed === room.currentWord!.trim().toLowerCase();

if (isCorrect && participant.score < 100) {
participant.score = 100;
}

const guess: Guess = {
participantId,
participantName: participant.name,
text: text.trim(),
isCorrect,
submittedAt: now()
};

room.guesses.push(guess);
room.updatedAt = now();
rooms.set(room.code, room);

return toRoomSnapshot(cloneRoom(room), participantId);
}

export function endRound(code: string, participantId: string) {
const room = rooms.get(code);

if (!room) return null;
if (room.hostId !== participantId) return "not-host" as const;
if (room.status !== "playing") return "not-playing" as const;

room.status = "finished";
room.updatedAt = now();
rooms.set(room.code, room);

return toRoomSnapshot(cloneRoom(room), participantId);
}

export function restartGame(code: string, participantId: string) {
const room = rooms.get(code);

if (!room) return null;
if (room.hostId !== participantId) return "not-host" as const;
if (room.status !== "finished") return "not-finished" as const;

room.status = "lobby";
room.drawerId = null;
room.currentWord = null;
room.guesses = [];
room.participants.forEach((p) => { p.score = 0; });
room.updatedAt = now();
rooms.set(room.code, room);

return toRoomSnapshot(cloneRoom(room), participantId);
}

export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot {
return {
code: room.code,
status: room.status,
hostId: room.hostId,
drawerId: room.drawerId,
currentWord: room.status === "finished" || viewerParticipantId === room.drawerId ? room.currentWord : null,
guesses: room.guesses.map((g) => ({ ...g })),
participants: room.participants.map((participant) => ({ ...participant })),
availableWords: listWords(),
roles: [...STARTER_ROLES]
Expand Down
Loading
Loading