Skip to content

Reduce lobby broadcast bandwidth via counts-only deltas#4116

Merged
evanpelle merged 1 commit into
mainfrom
reduce-lobby-bandwidth
Jun 2, 2026
Merged

Reduce lobby broadcast bandwidth via counts-only deltas#4116
evanpelle merged 1 commit into
mainfrom
reduce-lobby-bandwidth

Conversation

@evanpelle

@evanpelle evanpelle commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

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 gameIDs 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: 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:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • 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

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8bb06fad-e633-47aa-9ac3-2f298a44c772

📥 Commits

Reviewing files that changed from the base of the PR and between 0337ae2 and 0bdb117.

📒 Files selected for processing (4)
  • src/client/LobbySocket.ts
  • src/core/Schemas.ts
  • src/server/WorkerLobbyService.ts
  • tests/LobbySocket.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/core/Schemas.ts
  • tests/LobbySocket.test.ts
  • src/client/LobbySocket.ts

Walkthrough

Server fingerprints and caches full lobby snapshots, broadcasting either full snapshots or counts-only deltas; client caches a full snapshot, applies incoming counts deltas to that cache, clears the cache on reconnect/stop, and tests verify message handling and immutability.

Changes

Lobby WebSocket Delta Update Protocol

Layer / File(s) Summary
Message Schema and Contract
src/core/Schemas.ts
Discriminated union PublicLobbyMessageSchema with type: "full" (includes games) and type: "counts" (includes counts map); exports PublicLobbyMessage type.
Client-side Cache and Delta Application
src/client/LobbySocket.ts
PublicLobbySocket adds lastFull cache; clears it on stop() and before reconnect; parses PublicLobbyMessage; full replaces cache and notifies; counts patches numClients onto cached games when base exists and notifies.
Server Snapshot Caching and Broadcasting
src/server/WorkerLobbyService.ts
WorkerLobbyService adds lastPublicGames and lastFullGameIds fingerprint; on broadcast computes fingerprint and chooses full (send and update fingerprint) or counts payload; sends cached full immediately to new clients and stores master publicGames.
Client Behavior Tests
tests/LobbySocket.test.ts
Tests for PublicLobbySocket.handleMessage covering full snapshot delivery, counts patching and ordering, ignoring counts before full, immutability of delivered snapshots, malformed/schema-invalid inputs, and lobby replacement on new full.

Sequence Diagram

sequenceDiagram
  participant Server as WorkerLobbyService
  participant Client as PublicLobbySocket
  participant Cache as lastFull

  rect rgba(100,150,200,0.5)
  Note over Server,Cache: Initial Connection
  Server->>Server: check lastPublicGames
  Server->>Client: send {type:full, games:{...}}
  Client->>Cache: store snapshot
  end

  rect rgba(150,200,100,0.5)
  Note over Server,Cache: Subsequent Updates (no structural change)
  Server->>Server: fingerprint unchanged
  Server->>Client: send {type:counts, counts:{gameID:numClients}}
  Client->>Cache: patch numClients only
  end

  rect rgba(200,150,100,0.5)
  Note over Server,Cache: State Changed
  Server->>Server: fingerprint changed
  Server->>Client: send {type:full, games:{...}}
  Client->>Cache: reset snapshot
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • Celant

"Snapshots stored, then deltas fly,
Counts patch in place, fulls reset on the fly,
Server fingerprints when games rearrange,
Tests hold the line as the messages change,
Lobbies humming, updates neat and spry."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: reducing lobby broadcast bandwidth through counts-only deltas instead of full snapshots.
Description check ✅ Passed The description comprehensively explains the optimization, implementation details, architectural decisions, and confirms required checklist items and contact information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/Schemas.ts`:
- Around line 186-206: Add unit tests that validate the new lobby wire contract
by using the PublicLobbyFullSchema, PublicLobbyCountsSchema and
PublicLobbyMessageSchema to parse/validate messages: write tests that assert
valid "full" payloads (with serverTime, games conforming to PublicGameTypeSchema
and PublicGameInfoSchema) and valid "counts" payloads (with serverTime and
counts map) successfully parse, and tests that mixed/invalid payloads (e.g., a
"full" object containing a counts field or wrong types) are rejected by
PublicLobbyMessageSchema; ensure tests also use the exported type
PublicLobbyMessage where applicable to assert runtime schema matches the
intended TypeScript shape.

In `@src/server/WorkerLobbyService.ts`:
- Around line 17-35: The fingerprintLobbies function should build a stable
fingerprint from all lobby fields except numClients and ensure a deterministic
order; update fingerprintLobbies to sort each games[type] array (e.g., by
lobby.gameID then other stable keys) and include a serialization of every
non-numClients field (for example include lobby.gameID, lobby.startsAt,
JSON.stringify(lobby.gameConfig || {}), and any other public fields on
PublicGameInfo) rather than only a config-existence flag; this ensures
config-only changes and reorderings produce different fingerprints and prevent
stale client state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 760e0217-2575-4edf-95a5-02254794c62d

📥 Commits

Reviewing files that changed from the base of the PR and between 431f22a and fb1f0be.

📒 Files selected for processing (3)
  • src/client/LobbySocket.ts
  • src/core/Schemas.ts
  • src/server/WorkerLobbyService.ts

Comment thread src/core/Schemas.ts
Comment thread src/server/WorkerLobbyService.ts Outdated
Comment on lines +17 to +35
const FULL_BROADCAST_INTERVAL_MS = 5000;

// Stable summary of everything in the lobby snapshot except numClients.
// When the fingerprint matches the last full we sent, a counts-only delta
// is enough; otherwise we need a fresh full snapshot.
function fingerprintLobbies(
games: Record<PublicGameType, PublicGameInfo[]>,
): string {
const parts: string[] = [];
for (const type of Object.keys(games).sort() as PublicGameType[]) {
parts.push(type);
for (const lobby of games[type]) {
parts.push(
`${lobby.gameID}|${lobby.startsAt ?? ""}|${lobby.gameConfig ? 1 : 0}`,
);
}
}
return parts.join(",");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the lobby fingerprint complete and order-stable.

Right now the fingerprint only includes gameID, startsAt, and whether gameConfig exists. A config-only change will keep sending counts, so clients stay stale until the 5s full refresh. Also, the lobby arrays are not sorted, so reorder-only broadcasts can force unnecessary full snapshots. Build the fingerprint from a sorted view of all non-numClients fields instead.

Possible fix
 function fingerprintLobbies(
   games: Record<PublicGameType, PublicGameInfo[]>,
 ): string {
-  const parts: string[] = [];
-  for (const type of Object.keys(games).sort() as PublicGameType[]) {
-    parts.push(type);
-    for (const lobby of games[type]) {
-      parts.push(
-        `${lobby.gameID}|${lobby.startsAt ?? ""}|${lobby.gameConfig ? 1 : 0}`,
-      );
-    }
-  }
-  return parts.join(",");
+  const normalized = (Object.keys(games).sort() as PublicGameType[]).flatMap(
+    (type) =>
+      [...games[type]]
+        .sort((a, b) => a.gameID.localeCompare(b.gameID))
+        .map((lobby) => ({
+          type,
+          gameID: lobby.gameID,
+          startsAt: lobby.startsAt ?? null,
+          publicGameType: lobby.publicGameType,
+          gameConfig: lobby.gameConfig ?? null,
+        })),
+  );
+  return JSON.stringify(normalized);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/WorkerLobbyService.ts` around lines 17 - 35, The
fingerprintLobbies function should build a stable fingerprint from all lobby
fields except numClients and ensure a deterministic order; update
fingerprintLobbies to sort each games[type] array (e.g., by lobby.gameID then
other stable keys) and include a serialization of every non-numClients field
(for example include lobby.gameID, lobby.startsAt,
JSON.stringify(lobby.gameConfig || {}), and any other public fields on
PublicGameInfo) rather than only a config-existence flag; this ensures
config-only changes and reorderings produce different fingerprints and prevent
stale client state.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/server/WorkerLobbyService.ts (1)

22-27: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

gameID-only fingerprint misses real lobby updates.

Line 86 updates startsAt on an existing game. The fingerprint at Lines 163-170 only uses sorted gameIDs, so that change still goes out as "counts" and clients keep stale startsAt / gameConfig / other lobby metadata. Fingerprint a stable, sorted view of all non-numClients lobby fields instead.

Also applies to: 78-87, 163-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/WorkerLobbyService.ts` around lines 22 - 27, The current
fingerprint stored in lastFullGameIds (a string) only includes sorted gameIDs
and misses updates to startsAt/gameConfig/etc; update the fingerprint logic (the
code that currently builds the "counts"/full decision — the block that
reads/sets lastFullGameIds) to produce a deterministic fingerprint over each
lobby's stable, sorted representation of all fields except numClients: for each
lobby, build an object excluding numClients, produce a JSON string with keys
sorted deterministically, sort the resulting per-lobby strings by gameID (or the
existing stable key), join them into one string and use a hash or that joined
string for lastFullGameIds; encapsulate this in a helper like
computeLobbyFingerprint(lobbies) and use it wherever the old gameID-only
fingerprint was used so changes to startsAt/gameConfig will trigger a full
update.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/WorkerLobbyService.ts`:
- Around line 19-21: The cached connect snapshot lastFullJson is only updated on
the "full" branch so new clients can receive stale numClients after a "counts"
broadcast; modify the "counts" handling to refresh lastFullJson (or recreate the
connect payload) using the current publicGames/numClients state before it's sent
to new connections. Concretely, in the method that processes master broadcasts
(the branch handling the "counts" message), regenerate the same serialized
connect-time payload you build for the "full" path (or call the helper that
builds it from publicGames) and assign it to lastFullJson so subsequent
on-connect sends the up-to-date snapshot. Ensure the same change is applied to
all places noted (the counts path and the places around the existing
full-snapshot send logic).
- Around line 21-27: The broadcast logic treats an empty lobby fingerprint ("")
as equal to the initialized lastFullGameIds, so the first broadcast sends a
counts-only delta with no base; change lastFullGameIds from string = "" to
string | null = null (or another unambiguous sentinel) and update the comparison
in the broadcast/emit logic (the code that decides between "full" and "counts",
referencing lastFullGameIds and lastFullJson) to treat null as "no previous full
sent" and force sending a full snapshot for the first broadcast (also ensure
lastFullJson is set after sending the full snapshot).

---

Duplicate comments:
In `@src/server/WorkerLobbyService.ts`:
- Around line 22-27: The current fingerprint stored in lastFullGameIds (a
string) only includes sorted gameIDs and misses updates to
startsAt/gameConfig/etc; update the fingerprint logic (the code that currently
builds the "counts"/full decision — the block that reads/sets lastFullGameIds)
to produce a deterministic fingerprint over each lobby's stable, sorted
representation of all fields except numClients: for each lobby, build an object
excluding numClients, produce a JSON string with keys sorted deterministically,
sort the resulting per-lobby strings by gameID (or the existing stable key),
join them into one string and use a hash or that joined string for
lastFullGameIds; encapsulate this in a helper like
computeLobbyFingerprint(lobbies) and use it wherever the old gameID-only
fingerprint was used so changes to startsAt/gameConfig will trigger a full
update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9ce65a22-fc5b-4f54-b959-99d4c0160a80

📥 Commits

Reviewing files that changed from the base of the PR and between fb1f0be and 0337ae2.

📒 Files selected for processing (4)
  • src/client/LobbySocket.ts
  • src/core/Schemas.ts
  • src/server/WorkerLobbyService.ts
  • tests/LobbySocket.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/Schemas.ts

Comment thread src/server/WorkerLobbyService.ts Outdated
Comment thread src/server/WorkerLobbyService.ts Outdated
@evanpelle evanpelle changed the title lobby bandwidth Reduce lobby bandwidth by only sending partial updates Jun 2, 2026
@evanpelle evanpelle changed the title Reduce lobby bandwidth by only sending partial updates Reduce lobby broadcast bandwidth via counts-only deltas Jun 2, 2026
@evanpelle
evanpelle marked this pull request as ready for review June 2, 2026 22:32
@evanpelle
evanpelle requested a review from a team as a code owner June 2, 2026 22:32
@evanpelle evanpelle added this to the v32 milestone Jun 2, 2026
@evanpelle
evanpelle force-pushed the reduce-lobby-bandwidth branch from 0337ae2 to 0bdb117 Compare June 2, 2026 22:47
@evanpelle
evanpelle merged commit 48609fa into main Jun 2, 2026
14 checks passed
@evanpelle
evanpelle deleted the reduce-lobby-bandwidth branch June 2, 2026 22:52
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant