Files
OpenFrontIO/src/client/view/PlayerView.ts
T
Evan 22c873cf55 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>
2026-07-05 13:46:02 -07:00

633 lines
18 KiB
TypeScript

import { Colord, colord } from "colord";
import { base64url } from "jose";
import { ColorPalette } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { ClientID, PlayerCosmetics } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import {
BuildableUnit,
Cell,
EmojiMessage,
Gold,
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
Team,
Tick,
UnitType,
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { applyStateUpdate } from "../../core/game/GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
PlayerUpdate,
} from "../../core/game/GameUpdates";
import { UserSettings } from "../../core/game/UserSettings";
import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types";
import { themeProvider } from "../theme/ThemeProvider";
import { GameView } from "./GameView";
import { UnitView } from "./UnitView";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
switch (t) {
case PlayerType.Human:
return PlayerTypeEnum.Human;
case PlayerType.Bot:
return PlayerTypeEnum.Bot;
case PlayerType.Nation:
return PlayerTypeEnum.Nation;
default:
return PlayerTypeEnum.Bot;
}
}
// First-emission updates from the engine always include every field; these
// builders assert non-null for that contract. Subsequent diffs are partial
// and flow through applyStateUpdate() below.
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
return {
smallID: pu.smallID!,
id: pu.id,
name: pu.name!,
displayName: pu.displayName!,
clientID: pu.clientID ?? null,
playerType: gamePlayerTypeToEnum(pu.playerType!),
team: pu.team ?? null,
isLobbyCreator: pu.isLobbyCreator!,
};
}
function stateFromUpdate(pu: PlayerUpdate): PlayerState {
// embargoes: Set<PlayerID strings> on the wire, but the renderer stores
// smallIDs (numbers). GameView fills these in via setEmbargoes() because
// it has the PlayerID → smallID lookup table.
return {
smallID: pu.smallID!,
isAlive: pu.isAlive!,
isDisconnected: pu.isDisconnected!,
tilesOwned: pu.tilesOwned!,
gold: Number(pu.gold!),
troops: pu.troops!,
isTraitor: pu.isTraitor!,
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
inDoomsdayClock: pu.inDoomsdayClock ?? false,
markedDoomsdayClockTick: pu.markedDoomsdayClockTick ?? -1,
betrayals: pu.betrayals!,
hasSpawned: pu.hasSpawned!,
spawnTile: pu.spawnTile,
lastDeleteUnitTick: pu.lastDeleteUnitTick!,
allies: pu.allies!.slice(),
embargoes: [],
targets: pu.targets!.slice(),
outgoingAttacks: pu.outgoingAttacks!,
incomingAttacks: pu.incomingAttacks!,
outgoingAllianceRequests: pu.outgoingAllianceRequests!.slice(),
alliances: pu.alliances!,
outgoingEmojis: pu.outgoingEmojis!,
};
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
/** Long-lived renderer state — mutated in place by applyUpdate(). */
public state: PlayerState;
/** Static header data — set once at construction, never mutated. */
public static: PlayerStatic;
// Assigned via computeColors() in the constructor; re-assignable on theme change.
private _territoryColor!: Colord;
private _borderColor!: Colord;
private _railColor!: Colord;
// Update here to include structure light and dark colors
private _structureColors!: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral!: Colord;
private _borderColorFriendly!: Colord;
private _borderColorEmbargo!: Colord;
private _borderColorDefendedNeutral!: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly!: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo!: { light: Colord; dark: Colord };
constructor(
private game: GameView,
data: PlayerUpdate,
// Undefined until the worker's first name placement for this player.
public nameData: NameViewData | undefined,
public cosmetics: PlayerCosmetics,
) {
this.state = stateFromUpdate(data);
this.static = staticFromUpdate(data);
// First emission always carries name + playerType (see staticFromUpdate).
if (data.clientID === game.myClientID()) {
this.anonymousName = data.name!;
} else {
this.anonymousName = createRandomName(data.name!, data.playerType!);
}
this.computeColors();
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
/**
* Compute every theme-derived color (fill, border, structure, and the
* neutral/friendly/embargo border variants) from the active theme. Re-callable
* so a mid-game theme change — e.g. toggling colorblind mode — can refresh them.
*/
private computeColors(): void {
const theme = themeProvider.current();
const defaultTerritoryColor = theme.territoryColor(this);
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = theme.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.static.clientID
? theme.focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
// Rail color (only used for the local player's rails): white for
// visibility, flipped to black when the territory is too light for white
// to read against it. Patterns paint both colors, so average them.
const railBackdropBrightness = pattern
? (this._territoryColor.brightness() + this._borderColor.brightness()) / 2
: this._territoryColor.brightness();
this._railColor =
railBackdropBrightness > 0.8
? colord("rgb(0,0,0)")
: theme.focusedBorderColor();
const baseRgb = this._borderColor.toRgb();
this._borderColorNeutral = this._borderColor;
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
}
/** Recompute colors after the active theme changes (e.g. colorblind toggle). */
refreshColors(): void {
this.computeColors();
}
/**
* Update mutable state in place. Called by GameView.update() each tick the
* player appears in the PlayerUpdate stream.
*/
applyUpdate(pu: PlayerUpdate): void {
applyStateUpdate(this.state, pu);
}
/** Set the renderer-format embargoes (smallIDs). */
setEmbargoSmallIDs(smallIDs: number[]): void {
this.state.embargoes = smallIDs;
}
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
}
const isPrimary = this.decoder.isPrimary(
this.game.x(tile),
this.game.y(tile),
);
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
railColor(): Colord {
return this._railColor;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
outgoingAttacks(): AttackUpdate[] {
return this.state.outgoingAttacks;
}
incomingAttacks(): AttackUpdate[] {
return this.state.incomingAttacks;
}
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
const owned = this.game.unitsOwnedBy(this.smallID());
if (types.length === 0) {
return [...owned];
}
return owned.filter((u) => types.includes(u.type()));
}
nameLocation(): NameViewData | undefined {
return this.nameData;
}
smallID(): number {
return this.state.smallID;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.name;
}
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.displayName;
}
clientID(): ClientID | null {
return this.static.clientID;
}
id(): PlayerID {
return this.static.id;
}
team(): Team | null {
return this.static.team;
}
type(): PlayerType {
// Map PlayerStatic's numeric enum back to engine string enum.
switch (this.static.playerType) {
case PlayerTypeEnum.Human:
return PlayerType.Human;
case PlayerTypeEnum.Bot:
return PlayerType.Bot;
case PlayerTypeEnum.Nation:
return PlayerType.Nation;
default:
return PlayerType.Bot;
}
}
isAlive(): boolean {
return this.state.isAlive;
}
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
return this.state.tilesOwned;
}
allies(): PlayerView[] {
return this.state.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.state.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
// Engine Gold is bigint; renderer state stores number. Convert back at the
// accessor for game-code that still expects bigint semantics.
return BigInt(this.state.gold);
}
troops(): number {
return this.state.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isLobbyCreator(): boolean {
return this.static.isLobbyCreator;
}
isAlliedWith(other: PlayerView): boolean {
return this.state.allies.some((n) => other.smallID() === n);
}
isOnSameTeam(other: PlayerView): boolean {
return this.static.team !== null && this.static.team === other.static.team;
}
isFriendly(other: PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
isRequestingAllianceWith(other: PlayerView) {
return this.state.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.state.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.state.embargoes.includes(other.smallID());
}
hasEmbargo(other: PlayerView): boolean {
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
const result: PlayerView[] = [];
// Add own targets
for (const id of this.state.targets) {
result.push(this.game.playerBySmallID(id) as PlayerView);
}
// Add allies' targets
for (const allyID of this.state.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
for (const targetId of ally.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
// Add teammates' targets
const myTeam = this.static.team;
if (myTeam !== null) {
for (const p of this.game.playerViews()) {
if (p !== this && p.static.team === myTeam) {
for (const targetId of p.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
}
}
return result;
}
hasTransitiveTarget(sid: number): boolean {
if (this.state.targets.includes(sid)) return true;
for (const allyID of this.state.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
if (ally && ally.state.targets.includes(sid)) {
return true;
}
}
const myTeam = this.static.team;
if (myTeam !== null) {
for (const p of this.game.playerViews()) {
if (
p !== this &&
p.static.team === myTeam &&
p.state.targets.includes(sid)
) {
return true;
}
}
}
return false;
}
isTraitor(): boolean {
return this.state.isTraitor;
}
getTraitorRemainingTicks(): number {
return this.state.traitorRemainingTicks;
}
inDoomsdayClock(): boolean {
return this.state.inDoomsdayClock;
}
doomsdayClockTicks(): number {
return this.inDoomsdayClock()
? this.game.ticks() - this.state.markedDoomsdayClockTick
: 0;
}
betrayals(): number {
return this.state.betrayals;
}
outgoingEmojis(): EmojiMessage[] {
return this.state.outgoingEmojis;
}
hasSpawned(): boolean {
return this.state.hasSpawned;
}
isDisconnected(): boolean {
return this.state.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.state.lastDeleteUnitTick;
}
deleteUnitCooldown(): number {
return (
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}