Cut worker→main bandwidth ~3.3× by switching PlayerUpdate to deltas (#3967)

## Description:

Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full
per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now
caches the last sent update and returns only changed fields, or null if
nothing changed. The client-side applyStateUpdate() merges instead of
overwriting.

Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone
went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new
GameUpdateUtils.ts module with unit tests.

## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## 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-05-18 17:07:40 -07:00
committed by GitHub
parent ed928db081
commit 62e15d2794
9 changed files with 577 additions and 92 deletions
+1 -1
View File
@@ -828,7 +828,7 @@ export interface Player {
executeRetreat(attackID: string): void;
// Misc
toUpdate(): PlayerUpdate;
toUpdate(): PlayerUpdate | null;
playerProfile(): PlayerProfile;
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
+2 -2
View File
@@ -455,8 +455,8 @@ export class GameImpl implements Game {
this.execs.push(...inited);
this.unInitExecs = unInited;
for (const player of this._players.values()) {
// Players change each to so always add them
this.addUpdate(player.toUpdate());
const update = player.toUpdate();
if (update !== null) this.addUpdate(update);
}
if (this.ticks() % 10 === 0) {
this.addUpdate({
+148
View File
@@ -0,0 +1,148 @@
import type { PlayerState } from "../../client/render/types";
import { GameUpdateType, PlayerUpdate } from "./GameUpdates";
/**
* Build a partial PlayerUpdate containing only fields whose value differs
* between `prev` and `next`. Returns null if nothing changed.
*
* `type` and `id` are always included on the returned diff. Array/object
* fields are compared by structural equality (length + per-element);
* `embargoes` is compared as a set; primitive fields by `===`.
*/
export function diffPlayerUpdate(
prev: PlayerUpdate,
next: PlayerUpdate,
): PlayerUpdate | null {
const diff: PlayerUpdate = { type: GameUpdateType.Player, id: next.id };
let changed = false;
const setIfDifferent = <K extends keyof PlayerUpdate>(
key: K,
equal: boolean,
) => {
if (!equal) {
(diff[key] as PlayerUpdate[K]) = next[key] as PlayerUpdate[K];
changed = true;
}
};
setIfDifferent("clientID", prev.clientID === next.clientID);
setIfDifferent("name", prev.name === next.name);
setIfDifferent("displayName", prev.displayName === next.displayName);
setIfDifferent("team", prev.team === next.team);
setIfDifferent("smallID", prev.smallID === next.smallID);
setIfDifferent("playerType", prev.playerType === next.playerType);
setIfDifferent("isAlive", prev.isAlive === next.isAlive);
setIfDifferent("isDisconnected", prev.isDisconnected === next.isDisconnected);
setIfDifferent("tilesOwned", prev.tilesOwned === next.tilesOwned);
setIfDifferent("gold", prev.gold === next.gold);
setIfDifferent("troops", prev.troops === next.troops);
setIfDifferent("isTraitor", prev.isTraitor === next.isTraitor);
setIfDifferent(
"traitorRemainingTicks",
prev.traitorRemainingTicks === next.traitorRemainingTicks,
);
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
setIfDifferent("betrayals", prev.betrayals === next.betrayals);
setIfDifferent(
"lastDeleteUnitTick",
prev.lastDeleteUnitTick === next.lastDeleteUnitTick,
);
setIfDifferent("isLobbyCreator", prev.isLobbyCreator === next.isLobbyCreator);
setIfDifferent("allies", numberArrayEqual(prev.allies, next.allies));
setIfDifferent("targets", numberArrayEqual(prev.targets, next.targets));
setIfDifferent(
"outgoingAllianceRequests",
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
),
);
setIfDifferent("embargoes", stringSetEqual(prev.embargoes, next.embargoes));
setIfDifferent(
"outgoingEmojis",
jsonEqual(prev.outgoingEmojis, next.outgoingEmojis),
);
setIfDifferent(
"outgoingAttacks",
jsonEqual(prev.outgoingAttacks, next.outgoingAttacks),
);
setIfDifferent(
"incomingAttacks",
jsonEqual(prev.incomingAttacks, next.incomingAttacks),
);
setIfDifferent("alliances", jsonEqual(prev.alliances, next.alliances));
return changed ? diff : null;
}
/**
* Merge a partial PlayerUpdate into a long-lived PlayerState in place.
*
* Only fields present on `pu` are applied; `undefined` means "no change since
* last emission". The first emission per player carries every field, so the
* target state is fully populated after one merge of the initial update.
*/
export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
// smallID is identity — never changes for a given player.
if (pu.isAlive !== undefined) target.isAlive = pu.isAlive;
if (pu.isDisconnected !== undefined)
target.isDisconnected = pu.isDisconnected;
if (pu.tilesOwned !== undefined) target.tilesOwned = pu.tilesOwned;
if (pu.gold !== undefined) target.gold = Number(pu.gold);
if (pu.troops !== undefined) target.troops = pu.troops;
if (pu.isTraitor !== undefined) target.isTraitor = pu.isTraitor;
if (pu.traitorRemainingTicks !== undefined) {
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks);
}
if (pu.betrayals !== undefined) target.betrayals = pu.betrayals;
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
if (pu.lastDeleteUnitTick !== undefined) {
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
}
// Slice() to detach from the wire object — accumulated state mustn't share
// mutable arrays with per-tick update payloads.
if (pu.allies !== undefined) target.allies = pu.allies.slice();
if (pu.targets !== undefined) target.targets = pu.targets.slice();
if (pu.outgoingAllianceRequests !== undefined) {
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
}
if (pu.outgoingAttacks !== undefined) {
target.outgoingAttacks = pu.outgoingAttacks;
}
if (pu.incomingAttacks !== undefined) {
target.incomingAttacks = pu.incomingAttacks;
}
if (pu.alliances !== undefined) target.alliances = pu.alliances;
if (pu.outgoingEmojis !== undefined)
target.outgoingEmojis = pu.outgoingEmojis;
}
function numberArrayEqual(a?: number[], b?: number[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringSetEqual(a?: Set<string>, b?: Set<string>): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
function jsonEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
+32 -24
View File
@@ -164,35 +164,43 @@ export interface AttackUpdate {
retreating: boolean;
}
/**
* Player snapshot delivered worker -> main thread.
*
* Only `type` and `id` are guaranteed. Every other field is omitted when its
* value matches the previous emission for the same player. The first emission
* for a player always includes all fields; consumers must handle subsequent
* partial updates by merging into local state, not overwriting.
*/
export interface PlayerUpdate {
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID | null;
name: string;
displayName: string;
id: PlayerID;
nameViewData?: NameViewData;
clientID?: ClientID | null;
name?: string;
displayName?: string;
team?: Team;
smallID: number;
playerType: PlayerType;
isAlive: boolean;
isDisconnected: boolean;
tilesOwned: number;
gold: Gold;
troops: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
smallID?: number;
playerType?: PlayerType;
isAlive?: boolean;
isDisconnected?: boolean;
tilesOwned?: number;
gold?: Gold;
troops?: number;
allies?: number[];
embargoes?: Set<PlayerID>;
isTraitor?: boolean;
traitorRemainingTicks?: number;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
betrayals: number;
lastDeleteUnitTick: Tick;
isLobbyCreator: boolean;
targets?: number[];
outgoingEmojis?: EmojiMessage[];
outgoingAttacks?: AttackUpdate[];
incomingAttacks?: AttackUpdate[];
outgoingAllianceRequests?: PlayerID[];
alliances?: AllianceView[];
hasSpawned?: boolean;
betrayals?: number;
lastDeleteUnitTick?: Tick;
isLobbyCreator?: boolean;
}
export interface AllianceView {
+26 -1
View File
@@ -43,6 +43,7 @@ import {
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { diffPlayerUpdate } from "./GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
@@ -105,6 +106,13 @@ export class PlayerImpl implements Player {
private _spawnTile: TileRef | undefined;
private _isDisconnected = false;
/**
* Last PlayerUpdate emitted for this player on the worker→main channel.
* Used by GameImpl's tick loop to compute field-level diffs. Undefined on
* first emission (full snapshot sent).
*/
public lastSentUpdate: PlayerUpdate | undefined;
constructor(
private mg: GameImpl,
private _smallID: number,
@@ -119,7 +127,24 @@ export class PlayerImpl implements Player {
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
toUpdate(): PlayerUpdate {
/**
* Build a PlayerUpdate for the worker→main wire.
*
* The first call for a player returns the full snapshot. Subsequent calls
* return only fields that changed since the previous call (a partial
* `{ type, id, ...changedFields }`), or `null` if nothing changed.
*
* `lastSentUpdate` is updated to the full snapshot on every call.
*/
toUpdate(): PlayerUpdate | null {
const full = this.toFullUpdate();
const prev = this.lastSentUpdate;
this.lastSentUpdate = full;
if (prev === undefined) return full;
return diffPlayerUpdate(prev, full);
}
private toFullUpdate(): PlayerUpdate {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
);