Speed up diffPlayerUpdate with typed field comparisons

diffPlayerUpdate runs once per player per tick on the worker thread. The
array/object fields (outgoingAttacks, incomingAttacks, alliances,
outgoingEmojis) were compared via JSON.stringify — two string allocations per
field, run on every call even when nothing changed. This made the cost flat at
~3.4µs/call regardless of what actually changed.

Replace jsonEqual with three typed structural comparators (attackArrayEqual,
allianceArrayEqual, emojiArrayEqual) that short-circuit on reference/length,
compare known fields with ===, early-exit on the first difference, and
allocate nothing — matching the existing numberArrayEqual/stringArrayEqual
style. ~9-10x faster across all cases (276k -> 2.4M ops/sec when unchanged).

Add tests/perf/DiffPlayerUpdatePerf.ts (BEFORE/AFTER benchmark, run via
npm run perf) and warnings on PlayerUpdate and diffPlayerUpdate noting that new
fields must be wired into the diff/apply functions or their changes are
silently dropped after the first emission.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
evanpelle
2026-06-05 15:59:58 -07:00
parent 312b38fda5
commit be87c7658f
3 changed files with 299 additions and 7 deletions
+76 -7
View File
@@ -1,5 +1,11 @@
import type { PlayerState } from "../../client/render/types";
import { GameUpdateType, PlayerUpdate } from "./GameUpdates";
import type { EmojiMessage } from "./Game";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
PlayerUpdate,
} from "./GameUpdates";
/**
* Build a partial PlayerUpdate containing only fields whose value differs
@@ -8,6 +14,12 @@ import { GameUpdateType, PlayerUpdate } from "./GameUpdates";
* `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 `===`.
*
* WARNING: this diff is field-by-field by design (no JSON.stringify, for
* perf — see tests/perf/DiffPlayerUpdatePerf.ts). When you add a field to
* PlayerUpdate, you MUST add a matching setIfDifferent(...) line here, and an
* apply line in applyStateUpdate below. A field missing here is never diffed,
* so its changes silently never reach the main thread after the first update.
*/
export function diffPlayerUpdate(
prev: PlayerUpdate,
@@ -62,17 +74,20 @@ export function diffPlayerUpdate(
setIfDifferent("embargoes", stringSetEqual(prev.embargoes, next.embargoes));
setIfDifferent(
"outgoingEmojis",
jsonEqual(prev.outgoingEmojis, next.outgoingEmojis),
emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis),
);
setIfDifferent(
"outgoingAttacks",
jsonEqual(prev.outgoingAttacks, next.outgoingAttacks),
attackArrayEqual(prev.outgoingAttacks, next.outgoingAttacks),
);
setIfDifferent(
"incomingAttacks",
jsonEqual(prev.incomingAttacks, next.incomingAttacks),
attackArrayEqual(prev.incomingAttacks, next.incomingAttacks),
);
setIfDifferent(
"alliances",
allianceArrayEqual(prev.alliances, next.alliances),
);
setIfDifferent("alliances", jsonEqual(prev.alliances, next.alliances));
return changed ? diff : null;
}
@@ -144,7 +159,61 @@ function stringSetEqual(a?: Set<string>, b?: Set<string>): boolean {
return true;
}
function jsonEqual(a: unknown, b: unknown): boolean {
function attackArrayEqual(a?: AttackUpdate[], b?: AttackUpdate[]): boolean {
if (a === b) return true;
return JSON.stringify(a) === JSON.stringify(b);
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.attackerID !== y.attackerID ||
x.targetID !== y.targetID ||
x.troops !== y.troops ||
x.id !== y.id ||
x.retreating !== y.retreating
) {
return false;
}
}
return true;
}
function allianceArrayEqual(a?: AllianceView[], b?: AllianceView[]): 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++) {
const x = a[i];
const y = b[i];
if (
x.id !== y.id ||
x.other !== y.other ||
x.createdAt !== y.createdAt ||
x.expiresAt !== y.expiresAt ||
x.hasExtensionRequest !== y.hasExtensionRequest
) {
return false;
}
}
return true;
}
function emojiArrayEqual(a?: EmojiMessage[], b?: EmojiMessage[]): 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++) {
const x = a[i];
const y = b[i];
if (
x.message !== y.message ||
x.senderID !== y.senderID ||
x.recipientID !== y.recipientID ||
x.createdAt !== y.createdAt
) {
return false;
}
}
return true;
}
+4
View File
@@ -181,6 +181,10 @@ export interface AttackUpdate {
* 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.
*
* When adding a field here, also wire it into diffPlayerUpdate() and
* applyStateUpdate() in GameUpdateUtils.ts — otherwise it is only ever sent on
* the first emission and later changes are silently dropped.
*/
export interface PlayerUpdate {
type: GameUpdateType.Player;