perf(core): speed up packedTileUpdates (Uint32 pairs, no tile wrappers) (#3255)

## Description
Reduces CPU + GC pressure from tile update serialization.

**What changed**
- Switched `packedTileUpdates` from `BigUint64Array` (BigInt packing) to
`Uint32Array` `[tileRef, state]` pairs, updating `GameView` ingestion.
- Updated tile state to use `GameMap.tileState(tile)` and
`GameMap.updateTile(tile, state)`.
- Removed per-tile `GameUpdateType.Tile` wrapper allocations by
recording raw `(tile, state)` pairs in `GameImpl` and draining them via
`drainPackedTileUpdates()` in `GameRunner`.

**Why it’s faster**
- Avoids BigInt and pack/unpack.
- Avoids per-tile object allocations.

**Compatibility**
- Wire format change: `packedTileUpdates` is now `Uint32Array` pairs
instead of `BigUint64Array`.

## 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
- [ ] 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:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-02-21 06:01:03 +01:00
committed by GitHub
parent 444aa16ac8
commit ea2a76609f
6 changed files with 68 additions and 63 deletions
+2 -12
View File
@@ -22,11 +22,7 @@ import {
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import { GameMapLoader } from "./game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
GameUpdateViewData,
} from "./game/GameUpdates";
import { ErrorUpdate, GameUpdateViewData } from "./game/GameUpdates";
import { createNationsForGame } from "./game/NationCreation";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
@@ -172,13 +168,7 @@ export class GameRunner {
});
}
// Many tiles are updated to pack it into an array
const tileUpdates = updates[GameUpdateType.Tile];
const packedTileUpdates = new BigUint64Array(tileUpdates.length);
for (let i = 0; i < tileUpdates.length; i++) {
packedTileUpdates[i] = tileUpdates[i].update;
}
updates[GameUpdateType.Tile] = [];
const packedTileUpdates = this.game.drainPackedTileUpdates();
this.callBack({
tick: this.game.ticks(),
+1
View File
@@ -766,6 +766,7 @@ export interface Game extends GameMap {
ticks(): Tick;
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
drainPackedTileUpdates(): Uint32Array;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
getWinner(): Player | Team | null;
config(): Config;
+24 -17
View File
@@ -39,7 +39,7 @@ import {
UnitInfo,
UnitType,
} from "./Game";
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import { GameMap, TileRef } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
@@ -83,6 +83,7 @@ export class GameImpl implements Game {
private _nextUnitID = 1;
private updates: GameUpdates = createGameUpdatesMap();
private tileUpdatePairs: number[] = [];
private unitGrid: UnitGrid;
private playerTeams: Team[];
@@ -248,10 +249,7 @@ export class GameImpl implements Game {
return;
}
this._map.setFallout(tile, value);
this.addUpdate({
type: GameUpdateType.Tile,
update: this.toTileUpdate(tile),
});
this.recordTileUpdate(tile);
}
units(...types: UnitType[]): Unit[] {
@@ -379,6 +377,7 @@ export class GameImpl implements Game {
executeNextTick(): GameUpdates {
this.updates = createGameUpdatesMap();
this.tileUpdatePairs.length = 0;
this.execs.forEach((e) => {
if (
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
@@ -417,6 +416,20 @@ export class GameImpl implements Game {
return this.updates;
}
private recordTileUpdate(tile: TileRef): void {
this.tileUpdatePairs.push(tile, this._map.tileState(tile));
}
drainPackedTileUpdates(): Uint32Array {
const pairs = this.tileUpdatePairs;
const packed = new Uint32Array(pairs.length);
for (let i = 0; i < pairs.length; i++) {
packed[i] = pairs[i];
}
pairs.length = 0;
return packed;
}
private hash(): number {
let hash = 1;
this._players.forEach((p) => {
@@ -588,10 +601,7 @@ export class GameImpl implements Game {
owner._lastTileChange = this._ticks;
this.updateBorders(tile);
this._map.setFallout(tile, false);
this.addUpdate({
type: GameUpdateType.Tile,
update: this.toTileUpdate(tile),
});
this.recordTileUpdate(tile);
}
relinquish(tile: TileRef) {
@@ -609,10 +619,7 @@ export class GameImpl implements Game {
this._map.setOwnerID(tile, 0);
this.updateBorders(tile);
this.addUpdate({
type: GameUpdateType.Tile,
update: this.toTileUpdate(tile),
});
this.recordTileUpdate(tile);
}
private updateBorders(tile: TileRef) {
@@ -1017,11 +1024,11 @@ export class GameImpl implements Game {
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
toTileUpdate(tile: TileRef): bigint {
return this._map.toTileUpdate(tile);
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tu: TileUpdate): TileRef {
return this._map.updateTile(tu);
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
+20 -18
View File
@@ -1,7 +1,6 @@
import { Cell, TerrainType } from "./Game";
export type TileRef = number;
export type TileUpdate = bigint;
export interface GameMap {
ref(x: number, y: number): TileRef;
@@ -49,8 +48,20 @@ export interface GameMap {
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef>;
toTileUpdate(tile: TileRef): bigint;
updateTile(tu: TileUpdate): TileRef;
/**
* Returns the packed per-tile state as an unsigned 16-bit value (`0..65535`).
*
* Backed by a `Uint16Array` in `GameMapImpl`, so callers must treat this as `uint16`.
*/
tileState(tile: TileRef): number;
/**
* Applies a packed per-tile state value.
*
* `state` must be an unsigned 16-bit value (`0..65535`). Implementations may
* store this in a `Uint16Array` and will truncate higher bits if provided.
*/
updateTile(tile: TileRef, state: number): void;
numTilesWithFallout(): number;
}
@@ -342,29 +353,20 @@ export class GameMapImpl implements GameMap {
return seen;
}
toTileUpdate(tile: TileRef): bigint {
// Pack the tile reference and state into a bigint
// Format: [32 bits for tile reference][16 bits for state]
return (BigInt(tile) << 16n) | BigInt(this.state[tile]);
tileState(tile: TileRef): number {
return this.state[tile];
}
updateTile(tu: TileUpdate): TileRef {
// Extract tile reference and state from the TileUpdate
// Last 16 bits are state, rest is tile reference
const tileRef = Number(tu >> 16n);
const state = Number(tu & 0xffffn);
const existingFallout = this.hasFallout(tileRef);
this.state[tileRef] = state;
const newFallout = this.hasFallout(tileRef);
updateTile(tile: TileRef, state: number): void {
const existingFallout = this.hasFallout(tile);
this.state[tile] = state;
const newFallout = this.hasFallout(tile);
if (existingFallout && !newFallout) {
this._numTilesWithFallout--;
}
if (!existingFallout && newFallout) {
this._numTilesWithFallout++;
}
return tileRef;
}
}
+9 -8
View File
@@ -12,12 +12,18 @@ import {
TrainType,
UnitType,
} from "./Game";
import { TileRef, TileUpdate } from "./GameMap";
import { TileRef } from "./GameMap";
export interface GameUpdateViewData {
tick: number;
updates: GameUpdates;
packedTileUpdates: BigUint64Array;
/**
* Packed tile updates as `[tileRef, state]` uint32 pairs.
*
* `tileRef` is a `TileRef` (fits in uint32), and `state` is the packed per-tile
* state (`uint16`) stored in a `uint32` lane.
*/
packedTileUpdates: Uint32Array;
playerNameViewData: Record<string, NameViewData>;
tickExecutionDuration?: number;
pendingTurns?: number;
@@ -29,6 +35,7 @@ export interface ErrorUpdate {
}
export enum GameUpdateType {
// Tile updates are delivered via `packedTileUpdates` on the outer GameUpdateViewData.
Tile,
Unit,
Player,
@@ -54,7 +61,6 @@ export enum GameUpdateType {
}
export type GameUpdate =
| TileUpdateWrapper
| UnitUpdate
| PlayerUpdate
| AllianceRequestUpdate
@@ -112,11 +118,6 @@ export interface ConquestUpdate {
gold: Gold;
}
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
update: TileUpdate;
}
export interface UnitUpdate {
type: GameUpdateType.Unit;
unitType: UnitType;
+12 -8
View File
@@ -25,7 +25,7 @@ import {
UnitInfo,
UnitType,
} from "./Game";
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import { GameMap, TileRef } from "./GameMap";
import {
AllianceView,
AttackUpdate,
@@ -648,9 +648,13 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
this.updateTile(tile, state);
this.updatedTiles.push(tile);
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
@@ -949,11 +953,11 @@ export class GameView implements GameMap {
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
toTileUpdate(tile: TileRef): bigint {
return this._map.toTileUpdate(tile);
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tu: TileUpdate): TileRef {
return this._map.updateTile(tu);
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();