diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index 4e4922508..909964a85 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -1,5 +1,9 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { PublicGames, PublicGamesSchema } from "../core/Schemas"; +import { + LobbyCountsSchema, + PublicGames, + PublicGamesSchema, +} from "../core/Schemas"; interface LobbySocketOptions { reconnectDelay?: number; @@ -19,6 +23,7 @@ export class PublicLobbySocket { private wsAttemptCounted = false; private workerPath: string = ""; private stopped = true; + private lastGames: PublicGames | null = null; private readonly reconnectDelay: number; private readonly maxWsAttempts: number; @@ -79,10 +84,30 @@ export class PublicLobbySocket { private handleMessage(event: MessageEvent) { try { - const publicGames = PublicGamesSchema.parse( - JSON.parse(event.data as string), - ); - this.onLobbiesUpdate(publicGames); + const raw = JSON.parse(event.data as string); + + // Slim update — just counts/timers, merge into cached full state. + const counts = LobbyCountsSchema.safeParse(raw); + if (counts.success && this.lastGames) { + const games = {} as PublicGames["games"]; + for (const [type, lobbies] of Object.entries(this.lastGames.games) as [ + keyof PublicGames["games"], + PublicGames["games"][keyof PublicGames["games"]], + ][]) { + games[type] = lobbies.map((lobby) => { + const update = counts.data.counts[lobby.gameID]; + return update ? { ...lobby, ...update } : lobby; + }) as PublicGames["games"][typeof type]; + } + this.onLobbiesUpdate({ serverTime: counts.data.serverTime, games }); + this.lastGames = { serverTime: counts.data.serverTime, games }; + return; + } + + // Full update — store and forward. + const incoming = PublicGamesSchema.parse(raw); + this.lastGames = incoming; + this.onLobbiesUpdate(incoming); } catch (error) { console.error("Error parsing WebSocket message:", error); if (this.ws && this.ws.readyState === WebSocket.OPEN) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 07b7f263e..abe37d3df 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -169,6 +169,16 @@ export const PublicGamesSchema = z.object({ games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), }); +// Slim update sent when game configs haven't changed — just player counts + timers. +export const LobbyCountsSchema = z.object({ + serverTime: z.number(), + counts: z.record( + z.string(), // gameID + z.object({ numClients: z.number(), startsAt: z.number().optional() }), + ), +}); +export type LobbyCounts = z.infer; + export class LobbyInfoEvent implements GameEvent { constructor( public lobby: GameInfo, diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 9285b8a91..0bffa1937 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -75,7 +75,7 @@ export class MasterLobbyService { if (this.readyWorkers.size === this.config.numWorkers() && !this.started) { this.started = true; this.log.info("All workers ready, starting game scheduling"); - startPolling(async () => this.broadcastLobbies(), 500); + startPolling(async () => this.broadcastLobbies(), 250); startPolling(async () => await this.maybeScheduleLobby(), 1000); } } diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index ab8852968..8e6bba496 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -1,6 +1,6 @@ import http from "http"; import { WebSocket, WebSocketServer } from "ws"; -import { PublicGameInfo, PublicGames } from "../core/Schemas"; +import { LobbyCounts, PublicGameInfo, PublicGames } from "../core/Schemas"; import { GameManager } from "./GameManager"; import { MasterMessageSchema, @@ -12,6 +12,8 @@ import { logger } from "./Logger"; export class WorkerLobbyService { private readonly lobbiesWss: WebSocketServer; private readonly lobbyClients: Set = new Set(); + private lastPublicGames: PublicGames | null = null; + private lastGameSetKey: string = ""; constructor( private readonly server: http.Server, @@ -112,6 +114,10 @@ export class WorkerLobbyService { private setupLobbiesWebSocket() { this.lobbiesWss.on("connection", (ws: WebSocket) => { this.lobbyClients.add(ws); + // Send the last known full state immediately on connect. + if (this.lastPublicGames && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(this.lastPublicGames)); + } ws.on("message", () => { ws.terminate(); }); @@ -136,8 +142,39 @@ export class WorkerLobbyService { }); } + private buildCountsMessage(publicGames: PublicGames): string { + const counts: LobbyCounts["counts"] = {}; + for (const lobbies of Object.values(publicGames.games)) { + for (const lobby of lobbies) { + counts[lobby.gameID] = { + numClients: lobby.numClients, + startsAt: lobby.startsAt, + }; + } + } + return JSON.stringify({ + serverTime: publicGames.serverTime, + counts, + } satisfies LobbyCounts); + } + private broadcastLobbiesToClients(publicGames: PublicGames) { - const message = JSON.stringify(publicGames); + const allLobbies = Object.values(publicGames.games).flat(); + const gameSetKey = allLobbies + .map((g) => g.gameID) + .sort() + .join(","); + + const configsChanged = gameSetKey !== this.lastGameSetKey; + this.lastGameSetKey = gameSetKey; + + if (configsChanged) { + this.lastPublicGames = publicGames; + } + + const message = configsChanged + ? JSON.stringify(publicGames) + : this.buildCountsMessage(publicGames); const clientsToRemove: WebSocket[] = []; this.lobbyClients.forEach((client) => {