feat(web): add multiplayer lobby, game, and score screen routes

- lobby.$code.tsx: waiting room with live player list via lobby:state,
  copyable lobby code, host Start Game button (disabled until 2+ players),
  sends lobby:join on connect, lobby:leave on unmount
- game.$code.tsx: in-game view, sends game:ready on mount to get current
  question, handles game:question/answer_result/finished messages,
  reuses QuestionCard component, shows round results after each answer
- MultiplayerScoreScreen: final score screen sorted by score, highlights
  winner(s) with crown, handles ties via winnerIds array, Play Again
  navigates back to lobby, Leave goes to multiplayer landing
- GameRouteSearchSchema added to shared for typed lobbyId search param
  without requiring Zod in apps/web
This commit is contained in:
lila 2026-04-18 10:33:48 +02:00
parent d064338145
commit f2eb6ce17f
5 changed files with 340 additions and 11 deletions

View file

@ -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 (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Result header */}
<div className="text-center flex flex-col gap-1">
<h1 className="text-2xl font-bold text-purple-800">
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
</h1>
<p className="text-sm text-gray-500">
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
</p>
</div>
<div className="border-t border-gray-200" />
{/* Score list */}
<div className="flex flex-col gap-2">
{sortedPlayers.map((player, index) => {
const isCurrentUser = player.userId === currentUserId;
const isPlayerWinner = winnerIds.includes(player.userId);
return (
<div
key={player.userId}
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
isCurrentUser
? "bg-purple-50 border border-purple-200"
: "bg-gray-50"
}`}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-400 w-4">
{index + 1}.
</span>
<span
className={`text-sm font-medium ${
isCurrentUser ? "text-purple-800" : "text-gray-700"
}`}
>
{player.user.name}
{isCurrentUser && (
<span className="text-xs text-purple-400 ml-1">
(you)
</span>
)}
</span>
{isPlayerWinner && (
<span className="text-xs text-yellow-500 font-medium">
👑
</span>
)}
</div>
<span className="text-sm font-bold text-gray-700">
{player.score} pts
</span>
</div>
);
})}
</div>
<div className="border-t border-gray-200" />
{/* Actions */}
<div className="flex flex-col gap-3">
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
onClick={() => {
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: lobbyCode },
});
}}
>
Play Again
</button>
<button
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
onClick={() => {
void navigate({ to: "/multiplayer" });
}}
>
Leave
</button>
</div>
</div>
</div>
);
};

View file

@ -9,6 +9,7 @@ export const Route = createFileRoute("/multiplayer")({
if (!session) { if (!session) {
throw redirect({ to: "/login" }); throw redirect({ to: "/login" });
} }
return { session };
}, },
}); });

View file

@ -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')({ import type {
component: RouteComponent, WsGameQuestion,
WsGameAnswerResult,
WsGameFinished,
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,
});
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<WsGameQuestion | null>(
null,
);
const [answerResult, setAnswerResult] = useState<WsGameAnswerResult | null>(
null,
);
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);
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) => {
function RouteComponent() { console.error("Failed to connect to WebSocket:", err);
return <div>Hello "/multiplayer/game/$code"!</div> 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 (
<MultiplayerScoreScreen
players={gameFinished.players}
winnerIds={gameFinished.winnerIds}
currentUserId={currentUserId}
lobbyCode={code}
/>
);
}
// Phase: loading
if (!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..."}
</p>
</div>
);
}
// Phase: playing
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Progress */}
<p className="text-sm text-gray-500 text-center">
Question {currentQuestion.questionNumber} of{" "}
{currentQuestion.totalQuestions}
</p>
{/* Question */}
<QuestionCard
question={currentQuestion.question}
questionNumber={currentQuestion.questionNumber}
totalQuestions={currentQuestion.totalQuestions}
currentResult={
answerResult
? {
questionId: currentQuestion.question.questionId,
isCorrect:
answerResult.results.find((r) => 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 && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Round results */}
{answerResult && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">
Round results
</h3>
{answerResult.players.map((player) => {
const result = answerResult.results.find(
(r) => r.userId === player.userId,
);
return (
<div
key={player.userId}
className="flex items-center justify-between text-sm"
>
<span className="text-gray-700">{player.user.name}</span>
<span
className={
result?.isCorrect
? "text-green-600 font-medium"
: "text-red-500"
}
>
{result?.selectedOptionId === null
? "Timed out"
: result?.isCorrect
? "✓ Correct"
: "✗ Wrong"}
</span>
<span className="text-gray-500">{player.score} pts</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
} }

View file

@ -1,11 +1,16 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { import {
useWsClient, useWsClient,
useWsConnect, useWsConnect,
useWsDisconnect, useWsDisconnect,
} from "../../lib/ws-hooks.js"; } 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) || ""; const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
@ -26,14 +31,24 @@ function LobbyPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false); const [isStarting, setIsStarting] = useState(false);
const lobbyIdRef = useRef<string | null>(null);
const handleLobbyState = useCallback((msg: WsLobbyState) => { const handleLobbyState = useCallback((msg: WsLobbyState) => {
setLobby(msg.lobby); setLobby(msg.lobby);
lobbyIdRef.current = msg.lobby.id;
setError(null); setError(null);
}, []); }, []);
const handleGameQuestion = useCallback(() => { const handleGameQuestion = useCallback(
void navigate({ to: "/multiplayer/game/$code", params: { code } }); (_msg: WsGameQuestion) => {
}, [navigate, code]); void navigate({
to: "/multiplayer/game/$code",
params: { code },
search: { lobbyId: lobbyIdRef.current ?? "" },
});
},
[navigate, code],
);
const handleWsError = useCallback((msg: WsError) => { const handleWsError = useCallback((msg: WsError) => {
setError(msg.message); setError(msg.message);

View file

@ -27,6 +27,9 @@ export const JoinLobbyResponseSchema = LobbySchema;
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>; export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() });
export type GameRouteSearch = z.infer<typeof GameRouteSearchSchema>;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// WebSocket: Client → Server // WebSocket: Client → Server
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------