Reduce lobby broadcast bandwidth via counts-only deltas (#4116)

## Description:

- The lobby WebSocket broadcast (`/lobbies`) was re-sending the full
`PublicGames` snapshot — including each lobby's `gameConfig` — to every
connected client every 500ms. Almost nothing in that payload changes
tick-to-tick; only `numClients` moves.
- `WorkerLobbyService` now tracks the sorted set of `gameID`s it last
sent as a full snapshot. On each incoming broadcast it sends a `full`
only when that set changes; otherwise it sends a `counts` delta carrying
just `{gameID → numClients}`.
- This relies on the master-side coupling at
[MasterLobbyService.ts:140-159](src/server/MasterLobbyService.ts#L140-L159):
when master finds a lobby without `startsAt`, it both sets `startsAt`
AND schedules a fresh lobby on the same tick, so the gameID change
brings the `startsAt` (and `gameConfig`) along with it.
- New WS connections are primed with the worker's cached last `full` so
late joiners don't have to wait for the next structural change.
- `LobbySocket` parses the new discriminated union (`PublicLobbyMessage
= full | counts`), keeps the last full snapshot in memory, and merges
counts into it before invoking the existing callback. `GameModeSelector`
is unchanged.
- Master → worker IPC is unchanged — still sends the full snapshot every
500ms. The optimization only applies to the worker → WS-client boundary,
which is the fan-out point.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-06-02 15:52:14 -07:00
committed by GitHub
parent 775ae77e0a
commit 48609fa70a
4 changed files with 260 additions and 6 deletions
+40 -3
View File
@@ -1,5 +1,5 @@
import { ClientEnv } from "src/client/ClientEnv";
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
import { PublicGames, PublicLobbyMessageSchema } from "../core/Schemas";
interface LobbySocketOptions {
reconnectDelay?: number;
@@ -19,6 +19,8 @@ export class PublicLobbySocket {
private wsAttemptCounted = false;
private workerPath: string = "";
private stopped = true;
// Latest full snapshot, used as the base for applying counts-only deltas.
private lastFull: PublicGames | null = null;
private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
@@ -41,6 +43,7 @@ export class PublicLobbySocket {
stop() {
this.stopped = true;
this.lastFull = null;
this.disconnectWebSocket();
}
@@ -51,6 +54,9 @@ export class PublicLobbySocket {
this.ws.close();
this.ws = null;
}
// Drop any cached snapshot — the server primes new connections with a
// fresh full message, and a stale base could mis-merge incoming deltas.
this.lastFull = null;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}${this.workerPath}/lobbies`;
@@ -78,10 +84,41 @@ export class PublicLobbySocket {
private handleMessage(event: MessageEvent) {
try {
const publicGames = PublicGamesSchema.parse(
const message = PublicLobbyMessageSchema.parse(
JSON.parse(event.data as string),
);
this.onLobbiesUpdate(publicGames);
if (message.type === "full") {
this.lastFull = {
serverTime: message.serverTime,
games: message.games,
};
this.onLobbiesUpdate(this.lastFull);
return;
}
// counts: patch numClients onto the last full snapshot. If we have no
// base yet (shouldn't happen — server primes on connect), ignore it
// and wait for the next full.
if (this.lastFull === null) {
return;
}
const patchedGames = { ...this.lastFull.games };
for (const type of Object.keys(patchedGames) as Array<
keyof typeof patchedGames
>) {
const list = patchedGames[type];
if (!list) continue;
patchedGames[type] = list.map((lobby) => {
const next = message.counts[lobby.gameID];
return next === undefined || next === lobby.numClients
? lobby
: { ...lobby, numClients: next };
});
}
this.lastFull = {
serverTime: message.serverTime,
games: patchedGames,
};
this.onLobbiesUpdate(this.lastFull);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {