diff --git a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
new file mode 100644
index 0000000..5e95588
--- /dev/null
+++ b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
@@ -0,0 +1,114 @@
+import { useNavigate } from "@tanstack/react-router";
+import type { LobbyPlayer } from "@lila/shared";
+
+type MultiplayerScoreScreenProps = {
+ players: LobbyPlayer[];
+ winnerIds: string[];
+ currentUserId: string;
+ lobbyCode: string;
+};
+
+export const MultiplayerScoreScreen = ({
+ players,
+ winnerIds,
+ currentUserId,
+ lobbyCode,
+}: MultiplayerScoreScreenProps) => {
+ const navigate = useNavigate();
+
+ const sortedPlayers = [...players].sort((a, b) => b.score - a.score);
+
+ const isWinner = winnerIds.includes(currentUserId);
+ const isTie = winnerIds.length > 1;
+
+ const winnerNames = winnerIds
+ .map((id) => players.find((p) => p.userId === id)?.user.name ?? id)
+ .join(" and ");
+
+ return (
+
+
+ {/* Result header */}
+
+
+ {isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
+
+
+ {isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
+
+
+
+
+
+ {/* Score list */}
+
+ {sortedPlayers.map((player, index) => {
+ const isCurrentUser = player.userId === currentUserId;
+ const isPlayerWinner = winnerIds.includes(player.userId);
+ return (
+
+
+
+ {index + 1}.
+
+
+ {player.user.name}
+ {isCurrentUser && (
+
+ (you)
+
+ )}
+
+ {isPlayerWinner && (
+
+ 👑
+
+ )}
+
+
+ {player.score} pts
+
+
+ );
+ })}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx
index 9acb22f..cf25437 100644
--- a/apps/web/src/routes/multiplayer.tsx
+++ b/apps/web/src/routes/multiplayer.tsx
@@ -9,6 +9,7 @@ export const Route = createFileRoute("/multiplayer")({
if (!session) {
throw redirect({ to: "/login" });
}
+ return { session };
},
});
diff --git a/apps/web/src/routes/multiplayer/game.$code.tsx b/apps/web/src/routes/multiplayer/game.$code.tsx
index 2b46605..ebe602c 100644
--- a/apps/web/src/routes/multiplayer/game.$code.tsx
+++ b/apps/web/src/routes/multiplayer/game.$code.tsx
@@ -1,9 +1,205 @@
-import { createFileRoute } from '@tanstack/react-router'
+import { createFileRoute } from "@tanstack/react-router";
+import { useEffect, useState, useCallback, useRef } from "react";
+import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js";
+import { QuestionCard } from "../../components/game/QuestionCard.js";
+import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
+import { GameRouteSearchSchema } from "@lila/shared";
-export const Route = createFileRoute('/multiplayer/game/$code')({
- component: RouteComponent,
-})
+import type {
+ WsGameQuestion,
+ WsGameAnswerResult,
+ WsGameFinished,
+ WsError,
+} from "@lila/shared";
-function RouteComponent() {
- return Hello "/multiplayer/game/$code"!
+const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
+
+export const Route = createFileRoute("/multiplayer/game/$code")({
+ component: GamePage,
+ validateSearch: GameRouteSearchSchema,
+});
+
+function GamePage() {
+ const { code } = Route.useParams();
+ const { lobbyId } = Route.useSearch();
+ const { session } = Route.useRouteContext();
+ const currentUserId = session.user.id;
+ const client = useWsClient();
+ const connect = useWsConnect();
+
+ const [currentQuestion, setCurrentQuestion] = useState(
+ null,
+ );
+ const [answerResult, setAnswerResult] = useState(
+ null,
+ );
+ const [gameFinished, setGameFinished] = useState(null);
+ const [error, setError] = useState(null);
+ const [hasAnswered, setHasAnswered] = useState(false);
+ const isConnectedRef = useRef(false);
+
+ const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
+ setCurrentQuestion(msg);
+ setAnswerResult(null);
+ setHasAnswered(false);
+ setError(null);
+ }, []);
+
+ const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => {
+ setAnswerResult(msg);
+ }, []);
+
+ const handleGameFinished = useCallback((msg: WsGameFinished) => {
+ setGameFinished(msg);
+ }, []);
+
+ const handleWsError = useCallback((msg: WsError) => {
+ setError(msg.message);
+ }, []);
+
+ useEffect(() => {
+ 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;
+ }
+
+ return () => {
+ client.off("game:question", handleGameQuestion);
+ client.off("game:answer_result", handleAnswerResult);
+ 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
+ }, []);
+
+ const handleAnswer = useCallback(
+ (optionId: number) => {
+ if (hasAnswered || !currentQuestion) return;
+ setHasAnswered(true);
+ client.send({
+ type: "game:answer",
+ lobbyId,
+ questionId: currentQuestion.question.questionId,
+ selectedOptionId: optionId,
+ });
+ },
+ [hasAnswered, currentQuestion, client, lobbyId],
+ );
+
+ // Phase: finished
+ if (gameFinished) {
+ return (
+
+ );
+ }
+
+ // Phase: loading
+ if (!currentQuestion) {
+ return (
+
+
+ {error ?? "Loading game..."}
+
+
+ );
+ }
+
+ // Phase: playing
+ return (
+
+
+ {/* Progress */}
+
+ Question {currentQuestion.questionNumber} of{" "}
+ {currentQuestion.totalQuestions}
+
+
+ {/* Question */}
+
r.userId === currentUserId)
+ ?.isCorrect ?? false,
+ correctOptionId: answerResult.correctOptionId,
+ selectedOptionId:
+ answerResult.results.find((r) => r.userId === currentUserId)
+ ?.selectedOptionId ?? 0,
+ }
+ : null
+ }
+ onAnswer={handleAnswer}
+ onNext={() => {
+ setAnswerResult(null);
+ }}
+ />
+
+ {/* Error */}
+ {error && {error}
}
+
+ {/* Round results */}
+ {answerResult && (
+
+
+ Round results
+
+ {answerResult.players.map((player) => {
+ const result = answerResult.results.find(
+ (r) => r.userId === player.userId,
+ );
+ return (
+
+ {player.user.name}
+
+ {result?.selectedOptionId === null
+ ? "Timed out"
+ : result?.isCorrect
+ ? "✓ Correct"
+ : "✗ Wrong"}
+
+ {player.score} pts
+
+ );
+ })}
+
+ )}
+
+
+ );
}
diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx
index dcebfa2..57bb098 100644
--- a/apps/web/src/routes/multiplayer/lobby.$code.tsx
+++ b/apps/web/src/routes/multiplayer/lobby.$code.tsx
@@ -1,11 +1,16 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useEffect, useState, useCallback } from "react";
+import { useEffect, useState, useCallback, useRef } from "react";
import {
useWsClient,
useWsConnect,
useWsDisconnect,
} from "../../lib/ws-hooks.js";
-import type { Lobby, WsLobbyState, WsError } from "@lila/shared";
+import type {
+ Lobby,
+ WsLobbyState,
+ WsError,
+ WsGameQuestion,
+} from "@lila/shared";
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
@@ -26,14 +31,24 @@ function LobbyPage() {
const [error, setError] = useState(null);
const [isStarting, setIsStarting] = useState(false);
+ const lobbyIdRef = useRef(null);
+
const handleLobbyState = useCallback((msg: WsLobbyState) => {
setLobby(msg.lobby);
+ lobbyIdRef.current = msg.lobby.id;
setError(null);
}, []);
- const handleGameQuestion = useCallback(() => {
- void navigate({ to: "/multiplayer/game/$code", params: { code } });
- }, [navigate, code]);
+ const handleGameQuestion = useCallback(
+ (_msg: WsGameQuestion) => {
+ void navigate({
+ to: "/multiplayer/game/$code",
+ params: { code },
+ search: { lobbyId: lobbyIdRef.current ?? "" },
+ });
+ },
+ [navigate, code],
+ );
const handleWsError = useCallback((msg: WsError) => {
setError(msg.message);
diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts
index 7b7b7d5..c9687c4 100644
--- a/packages/shared/src/schemas/lobby.ts
+++ b/packages/shared/src/schemas/lobby.ts
@@ -27,6 +27,9 @@ export const JoinLobbyResponseSchema = LobbySchema;
export type JoinLobbyResponse = z.infer;
+export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() });
+export type GameRouteSearch = z.infer;
+
// ----------------------------------------------------------------------------
// WebSocket: Client → Server
// ----------------------------------------------------------------------------