perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)

## Problem

Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.

## Harness (`npm run perf:client-tick`)

Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.

Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.

## Baseline (Giant World Map, 400 bots, headless)

Dispatch handler ms — cost **grows with game progression**:

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |

Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.

## Round 1 — algorithmic fixes

- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.

After (same map, same spawn tile):

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |

Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.

## Round 2 — allocation churn + time slicing

Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:

- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.

Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.

## Verification

- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-05 13:46:02 -07:00
committed by GitHub
parent 571f58440d
commit 22c873cf55
15 changed files with 893 additions and 114 deletions
+4 -2
View File
@@ -8,7 +8,6 @@ import type {
NukeTelegraphData,
PlayerState,
PlayerStatusData,
TilePair,
UnitState,
} from "../types";
@@ -21,7 +20,10 @@ export interface FrameUploadTarget {
tileState: Uint16Array,
trailState: Uint16Array,
): void;
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void;
uploadLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void;
uploadLiveTrailDelta(
trailState: Uint16Array,
dirtyRowMin: number,
+4 -2
View File
@@ -25,7 +25,6 @@ import type {
PlayerStatic,
PlayerStatusData,
RendererConfig,
TilePair,
UnitState,
} from "../types";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
@@ -124,7 +123,10 @@ export class MapRenderer {
// ---- Data upload ----
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
uploadLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void {
this.renderer?.uploadLiveDelta(tileState, changedTiles);
}
uploadLiveTrailDelta(
+4 -2
View File
@@ -23,7 +23,6 @@ import type {
PlayerStatic,
PlayerStatusData,
RendererConfig,
TilePair,
UnitState,
} from "../types";
import { Camera } from "./Camera";
@@ -653,7 +652,10 @@ export class GPURenderer {
this.trailPass.setLiveRef(trailState);
}
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
uploadLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void {
this.territoryPass.applyLiveDelta(tileState, changedTiles);
}
-1
View File
@@ -26,6 +26,5 @@ export type {
PlayerState,
PlayerStatic,
RendererConfig,
TilePair,
UnitState,
} from "../types";
+5 -3
View File
@@ -12,7 +12,6 @@
* uploads across render frames.
*/
import type { TilePair } from "../../types";
import type { RenderSettings } from "../RenderSettings";
import { getPaletteSize } from "../utils/ColorUtils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
@@ -229,11 +228,14 @@ export class TerritoryPass {
* Stable per-ref hash means repeated updates to the same tile stay in
* arrival order in the same bucket — last write wins when drained.
*/
applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
applyLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void {
const N = this.nBuckets;
const buckets = this.dripBuckets;
for (let i = 0; i < changedTiles.length; i++) {
const ref = changedTiles[i].ref;
const ref = changedTiles[i];
const b = ((ref * 2654435761) >>> 0) % N;
buckets[b].push(ref, tileState[ref]);
}
+74 -72
View File
@@ -81,7 +81,6 @@ export class NamePass {
// Player management
private playerByID: Map<string, PlayerStatic>;
private smallIDToPlayerID: Map<number, string>;
private slots: Map<string, PlayerSlot> = new Map();
private maxPlayers: number;
private playerColors: Map<string, [number, number, number]> = new Map();
@@ -102,16 +101,15 @@ export class NamePass {
private stringRow: Uint8Array;
private cursorRow: Float32Array;
// Reusable per-tick lookup maps (avoid allocation + GC)
private alivePlayerIDs = new Set<string>();
private troopsByPlayerID = new Map<string, number>();
/** Slots refreshed per updateNames call: index % UPDATE_SLICES === phase. */
private static readonly UPDATE_SLICES = 4;
private slicePhase = 0;
// Hovered player's small ID (0 = no highlight, matches TerritoryPass).
private highlightOwnerID = 0;
// Cursor in world coords — fades names under it (far off-map = no fade).
private mouseWorldX = -1e9;
private mouseWorldY = -1e9;
private playerStateByID = new Map<string, PlayerState>();
constructor(
gl: WebGL2RenderingContext,
@@ -148,10 +146,8 @@ export class NamePass {
// Build player lookups and extract territory colors from palette
this.playerByID = new Map();
this.smallIDToPlayerID = new Map();
for (const p of header.players) {
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
@@ -215,7 +211,6 @@ export class NamePass {
for (const p of players) {
if (this.playerByID.has(p.id)) continue;
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
@@ -294,75 +289,83 @@ export class NamePass {
): void {
const now = performance.now() / 1000;
// Build alive set and emoji lookup from smallID → playerID
const alivePlayerIDs = this.alivePlayerIDs;
alivePlayerIDs.clear();
const troopsByPlayerID = this.troopsByPlayerID;
troopsByPlayerID.clear();
const playerStateByID = this.playerStateByID;
playerStateByID.clear();
for (const [, ps] of players) {
const pid = this.smallIDToPlayerID.get(ps.smallID);
if (!pid) continue;
if (ps.isAlive) alivePlayerIDs.add(pid);
troopsByPlayerID.set(pid, ps.troops ?? 0);
playerStateByID.set(pid, ps);
}
// Assign slot indices to players (stable ordering by header index)
let nextSlotIndex = 0;
for (const p of this.playerByID.values()) {
if (!this.slots.has(p.id)) {
const slot: PlayerSlot = {
index: nextSlotIndex++,
playerID: p.id,
static: p,
srcX: 0,
srcY: 0,
srcScale: 0,
tgtX: 0,
tgtY: 0,
tgtScale: 0,
startTime: now,
alive: false,
nameLen: 0,
troopLen: 0,
lastTroopStr: "",
lastTroopBucket: -1,
flagUrl: p.flag,
flagLayerIdx: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
traitor: false,
disconnected: false,
alliance: false,
allianceReq: false,
target: false,
embargo: false,
nukeActive: false,
nukeTargetsMe: false,
inDoomsdayClock: false,
doomsdayClockDraining: false,
traitorRemainingTicks: 0,
allianceFraction: 0,
allianceRemainingTicks: 0,
};
this.slots.set(p.id, slot);
this.resolveSlotFlag(slot);
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
this.slots.get(p.id)!.index + 1,
);
// Assign slot indices to players (stable ordering by header index).
// Both maps only ever grow, and every assignment pass leaves them the
// same size — equal sizes means every player already has a slot.
if (this.slots.size !== this.playerByID.size) {
let nextSlotIndex = 0;
for (const p of this.playerByID.values()) {
if (!this.slots.has(p.id)) {
const slot: PlayerSlot = {
index: nextSlotIndex++,
playerID: p.id,
static: p,
srcX: 0,
srcY: 0,
srcScale: 0,
tgtX: 0,
tgtY: 0,
tgtScale: 0,
startTime: now,
alive: false,
nameLen: 0,
troopLen: 0,
lastTroopStr: "",
lastTroopBucket: -1,
flagUrl: p.flag,
flagLayerIdx: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
traitor: false,
disconnected: false,
alliance: false,
allianceReq: false,
target: false,
embargo: false,
nukeActive: false,
nukeTargetsMe: false,
inDoomsdayClock: false,
doomsdayClockDraining: false,
traitorRemainingTicks: 0,
allianceFraction: 0,
allianceRemainingTicks: 0,
};
this.slots.set(p.id, slot);
this.resolveSlotFlag(slot);
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
this.slots.get(p.id)!.index + 1,
);
}
}
}
// Round-robin time slicing: each call refreshes 1/UPDATE_SLICES of the
// slots, spreading the per-player diff work across game ticks (full
// refresh every UPDATE_SLICES ticks ≈ 400 ms — under the 500 ms troop
// text cadence, and name positions lerp continuously anyway). Slots
// without a written name (new player, refreshNames reset) and snap
// passes (seek) are always processed so nothing pops in late.
const phase = this.slicePhase;
this.slicePhase = (phase + 1) % NamePass.UPDATE_SLICES;
for (const [playerID, entry] of names) {
const slot = this.slots.get(playerID);
if (!slot) continue;
const alive = alivePlayerIDs.has(playerID);
if (
!snap &&
slot.nameLen !== 0 &&
slot.index % NamePass.UPDATE_SLICES !== phase
) {
continue;
}
// Per-player state straight from the caller's map — smallID is the key.
const ps = players.get(slot.static.smallID);
const alive = ps?.isAlive ?? false;
// Skip dead players already marked dead — no work needed
if (!alive && !slot.alive) continue;
@@ -394,7 +397,7 @@ export class NamePass {
const troopBucket = Math.floor((now + (slot.index % 5) * 0.1) / 0.5);
if (snap || slot.troopLen === 0 || troopBucket !== slot.lastTroopBucket) {
slot.lastTroopBucket = troopBucket;
const troops = troopsByPlayerID.get(playerID) ?? 0;
const troops = ps?.troops ?? 0;
const troopStr = renderTroops(troops);
if (troopStr !== slot.lastTroopStr) {
slot.troopLen = Math.min(troopStr.length, MAX_CHARS);
@@ -433,7 +436,6 @@ export class NamePass {
// Resolve active broadcast emoji for this player
let newEmoji = -1;
const ps = playerStateByID.get(playerID);
if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) {
for (const e of ps.outgoingEmojis) {
if (e.recipientID === "AllPlayers") {
+2 -3
View File
@@ -5,7 +5,6 @@ import type {
NukeTelegraphData,
PlayerState,
PlayerStatusData,
TilePair,
UnitState,
} from "./Renderer";
@@ -37,11 +36,11 @@ export interface FrameData {
// ── Upload hints ──────────────────────────────────────────────────────
/**
* Changed tiles this frame for delta uploads.
* Changed tile refs this frame for delta uploads.
* - `null` → no delta info; full upload needed (first tick)
* - array → only these tiles changed (empty = skip upload)
*/
readonly changedTiles: TilePair[] | null;
readonly changedTiles: readonly number[] | null;
readonly railroadDirty: boolean;
readonly revealedRailTiles: number[];
-5
View File
@@ -173,11 +173,6 @@ export interface ConquestFx {
tickAge?: number;
}
export interface TilePair {
ref: number;
state: number;
}
export interface NameEntry {
playerID: string;
x: number;
-1
View File
@@ -21,7 +21,6 @@ export type {
PlayerStatic,
PlayerStatusData,
RendererConfig,
TilePair,
UnitState,
} from "./Renderer";
+47 -14
View File
@@ -34,7 +34,7 @@ import { computePlayerStatus } from "../render/frame/derive/PlayerStatus";
import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix";
import { RailroadCache } from "../render/frame/RailroadCache";
import { TrailManager } from "../render/frame/TrailManager";
import type { FrameData, NameEntry, TilePair } from "../render/types";
import type { FrameData, NameEntry } from "../render/types";
import { STRUCTURE_TYPES } from "../render/types";
import { PlayerView } from "./PlayerView";
import { UnitView } from "./UnitView";
@@ -81,6 +81,14 @@ export class GameView implements GameMap {
private _teams = new Map<number, string>();
private updatedTiles: TileRef[] = [];
private updatedTerrainTiles: TileRef[] = [];
/**
* Active units grouped by owner smallID, built lazily at most once per
* tick. Keeps per-player unit queries (PlayerView.units) at O(own units)
* instead of O(all units) — the leaderboard asks for every player's units
* in one pass, which is O(players × units) without this.
*/
private _unitsByOwner = new Map<number, UnitView[]>();
private _unitsByOwnerStale = true;
// ── FrameData accumulators (renderer-bound state) ─────────────────────
private trailManager!: TrailManager;
@@ -88,7 +96,6 @@ export class GameView implements GameMap {
/** Long-lived NameEntry map for the renderer's NamePass. */
private _names = new Map<string, NameEntry>();
/** Reusable scratch buffers for per-tick deltas. */
private readonly _changedTilesScratch: TilePair[] = [];
private readonly _trailIdsScratch: number[] = [];
/**
* The single long-lived FrameData object. Fields are mutated in place each
@@ -167,9 +174,9 @@ export class GameView implements GameMap {
this.railroadCache = new RailroadCache(mapW, mapH);
// Long-lived FrameData. Most fields are mutable references to long-lived
// buffers (tileState, trailState, etc.); some (_changedTilesScratch,
// derived arrays) are reused each tick. Properties marked `readonly` on
// FrameData only prevent reassignment, not mutation through the reference.
// buffers (tileState, trailState, etc.); changedTiles points at this
// tick's updatedTiles. Properties marked `readonly` on FrameData only
// prevent reassignment, not mutation through the reference.
// events: fresh arrays we own; cleared and repopulated each tick.
this._frame = {
tick: 0,
@@ -185,7 +192,7 @@ export class GameView implements GameMap {
conquestEvents: [],
bonusEvents: [],
},
changedTiles: this._changedTilesScratch,
changedTiles: null,
railroadDirty: false,
revealedRailTiles: this.railroadCache.revealedRailTiles,
trailDirtyRowMin: 0,
@@ -261,6 +268,9 @@ export class GameView implements GameMap {
}
public update(gu: GameUpdateViewData) {
// Unit set/ownership changes below; rebuild the owner index on demand.
this._unitsByOwnerStale = true;
this.toDelete.forEach((id) => {
this._units.delete(id);
this._unitStates.delete(id);
@@ -437,7 +447,12 @@ export class GameView implements GameMap {
for (const unit of this._units.values()) {
unit._wasUpdated = false;
unit.lastPos = unit.lastPos.slice(-1);
// Only trim when a move appended a position — slicing a ≤1-element
// array would allocate an identical array per unit per tick, and most
// units (structures) never move.
if (unit.lastPos.length > 1) {
unit.lastPos = unit.lastPos.slice(-1);
}
}
gu.updates[GameUpdateType.Unit].forEach((update) => {
let unit = this._units.get(update.id);
@@ -513,12 +528,6 @@ export class GameView implements GameMap {
this._trailIdsScratch,
);
// Changed-tile delta refs (zero-copy: state field unused in live mode).
this._changedTilesScratch.length = 0;
for (let i = 0; i < this.updatedTiles.length; i++) {
this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 });
}
// Names map — rebuilt only when a placement record arrived or a player
// was added (nameData values cannot change between those ticks). Entry
// order is irrelevant for the renderer.
@@ -608,7 +617,9 @@ export class GameView implements GameMap {
f.structuresDirty = true; // force initial structure upload
this._firstPopulate = false;
} else {
f.changedTiles = this._changedTilesScratch;
// Live reference to this tick's changed refs — consumers copy what
// they keep (TerritoryPass buckets them synchronously in the upload).
f.changedTiles = this.updatedTiles;
}
// Reset transient flags for next tick.
@@ -1059,6 +1070,28 @@ export class GameView implements GameMap {
(u) => u.isActive() && types.includes(u.type()),
);
}
/**
* Active units owned by the given player (smallID). The grouping is built
* lazily at most once per tick; the returned array must not be mutated.
*/
unitsOwnedBy(ownerSmallID: number): readonly UnitView[] {
if (this._unitsByOwnerStale) {
this._unitsByOwnerStale = false;
this._unitsByOwner.clear();
for (const u of this._units.values()) {
if (!u.isActive()) continue;
const sid = u.state.ownerID;
const arr = this._unitsByOwner.get(sid);
if (arr === undefined) {
this._unitsByOwner.set(sid, [u]);
} else {
arr.push(u);
}
}
}
return this._unitsByOwner.get(ownerSmallID) ?? [];
}
unit(id: number): UnitView | undefined {
return this._units.get(id);
}
+5 -3
View File
@@ -403,9 +403,11 @@ export class PlayerView {
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
.filter((u) => u.owner().smallID() === this.smallID());
const owned = this.game.unitsOwnedBy(this.smallID());
if (types.length === 0) {
return [...owned];
}
return owned.filter((u) => types.includes(u.type()));
}
nameLocation(): NameViewData | undefined {