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
+60 -3
View File
@@ -1,6 +1,10 @@
import http from "http";
import { WebSocket, WebSocketServer } from "ws";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import {
PublicGameInfo,
PublicGames,
PublicLobbyMessage,
} from "../core/Schemas";
import { GameManager } from "./GameManager";
import {
MasterMessageSchema,
@@ -12,6 +16,16 @@ import { logger } from "./Logger";
export class WorkerLobbyService {
private readonly lobbiesWss: WebSocketServer;
private readonly lobbyClients: Set<WebSocket> = new Set();
// Most recent snapshot from master, serialized on demand for new
// connections so they don't have to wait for the next broadcast.
private lastPublicGames: PublicGames | null = null;
// Sorted gameIDs of the last full we broadcast, or null if we've never
// broadcast one. When the set changes we send a fresh full; otherwise a
// counts-only delta is enough. This relies on master creating a new lobby
// whenever it sets startsAt on the previous one, so structural state
// (startsAt, gameConfig) rides along with a gameID change. Null (not "")
// is used so that an empty-lobby first broadcast still emits a full.
private lastFullGameIds: string | null = null;
constructor(
private readonly server: http.Server,
@@ -39,6 +53,7 @@ export class WorkerLobbyService {
const msg = result.data;
switch (msg.type) {
case "lobbiesBroadcast":
this.lastPublicGames = msg.publicGames;
// Forward message to all clients
this.broadcastLobbiesToClients(msg.publicGames);
// Update master with my lobby info
@@ -116,6 +131,17 @@ export class WorkerLobbyService {
private setupLobbiesWebSocket() {
this.lobbiesWss.on("connection", (ws: WebSocket) => {
this.lobbyClients.add(ws);
// Prime the new client with the most recent snapshot — otherwise it
// would only see counts-only deltas (which it can't apply without a
// base) until the next structural change.
if (this.lastPublicGames !== null) {
const fullJson = JSON.stringify({
type: "full",
serverTime: this.lastPublicGames.serverTime,
games: this.lastPublicGames.games,
} satisfies PublicLobbyMessage);
ws.send(fullJson);
}
ws.on("message", () => {
ws.terminate();
});
@@ -141,12 +167,43 @@ export class WorkerLobbyService {
}
private broadcastLobbiesToClients(publicGames: PublicGames) {
const message = JSON.stringify(publicGames);
const gameIds: string[] = [];
for (const list of Object.values(publicGames.games)) {
for (const lobby of list) {
gameIds.push(lobby.gameID);
}
}
gameIds.sort();
const fingerprint = gameIds.join(",");
const shouldSendFull = fingerprint !== this.lastFullGameIds;
let payload: PublicLobbyMessage;
if (shouldSendFull) {
payload = {
type: "full",
serverTime: publicGames.serverTime,
games: publicGames.games,
};
this.lastFullGameIds = fingerprint;
} else {
const counts: Record<string, number> = {};
for (const list of Object.values(publicGames.games)) {
for (const lobby of list) {
counts[lobby.gameID] = lobby.numClients;
}
}
payload = {
type: "counts",
serverTime: publicGames.serverTime,
counts,
};
}
const json = JSON.stringify(payload);
const clientsToRemove: WebSocket[] = [];
this.lobbyClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
client.send(json);
} else {
clientsToRemove.push(client);
}