feat: multiplayer slice — end to end working

WebSocket server:
- WS auth via Better Auth session on upgrade request
- Router with discriminated union dispatch and two-layer error handling
- In-memory connections map with broadcastToLobby
- Lobby handlers: join, leave, start
- Game handlers: answer, resolve round, end game, game:ready for state sync
- Shared game state store (LobbyGameStore interface + InMemory impl)
- Timer map separate from store for Valkey-readiness

REST API:
- POST /api/v1/lobbies — create lobby + add host as first player
- POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks
- getLobbyWithPlayers added to model for id-based lookup

Frontend:
- WsClient class with typed on/off, connect/disconnect, isConnected
- WsProvider owns connection lifecycle (connect/disconnect/isConnected state)
- WsConnector component triggers connection at multiplayer layout mount
- Lobby waiting room: live player list, copyable code, host Start button
- Game view: reuses QuestionCard, game:ready on mount, round results
- MultiplayerScoreScreen: sorted scores, winner highlight, tie handling
- Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix

Tests:
- lobbyService.test.ts: create, join, retry, idempotency, full lobby
- auth.test.ts: 401 reject, upgrade success, 500 on error
- router.test.ts: dispatch all message types, error handling
- vitest.config.ts: exclude dist folder

Fixes:
- server.ts: server.listen() instead of app.listen() for WS support
- StrictMode removed from main.tsx (incompatible with WS lifecycle)
- getLobbyWithPlayers(id) added for handleLobbyStart lookup
This commit is contained in:
lila 2026-04-18 23:32:21 +02:00
parent 540155788a
commit 8aaafea3fc
13 changed files with 545 additions and 78 deletions

View file

@ -25,25 +25,27 @@ export class WsClient {
public onClose: ((event: CloseEvent) => void) | null = null;
connect(apiUrl: string): Promise<void> {
// If already connected or connecting, resolve immediately
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
if (this.ws) {
this.ws.close();
this.ws = null;
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
) {
resolve();
return;
}
const wsUrl = apiUrl
.replace(/^https:\/\//, "wss://")
.replace(/^http:\/\//, "ws://");
let wsUrl: string;
if (!apiUrl) {
wsUrl = "/ws";
} else {
wsUrl =
apiUrl
.replace(/^https:\/\//, "wss://")
.replace(/^http:\/\//, "ws://") + "/ws";
}
this.ws = new WebSocket(`${wsUrl}/ws`);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
resolve();

View file

@ -1,4 +1,3 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./index.css";
@ -20,9 +19,5 @@ declare module "@tanstack/react-router" {
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
root.render(<RouterProvider router={router} />);
}

View file

@ -1,6 +1,13 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { WsProvider } from "../lib/ws-provider.js";
import { useEffect } from "react";
import { authClient } from "../lib/auth-client.js";
import { WsProvider } from "../lib/ws-provider.js";
import { useWsConnect } from "../lib/ws-hooks.js";
const wsBaseUrl =
(import.meta.env["VITE_WS_URL"] as string) ||
(import.meta.env["VITE_API_URL"] as string) ||
"";
export const Route = createFileRoute("/multiplayer")({
component: MultiplayerLayout,
@ -13,9 +20,23 @@ export const Route = createFileRoute("/multiplayer")({
},
});
function WsConnector() {
const connect = useWsConnect();
useEffect(() => {
void connect(wsBaseUrl).catch((err) => {
console.error("WebSocket connection failed:", err);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
function MultiplayerLayout() {
return (
<WsProvider>
<WsConnector />
<Outlet />
</WsProvider>
);

View file

@ -1,10 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState, useCallback, useRef } from "react";
import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js";
import { useEffect, useState, useCallback } from "react";
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
import { QuestionCard } from "../../components/game/QuestionCard.js";
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
import { GameRouteSearchSchema } from "@lila/shared";
import type {
WsGameQuestion,
WsGameAnswerResult,
@ -12,8 +11,6 @@ import type {
WsError,
} from "@lila/shared";
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
export const Route = createFileRoute("/multiplayer/game/$code")({
component: GamePage,
validateSearch: GameRouteSearchSchema,
@ -25,7 +22,7 @@ function GamePage() {
const { session } = Route.useRouteContext();
const currentUserId = session.user.id;
const client = useWsClient();
const connect = useWsConnect();
const isConnected = useWsConnected();
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
null,
@ -36,7 +33,6 @@ function GamePage() {
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
const [error, setError] = useState<string | null>(null);
const [hasAnswered, setHasAnswered] = useState(false);
const isConnectedRef = useRef(false);
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
setCurrentQuestion(msg);
@ -58,25 +54,14 @@ function GamePage() {
}, []);
useEffect(() => {
if (!isConnected) return;
client.on("game:question", handleGameQuestion);
client.on("game:answer_result", handleAnswerResult);
client.on("game:finished", handleGameFinished);
client.on("error", handleWsError);
if (!client.isConnected()) {
void connect(API_URL)
.then(() => {
client.send({ type: "game:ready", lobbyId });
isConnectedRef.current = true;
})
.catch((err) => {
console.error("Failed to connect to WebSocket:", err);
setError("Could not connect to server. Please try again.");
});
} else {
client.send({ type: "game:ready", lobbyId });
isConnectedRef.current = true;
}
client.send({ type: "game:ready", lobbyId });
return () => {
client.off("game:question", handleGameQuestion);
@ -84,10 +69,8 @@ function GamePage() {
client.off("game:finished", handleGameFinished);
client.off("error", handleWsError);
};
// stable deps: client, connect, lobbyId is a search param stable for
// this route. handlers wrapped in useCallback with stable deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isConnected]);
const handleAnswer = useCallback(
(optionId: number) => {
@ -116,11 +99,11 @@ function GamePage() {
}
// Phase: loading
if (!currentQuestion) {
if (!isConnected || !currentQuestion) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">
{error ?? "Loading game..."}
{error ?? (isConnected ? "Loading game..." : "Connecting...")}
</p>
</div>
);

View file

@ -1,10 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState, useCallback, useRef } from "react";
import {
useWsClient,
useWsConnect,
useWsDisconnect,
} from "../../lib/ws-hooks.js";
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
import type {
Lobby,
WsLobbyState,
@ -12,8 +8,6 @@ import type {
WsGameQuestion,
} from "@lila/shared";
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
export const Route = createFileRoute("/multiplayer/lobby/$code")({
component: LobbyPage,
});
@ -24,13 +18,11 @@ function LobbyPage() {
const currentUserId = session.user.id;
const navigate = useNavigate();
const client = useWsClient();
const connect = useWsConnect();
const disconnect = useWsDisconnect();
const isConnected = useWsConnected();
const [lobby, setLobby] = useState<Lobby | null>(null);
const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const lobbyIdRef = useRef<string | null>(null);
const handleLobbyState = useCallback((msg: WsLobbyState) => {
@ -56,31 +48,24 @@ function LobbyPage() {
}, []);
useEffect(() => {
if (!isConnected) return;
client.on("lobby:state", handleLobbyState);
client.on("game:question", handleGameQuestion);
client.on("error", handleWsError);
void connect(API_URL)
.then(() => {
client.send({ type: "lobby:join", code });
})
.catch((err) => {
console.error("Failed to connect to WebSocket:", err);
setError("Could not connect to server. Please try again.");
});
client.send({ type: "lobby:join", code });
return () => {
client.off("lobby:state", handleLobbyState);
client.off("game:question", handleGameQuestion);
client.off("error", handleWsError);
client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" });
disconnect();
if (lobbyIdRef.current) {
client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current });
}
};
// Effect runs once on mount. All referenced values are stable:
// client/connect/disconnect from context (useCallback), handlers
// wrapped in useCallback, code is a URL param. lobbyIdRef is a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isConnected]);
const handleStart = useCallback(() => {
if (!lobby) return;
@ -88,11 +73,11 @@ function LobbyPage() {
client.send({ type: "lobby:start", lobbyId: lobby.id });
}, [lobby, client]);
if (!lobby) {
if (!isConnected || !lobby) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">
{error ?? "Connecting..."}
{error ?? (isConnected ? "Joining lobby..." : "Connecting...")}
</p>
</div>
);