Compare commits

...

2 commits

Author SHA1 Message Date
lila
6975384751 feat(api): add game:ready message for client state sync
- WsGameReadySchema added to shared schemas and WsClientMessageSchema
- handleGameReady sends current game:question directly to requesting
  client socket (not broadcast) — foundation for reconnection slice
- router dispatches game:ready to handleGameReady handler
2026-04-18 09:54:31 +02:00
lila
4d4715b4ee feat(web): add multiplayer layout route and landing page
- multiplayer.tsx: layout route wrapping all multiplayer children
  with WsProvider, auth guard via beforeLoad
- multiplayer/index.tsx: create/join landing page
  - POST /api/v1/lobbies to create, navigates to lobby waiting room
  - POST /api/v1/lobbies/:code/join to join, normalizes code to
    uppercase before sending
  - loading states per action, error display, Enter key on join input
  - imports Lobby type from @lila/shared (single source of truth)
2026-04-17 21:33:40 +02:00
8 changed files with 330 additions and 5 deletions

View file

@ -1,6 +1,6 @@
import type { WebSocket } from "ws";
import type { User } from "better-auth";
import type { WsGameAnswer } from "@lila/shared";
import type { WsGameAnswer, WsGameReady } from "@lila/shared";
import { finishGame, getLobbyByCodeWithPlayers } from "@lila/db";
import { broadcastToLobby, getConnections } from "../connections.js";
import { lobbyGameStore, timers } from "../gameState.js";
@ -52,6 +52,32 @@ export const handleGameAnswer = async (
}
};
export const handleGameReady = async (
ws: WebSocket,
msg: WsGameReady,
_user: User,
): Promise<void> => {
const state = await lobbyGameStore.get(msg.lobbyId);
if (!state) throw new NotFoundError("Game not found");
const currentQuestion = state.questions[state.currentIndex];
if (!currentQuestion) throw new NotFoundError("No active question");
ws.send(
JSON.stringify({
type: "game:question",
question: {
questionId: currentQuestion.questionId,
prompt: currentQuestion.prompt,
gloss: currentQuestion.gloss,
options: currentQuestion.options,
},
questionNumber: state.currentIndex + 1,
totalQuestions: state.questions.length,
}),
);
};
export const resolveRound = async (
lobbyId: string,
questionIndex: number,

View file

@ -6,7 +6,7 @@ import {
handleLobbyLeave,
handleLobbyStart,
} from "./handlers/lobbyHandlers.js";
import { handleGameAnswer } from "./handlers/gameHandlers.js";
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
import { AppError } from "../errors/AppError.js";
export type AuthenticatedUser = { session: Session; user: User };
@ -60,6 +60,9 @@ export const handleMessage = async (
case "game:answer":
await handleGameAnswer(ws, msg, auth.user);
break;
case "game:ready":
await handleGameReady(ws, msg, auth.user);
break;
default:
assertExhaustive(msg);
}

View file

@ -10,15 +10,24 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
const PlayRoute = PlayRouteImport.update({
id: '/play',
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const MultiplayerRoute = MultiplayerRouteImport.update({
id: '/multiplayer',
path: '/multiplayer',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@ -34,38 +43,89 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const MultiplayerIndexRoute = MultiplayerIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => MultiplayerRoute,
} as any)
const MultiplayerLobbyCodeRoute = MultiplayerLobbyCodeRouteImport.update({
id: '/lobby/$code',
path: '/lobby/$code',
getParentRoute: () => MultiplayerRoute,
} as any)
const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
id: '/game/$code',
path: '/game/$code',
getParentRoute: () => MultiplayerRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
'/multiplayer': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/login' | '/play'
fullPaths:
| '/'
| '/about'
| '/login'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
to:
| '/'
| '/about'
| '/login'
| '/play'
| '/multiplayer'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
id:
| '__root__'
| '/'
| '/about'
| '/login'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
MultiplayerRoute: typeof MultiplayerRouteWithChildren
PlayRoute: typeof PlayRoute
}
@ -78,6 +138,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/multiplayer': {
id: '/multiplayer'
path: '/multiplayer'
fullPath: '/multiplayer'
preLoaderRoute: typeof MultiplayerRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@ -99,13 +166,51 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/multiplayer/': {
id: '/multiplayer/'
path: '/'
fullPath: '/multiplayer/'
preLoaderRoute: typeof MultiplayerIndexRouteImport
parentRoute: typeof MultiplayerRoute
}
'/multiplayer/lobby/$code': {
id: '/multiplayer/lobby/$code'
path: '/lobby/$code'
fullPath: '/multiplayer/lobby/$code'
preLoaderRoute: typeof MultiplayerLobbyCodeRouteImport
parentRoute: typeof MultiplayerRoute
}
'/multiplayer/game/$code': {
id: '/multiplayer/game/$code'
path: '/game/$code'
fullPath: '/multiplayer/game/$code'
preLoaderRoute: typeof MultiplayerGameCodeRouteImport
parentRoute: typeof MultiplayerRoute
}
}
}
interface MultiplayerRouteChildren {
MultiplayerIndexRoute: typeof MultiplayerIndexRoute
MultiplayerGameCodeRoute: typeof MultiplayerGameCodeRoute
MultiplayerLobbyCodeRoute: typeof MultiplayerLobbyCodeRoute
}
const MultiplayerRouteChildren: MultiplayerRouteChildren = {
MultiplayerIndexRoute: MultiplayerIndexRoute,
MultiplayerGameCodeRoute: MultiplayerGameCodeRoute,
MultiplayerLobbyCodeRoute: MultiplayerLobbyCodeRoute,
}
const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
MultiplayerRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
MultiplayerRoute: MultiplayerRouteWithChildren,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport

View file

@ -0,0 +1,21 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { WsProvider } from "../lib/ws-provider.js";
import { authClient } from "../lib/auth-client.js";
export const Route = createFileRoute("/multiplayer")({
component: MultiplayerLayout,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
},
});
function MultiplayerLayout() {
return (
<WsProvider>
<Outlet />
</WsProvider>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/multiplayer/game/$code')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/multiplayer/game/$code"!</div>
}

View file

@ -0,0 +1,145 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import type { Lobby } from "@lila/shared";
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
type LobbySuccessResponse = { success: true; data: Lobby };
type LobbyErrorResponse = { success: false; error: string };
type LobbyApiResponse = LobbySuccessResponse | LobbyErrorResponse;
export const Route = createFileRoute("/multiplayer/")({
component: MultiplayerPage,
});
function MultiplayerPage() {
const navigate = useNavigate();
const [joinCode, setJoinCode] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async (): Promise<void> => {
setIsCreating(true);
setError(null);
try {
const response = await fetch(`${API_URL}/api/v1/lobbies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
const data = (await response.json()) as LobbyApiResponse;
if (!data.success) {
setError(data.error);
return;
}
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: data.data.code },
});
} catch {
setError("Could not connect to server. Please try again.");
} finally {
setIsCreating(false);
}
};
const handleJoin = async (): Promise<void> => {
const code = joinCode.trim().toUpperCase();
if (!code) {
setError("Please enter a lobby code.");
return;
}
setIsJoining(true);
setError(null);
try {
const response = await fetch(`${API_URL}/api/v1/lobbies/${code}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
const data = (await response.json()) as LobbyApiResponse;
if (!data.success) {
setError(data.error);
return;
}
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: data.data.code },
});
} catch {
setError("Could not connect to server. Please try again.");
} finally {
setIsJoining(false);
}
};
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">
<h1 className="text-2xl font-bold text-center text-purple-800">
Multiplayer
</h1>
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Create lobby */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">
Create a lobby
</h2>
<p className="text-sm text-gray-500">
Start a new game and invite friends with a code.
</p>
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
onClick={() => {
void handleCreate().catch((err) => {
console.error("Create lobby error:", err);
});
}}
disabled={isCreating || isJoining}
>
{isCreating ? "Creating..." : "Create Lobby"}
</button>
</div>
<div className="border-t border-gray-200" />
{/* Join lobby */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
<p className="text-sm text-gray-500">
Enter the code shared by your host.
</p>
<input
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
placeholder="Enter code (e.g. WOLF42)"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleJoin().catch((err) => {
console.error("Join lobby error:", err);
});
}
}}
maxLength={10}
disabled={isCreating || isJoining}
/>
<button
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
onClick={() => {
void handleJoin().catch((err) => {
console.error("Join lobby error:", err);
});
}}
disabled={isCreating || isJoining || !joinCode.trim()}
>
{isJoining ? "Joining..." : "Join Lobby"}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/multiplayer/lobby/$code')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/multiplayer/lobby/$code"!</div>
}

View file

@ -52,6 +52,12 @@ export const WsLobbyStartSchema = z.object({
export type WsLobbyStart = z.infer<typeof WsLobbyStartSchema>;
export const WsGameReadySchema = z.object({
type: z.literal("game:ready"),
lobbyId: z.uuid(),
});
export type WsGameReady = z.infer<typeof WsGameReadySchema>;
export const WsGameAnswerSchema = z.object({
type: z.literal("game:answer"),
lobbyId: z.uuid(),
@ -66,6 +72,7 @@ export const WsClientMessageSchema = z.discriminatedUnion("type", [
WsLobbyLeaveSchema,
WsLobbyStartSchema,
WsGameAnswerSchema,
WsGameReadySchema,
]);
export type WsClientMessage = z.infer<typeof WsClientMessageSchema>;