Skip to content
Closed
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/001-room-setup-lobby"
}
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri
- Give concise, direct answers.
- Do not output large blocks of code if a small change suffices.
- When creating or editing files, ensure consistency with the existing directory structure detailed above.

<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the implementation plan
at `specs/001-room-setup-lobby/plan.md` and the research at
`specs/001-room-setup-lobby/research.md`.
<!-- SPECKIT END -->
36 changes: 33 additions & 3 deletions backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
HttpError,
joinRoomSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
roomViewerQuerySchema,
startRoomSchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand All @@ -32,7 +33,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. Please check the code and try again");
}

response.json({
Expand All @@ -44,6 +45,35 @@ export function createRoomsRouter() {
}
});

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

const result = startGame(code.toUpperCase(), participantId);

if (!result) {
throw new HttpError(404, "Room not found");
}

response.json({
room: toRoomSnapshot(result.room, participantId)
});
} catch (error) {
if (error instanceof Error && error.message === "Only the host can start the game") {
next(new HttpError(403, error.message));
return;
}

if (error instanceof Error && error.message === "At least 2 players are needed to start the game") {
next(new HttpError(400, error.message));
return;
}

next(error);
}
});

router.get("/:code", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
Expand Down
6 changes: 5 additions & 1 deletion backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ export const joinRoomSchema = z.object({
});

export const roomCodeParamsSchema = z.object({
code: z.string()
code: z.string().min(1, "Room code is required")
});

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

export const roomViewerQuerySchema = z.object({
Expand Down
4 changes: 3 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "playing";

export interface Participant {
id: string;
Expand All @@ -9,6 +9,7 @@ export interface Participant {

export interface Room {
code: string;
hostId: string;
status: RoomStatus;
participants: Participant[];
createdAt: string;
Expand All @@ -17,6 +18,7 @@ export interface Room {

export interface RoomSnapshot {
code: string;
hostId: string;
status: RoomStatus;
participants: Participant[];
availableWords: string[];
Expand Down
26 changes: 26 additions & 0 deletions backend/src/services/roomStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function createRoom(playerName?: string) {
const participant = createParticipant(playerName);
const room: Room = {
code: generateUniqueCode(),
hostId: participant.id,
status: "lobby",
participants: [participant],
createdAt: now(),
Expand Down Expand Up @@ -96,11 +97,36 @@ export function saveRoom(room: Room) {
return getRoom(room.code);
}

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

if (!room) {
return null;
}

if (room.hostId !== participantId) {
throw new Error("Only the host can start the game");
}

if (room.participants.length < 2) {
throw new Error("At least 2 players are needed to start the game");
}

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

return {
room: getRoom(room.code)!
};
}

export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot {
void viewerParticipantId;

return {
code: room.code,
hostId: room.hostId,
status: room.status,
participants: room.participants.map((participant) => ({ ...participant })),
availableWords: listWords(),
Expand Down
6 changes: 0 additions & 6 deletions frontend/package-lock.json

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

10 changes: 8 additions & 2 deletions frontend/src/pages/JoinRoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ export function JoinRoomPage() {

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);

const trimmedCode = roomCode.trim();
if (!trimmedCode) {
setError("Please enter a room code");
return;
}

try {
setError(null);
await roomStore.joinRoom(roomCode.toUpperCase(), playerName);
await roomStore.joinRoom(trimmedCode.toUpperCase(), playerName);
navigate("/lobby");
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : "Unable to join room");
Expand Down
57 changes: 52 additions & 5 deletions frontend/src/pages/LobbyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useRoomState, useRoomStore } from "../state/roomStore";
export function LobbyPage() {
const navigate = useNavigate();
const roomStore = useRoomStore();
const { room, error, isLoading } = useRoomState();
const { room, error, isLoading, participantId } = useRoomState();
const [refreshError, setRefreshError] = useState<string | null>(null);

useEffect(() => {
Expand All @@ -17,6 +17,29 @@ export function LobbyPage() {
}
}, [navigate, room]);

useEffect(() => {
if (room?.status === "playing") {
navigate("/game");
}
}, [navigate, room?.status]);

useEffect(() => {
if (!room) {
return;
}

const interval = setInterval(async () => {
try {
setRefreshError(null);
await roomStore.fetchRoom();
} catch (caughtError) {
setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room");
}
}, 2000);

return () => clearInterval(interval);
}, [room, roomStore]);

async function handleRefresh() {
try {
setRefreshError(null);
Expand All @@ -30,6 +53,20 @@ export function LobbyPage() {
return null;
}

const isHost = participantId === room.hostId;
const hasEnoughPlayers = room.participants.length >= 2;
const canStart = isHost && hasEnoughPlayers && !isLoading;

async function handleStartGame() {
try {
setRefreshError(null);
await roomStore.startGame();
navigate("/game");
} catch (caughtError) {
setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to start game");
}
}

return (
<section className="panel placeholder-page">
<div className="lobby-header">
Expand All @@ -50,7 +87,11 @@ export function LobbyPage() {
{room.participants.map((participant) => (
<li key={participant.id}>
<span>{participant.name}</span>
<span className="player-list__meta">joined</span>
{participant.id === room.hostId ? (
<span className="player-list__meta player-list__meta--host">Host</span>
) : (
<span className="player-list__meta">joined</span>
)}
</li>
))}
</ul>
Expand All @@ -61,16 +102,22 @@ export function LobbyPage() {
<p className="status-line" style={{ backgroundColor: isLoading ? '#fef3c7' : '#e0e7ff', color: isLoading ? '#b45309' : '#3730a3' }}>
{isLoading ? "Refreshing players..." : "Ready to play"}
</p>
<p style={{ marginTop: '8px' }}>{error ?? refreshError ?? "Waiting for the host to start the game."}</p>
<p style={{ marginTop: '8px' }}>
{error ?? refreshError ?? (
isHost
? (hasEnoughPlayers ? "Click Start Game to begin" : "Need at least 2 players to start")
: "Waiting for the host to start the game."
)}
</p>
</Card>
</div>

<div className="button-row button-row--spread">
<button className="button button--secondary" disabled={isLoading} onClick={handleRefresh}>
{isLoading ? "Refreshing..." : "Refresh Room"}
</button>
<button className="button button--primary" onClick={() => navigate("/game")}>
Start Game
<button className="button button--primary" disabled={!canStart} onClick={handleStartGame}>
{isHost ? "Start Game" : "Waiting for host..."}
</button>
</div>
</section>
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export interface Participant {

export interface RoomSnapshot {
code: string;
status: "lobby";
hostId: string;
status: "lobby" | "playing";
participants: Participant[];
availableWords: string[];
roles: ParticipantRole[];
Expand Down Expand Up @@ -57,5 +58,11 @@ export const api = {
fetchRoom(code: string, participantId?: string) {
const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : "";
return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`);
},
startGame(code: string, participantId: string) {
return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, {
method: "POST",
body: JSON.stringify({ participantId })
});
}
};
12 changes: 12 additions & 0 deletions frontend/src/state/roomStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ class RoomStore {
this.setRoomSnapshot(response.room);
return response.room;
}

async startGame() {
if (!this.state.room || !this.state.participantId) {
throw new Error("No active room session");
}

const response = await this.withLoading(() =>
api.startGame(this.state.room!.code, this.state.participantId!)
);
this.setRoomSnapshot(response.room);
return response.room;
}
}

const RoomStoreContext = createContext<RoomStore | null>(null);
Expand Down
34 changes: 34 additions & 0 deletions specs/001-room-setup-lobby/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Specification Quality Checklist: Room Setup And Lobby

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-27
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

All items passed validation. No clarifications needed. Spec is ready for planning.
Loading
Loading