diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a4..09f1d21e7d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,7 @@ import { import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { PlayerActions, UnitType } from "../core/game/Game"; +import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { GameMapLoader } from "../core/game/GameMapLoader"; import { @@ -25,9 +25,18 @@ import { import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; +import { + createSharedTileRingBuffers, + createSharedTileRingViews, + drainTileUpdates, + SharedTileRingBuffers, + SharedTileRingViews, + TILE_RING_HEADER_OVERFLOW, +} from "../core/worker/SharedTileRing"; import { WorkerClient } from "../core/worker/WorkerClient"; import { AutoUpgradeEvent, + BacklogStatusEvent, DoBoatAttackEvent, DoGroundAttackEvent, InputHandler, @@ -161,9 +170,44 @@ async function createClientGame( mapLoader, ); } + + let sharedTileRingBuffers: SharedTileRingBuffers | undefined; + let sharedTileRingViews: SharedTileRingViews | null = null; + let sharedDirtyBuffer: SharedArrayBuffer | undefined; + let sharedDirtyFlags: Uint8Array | null = null; + const isIsolated = + typeof (globalThis as any).crossOriginIsolated === "boolean" + ? (globalThis as any).crossOriginIsolated === true + : false; + const canUseSharedBuffers = + typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + isIsolated; + const sharedStateBuffer = + canUseSharedBuffers && gameMap.sharedStateBuffer + ? gameMap.sharedStateBuffer + : undefined; + const usesSharedTileState = !!sharedStateBuffer; + + if (canUseSharedBuffers) { + const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height(); + // Ring capacity scales with world size: at most one entry per tile. + const TILE_RING_CAPACITY = numTiles; + sharedTileRingBuffers = createSharedTileRingBuffers( + TILE_RING_CAPACITY, + numTiles, + ); + sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers); + sharedDirtyBuffer = sharedTileRingBuffers.dirty; + sharedDirtyFlags = sharedTileRingViews.dirtyFlags; + } + const worker = new WorkerClient( lobbyConfig.gameStartInfo, lobbyConfig.clientID, + sharedTileRingBuffers, + sharedStateBuffer, + sharedDirtyBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -173,6 +217,7 @@ async function createClientGame( lobbyConfig.clientID, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, + usesSharedTileState, ); const canvas = createCanvas(); @@ -190,6 +235,8 @@ async function createClientGame( transport, worker, gameView, + sharedTileRingViews, + sharedDirtyFlags, ); } @@ -208,6 +255,22 @@ export class ClientGameRunner { private lastTickReceiveTime: number = 0; private currentTickDelay: number | undefined = undefined; + // Track how far behind the client simulation is compared to the server. + private serverTurnHighWater: number = 0; + private lastProcessedTick: number = 0; + private backlogTurns: number = 0; + private backlogGrowing: boolean = false; + private lastRenderedTick: number = 0; + private workerTicksSinceSample: number = 0; + private renderTicksSinceSample: number = 0; + private metricsSampleStart: number = 0; + + private pendingUpdates: GameUpdateViewData[] = []; + private pendingStart = 0; + private isProcessingUpdates = false; + private tileRingViews: SharedTileRingViews | null; + private dirtyFlags: Uint8Array | null; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -216,8 +279,12 @@ export class ClientGameRunner { private transport: Transport, private worker: WorkerClient, private gameView: GameView, + tileRingViews: SharedTileRingViews | null, + dirtyFlags: Uint8Array | null, ) { this.lastMessageTime = Date.now(); + this.tileRingViews = tileRingViews; + this.dirtyFlags = dirtyFlags; } private saveGame(update: WinUpdate) { @@ -292,33 +359,9 @@ export class ClientGameRunner { this.stop(); return; } - this.transport.turnComplete(); - gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { - this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); - }); - this.gameView.update(gu); - this.renderer.tick(); - - // Emit tick metrics event for performance overlay - this.eventBus.emit( - new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), - ); - - // Reset tick delay for next measurement - this.currentTickDelay = undefined; - - if (gu.updates[GameUpdateType.Win].length > 0) { - this.saveGame(gu.updates[GameUpdateType.Win][0]); - } + this.pendingUpdates.push(gu); + this.processPendingUpdates(); }); - const worker = this.worker; - const keepWorkerAlive = () => { - if (this.isActive) { - worker.sendHeartbeat(); - requestAnimationFrame(keepWorkerAlive); - } - }; - requestAnimationFrame(keepWorkerAlive); const onconnect = () => { console.log("Connected to game server!"); @@ -363,6 +406,10 @@ export class ClientGameRunner { } for (const turn of message.turns) { + this.serverTurnHighWater = Math.max( + this.serverTurnHighWater, + turn.turnNumber, + ); if (turn.turnNumber < this.turnsSeen) { continue; } @@ -415,6 +462,11 @@ export class ClientGameRunner { } this.lastTickReceiveTime = now; + this.serverTurnHighWater = Math.max( + this.serverTurnHighWater, + message.turn.turnNumber, + ); + if (this.turnsSeen !== message.turn.turnNumber) { console.error( `got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`, @@ -445,6 +497,264 @@ export class ClientGameRunner { } } + private processPendingUpdates() { + const pendingCount = this.pendingUpdates.length - this.pendingStart; + if (this.isProcessingUpdates || pendingCount <= 0) { + return; + } + + this.isProcessingUpdates = true; + const processFrame = () => { + const BASE_SLICE_BUDGET_MS = 8; // keep UI responsive while catching up + const MAX_SLICE_BUDGET_MS = 1000; // allow longer slices when backlog is large + const BACKLOG_FREE_TURNS = 10; // scaling starts at this many turns + const BACKLOG_MAX_TURNS = 500; // MAX_SLICE_BUDGET_MS is reached at this many turns + const MAX_TICKS_PER_SLICE = 1000; + + const backlogOverhead = Math.max( + 0, + this.backlogTurns - BACKLOG_FREE_TURNS, + ); + const backlogScale = Math.min( + 1, + backlogOverhead / (BACKLOG_MAX_TURNS - BACKLOG_FREE_TURNS), + ); + const sliceBudgetMs = + BASE_SLICE_BUDGET_MS + + backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS); + + const frameStart = performance.now(); + const batch: GameUpdateViewData[] = []; + let lastTickDuration: number | undefined; + let lastTick: number | undefined; + + let processedCount = 0; + + // Consume updates until we hit the time budget or per-slice cap. + while (this.pendingStart < this.pendingUpdates.length) { + const gu = this.pendingUpdates[this.pendingStart++]; + processedCount++; + this.workerTicksSinceSample++; + batch.push(gu); + + this.transport.turnComplete(); + gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { + this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); + }); + this.updateBacklogMetrics(gu.tick); + + if (gu.updates[GameUpdateType.Win].length > 0) { + this.saveGame(gu.updates[GameUpdateType.Win][0]); + } + + if (gu.tickExecutionDuration !== undefined) { + lastTickDuration = gu.tickExecutionDuration; + } + lastTick = gu.tick; + + const elapsed = performance.now() - frameStart; + if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) { + break; + } + } + + // Compact the queue if we've advanced far into it. + if ( + this.pendingStart > 0 && + (this.pendingStart > 1024 || + this.pendingStart >= this.pendingUpdates.length / 2) + ) { + this.pendingUpdates = this.pendingUpdates.slice(this.pendingStart); + this.pendingStart = 0; + } + + // Only update view and render when ALL processing is complete + if ( + this.pendingStart >= this.pendingUpdates.length && + batch.length > 0 && + lastTick !== undefined + ) { + const { gameUpdate: combinedGu, tileMetrics } = + this.mergeGameUpdates(batch); + if (combinedGu) { + this.gameView.update(combinedGu); + } + + const ticksPerRender = + this.lastRenderedTick === 0 + ? lastTick + : lastTick - this.lastRenderedTick; + this.lastRenderedTick = lastTick; + + this.renderTicksSinceSample++; + + let workerTicksPerSecond: number | undefined; + let renderTicksPerSecond: number | undefined; + const now = performance.now(); + if (this.metricsSampleStart === 0) { + this.metricsSampleStart = now; + } else { + const elapsedSeconds = (now - this.metricsSampleStart) / 1000; + if (elapsedSeconds > 0) { + workerTicksPerSecond = this.workerTicksSinceSample / elapsedSeconds; + renderTicksPerSecond = this.renderTicksSinceSample / elapsedSeconds; + } + this.metricsSampleStart = now; + this.workerTicksSinceSample = 0; + this.renderTicksSinceSample = 0; + } + + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + ticksPerRender, + workerTicksPerSecond, + renderTicksPerSecond, + tileMetrics.count, + tileMetrics.utilization, + tileMetrics.overflow, + tileMetrics.drainTime, + ), + ); + + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } + + if (this.pendingStart < this.pendingUpdates.length) { + requestAnimationFrame(processFrame); + } else { + this.isProcessingUpdates = false; + } + }; + + requestAnimationFrame(processFrame); + } + + private mergeGameUpdates(batch: GameUpdateViewData[]): { + gameUpdate: GameUpdateViewData | null; + tileMetrics: { + count: number; + utilization: number; + overflow: number; + drainTime: number; + }; + } { + if (batch.length === 0) { + return { + gameUpdate: null, + tileMetrics: { + count: 0, + utilization: 0, + overflow: 0, + drainTime: 0, + }, + }; + } + + const last = batch[batch.length - 1]; + const combinedUpdates: GameUpdates = {} as GameUpdates; + + // Initialize combinedUpdates with empty arrays for each existing key + for (const key in last.updates) { + const type = Number(key) as GameUpdateType; + combinedUpdates[type] = []; + } + + const combinedPackedTileUpdates: bigint[] = []; + + for (const gu of batch) { + for (const key in gu.updates) { + const type = Number(key) as GameUpdateType; + // We don't care about the specific update subtype here; just treat it + // as an array we can concatenate. + const updatesForType = gu.updates[type] as unknown as any[]; + (combinedUpdates[type] as unknown as any[]).push(...updatesForType); + } + } + + let tileMetrics = { + count: 0, + utilization: 0, + overflow: 0, + drainTime: 0, + }; + + if (this.tileRingViews) { + const MAX_TILE_UPDATES_PER_RENDER = 100000; + const tileRefs: TileRef[] = []; + const drainStart = performance.now(); + drainTileUpdates( + this.tileRingViews, + MAX_TILE_UPDATES_PER_RENDER, + tileRefs, + ); + const drainTime = performance.now() - drainStart; + + // Deduplicate tile refs for this render slice + const uniqueTiles = new Set(); + for (const ref of tileRefs) { + uniqueTiles.add(ref); + } + + // Calculate ring buffer utilization and overflow using dynamic capacity + const TILE_RING_CAPACITY = this.tileRingViews.capacity; + const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100; + const overflow = Atomics.load( + this.tileRingViews.header, + TILE_RING_HEADER_OVERFLOW, + ); + + tileMetrics = { + count: uniqueTiles.size, + utilization, + overflow, + drainTime, + }; + + for (const ref of uniqueTiles) { + if (this.dirtyFlags) { + Atomics.store(this.dirtyFlags, ref, 0); + } + combinedPackedTileUpdates.push(BigInt(ref)); + } + } else { + // Non-SAB mode: count tile updates from batch + let totalTileUpdates = 0; + for (const gu of batch) { + totalTileUpdates += gu.packedTileUpdates.length; + } + tileMetrics.count = totalTileUpdates; + } + + return { + gameUpdate: { + tick: last.tick, + updates: combinedUpdates, + packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates), + playerNameViewData: last.playerNameViewData, + tickExecutionDuration: last.tickExecutionDuration, + }, + tileMetrics, + }; + } + + private updateBacklogMetrics(processedTick: number) { + this.lastProcessedTick = processedTick; + const previousBacklog = this.backlogTurns; + this.backlogTurns = Math.max( + 0, + this.serverTurnHighWater - this.lastProcessedTick, + ); + this.backlogGrowing = this.backlogTurns > previousBacklog; + this.eventBus.emit( + new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing), + ); + } + private inputEvent(event: MouseUpEvent) { if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27d..bf2510e4df 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -129,6 +129,26 @@ export class TickMetricsEvent implements GameEvent { constructor( public readonly tickExecutionDuration?: number, public readonly tickDelay?: number, + // Number of turns the client is behind the server (if known) + public readonly backlogTurns?: number, + // Number of simulation ticks applied since last render + public readonly ticksPerRender?: number, + // Approximate worker simulation ticks per second + public readonly workerTicksPerSecond?: number, + // Approximate render tick() calls per second + public readonly renderTicksPerSecond?: number, + // Tile update metrics + public readonly tileUpdatesCount?: number, + public readonly ringBufferUtilization?: number, + public readonly ringBufferOverflows?: number, + public readonly ringDrainTime?: number, + ) {} +} + +export class BacklogStatusEvent implements GameEvent { + constructor( + public readonly backlogTurns: number, + public readonly backlogGrowing: boolean, ) {} } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd9..c99a460147 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -2,7 +2,10 @@ import { EventBus } from "../../core/EventBus"; import { GameView } from "../../core/game/GameView"; import { UserSettings } from "../../core/game/UserSettings"; import { GameStartingModal } from "../GameStartingModal"; -import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; +import { + BacklogStatusEvent, + RefreshGraphicsEvent as RedrawGraphicsEvent, +} from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; @@ -292,6 +295,9 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private backlogTurns: number = 0; + private backlogGrowing: boolean = false; + private lastRenderTime: number = 0; constructor( private game: GameView, @@ -309,6 +315,10 @@ export class GameRenderer { initialize() { this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); + this.eventBus.on(BacklogStatusEvent, (event: BacklogStatusEvent) => { + this.backlogTurns = event.backlogTurns; + this.backlogGrowing = event.backlogGrowing; + }); this.layers.forEach((l) => l.init?.()); document.body.appendChild(this.canvas); @@ -344,6 +354,28 @@ export class GameRenderer { } renderGame() { + const now = performance.now(); + + if (this.backlogTurns > 0) { + const BASE_FPS = 60; + const MIN_FPS = 10; + const BACKLOG_MAX_TURNS = 50; + + const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS); + const targetFps = BASE_FPS - scale * (BASE_FPS - MIN_FPS); + const minFrameInterval = 1000 / targetFps; + + if (this.lastRenderTime !== 0) { + const sinceLast = now - this.lastRenderTime; + if (sinceLast < minFrameInterval) { + requestAnimationFrame(() => this.renderGame()); + return; + } + } + } + + this.lastRenderTime = now; + FrameProfiler.clear(); const start = performance.now(); // Set background diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index fb744d4a03..6c53bb5b00 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -229,7 +229,18 @@ export class PerformanceOverlay extends LitElement implements Layer { this.setVisible(this.userSettings.performanceOverlay()); }); this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => { - this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); + this.updateTickMetrics( + event.tickExecutionDuration, + event.tickDelay, + event.backlogTurns, + event.ticksPerRender, + event.workerTicksPerSecond, + event.renderTicksPerSecond, + event.tileUpdatesCount, + event.ringBufferUtilization, + event.ringBufferOverflows, + event.ringDrainTime, + ); }); } @@ -312,6 +323,14 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerStats.clear(); this.layerBreakdown = []; + // reset tile metrics + this.tileUpdatesPerRender = 0; + this.tileUpdatesPeak = 0; + this.ringBufferUtilization = 0; + this.ringBufferOverflows = 0; + this.ringDrainTime = 0; + this.totalTilesUpdated = 0; + this.requestUpdate(); }; @@ -418,7 +437,48 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { + @state() + private backlogTurns: number = 0; + + @state() + private ticksPerRender: number = 0; + + @state() + private workerTicksPerSecond: number = 0; + + @state() + private renderTicksPerSecond: number = 0; + + @state() + private tileUpdatesPerRender: number = 0; + + @state() + private tileUpdatesPeak: number = 0; + + @state() + private ringBufferUtilization: number = 0; + + @state() + private ringBufferOverflows: number = 0; + + @state() + private ringDrainTime: number = 0; + + @state() + private totalTilesUpdated: number = 0; + + updateTickMetrics( + tickExecutionDuration?: number, + tickDelay?: number, + backlogTurns?: number, + ticksPerRender?: number, + workerTicksPerSecond?: number, + renderTicksPerSecond?: number, + tileUpdatesCount?: number, + ringBufferUtilization?: number, + ringBufferOverflows?: number, + ringDrainTime?: number, + ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; // Update tick execution duration stats @@ -455,6 +515,42 @@ export class PerformanceOverlay extends LitElement implements Layer { } } + if (backlogTurns !== undefined) { + this.backlogTurns = backlogTurns; + } + + if (ticksPerRender !== undefined) { + this.ticksPerRender = ticksPerRender; + } + + if (workerTicksPerSecond !== undefined) { + this.workerTicksPerSecond = workerTicksPerSecond; + } + + if (renderTicksPerSecond !== undefined) { + this.renderTicksPerSecond = renderTicksPerSecond; + } + + if (tileUpdatesCount !== undefined) { + this.tileUpdatesPerRender = tileUpdatesCount; + this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount); + this.totalTilesUpdated += tileUpdatesCount; + } + + if (ringBufferUtilization !== undefined) { + this.ringBufferUtilization = + Math.round(ringBufferUtilization * 100) / 100; + } + + if (ringBufferOverflows !== undefined && ringBufferOverflows !== 0) { + // Remember that an overflow has occurred at least once this run. + this.ringBufferOverflows = 1; + } + + if (ringDrainTime !== undefined) { + this.ringDrainTime = Math.round(ringDrainTime * 100) / 100; + } + this.requestUpdate(); } @@ -485,6 +581,14 @@ export class PerformanceOverlay extends LitElement implements Layer { executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, + tiles: { + updatesPerRender: this.tileUpdatesPerRender, + peakUpdates: this.tileUpdatesPeak, + ringBufferUtilization: this.ringBufferUtilization, + ringBufferOverflows: this.ringBufferOverflows, + ringDrainTimeMs: this.ringDrainTime, + totalTilesUpdated: this.totalTilesUpdated, + }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), }; } @@ -600,6 +704,37 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms) +
+ Worker ticks/s: + ${this.workerTicksPerSecond.toFixed(1)} +
+
+ Render ticks/s: + ${this.renderTicksPerSecond.toFixed(1)} +
+
+ Ticks per render: + ${this.ticksPerRender} +
+
+ Backlog turns: + ${this.backlogTurns} +
+
+ Tile updates/render: + ${this.tileUpdatesPerRender} + (peak: ${this.tileUpdatesPeak}) +
+
+ Ring buffer: + ${this.ringBufferUtilization}% + (${this.totalTilesUpdated} total, ${this.ringBufferOverflows} + overflows) +
+
+ Ring drain time: + ${this.ringDrainTime.toFixed(2)}ms +
${this.layerBreakdown.length ? html`
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 6a8a4042f3..552a15a9eb 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -37,12 +37,15 @@ export async function createGameRunner( clientID: ClientID, mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + tileUpdateSink?: (tile: TileRef) => void, + sharedStateBuffer?: SharedArrayBuffer, ): Promise { const config = await getConfig(gameStart.config, null); const gameMap = await loadGameMap( gameStart.config.gameMap, gameStart.config.gameMapSize, mapLoader, + sharedStateBuffer, ); const random = new PseudoRandom(simpleHash(gameStart.gameID)); @@ -85,6 +88,7 @@ export async function createGameRunner( game, new Executor(game, gameStart.gameID, clientID), callBack, + tileUpdateSink, ); gr.init(); return gr; @@ -101,6 +105,7 @@ export class GameRunner { public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + private tileUpdateSink?: (tile: TileRef) => void, ) {} init() { @@ -175,13 +180,25 @@ export class GameRunner { }); } - // Many tiles are updated to pack it into an array - const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update); + // Many tiles are updated; either publish them via a shared sink or pack + // them into the view data. + let packedTileUpdates: BigUint64Array; + const tileUpdates = updates[GameUpdateType.Tile]; + if (this.tileUpdateSink !== undefined) { + for (const u of tileUpdates) { + const tileRef = Number(u.update >> 16n) as TileRef; + this.tileUpdateSink(tileRef); + } + packedTileUpdates = new BigUint64Array(); + } else { + const raw = tileUpdates.map((u) => u.update); + packedTileUpdates = new BigUint64Array(raw); + } updates[GameUpdateType.Tile] = []; this.callBack({ tick: this.game.ticks(), - packedTileUpdates: new BigUint64Array(packedTileUpdates), + packedTileUpdates, updates: updates, playerNameViewData: this.playerViewData, tickExecutionDuration: tickExecutionDuration, @@ -272,4 +289,8 @@ export class GameRunner { } return player.bestTransportShipSpawn(targetTile); } + + public hasPendingTurns(): boolean { + return this.currTurn < this.turns.length; + } } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 7a3bd8e6d5..41368abac5 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -80,6 +80,7 @@ export class GameMapImpl implements GameMap { height: number, terrainData: Uint8Array, private numLandTiles_: number, + stateBuffer?: ArrayBufferLike, ) { if (terrainData.length !== width * height) { throw new Error( @@ -89,7 +90,17 @@ export class GameMapImpl implements GameMap { this.width_ = width; this.height_ = height; this.terrain = terrainData; - this.state = new Uint16Array(width * height); + if (stateBuffer !== undefined) { + const state = new Uint16Array(stateBuffer); + if (state.length !== width * height) { + throw new Error( + `State buffer length ${state.length} doesn't match dimensions ${width}x${height}`, + ); + } + this.state = state; + } else { + this.state = new Uint16Array(width * height); + } // Precompute the LUTs let ref = 0; this.refToX = new Array(width * height); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 4715557517..a6188b5320 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -472,6 +472,7 @@ export class GameView implements GameMap { private _cosmetics: Map = new Map(); private _map: GameMap; + private readonly usesSharedTileState: boolean; constructor( public worker: WorkerClient, @@ -480,8 +481,10 @@ export class GameView implements GameMap { private _myClientID: ClientID, private _gameID: GameID, private humans: Player[], + usesSharedTileState: boolean = false, ) { this._map = this._mapData.gameMap; + this.usesSharedTileState = usesSharedTileState; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); this._cosmetics = new Map( @@ -510,9 +513,16 @@ export class GameView implements GameMap { this.lastUpdate = gu; this.updatedTiles = []; - this.lastUpdate.packedTileUpdates.forEach((tu) => { - this.updatedTiles.push(this.updateTile(tu)); - }); + if (this.usesSharedTileState) { + this.lastUpdate.packedTileUpdates.forEach((tu) => { + const tileRef = Number(tu); + this.updatedTiles.push(tileRef); + }); + } else { + this.lastUpdate.packedTileUpdates.forEach((tu) => { + this.updatedTiles.push(this.updateTile(tu)); + }); + } if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index e11dd7131b..3f7e527744 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -6,6 +6,8 @@ export type TerrainMapData = { nations: Nation[]; gameMap: GameMap; miniGameMap: GameMap; + sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; }; const loadedMaps = new Map(); @@ -35,15 +37,42 @@ export async function loadTerrainMap( map: GameMapType, mapSize: GameMapSize, terrainMapFileLoader: GameMapLoader, + sharedStateBuffer?: SharedArrayBuffer, ): Promise { - const cached = loadedMaps.get(map); - if (cached !== undefined) return cached; + const useCache = sharedStateBuffer === undefined; + const canUseSharedBuffers = + typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + typeof (globalThis as any).crossOriginIsolated === "boolean" && + (globalThis as any).crossOriginIsolated === true; + + // Don't use cache if we can create SharedArrayBuffer but none was provided + const shouldUseCache = useCache && !canUseSharedBuffers; + + if (shouldUseCache) { + const cached = loadedMaps.get(map); + if (cached !== undefined) return cached; + } const mapFiles = terrainMapFileLoader.getMapData(map); const manifest = await mapFiles.manifest(); + const stateBuffer = + sharedStateBuffer ?? + (canUseSharedBuffers + ? new SharedArrayBuffer( + manifest.map.width * + manifest.map.height * + Uint16Array.BYTES_PER_ELEMENT, + ) + : undefined); + const gameMap = mapSize === GameMapSize.Normal - ? await genTerrainFromBin(manifest.map, await mapFiles.mapBin()) + ? await genTerrainFromBin( + manifest.map, + await mapFiles.mapBin(), + stateBuffer, + ) : await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin()); const miniMap = @@ -63,18 +92,28 @@ export async function loadTerrainMap( }); } - const result = { + const result: TerrainMapData = { nations: manifest.nations, gameMap: gameMap, miniGameMap: miniMap, + sharedStateBuffer: + typeof SharedArrayBuffer !== "undefined" && + stateBuffer instanceof SharedArrayBuffer + ? stateBuffer + : undefined, + sharedDirtyBuffer: undefined, // populated by consumer when needed }; - loadedMaps.set(map, result); + // Only cache the result when caching is actually used (non-SAB path) + if (shouldUseCache) { + loadedMaps.set(map, result); + } return result; } export async function genTerrainFromBin( mapData: MapMetadata, data: Uint8Array, + stateBuffer?: ArrayBufferLike, ): Promise { if (data.length !== mapData.width * mapData.height) { throw new Error( @@ -87,5 +126,6 @@ export async function genTerrainFromBin( mapData.height, data, mapData.num_land_tiles, + stateBuffer, ); } diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts new file mode 100644 index 0000000000..328def7306 --- /dev/null +++ b/src/core/worker/SharedTileRing.ts @@ -0,0 +1,85 @@ +import { TileRef } from "../game/GameMap"; + +export interface SharedTileRingBuffers { + header: SharedArrayBuffer; + data: SharedArrayBuffer; + dirty: SharedArrayBuffer; +} + +export interface SharedTileRingViews { + header: Int32Array; + buffer: Uint32Array; + dirtyFlags: Uint8Array; + capacity: number; +} + +// Header indices +export const TILE_RING_HEADER_WRITE_INDEX = 0; +export const TILE_RING_HEADER_READ_INDEX = 1; +export const TILE_RING_HEADER_OVERFLOW = 2; + +export function createSharedTileRingBuffers( + capacity: number, + numTiles: number, +): SharedTileRingBuffers { + const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT); + const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT); + const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT); + return { header, data, dirty }; +} + +export function createSharedTileRingViews( + buffers: SharedTileRingBuffers, +): SharedTileRingViews { + const header = new Int32Array(buffers.header); + const buffer = new Uint32Array(buffers.data); + const dirtyFlags = new Uint8Array(buffers.dirty); + return { + header, + buffer, + dirtyFlags, + capacity: buffer.length, + }; +} + +export function pushTileUpdate( + views: SharedTileRingViews, + value: TileRef, +): void { + const { header, buffer, capacity } = views; + + const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX); + const read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX); + const nextWrite = (write + 1) % capacity; + + // If the buffer is full, advance read (drop oldest) and mark overflow. + if (nextWrite === read) { + Atomics.store(header, TILE_RING_HEADER_OVERFLOW, 1); + const nextRead = (read + 1) % capacity; + Atomics.store(header, TILE_RING_HEADER_READ_INDEX, nextRead); + } + + buffer[write] = value; + Atomics.store(header, TILE_RING_HEADER_WRITE_INDEX, nextWrite); +} + +export function drainTileUpdates( + views: SharedTileRingViews, + maxItems: number, + out: TileRef[], +): void { + const { header, buffer, capacity } = views; + + let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX); + const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX); + + let count = 0; + + while (read !== write && count < maxItems) { + out.push(buffer[read]); + read = (read + 1) % capacity; + count++; + } + + Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read); +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 1014968fb2..aae5a69efd 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,7 +1,13 @@ import version from "../../../resources/version.txt"; import { createGameRunner, GameRunner } from "../GameRunner"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; +import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; +import { + createSharedTileRingViews, + pushTileUpdate, + SharedTileRingViews, +} from "./SharedTileRing"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -16,6 +22,9 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +let isProcessingTurns = false; +let sharedTileRing: SharedTileRingViews | null = null; +let dirtyFlags: Uint8Array | null = null; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -32,25 +41,68 @@ function sendMessage(message: WorkerMessage) { ctx.postMessage(message); } +async function processPendingTurns() { + if (isProcessingTurns) { + return; + } + if (!gameRunner) { + return; + } + + const gr = await gameRunner; + if (!gr || !gr.hasPendingTurns()) { + return; + } + + isProcessingTurns = true; + try { + while (gr.hasPendingTurns()) { + gr.executeNextTick(); + } + } finally { + isProcessingTurns = false; + } +} + ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { - case "heartbeat": - (await gameRunner)?.executeNextTick(); - break; case "init": try { + if (message.sharedTileRingHeader && message.sharedTileRingData) { + sharedTileRing = createSharedTileRingViews({ + header: message.sharedTileRingHeader, + data: message.sharedTileRingData, + dirty: message.sharedDirtyBuffer!, + }); + dirtyFlags = sharedTileRing.dirtyFlags; + } else { + sharedTileRing = null; + dirtyFlags = null; + } + gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, + sharedTileRing && dirtyFlags + ? (tile: TileRef) => { + if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) { + pushTileUpdate(sharedTileRing!, tile); + } + } + : sharedTileRing + ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) + : undefined, + message.sharedStateBuffer, ).then((gr) => { sendMessage({ type: "initialized", id: message.id, } as InitializedMessage); + processPendingTurns(); return gr; }); } catch (error) { @@ -67,6 +119,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; await gr.addTurn(message.turn); + processPendingTurns(); } catch (error) { console.error("Failed to process turn:", error); throw error; diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index bde436f398..1d824546d7 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -9,6 +9,7 @@ import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; +import { SharedTileRingBuffers } from "./SharedTileRing"; import { WorkerMessage } from "./WorkerMessages"; export class WorkerClient { @@ -22,6 +23,9 @@ export class WorkerClient { constructor( private gameStartInfo: GameStartInfo, private clientID: ClientID, + private sharedTileRingBuffers?: SharedTileRingBuffers, + private sharedStateBuffer?: SharedArrayBuffer, + private sharedDirtyBuffer?: SharedArrayBuffer, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -70,6 +74,10 @@ export class WorkerClient { id: messageId, gameStartInfo: this.gameStartInfo, clientID: this.clientID, + sharedTileRingHeader: this.sharedTileRingBuffers?.header, + sharedTileRingData: this.sharedTileRingBuffers?.data, + sharedStateBuffer: this.sharedStateBuffer, + sharedDirtyBuffer: this.sharedDirtyBuffer, }); // Add timeout for initialization @@ -100,12 +108,6 @@ export class WorkerClient { }); } - sendHeartbeat() { - this.worker.postMessage({ - type: "heartbeat", - }); - } - playerProfile(playerID: number): Promise { return new Promise((resolve, reject) => { if (!this.isInitialized) { diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1f..c6b8114188 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; export type WorkerMessageType = - | "heartbeat" | "init" | "initialized" | "turn" @@ -31,15 +30,15 @@ interface BaseWorkerMessage { id?: string; } -export interface HeartbeatMessage extends BaseWorkerMessage { - type: "heartbeat"; -} - // Messages from main thread to worker export interface InitMessage extends BaseWorkerMessage { type: "init"; gameStartInfo: GameStartInfo; clientID: ClientID; + sharedTileRingHeader?: SharedArrayBuffer; + sharedTileRingData?: SharedArrayBuffer; + sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage { @@ -114,7 +113,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage { // Union types for type safety export type MainThreadMessage = - | HeartbeatMessage | InitMessage | TurnMessage | PlayerActionsMessage