Two players can play a simultaneous quiz together #1

Open
opened 2026-04-15 15:42:49 +00:00 by forgejo-lila · 0 comments
Owner

User story

Two logged-in players can create and join a game lobby, then play a vocabulary quiz together in real time. Both players see the same question at the same time, answer independently, and see each other's results after each round. After all rounds, both see the final scores and a winner.

What the user experiences

  1. Player A clicks "Create Lobby" on the multiplayer page. They see a lobby screen with a code (e.g. WOLF-42) and a player list showing just themselves.
  2. Player A shares the code with Player B (via text, voice, etc.).
  3. Player B enters the code on the multiplayer page and clicks "Join." Both players now see each other in the lobby.
  4. Player A (the host) clicks "Start Game."
  5. Both players see the same question with 4 options. They each pick an answer independently.
  6. After both have answered (or a 15-second timeout), both see who got it right, the correct answer, and the updated scores.
  7. This repeats for 10 rounds.
  8. After the last round, both see a final score screen with the winner. They can choose to play again (back to lobby) or leave.

What this slice does NOT include

  • No public lobby browser (join by code only)
  • No game mode selection (hardcoded to simultaneous-answer)
  • No Valkey (use in-memory state — acceptable for this slice)
  • No reconnection handling (disconnect = you're out)
  • No private/public toggle (all lobbies are joinable by code)
  • No spectators
  • No more than basic styling

These are all separate slices that build on top of this one.

Technical implementation

Database

File: packages/db/src/db/schema.ts

Add two tables:

lobbies:
  id: uuid, PK, default random
  code: varchar(10), unique, not null
  host_user_id: text, FK to user.id, not null
  status: varchar(20), not null, CHECK ('waiting', 'in_progress', 'finished')
  created_at: timestamp with timezone, default now

lobby_players:
  lobby_id: uuid, FK to lobbies.id (cascade), not null
  user_id: text, FK to user.id (cascade), not null
  score: integer, default 0
  joined_at: timestamp with timezone, default now
  PK: (lobby_id, user_id)

Keep it minimal — no game_mode, is_private, settings, or max_players columns yet. Those come in later slices. Hardcode behavior to: simultaneous mode, max 4 players, public.

Add game mode constants to packages/shared/src/constants.ts for future use, but the schema doesn't need them yet.

Model: packages/db/src/models/lobbyModel.ts — new file. Functions: createLobby(), addPlayer(), removePlayer(), getLobbyByCode(), getLobbyWithPlayers(), updateLobbyStatus().

Generate and run migration: pnpm --filter db generate && pnpm --filter db migrate.

Shared schemas

File: packages/shared/src/schemas/lobby.ts — new file.

REST schemas:

  • CreateLobbyRequestSchema — empty for now (no options in this slice)
  • JoinLobbyResponseSchema — lobby with players
  • LobbySchema, LobbyPlayerSchema

WebSocket schemas:

  • Client → Server: WsLobbyJoinSchema { type: 'lobby:join', code }, WsLobbyLeaveSchema { type: 'lobby:leave', lobbyId }, WsLobbyStartSchema { type: 'lobby:start', lobbyId }, WsGameAnswerSchema { type: 'game:answer', lobbyId, questionId, selectedOptionId }
  • Server → Client: WsLobbyStateSchema { type: 'lobby:state', lobby }, WsGameQuestionSchema { type: 'game:question', question, questionNumber, totalQuestions }, WsGameAnswerResultSchema { type: 'game:answer_result', correctOptionId, players }, WsGameFinishedSchema { type: 'game:finished', players, winnerId }
  • Combined: WsClientMessageSchema = z.discriminatedUnion('type', [...]), WsServerMessageSchema = z.discriminatedUnion('type', [...])

Export everything from packages/shared/src/index.ts.

REST API

Router: apps/api/src/routes/lobbyRouter.ts — new file. Mount in apiRouter.ts on /lobbies.

POST /api/v1/lobbies         — create lobby, return lobby with code
POST /api/v1/lobbies/:code/join — join lobby, return lobby with players

Both require auth. Follow existing pattern: router → controller → service → model.

Controller: apps/api/src/controllers/lobbyController.ts — validate input, call service, send response.

Service: apps/api/src/services/lobbyService.tscreateLobby(hostUserId): generate unique code, insert lobby + host as first player. joinLobby(code, userId): validate lobby exists, status is 'waiting', not full (hardcode max 4), insert player.

Code generation: simple approach — random 6-character alphanumeric (e.g. A3X9K2). Check uniqueness via DB constraint, retry on collision.

WebSocket server

File: apps/api/src/ws/index.ts — new file. Set up ws.WebSocketServer with noServer: true.

File: apps/api/src/server.ts — change from app.listen() to:

const server = createServer(app);
const wss = setupWebSocket(server);
server.listen(PORT);

File: apps/api/src/ws/auth.ts — new file. authenticateWs(request) extracts Better Auth session from the upgrade request headers using auth.api.getSession({ headers: fromNodeHeaders(request.headers) }). Reject with 401 if no session.

File: apps/api/src/ws/router.ts — new file. Parse incoming JSON, validate against WsClientMessageSchema, dispatch by type to handlers.

File: apps/api/src/ws/connections.ts — new file. In-memory tracking of which WebSocket connections belong to which lobby. Map<lobbyId, Map<userId, WebSocket>>. Functions: addConnection(), removeConnection(), getConnections(), broadcastToLobby().

File: apps/api/src/ws/handlers/lobbyHandlers.ts — new file.

  • handleLobbyJoin(ws, data, user) — register connection, broadcast lobby:state to all in lobby.
  • handleLobbyLeave(ws, data, user) — remove from DB and connections, broadcast. If host leaves, close lobby.
  • handleLobbyStart(ws, data, user) — validate host, validate >= 2 players, generate questions, store game state in memory, update lobby status, broadcast first game:question, start 15s timer.

File: apps/api/src/ws/handlers/gameHandlers.ts — new file.

  • handleGameAnswer(ws, data, user) — store answer. When all players have answered OR timer expires: evaluate all answers, update scores, broadcast game:answer_result. If more rounds: broadcast next game:question after 3s delay. If last round: broadcast game:finished, update DB scores, set lobby status to 'finished'.

Game state (in-memory for this slice):

Map<lobbyId, {
  questions: GameQuestion[],       // generated at start
  answers: Map<string, number>[],  // correctOptionId per question
  currentIndex: number,
  playerAnswers: Map<userId, selectedOptionId>,  // current round
  scores: Map<userId, number>,
  timer: NodeJS.Timeout
}>

On WebSocket disconnect: treat as implicit leave. If during a game, that player is marked as timed out for remaining questions.

Frontend

Route: apps/web/src/routes/multiplayer.tsx — new file. Simple page with:

  • "Create Lobby" button (POST to create endpoint, navigate to lobby view)
  • "Join by Code" input + button (POST to join endpoint, navigate to lobby view)
  • Requires auth (same beforeLoad pattern as play.tsx)

Route: apps/web/src/routes/multiplayer/lobby.$code.tsx — new file. Lobby waiting room:

  • On mount: connect WebSocket, send lobby:join { code }.
  • Display: lobby code (large, copyable), player list (from lobby:state messages).
  • Host sees "Start Game" button (disabled until 2+ players).
  • On game:question received: navigate to game view.
  • On unmount: send lobby:leave, disconnect WebSocket.

Route: apps/web/src/routes/multiplayer/game.$code.tsx — new file. Game view:

  • Reuse QuestionCard and OptionButton from apps/web/src/components/game/.
  • Receive game:question → display question.
  • Player picks answer → send game:answer via WebSocket.
  • Receive game:answer_result → show correct answer, both players' results, scores. Show "Next" button or auto-advance after 3s.
  • Receive game:finished → show final scores.

Component: apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx — new file. Final score screen:

  • Shows both players' scores, highlights winner.
  • "Play Again" button (navigates back to lobby, lobby status resets to 'waiting').
  • "Leave" button (navigates to /multiplayer).

File: apps/web/src/lib/ws-client.ts — new file. Minimal WebSocket client:

  • connect(url), disconnect(), send(message), on(type, callback), off(type, callback).
  • No reconnection logic in this slice (comes later).
  • Connection URL: derive from VITE_API_URL — replace https:// with wss:// (or http:// with ws://).

Navigation: Add a "Multiplayer" link to the app navigation (in __root.tsx or wherever the nav is).

Caddy

No changes needed. Caddy already proxies WebSocket connections to the API automatically when it sees the Upgrade: websocket header.

Dependencies to install

  • ws and @types/ws in apps/api

Acceptance criteria

  • Player A can create a lobby and sees a join code
  • Player B can join using the code and both see each other in the player list
  • Host can start the game when 2+ players are present
  • Both players see the same question at the same time
  • After both answer (or 15s timeout), both see who got it right and the scores
  • After 10 rounds, both see final scores and a winner
  • "Play Again" returns both to the lobby
  • "Leave" returns to the multiplayer page
  • Works on mobile
  • Works in production (wss:// via Caddy)
  • Lobby is cleaned up if host disconnects during waiting phase
  • Player disconnecting during a game doesn't crash the other player's experience

Test plan

Manual testing

  • Open two browser tabs (or two devices), log in with different accounts
  • Create lobby in tab 1, join in tab 2, play a full game
  • Test: host leaves during waiting, player leaves during game, timeout behavior, both answer correctly, both answer wrong

Automated tests

  • lobbyService.test.ts — unit tests: create lobby, join, join full, join started, leave, host leave
  • gameHandlers.test.ts — unit tests: start game, submit answer, evaluate round, game finished, timeout
  • Integration tests with supertest for REST endpoints

Files summary

New files:

  • packages/shared/src/schemas/lobby.ts
  • packages/db/src/models/lobbyModel.ts
  • apps/api/src/routes/lobbyRouter.ts
  • apps/api/src/controllers/lobbyController.ts
  • apps/api/src/services/lobbyService.ts
  • apps/api/src/ws/index.ts
  • apps/api/src/ws/auth.ts
  • apps/api/src/ws/router.ts
  • apps/api/src/ws/connections.ts
  • apps/api/src/ws/handlers/lobbyHandlers.ts
  • apps/api/src/ws/handlers/gameHandlers.ts
  • apps/web/src/routes/multiplayer.tsx
  • apps/web/src/routes/multiplayer/lobby.$code.tsx
  • apps/web/src/routes/multiplayer/game.$code.tsx
  • apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
  • apps/web/src/lib/ws-client.ts

Modified files:

  • packages/db/src/db/schema.ts — add lobbies + lobby_players tables
  • packages/shared/src/index.ts — export new schemas
  • apps/api/src/routes/apiRouter.ts — mount lobby router
  • apps/api/src/server.ts — switch to createServer + WebSocket setup
  • apps/api/package.json — add ws dependency
  • apps/web/src/routes/__root.tsx — add multiplayer nav link

Drizzle migration: 1 new migration file generated

What comes next (future slices that build on this)

  1. Public lobby browser — browse and join without a code
  2. Countdown timer UI — visual timer synced with server
  3. Private lobbies — toggle when creating
  4. Game mode selection — choose mode at lobby creation
  5. Elimination mode — wrong answer eliminates
  6. Reconnection handling — graceful recovery
## User story Two logged-in players can create and join a game lobby, then play a vocabulary quiz together in real time. Both players see the same question at the same time, answer independently, and see each other's results after each round. After all rounds, both see the final scores and a winner. ## What the user experiences 1. Player A clicks "Create Lobby" on the multiplayer page. They see a lobby screen with a code (e.g. `WOLF-42`) and a player list showing just themselves. 2. Player A shares the code with Player B (via text, voice, etc.). 3. Player B enters the code on the multiplayer page and clicks "Join." Both players now see each other in the lobby. 4. Player A (the host) clicks "Start Game." 5. Both players see the same question with 4 options. They each pick an answer independently. 6. After both have answered (or a 15-second timeout), both see who got it right, the correct answer, and the updated scores. 7. This repeats for 10 rounds. 8. After the last round, both see a final score screen with the winner. They can choose to play again (back to lobby) or leave. ## What this slice does NOT include - No public lobby browser (join by code only) - No game mode selection (hardcoded to simultaneous-answer) - No Valkey (use in-memory state — acceptable for this slice) - No reconnection handling (disconnect = you're out) - No private/public toggle (all lobbies are joinable by code) - No spectators - No more than basic styling These are all separate slices that build on top of this one. ## Technical implementation ### Database **File:** `packages/db/src/db/schema.ts` Add two tables: ``` lobbies: id: uuid, PK, default random code: varchar(10), unique, not null host_user_id: text, FK to user.id, not null status: varchar(20), not null, CHECK ('waiting', 'in_progress', 'finished') created_at: timestamp with timezone, default now lobby_players: lobby_id: uuid, FK to lobbies.id (cascade), not null user_id: text, FK to user.id (cascade), not null score: integer, default 0 joined_at: timestamp with timezone, default now PK: (lobby_id, user_id) ``` Keep it minimal — no `game_mode`, `is_private`, `settings`, or `max_players` columns yet. Those come in later slices. Hardcode behavior to: simultaneous mode, max 4 players, public. Add game mode constants to `packages/shared/src/constants.ts` for future use, but the schema doesn't need them yet. **Model:** `packages/db/src/models/lobbyModel.ts` — new file. Functions: `createLobby()`, `addPlayer()`, `removePlayer()`, `getLobbyByCode()`, `getLobbyWithPlayers()`, `updateLobbyStatus()`. Generate and run migration: `pnpm --filter db generate && pnpm --filter db migrate`. ### Shared schemas **File:** `packages/shared/src/schemas/lobby.ts` — new file. REST schemas: - `CreateLobbyRequestSchema` — empty for now (no options in this slice) - `JoinLobbyResponseSchema` — lobby with players - `LobbySchema`, `LobbyPlayerSchema` WebSocket schemas: - Client → Server: `WsLobbyJoinSchema { type: 'lobby:join', code }`, `WsLobbyLeaveSchema { type: 'lobby:leave', lobbyId }`, `WsLobbyStartSchema { type: 'lobby:start', lobbyId }`, `WsGameAnswerSchema { type: 'game:answer', lobbyId, questionId, selectedOptionId }` - Server → Client: `WsLobbyStateSchema { type: 'lobby:state', lobby }`, `WsGameQuestionSchema { type: 'game:question', question, questionNumber, totalQuestions }`, `WsGameAnswerResultSchema { type: 'game:answer_result', correctOptionId, players }`, `WsGameFinishedSchema { type: 'game:finished', players, winnerId }` - Combined: `WsClientMessageSchema = z.discriminatedUnion('type', [...])`, `WsServerMessageSchema = z.discriminatedUnion('type', [...])` Export everything from `packages/shared/src/index.ts`. ### REST API **Router:** `apps/api/src/routes/lobbyRouter.ts` — new file. Mount in `apiRouter.ts` on `/lobbies`. ``` POST /api/v1/lobbies — create lobby, return lobby with code POST /api/v1/lobbies/:code/join — join lobby, return lobby with players ``` Both require auth. Follow existing pattern: router → controller → service → model. **Controller:** `apps/api/src/controllers/lobbyController.ts` — validate input, call service, send response. **Service:** `apps/api/src/services/lobbyService.ts` — `createLobby(hostUserId)`: generate unique code, insert lobby + host as first player. `joinLobby(code, userId)`: validate lobby exists, status is 'waiting', not full (hardcode max 4), insert player. Code generation: simple approach — random 6-character alphanumeric (e.g. `A3X9K2`). Check uniqueness via DB constraint, retry on collision. ### WebSocket server **File:** `apps/api/src/ws/index.ts` — new file. Set up `ws.WebSocketServer` with `noServer: true`. **File:** `apps/api/src/server.ts` — change from `app.listen()` to: ```typescript const server = createServer(app); const wss = setupWebSocket(server); server.listen(PORT); ``` **File:** `apps/api/src/ws/auth.ts` — new file. `authenticateWs(request)` extracts Better Auth session from the upgrade request headers using `auth.api.getSession({ headers: fromNodeHeaders(request.headers) })`. Reject with 401 if no session. **File:** `apps/api/src/ws/router.ts` — new file. Parse incoming JSON, validate against `WsClientMessageSchema`, dispatch by type to handlers. **File:** `apps/api/src/ws/connections.ts` — new file. In-memory tracking of which WebSocket connections belong to which lobby. `Map<lobbyId, Map<userId, WebSocket>>`. Functions: `addConnection()`, `removeConnection()`, `getConnections()`, `broadcastToLobby()`. **File:** `apps/api/src/ws/handlers/lobbyHandlers.ts` — new file. - `handleLobbyJoin(ws, data, user)` — register connection, broadcast `lobby:state` to all in lobby. - `handleLobbyLeave(ws, data, user)` — remove from DB and connections, broadcast. If host leaves, close lobby. - `handleLobbyStart(ws, data, user)` — validate host, validate >= 2 players, generate questions, store game state in memory, update lobby status, broadcast first `game:question`, start 15s timer. **File:** `apps/api/src/ws/handlers/gameHandlers.ts` — new file. - `handleGameAnswer(ws, data, user)` — store answer. When all players have answered OR timer expires: evaluate all answers, update scores, broadcast `game:answer_result`. If more rounds: broadcast next `game:question` after 3s delay. If last round: broadcast `game:finished`, update DB scores, set lobby status to 'finished'. Game state (in-memory for this slice): ```typescript Map<lobbyId, { questions: GameQuestion[], // generated at start answers: Map<string, number>[], // correctOptionId per question currentIndex: number, playerAnswers: Map<userId, selectedOptionId>, // current round scores: Map<userId, number>, timer: NodeJS.Timeout }> ``` On WebSocket disconnect: treat as implicit leave. If during a game, that player is marked as timed out for remaining questions. ### Frontend **Route:** `apps/web/src/routes/multiplayer.tsx` — new file. Simple page with: - "Create Lobby" button (POST to create endpoint, navigate to lobby view) - "Join by Code" input + button (POST to join endpoint, navigate to lobby view) - Requires auth (same `beforeLoad` pattern as `play.tsx`) **Route:** `apps/web/src/routes/multiplayer/lobby.$code.tsx` — new file. Lobby waiting room: - On mount: connect WebSocket, send `lobby:join { code }`. - Display: lobby code (large, copyable), player list (from `lobby:state` messages). - Host sees "Start Game" button (disabled until 2+ players). - On `game:question` received: navigate to game view. - On unmount: send `lobby:leave`, disconnect WebSocket. **Route:** `apps/web/src/routes/multiplayer/game.$code.tsx` — new file. Game view: - Reuse `QuestionCard` and `OptionButton` from `apps/web/src/components/game/`. - Receive `game:question` → display question. - Player picks answer → send `game:answer` via WebSocket. - Receive `game:answer_result` → show correct answer, both players' results, scores. Show "Next" button or auto-advance after 3s. - Receive `game:finished` → show final scores. **Component:** `apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx` — new file. Final score screen: - Shows both players' scores, highlights winner. - "Play Again" button (navigates back to lobby, lobby status resets to 'waiting'). - "Leave" button (navigates to `/multiplayer`). **File:** `apps/web/src/lib/ws-client.ts` — new file. Minimal WebSocket client: - `connect(url)`, `disconnect()`, `send(message)`, `on(type, callback)`, `off(type, callback)`. - No reconnection logic in this slice (comes later). - Connection URL: derive from `VITE_API_URL` — replace `https://` with `wss://` (or `http://` with `ws://`). **Navigation:** Add a "Multiplayer" link to the app navigation (in `__root.tsx` or wherever the nav is). ### Caddy No changes needed. Caddy already proxies WebSocket connections to the API automatically when it sees the `Upgrade: websocket` header. ### Dependencies to install - `ws` and `@types/ws` in `apps/api` ## Acceptance criteria - [ ] Player A can create a lobby and sees a join code - [ ] Player B can join using the code and both see each other in the player list - [ ] Host can start the game when 2+ players are present - [ ] Both players see the same question at the same time - [ ] After both answer (or 15s timeout), both see who got it right and the scores - [ ] After 10 rounds, both see final scores and a winner - [ ] "Play Again" returns both to the lobby - [ ] "Leave" returns to the multiplayer page - [ ] Works on mobile - [ ] Works in production (wss:// via Caddy) - [ ] Lobby is cleaned up if host disconnects during waiting phase - [ ] Player disconnecting during a game doesn't crash the other player's experience ## Test plan ### Manual testing - Open two browser tabs (or two devices), log in with different accounts - Create lobby in tab 1, join in tab 2, play a full game - Test: host leaves during waiting, player leaves during game, timeout behavior, both answer correctly, both answer wrong ### Automated tests - `lobbyService.test.ts` — unit tests: create lobby, join, join full, join started, leave, host leave - `gameHandlers.test.ts` — unit tests: start game, submit answer, evaluate round, game finished, timeout - Integration tests with supertest for REST endpoints ## Files summary New files: - `packages/shared/src/schemas/lobby.ts` - `packages/db/src/models/lobbyModel.ts` - `apps/api/src/routes/lobbyRouter.ts` - `apps/api/src/controllers/lobbyController.ts` - `apps/api/src/services/lobbyService.ts` - `apps/api/src/ws/index.ts` - `apps/api/src/ws/auth.ts` - `apps/api/src/ws/router.ts` - `apps/api/src/ws/connections.ts` - `apps/api/src/ws/handlers/lobbyHandlers.ts` - `apps/api/src/ws/handlers/gameHandlers.ts` - `apps/web/src/routes/multiplayer.tsx` - `apps/web/src/routes/multiplayer/lobby.$code.tsx` - `apps/web/src/routes/multiplayer/game.$code.tsx` - `apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx` - `apps/web/src/lib/ws-client.ts` Modified files: - `packages/db/src/db/schema.ts` — add lobbies + lobby_players tables - `packages/shared/src/index.ts` — export new schemas - `apps/api/src/routes/apiRouter.ts` — mount lobby router - `apps/api/src/server.ts` — switch to createServer + WebSocket setup - `apps/api/package.json` — add ws dependency - `apps/web/src/routes/__root.tsx` — add multiplayer nav link Drizzle migration: 1 new migration file generated ## What comes next (future slices that build on this) 1. Public lobby browser — browse and join without a code 2. Countdown timer UI — visual timer synced with server 3. Private lobbies — toggle when creating 4. Game mode selection — choose mode at lobby creation 5. Elimination mode — wrong answer eliminates 6. Reconnection handling — graceful recovery
forgejo-lila added this to the lila development project 2026-04-15 15:42:49 +00:00
forgejo-lila added the
multiplayer
feature
labels 2026-04-15 15:44:22 +00:00
Sign in to join this conversation.
No labels
feature
multiplayer
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: forgejo-lila/lila#1
No description provided.