From ea2a76609ff352ef4e122565a2e151fbff9a3599 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:01:03 +0100 Subject: [PATCH] perf(core): speed up packedTileUpdates (Uint32 pairs, no tile wrappers) (#3255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/GameRunner.ts | 14 ++---------- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 41 +++++++++++++++++++++--------------- src/core/game/GameMap.ts | 38 +++++++++++++++++---------------- src/core/game/GameUpdates.ts | 17 ++++++++------- src/core/game/GameView.ts | 20 +++++++++++------- 6 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index eb21c193f..e8c46803d 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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(), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 7381c35c7..11722e142 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 0339fdb33..6ff6c5081 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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 { 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(); diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 136fdf1d9..7ddb686e1 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -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; - 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; } } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index f5e125c3b..567f7ad14 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -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; 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; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f02e0e10b..717e09056 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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 { 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();