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

- No WebSockets.
- Use HTTP short polling at a fixed interval of 2 seconds for synchronization.
- The game supports a single active round at a time per room.
- Player names and guess inputs must be trimmed of leading/trailing whitespace.
- All comparison logic for names and guesses is case-insensitive.
- Rooms are isolated in-memory by 4-character room code.
- The host is always the first participant created in a room.
- Room state is kept in backend memory only; frontend must poll `/api/rooms/:code` for updates.
- New games can be restarted by clearing game-specific state while preserving the connected player list.
- Deterministic behavior is required for secret-word selection and point awards.
14 changes: 14 additions & 0 deletions DISCOVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Discovery Notes: Scribble Assignment

### 1. Incomplete Behaviors Identified
* **Manual Refresh Dependency:** The lobby does not update automatically when new players join; it requires a physical button click.
* **Lack of Host Tracking:** The system treats all players identically in the backend store; it does not know who created the room or who has permission to start the game.
* **Missing Game Loop Infrastructure:** The canvas, guess inputs, scoreboard, and round transition states are entirely non-functional placeholders.

### 2. Engineering Assumptions
* **Polling Interval:** We assume a ~2-second HTTP polling frequency on the frontend is sufficient for state synchronization without overloading the minimal in-memory backend.
* **Single Round Scope:** We assume the game consists of exactly one round with a deterministic word selection, as multi-round rotation is explicitly out of scope.

### 3. Relevant Files Map
* **Frontend:** `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx`, `frontend/src/pages/LobbyPage.tsx`, `frontend/src/api/client.ts`
* **Backend:** `backend/src/app.ts`, `backend/src/router.ts`, `backend/src/store.ts`
9 changes: 9 additions & 0 deletions REFLECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Reflection Report: Scribble Lab

### 1. What the starter app already had:
It possessed a foundational UI shell, routing configurations, and basic room creation/joining capabilities that allowed players to enter a static lobby.

### 2. What was added:
* Automated HTTP short-polling (2s) to synchronize game states across distributed browser windows without manual refreshes.
* State validation routines ensuring strict host authorization mapping, trimmed username enforcement, and isolated multi-room handling.
* Core single-round gameplay loop involving deterministic drawer role selection, case-insensitive guess matching, point allocation, and a clean lobby reset routine.
44 changes: 42 additions & 2 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
HttpError,
joinRoomSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startGameSchema,
restartGameSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import { createRoom, getRoom, joinRoom, startGame, restartGame, toRoomSnapshot } from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand Down Expand Up @@ -44,6 +46,44 @@ 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) {
throw new HttpError(403, "Unable to start game");
}

response.json({
participantId: result.participantId,
room: toRoomSnapshot(result.room, result.participantId)
});
} 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) {
throw new HttpError(403, "Unable to restart game");
}

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

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

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

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

export const roomCodeParamsSchema = z.object({
code: z.string()
code: z.string().trim().min(1)
});

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

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

export const roomViewerQuerySchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createApiRouter, errorHandler, notFoundHandler } from "./api/router.js"
export function createApp() {
const app = express();

app.use(cors());
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.use(express.json());

app.get("/health", (_request, response) => {
Expand Down
21 changes: 20 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "active";

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

export interface Guess {
id: string;
participantId: string;
text: string;
normalizedText: string;
createdAt: string;
}

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
drawerId?: string;
secretWord?: string;
participants: Participant[];
guesses: Guess[];
scores: Record<string, number>;
roundWon: boolean;
createdAt: string;
updatedAt: string;
}
Expand All @@ -19,6 +33,11 @@ export interface RoomSnapshot {
code: string;
status: RoomStatus;
participants: Participant[];
hostId: string;
drawerId?: string;
word: string;
guesses: Guess[];
scores: Record<string, number>;
availableWords: string[];
roles: ParticipantRole[];
}
Expand Down
85 changes: 84 additions & 1 deletion backend/src/services/roomStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ export function createRoom(playerName?: string) {
const room: Room = {
code: generateUniqueCode(),
status: "lobby",
hostId: participant.id,
participants: [participant],
guesses: [],
scores: {},
roundWon: false,
createdAt: now(),
updatedAt: now()
};
Expand Down Expand Up @@ -96,13 +100,92 @@ export function saveRoom(room: Room) {
return getRoom(room.code);
}

function hashString(value: string) {
let hash = 0;

for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}

return hash;
}

function chooseWord(code: string) {
const index = hashString(code) % STARTER_WORDS.length;
return STARTER_WORDS[index];
}

function maskWord(word: string) {
return word.replace(/./g, "_");
}

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

if (!room) {
return null;
}

if (room.hostId !== participantId) {
return undefined;
}

if (room.participants.length < 2) {
return undefined;
}

room.drawerId = room.hostId;
room.secretWord = chooseWord(room.code);
room.status = "active";
room.roundWon = false;
room.guesses = [];
room.updatedAt = now();
rooms.set(room.code, room);

return {
room: cloneRoom(room),
participantId
};
}

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

if (!room) {
return null;
}

if (room.hostId !== participantId) {
return undefined;
}

room.drawerId = undefined;
room.secretWord = undefined;
room.status = "lobby";
room.roundWon = false;
room.guesses = [];
room.updatedAt = now();
rooms.set(room.code, room);

return {
room: cloneRoom(room),
participantId
};
}

export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot {
void viewerParticipantId;
const isDrawer = viewerParticipantId != null && room.drawerId === viewerParticipantId;
const word = room.status === "active" && room.secretWord ? (isDrawer ? room.secretWord : maskWord(room.secretWord)) : "Waiting for game start";

return {
code: room.code,
status: room.status,
participants: room.participants.map((participant) => ({ ...participant })),
hostId: room.hostId,
drawerId: room.drawerId,
word,
guesses: room.guesses,
scores: room.scores,
availableWords: listWords(),
roles: [...STARTER_ROLES]
};
Expand Down
10 changes: 0 additions & 10 deletions frontend/package-lock.json

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

Loading