Two players can play a simultaneous quiz together #1
Labels
No labels
feature
multiplayer
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: forgejo-lila/lila#1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
WOLF-42) and a player list showing just themselves.What this slice does NOT include
These are all separate slices that build on top of this one.
Technical implementation
Database
File:
packages/db/src/db/schema.tsAdd two tables:
Keep it minimal — no
game_mode,is_private,settings, ormax_playerscolumns yet. Those come in later slices. Hardcode behavior to: simultaneous mode, max 4 players, public.Add game mode constants to
packages/shared/src/constants.tsfor 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 playersLobbySchema,LobbyPlayerSchemaWebSocket schemas:
WsLobbyJoinSchema { type: 'lobby:join', code },WsLobbyLeaveSchema { type: 'lobby:leave', lobbyId },WsLobbyStartSchema { type: 'lobby:start', lobbyId },WsGameAnswerSchema { type: 'game:answer', lobbyId, questionId, selectedOptionId }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 }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 inapiRouter.tson/lobbies.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 upws.WebSocketServerwithnoServer: true.File:
apps/api/src/server.ts— change fromapp.listen()to:File:
apps/api/src/ws/auth.ts— new file.authenticateWs(request)extracts Better Auth session from the upgrade request headers usingauth.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 againstWsClientMessageSchema, 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, broadcastlobby:stateto 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 firstgame: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, broadcastgame:answer_result. If more rounds: broadcast nextgame:questionafter 3s delay. If last round: broadcastgame:finished, update DB scores, set lobby status to 'finished'.Game state (in-memory for this slice):
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:beforeLoadpattern asplay.tsx)Route:
apps/web/src/routes/multiplayer/lobby.$code.tsx— new file. Lobby waiting room:lobby:join { code }.lobby:statemessages).game:questionreceived: navigate to game view.lobby:leave, disconnect WebSocket.Route:
apps/web/src/routes/multiplayer/game.$code.tsx— new file. Game view:QuestionCardandOptionButtonfromapps/web/src/components/game/.game:question→ display question.game:answervia WebSocket.game:answer_result→ show correct answer, both players' results, scores. Show "Next" button or auto-advance after 3s.game:finished→ show final scores.Component:
apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx— new file. Final score screen:/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).VITE_API_URL— replacehttps://withwss://(orhttp://withws://).Navigation: Add a "Multiplayer" link to the app navigation (in
__root.tsxor wherever the nav is).Caddy
No changes needed. Caddy already proxies WebSocket connections to the API automatically when it sees the
Upgrade: websocketheader.Dependencies to install
wsand@types/wsinapps/apiAcceptance criteria
Test plan
Manual testing
Automated tests
lobbyService.test.ts— unit tests: create lobby, join, join full, join started, leave, host leavegameHandlers.test.ts— unit tests: start game, submit answer, evaluate round, game finished, timeoutFiles summary
New files:
packages/shared/src/schemas/lobby.tspackages/db/src/models/lobbyModel.tsapps/api/src/routes/lobbyRouter.tsapps/api/src/controllers/lobbyController.tsapps/api/src/services/lobbyService.tsapps/api/src/ws/index.tsapps/api/src/ws/auth.tsapps/api/src/ws/router.tsapps/api/src/ws/connections.tsapps/api/src/ws/handlers/lobbyHandlers.tsapps/api/src/ws/handlers/gameHandlers.tsapps/web/src/routes/multiplayer.tsxapps/web/src/routes/multiplayer/lobby.$code.tsxapps/web/src/routes/multiplayer/game.$code.tsxapps/web/src/components/multiplayer/MultiplayerScoreScreen.tsxapps/web/src/lib/ws-client.tsModified files:
packages/db/src/db/schema.ts— add lobbies + lobby_players tablespackages/shared/src/index.ts— export new schemasapps/api/src/routes/apiRouter.ts— mount lobby routerapps/api/src/server.ts— switch to createServer + WebSocket setupapps/api/package.json— add ws dependencyapps/web/src/routes/__root.tsx— add multiplayer nav linkDrizzle migration: 1 new migration file generated
What comes next (future slices that build on this)