From 25236225225f0d87913ea1203f74c6534123b272 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:28:08 +0100 Subject: [PATCH 1/4] perf: Optimize cluster calculation with DFS and zero-allocation patterns Replace BFS with DFS and eliminate GC pressure in calculateClusters() hot path: - Switch from O(N) queue.shift() to O(1) stack.pop() operations - Replace Set.has()/Set.add() with Uint8Array bitfield - Add reusable buffer management to avoid repeated allocations - Implement callback-based neighbor iteration to eliminate array allocations - Add forEachNeighborWithDiag() method to Game interface and GameImpl - Remove now unused GameImpl import from PlayerExecution --- src/core/execution/PlayerExecution.ts | 64 +++++++++++++++++---------- src/core/game/Game.ts | 7 +++ src/core/game/GameImpl.ts | 24 ++++++++++ 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index a8cc3fb421..a76622be39 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,6 +1,5 @@ import { Config } from "../configuration/Config"; import { Execution, Game, Player, UnitType } from "../game/Game"; -import { GameImpl } from "../game/GameImpl"; import { GameMap, TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; @@ -11,8 +10,11 @@ export class PlayerExecution implements Execution { private lastCalc = 0; private mg: Game; private active = true; + private _visitedBuffer: Uint8Array; - constructor(private player: Player) {} + constructor(private player: Player) { + this._visitedBuffer = new Uint8Array(0); // Initialize empty buffer + } activeDuringSpawnPhase(): boolean { return false; @@ -259,31 +261,45 @@ export class PlayerExecution implements Execution { } private calculateClusters(): Set[] { - const seen = new Set(); - const border = this.player.borderTiles(); - const clusters: Set[] = []; - for (const tile of border) { - if (seen.has(tile)) { - continue; - } + const borderTiles = this.player.borderTiles(); + if (borderTiles.size === 0) return []; + + // Ensure buffer is large enough + const mapSize = this.mg.width() * this.mg.height(); + if (!this._visitedBuffer || this._visitedBuffer.length < mapSize) { + this._visitedBuffer = new Uint8Array(mapSize); + } else { + // Fast clear (much faster than creating a new Set) + this._visitedBuffer.fill(0); + } - const cluster = new Set(); - const queue: TileRef[] = [tile]; - seen.add(tile); - while (queue.length > 0) { - const curr = queue.shift(); - if (curr === undefined) throw new Error("curr is undefined"); - cluster.add(curr); - - const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr); - for (const neighbor of neighbors) { - if (border.has(neighbor) && !seen.has(neighbor)) { - queue.push(neighbor); - seen.add(neighbor); + const clusters: Set[] = []; + const stack: TileRef[] = []; // Reusable stack + + for (const startTile of borderTiles) { + // FAST: Array access instead of Set.has() + if (this._visitedBuffer[startTile] === 1) continue; + + const currentCluster = new Set(); + stack.push(startTile); + this._visitedBuffer[startTile] = 1; + + while (stack.length > 0) { + const tile = stack.pop()!; + currentCluster.add(tile); + + //Use callback to avoid creating a 'neighbors' Array + this.mg.forEachNeighborWithDiag(tile, (neighbor) => { + if ( + borderTiles.has(neighbor) && + this._visitedBuffer[neighbor] === 0 + ) { + stack.push(neighbor); + this._visitedBuffer[neighbor] = 1; } - } + }); } - clusters.push(cluster); + clusters.push(currentCluster); } return clusters; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9587e32be8..0cf13f917b 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -668,6 +668,13 @@ export interface Game extends GameMap { map(): GameMap; miniMap(): GameMap; forEachTile(fn: (tile: TileRef) => void): void; + // Zero-allocation neighbor iteration for performance-critical cluster calculation + // Alternative to neighborsWithDiag() that returns arrays + // Avoids creating intermediate arrays and uses a callback for better performance + forEachNeighborWithDiag( + tile: TileRef, + callback: (neighbor: TileRef) => void, + ): void; // Player Management player(id: PlayerID): Player; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2c23ad8641..c2c0e7b0e7 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -517,6 +517,30 @@ export class GameImpl implements Game { return ns; } + // Zero-allocation neighbor iteration for performance-critical code + forEachNeighborWithDiag( + tile: TileRef, + callback: (neighbor: TileRef) => void, + ): void { + const x = this.x(tile); + const y = this.y(tile); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + if (dx === 0 && dy === 0) continue; // Skip the center tile + const newX = x + dx; + const newY = y + dy; + if ( + newX >= 0 && + newX < this._width && + newY >= 0 && + newY < this._height + ) { + callback(this._map.ref(newX, newY)); + } + } + } + } + conquer(owner: PlayerImpl, tile: TileRef): void { if (!this.isLand(tile)) { throw Error(`cannot conquer water`); From f35916feb6d4649cec98bcc066cd82a7ed68afc0 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:37:24 +0100 Subject: [PATCH 2/4] Use game-scoped generational visited buffer in PlayerExecution - Introduce ClusterTraversalState and a WeakMap in PlayerExecution.ts to store visited: Uint32Array and gen per game instance. - Remove the per-player _visitedBuffer - Update calculateClusters() to allocate/resize a single Uint32Array(totalTiles) per Game, use a generation counter instead of calling fill(0) to clear. - Switch visited checks to visited[tile] === currentGen. --- src/core/execution/PlayerExecution.ts | 56 +++++++++++++++++---------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index a76622be39..8f81c62421 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -3,6 +3,14 @@ import { Execution, Game, Player, UnitType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; +interface ClusterTraversalState { + visited: Uint32Array; + gen: number; +} + +// Per-game traversal state used by calculateClusters() to avoid per-player buffers. +const traversalStates = new WeakMap(); + export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; @@ -10,11 +18,8 @@ export class PlayerExecution implements Execution { private lastCalc = 0; private mg: Game; private active = true; - private _visitedBuffer: Uint8Array; - constructor(private player: Player) { - this._visitedBuffer = new Uint8Array(0); // Initialize empty buffer - } + constructor(private player: Player) {} activeDuringSpawnPhase(): boolean { return false; @@ -264,38 +269,47 @@ export class PlayerExecution implements Execution { const borderTiles = this.player.borderTiles(); if (borderTiles.size === 0) return []; - // Ensure buffer is large enough - const mapSize = this.mg.width() * this.mg.height(); - if (!this._visitedBuffer || this._visitedBuffer.length < mapSize) { - this._visitedBuffer = new Uint8Array(mapSize); - } else { - // Fast clear (much faster than creating a new Set) - this._visitedBuffer.fill(0); + const totalTiles = this.mg.width() * this.mg.height(); + + // Retrieve or initialize traversal state for this specific Game instance. + let state = traversalStates.get(this.mg); + if (!state || state.visited.length < totalTiles) { + state = { + visited: new Uint32Array(totalTiles), + gen: 0, + }; + traversalStates.set(this.mg, state); + } + + // Generational clear: bump generation instead of filling the array. + state.gen++; + if (state.gen === 0xffffffff) { + // Extremely rare wrap-around; reset the buffer. + state.visited.fill(0); + state.gen = 1; } + const currentGen = state.gen; + const visited = state.visited; + const clusters: Set[] = []; - const stack: TileRef[] = []; // Reusable stack + const stack: TileRef[] = []; for (const startTile of borderTiles) { - // FAST: Array access instead of Set.has() - if (this._visitedBuffer[startTile] === 1) continue; + if (visited[startTile] === currentGen) continue; const currentCluster = new Set(); stack.push(startTile); - this._visitedBuffer[startTile] = 1; + visited[startTile] = currentGen; while (stack.length > 0) { const tile = stack.pop()!; currentCluster.add(tile); - //Use callback to avoid creating a 'neighbors' Array this.mg.forEachNeighborWithDiag(tile, (neighbor) => { - if ( - borderTiles.has(neighbor) && - this._visitedBuffer[neighbor] === 0 - ) { + if (borderTiles.has(neighbor) && visited[neighbor] !== currentGen) { stack.push(neighbor); - this._visitedBuffer[neighbor] = 1; + visited[neighbor] = currentGen; } }); } From 8d75f62a2d820f643e49964d2fd40f8442661cf2 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:43:32 +0100 Subject: [PATCH 3/4] unify flood-fill and neighbor callbacks in PlayerExecution - Convert neighbor loops in surroundedBySamePlayer, isSurrounded, getCapturingPlayer to forEachNeighbor callbacks - Implement floodFillWithGen() method for configurable zero-allocation flood fill - Replace BFS in removeCluster() with floodFillWithGen using cardinal neighbors - Refactor calculateClusters() to use floodFillWithGen with diagonal neighbors - Add generational state management and forEachNeighbor interface method --- src/core/execution/PlayerExecution.ts | 157 ++++++++++++++++---------- src/core/game/Game.ts | 2 + src/core/game/GameImpl.ts | 9 ++ 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 8f81c62421..16549c6071 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,6 +1,6 @@ import { Config } from "../configuration/Config"; import { Execution, Game, Player, UnitType } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; +import { TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; interface ClusterTraversalState { @@ -139,17 +139,23 @@ export class PlayerExecution implements Execution { private surroundedBySamePlayer(cluster: Set): false | Player { const enemies = new Set(); for (const tile of cluster) { - if ( - this.mg.isOceanShore(tile) || - this.mg.isOnEdgeOfMap(tile) || - this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n)) - ) { + let hasUnownedNeighbor = false; + if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { + return false; + } + this.mg.forEachNeighbor(tile, (n) => { + if (!this.mg.hasOwner(n)) { + hasUnownedNeighbor = true; + return; + } + const ownerId = this.mg.ownerID(n); + if (ownerId !== this.player.smallID()) { + enemies.add(ownerId); + } + }); + if (hasUnownedNeighbor) { return false; } - this.mg - .neighbors(tile) - .filter((n) => this.mg?.ownerID(n) !== this.player?.smallID()) - .forEach((p) => this.mg && enemies.add(this.mg.ownerID(p))); if (enemies.size !== 1) { return false; } @@ -172,14 +178,12 @@ export class PlayerExecution implements Execution { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { return false; } - this.mg - .neighbors(tr) - .filter( - (n) => - this.mg?.owner(n).isPlayer() && - this.mg?.ownerID(n) !== this.player?.smallID(), - ) - .forEach((n) => enemyTiles.add(n)); + this.mg.forEachNeighbor(tr, (n) => { + const owner = this.mg.owner(n); + if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) { + enemyTiles.add(n); + } + }); } if (enemyTiles.size === 0) { return false; @@ -210,9 +214,13 @@ export class PlayerExecution implements Execution { return; } - const filter = (_: GameMap, t: TileRef): boolean => - this.mg?.ownerID(t) === this.player?.smallID(); - const tiles = this.mg.bfs(firstTile, filter); + const tiles = this.floodFillWithGen( + this.bumpGeneration(), + this.traversalState().visited, + [firstTile], + (tile, cb) => this.mg.forEachNeighbor(tile, cb), + (tile) => this.mg.ownerID(tile) === this.player.smallID(), + ); if (this.player.numTilesOwned() === tiles.size) { this.mg.conquerPlayer(capturing, this.player); @@ -226,7 +234,7 @@ export class PlayerExecution implements Execution { private getCapturingPlayer(cluster: Set): Player | null { const neighbors = new Map(); for (const t of cluster) { - for (const neighbor of this.mg.neighbors(t)) { + this.mg.forEachNeighbor(t, (neighbor) => { const owner = this.mg.owner(neighbor); if ( owner.isPlayer() && @@ -235,7 +243,7 @@ export class PlayerExecution implements Execution { ) { neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1); } - } + }); } // If there are no enemies, return null @@ -269,9 +277,40 @@ export class PlayerExecution implements Execution { const borderTiles = this.player.borderTiles(); if (borderTiles.size === 0) return []; - const totalTiles = this.mg.width() * this.mg.height(); + const state = this.traversalState(); + const currentGen = this.bumpGeneration(); + const visited = state.visited; + + const clusters: Set[] = []; + + for (const startTile of borderTiles) { + if (visited[startTile] === currentGen) continue; + + const cluster = this.floodFillWithGen( + currentGen, + visited, + [startTile], + (tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb), + (tile) => borderTiles.has(tile), + ); + clusters.push(cluster); + } + return clusters; + } + + owner(): Player { + if (this.player === null) { + throw new Error("Not initialized"); + } + return this.player; + } - // Retrieve or initialize traversal state for this specific Game instance. + isActive(): boolean { + return this.active; + } + + private traversalState(): ClusterTraversalState { + const totalTiles = this.mg.width() * this.mg.height(); let state = traversalStates.get(this.mg); if (!state || state.visited.length < totalTiles) { state = { @@ -280,52 +319,52 @@ export class PlayerExecution implements Execution { }; traversalStates.set(this.mg, state); } + return state; + } - // Generational clear: bump generation instead of filling the array. + private bumpGeneration(): number { + const state = this.traversalState(); state.gen++; if (state.gen === 0xffffffff) { - // Extremely rare wrap-around; reset the buffer. state.visited.fill(0); state.gen = 1; } + return state.gen; + } - const currentGen = state.gen; - const visited = state.visited; - - const clusters: Set[] = []; + private floodFillWithGen( + currentGen: number, + visited: Uint32Array, + startTiles: TileRef[], + neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void, + includeFn: (tile: TileRef) => boolean, + ): Set { + const result = new Set(); const stack: TileRef[] = []; - for (const startTile of borderTiles) { - if (visited[startTile] === currentGen) continue; - - const currentCluster = new Set(); - stack.push(startTile); - visited[startTile] = currentGen; - - while (stack.length > 0) { - const tile = stack.pop()!; - currentCluster.add(tile); - - this.mg.forEachNeighborWithDiag(tile, (neighbor) => { - if (borderTiles.has(neighbor) && visited[neighbor] !== currentGen) { - stack.push(neighbor); - visited[neighbor] = currentGen; - } - }); - } - clusters.push(currentCluster); + for (const start of startTiles) { + if (visited[start] === currentGen) continue; + if (!includeFn(start)) continue; + visited[start] = currentGen; + result.add(start); + stack.push(start); } - return clusters; - } - owner(): Player { - if (this.player === null) { - throw new Error("Not initialized"); + while (stack.length > 0) { + const tile = stack.pop()!; + neighborFn(tile, (neighbor) => { + if (visited[neighbor] === currentGen) { + return; + } + if (!includeFn(neighbor)) { + return; + } + visited[neighbor] = currentGen; + result.add(neighbor); + stack.push(neighbor); + }); } - return this.player; - } - isActive(): boolean { - return this.active; + return result; } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0cf13f917b..93b8af56d4 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -668,6 +668,8 @@ export interface Game extends GameMap { map(): GameMap; miniMap(): GameMap; forEachTile(fn: (tile: TileRef) => void): void; + // Zero-allocation neighbor iteration (cardinal only) to avoid creating arrays + forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void; // Zero-allocation neighbor iteration for performance-critical cluster calculation // Alternative to neighborsWithDiag() that returns arrays // Avoids creating intermediate arrays and uses a callback for better performance diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index c2c0e7b0e7..61a6e38d72 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -882,6 +882,15 @@ export class GameImpl implements Game { neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref); } + // Zero-allocation neighbor iteration (cardinal only) + forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void { + const x = this.x(tile); + const y = this.y(tile); + if (x > 0) callback(this._map.ref(x - 1, y)); + if (x + 1 < this._width) callback(this._map.ref(x + 1, y)); + if (y > 0) callback(this._map.ref(x, y - 1)); + if (y + 1 < this._height) callback(this._map.ref(x, y + 1)); + } isWater(ref: TileRef): boolean { return this._map.isWater(ref); } From 3144ce9ae38e9ab0965750f13d723f91cb5ee829 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:37:27 +0100 Subject: [PATCH 4/4] - Replace the WeakMap for per-game traversal states with a single shared traversal state variable. - Update traversal state management --- src/core/execution/PlayerExecution.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 16549c6071..3f00de2748 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -8,8 +8,8 @@ interface ClusterTraversalState { gen: number; } -// Per-game traversal state used by calculateClusters() to avoid per-player buffers. -const traversalStates = new WeakMap(); +// Single shared traversal state; there is only ever one Game per runtime. +let traversalState: ClusterTraversalState | null = null; export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; @@ -311,15 +311,13 @@ export class PlayerExecution implements Execution { private traversalState(): ClusterTraversalState { const totalTiles = this.mg.width() * this.mg.height(); - let state = traversalStates.get(this.mg); - if (!state || state.visited.length < totalTiles) { - state = { + if (!traversalState || traversalState.visited.length < totalTiles) { + traversalState = { visited: new Uint32Array(totalTiles), gen: 0, }; - traversalStates.set(this.mg, state); } - return state; + return traversalState; } private bumpGeneration(): number {