From e31ac7f2cc7304d0ca517a76531382dbbf21528f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:07:12 +0100 Subject: [PATCH 01/27] Add client catch-up mode Increase worker heartbeats per frame when far behind server to fast-forward simulation. Track backlog and expose catch-up status via TickMetricsEvent. Extend performance overlay to display backlog turns and indicate active catch-up mode. --- src/client/ClientGameRunner.ts | 62 ++++++++++++++++++- src/client/InputHandler.ts | 4 ++ .../graphics/layers/PerformanceOverlay.ts | 32 +++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a..c7c95a24b 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -208,6 +208,16 @@ 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 catchUpMode: boolean = false; + private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up + private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up + private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -299,9 +309,43 @@ export class ClientGameRunner { this.gameView.update(gu); this.renderer.tick(); + // Update tick / backlog metrics + if (!("errMsg" in gu)) { + this.lastProcessedTick = gu.tick; + this.backlogTurns = Math.max( + 0, + this.serverTurnHighWater - this.lastProcessedTick, + ); + + const wasCatchUp = this.catchUpMode; + if ( + !this.catchUpMode && + this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG + ) { + this.catchUpMode = true; + } else if ( + this.catchUpMode && + this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG + ) { + this.catchUpMode = false; + } + if (wasCatchUp !== this.catchUpMode) { + console.log( + `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${ + this.backlogTurns + } turns)`, + ); + } + } + // Emit tick metrics event for performance overlay this.eventBus.emit( - new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), + new TickMetricsEvent( + gu.tickExecutionDuration, + this.currentTickDelay, + this.backlogTurns, + this.catchUpMode, + ), ); // Reset tick delay for next measurement @@ -314,7 +358,12 @@ export class ClientGameRunner { const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { - worker.sendHeartbeat(); + const beatsPerFrame = this.catchUpMode + ? this.CATCH_UP_HEARTBEATS_PER_FRAME + : 1; + for (let i = 0; i < beatsPerFrame; i++) { + worker.sendHeartbeat(); + } requestAnimationFrame(keepWorkerAlive); } }; @@ -363,6 +412,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 +468,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}`, diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27..5ecc23915 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -129,6 +129,10 @@ 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, + // Whether the client is currently in catch-up mode + public readonly inCatchUpMode?: boolean, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index fb744d4a0..0fc50ffdf 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -229,7 +229,12 @@ 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.inCatchUpMode, + ); }); } @@ -418,7 +423,18 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { + @state() + private backlogTurns: number = 0; + + @state() + private inCatchUpMode: boolean = false; + + updateTickMetrics( + tickExecutionDuration?: number, + tickDelay?: number, + backlogTurns?: number, + inCatchUpMode?: boolean, + ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; // Update tick execution duration stats @@ -455,6 +471,13 @@ export class PerformanceOverlay extends LitElement implements Layer { } } + if (backlogTurns !== undefined) { + this.backlogTurns = backlogTurns; + } + if (inCatchUpMode !== undefined) { + this.inCatchUpMode = inCatchUpMode; + } + this.requestUpdate(); } @@ -600,6 +623,11 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms) +
+ Backlog turns: + ${this.backlogTurns} + ${this.inCatchUpMode ? html` (catch-up)` : html``} +
${this.layerBreakdown.length ? html`
From d5f53af3da2b700a9540b028cf1439ccccd6d7b4 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:28:34 +0100 Subject: [PATCH 02/27] Batch worker updates in client catch-up mode to reduce render cost - Refactor worker update handling into processPendingUpdates so multiple GameUpdateViewData objects are batched per frame. - Combine all tick updates in a batch into a single GameUpdateViewData before applying it to GameView, while still running per-tick side effects (turnComplete, hashes, backlog metrics, win saving). - Ensure layers using updatesSinceLastTick and recentlyUpdatedTiles see all events in a batch, fixing visual artifacts during fast-forward resync. --- src/client/ClientGameRunner.ts | 183 +++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 52 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c7c95a24b..dcf769b8b 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 { @@ -218,6 +218,9 @@ export class ClientGameRunner { private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; + private pendingUpdates: GameUpdateViewData[] = []; + private isProcessingUpdates = false; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -302,57 +305,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(); - - // Update tick / backlog metrics - if (!("errMsg" in gu)) { - this.lastProcessedTick = gu.tick; - this.backlogTurns = Math.max( - 0, - this.serverTurnHighWater - this.lastProcessedTick, - ); - - const wasCatchUp = this.catchUpMode; - if ( - !this.catchUpMode && - this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG - ) { - this.catchUpMode = true; - } else if ( - this.catchUpMode && - this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG - ) { - this.catchUpMode = false; - } - if (wasCatchUp !== this.catchUpMode) { - console.log( - `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${ - this.backlogTurns - } turns)`, - ); - } - } - - // Emit tick metrics event for performance overlay - this.eventBus.emit( - new TickMetricsEvent( - gu.tickExecutionDuration, - this.currentTickDelay, - this.backlogTurns, - this.catchUpMode, - ), - ); - - // 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); + if (!this.catchUpMode) { + this.processPendingUpdates(); } }); const worker = this.worker; @@ -364,6 +319,7 @@ export class ClientGameRunner { for (let i = 0; i < beatsPerFrame; i++) { worker.sendHeartbeat(); } + this.processPendingUpdates(); requestAnimationFrame(keepWorkerAlive); } }; @@ -503,6 +459,129 @@ export class ClientGameRunner { } } + private processPendingUpdates() { + if (this.isProcessingUpdates) { + return; + } + if (this.pendingUpdates.length === 0) { + return; + } + + this.isProcessingUpdates = true; + const batch = this.pendingUpdates.splice(0); + + let processedCount = 0; + let lastTickDuration: number | undefined; + let lastTick: number | undefined; + + try { + for (const gu of batch) { + processedCount++; + + 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; + } + } finally { + this.isProcessingUpdates = false; + } + + if (processedCount > 0 && lastTick !== undefined) { + const combinedGu = this.mergeGameUpdates(batch); + if (combinedGu) { + this.gameView.update(combinedGu); + } + + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + this.catchUpMode, + ), + ); + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } + } + + private mergeGameUpdates( + batch: GameUpdateViewData[], + ): GameUpdateViewData | null { + if (batch.length === 0) { + return null; + } + + 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); + } + gu.packedTileUpdates.forEach((tu) => { + combinedPackedTileUpdates.push(tu); + }); + } + + return { + tick: last.tick, + updates: combinedUpdates, + packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates), + playerNameViewData: last.playerNameViewData, + tickExecutionDuration: last.tickExecutionDuration, + }; + } + + private updateBacklogMetrics(processedTick: number) { + this.lastProcessedTick = processedTick; + this.backlogTurns = Math.max( + 0, + this.serverTurnHighWater - this.lastProcessedTick, + ); + + const wasCatchUp = this.catchUpMode; + if (!this.catchUpMode && this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG) { + this.catchUpMode = true; + } else if ( + this.catchUpMode && + this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG + ) { + this.catchUpMode = false; + } + if (wasCatchUp !== this.catchUpMode) { + console.log( + `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${ + this.backlogTurns + } turns)`, + ); + } + } + private inputEvent(event: MouseUpEvent) { if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; From b6515d4366757797d39e137952b2d7bb39573fe6 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:02:22 +0100 Subject: [PATCH 03/27] frameskip --- src/client/ClientGameRunner.ts | 66 ++++++++++++++++++- src/client/InputHandler.ts | 2 + .../graphics/layers/PerformanceOverlay.ts | 22 +++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index dcf769b8b..e0804b885 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -216,11 +216,18 @@ export class ClientGameRunner { private catchUpMode: boolean = false; private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up - private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; + private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; //upper bound on heartbeats per frame when in catch-up mode private pendingUpdates: GameUpdateViewData[] = []; private isProcessingUpdates = false; + // Adaptive rendering during catch-up: render at most once every N frames. + private renderEveryN: number = 1; + private renderSkipCounter: number = 0; + private lastFrameTime: number = 0; + private readonly MAX_RENDER_EVERY_N = 60; + private lastBeatsPerFrame: number = 1; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -313,13 +320,36 @@ export class ClientGameRunner { const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { + const now = performance.now(); + let frameDuration = 0; + if (this.lastFrameTime !== 0) { + frameDuration = now - this.lastFrameTime; + } + this.lastFrameTime = now; + const beatsPerFrame = this.catchUpMode ? this.CATCH_UP_HEARTBEATS_PER_FRAME : 1; + this.lastBeatsPerFrame = beatsPerFrame; for (let i = 0; i < beatsPerFrame; i++) { worker.sendHeartbeat(); } - this.processPendingUpdates(); + + // Decide whether to render (and thus process pending updates) this frame. + let shouldRender = true; + if (this.catchUpMode && this.renderEveryN > 1) { + if (this.renderSkipCounter < this.renderEveryN - 1) { + shouldRender = false; + this.renderSkipCounter++; + } else { + this.renderSkipCounter = 0; + } + } + + if (shouldRender) { + this.processPendingUpdates(); + } + this.adaptRenderFrequency(frameDuration); requestAnimationFrame(keepWorkerAlive); } }; @@ -510,13 +540,45 @@ export class ClientGameRunner { this.currentTickDelay, this.backlogTurns, this.catchUpMode, + this.renderEveryN, + this.lastBeatsPerFrame, ), ); + // Reset tick delay for next measurement this.currentTickDelay = undefined; } } + private adaptRenderFrequency(frameDuration: number) { + if (!this.catchUpMode) { + // Back to normal rendering. + this.renderEveryN = 1; + this.renderSkipCounter = 0; + return; + } + + const HIGH_BACKLOG = 200; + const LOW_BACKLOG = 50; + const HIGH_FRAME_MS = 25; + const LOW_FRAME_MS = 18; + + if (this.backlogTurns > HIGH_BACKLOG && frameDuration > HIGH_FRAME_MS) { + // We are far behind and frames are heavy → render less often. + if (this.renderEveryN < this.MAX_RENDER_EVERY_N) { + this.renderEveryN++; + } + } else if ( + this.backlogTurns < LOW_BACKLOG || + (frameDuration > 0 && frameDuration < LOW_FRAME_MS) + ) { + // Either mostly caught up or frames are cheap again → move back toward normal. + if (this.renderEveryN > 1) { + this.renderEveryN--; + } + } + } + private mergeGameUpdates( batch: GameUpdateViewData[], ): GameUpdateViewData | null { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 5ecc23915..2cfa212cb 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -133,6 +133,8 @@ export class TickMetricsEvent implements GameEvent { public readonly backlogTurns?: number, // Whether the client is currently in catch-up mode public readonly inCatchUpMode?: boolean, + public readonly renderEveryN?: number, + public readonly beatsPerFrame?: number, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 0fc50ffdf..3cd688449 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -234,6 +234,8 @@ export class PerformanceOverlay extends LitElement implements Layer { event.tickDelay, event.backlogTurns, event.inCatchUpMode, + event.renderEveryN, + event.beatsPerFrame, ); }); } @@ -429,11 +431,19 @@ export class PerformanceOverlay extends LitElement implements Layer { @state() private inCatchUpMode: boolean = false; + @state() + private renderEveryN: number = 1; + + @state() + private beatsPerFrame: number = 1; + updateTickMetrics( tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, inCatchUpMode?: boolean, + renderEveryN?: number, + beatsPerFrame?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -477,6 +487,12 @@ export class PerformanceOverlay extends LitElement implements Layer { if (inCatchUpMode !== undefined) { this.inCatchUpMode = inCatchUpMode; } + if (renderEveryN !== undefined) { + this.renderEveryN = renderEveryN; + } + if (beatsPerFrame !== undefined) { + this.beatsPerFrame = beatsPerFrame; + } this.requestUpdate(); } @@ -628,6 +644,12 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.backlogTurns} ${this.inCatchUpMode ? html` (catch-up)` : html``}
+ ${this.inCatchUpMode + ? html`
+ Render every ${this.renderEveryN} frame(s), + heartbeats per frame: ${this.beatsPerFrame} +
` + : html``} ${this.layerBreakdown.length ? html`
From dcd5b550cf2c4f0a1e2d67837c23a0c6f7a7ec34 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:03:22 +0100 Subject: [PATCH 04/27] Worker now self-clocks; no heartbeats needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameRunner exposes pending work via a new hasPendingTurns() so the worker can check whether more ticks need to be processed. Worker auto-runs ticks: as soon as it initializes or receives a new turn, it calls processPendingTurns() and loops executeNextTick() while hasPendingTurns() is true. No more "heartbeat" message type; the worker no longer depends on the main thread’s RAF loop to advance the simulation. Client main thread simplified: Removed CATCH_UP_HEARTBEATS_PER_FRAME, the heartbeat loop, and the lastBeatsPerFrame tracking. keepWorkerAlive now just manages frame skipping + draining. When it decides to render (based on renderEveryN), it drains pendingUpdates, merges them, updates GameView, and runs renderer.tick(). Because rendering a batch always implies draining, we restored the invariant that every GameView.update is paired with a layer tick() (no more lost incremental updates). MAX_RENDER_EVERY_N is now 5 to keep the queue from growing too large while the worker sprints. --- src/client/ClientGameRunner.ts | 14 +-------- .../graphics/layers/PerformanceOverlay.ts | 7 +++-- src/core/GameRunner.ts | 4 +++ src/core/worker/Worker.worker.ts | 29 +++++++++++++++++-- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index e0804b885..348e80d6d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -216,7 +216,6 @@ export class ClientGameRunner { private catchUpMode: boolean = false; private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up - private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; //upper bound on heartbeats per frame when in catch-up mode private pendingUpdates: GameUpdateViewData[] = []; private isProcessingUpdates = false; @@ -225,8 +224,7 @@ export class ClientGameRunner { private renderEveryN: number = 1; private renderSkipCounter: number = 0; private lastFrameTime: number = 0; - private readonly MAX_RENDER_EVERY_N = 60; - private lastBeatsPerFrame: number = 1; + private readonly MAX_RENDER_EVERY_N = 5; constructor( private lobby: LobbyConfig, @@ -317,7 +315,6 @@ export class ClientGameRunner { this.processPendingUpdates(); } }); - const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { const now = performance.now(); @@ -327,14 +324,6 @@ export class ClientGameRunner { } this.lastFrameTime = now; - const beatsPerFrame = this.catchUpMode - ? this.CATCH_UP_HEARTBEATS_PER_FRAME - : 1; - this.lastBeatsPerFrame = beatsPerFrame; - for (let i = 0; i < beatsPerFrame; i++) { - worker.sendHeartbeat(); - } - // Decide whether to render (and thus process pending updates) this frame. let shouldRender = true; if (this.catchUpMode && this.renderEveryN > 1) { @@ -541,7 +530,6 @@ export class ClientGameRunner { this.backlogTurns, this.catchUpMode, this.renderEveryN, - this.lastBeatsPerFrame, ), ); diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 3cd688449..bd101f80d 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -435,7 +435,7 @@ export class PerformanceOverlay extends LitElement implements Layer { private renderEveryN: number = 1; @state() - private beatsPerFrame: number = 1; + private beatsPerFrame: number | null = null; updateTickMetrics( tickExecutionDuration?: number, @@ -491,7 +491,7 @@ export class PerformanceOverlay extends LitElement implements Layer { this.renderEveryN = renderEveryN; } if (beatsPerFrame !== undefined) { - this.beatsPerFrame = beatsPerFrame; + this.beatsPerFrame = beatsPerFrame ?? null; } this.requestUpdate(); @@ -647,7 +647,8 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.inCatchUpMode ? html`
Render every ${this.renderEveryN} frame(s), - heartbeats per frame: ${this.beatsPerFrame} + heartbeats per frame: + ${this.beatsPerFrame ?? "auto"}
` : html``} ${this.layerBreakdown.length diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 6a8a4042f..514ed2758 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -272,4 +272,8 @@ export class GameRunner { } return player.bestTransportShipSpawn(targetTile); } + + public hasPendingTurns(): boolean { + return this.currTurn < this.turns.length; + } } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 1014968fb..a6bb92510 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -16,6 +16,7 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +let isProcessingTurns = false; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -32,13 +33,33 @@ 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 { gameRunner = createGameRunner( @@ -51,6 +72,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { type: "initialized", id: message.id, } as InitializedMessage); + processPendingTurns(); return gr; }); } catch (error) { @@ -67,6 +89,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; From 8e0a42c513aec59026bef5b5205451f609881666 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:01:17 +0100 Subject: [PATCH 05/27] Clean up previous implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removed: - catchUpMode and its CATCH_UP_ENTER/EXIT thresholds in ClientGameRunner - tick metrics fields and overlay UI for inCatchUpMode and beatsPerFrame - leftover worker heartbeat plumbing (message type + WorkerClient.sendHeartbeat) that was no longer used after self-clocking changed: - backlog tracking: keep serverTurnHighWater / lastProcessedTick / backlogTurns, but simplify it to just compute backlog and a backlogGrowing flag instead of driving a dedicated catch-up mode - frame skip: adaptRenderFrequency now only increases renderEveryN when backlog > 0 and still growing; when backlog is stable/shrinking or zero, it decays renderEveryN back toward 1 - render loop: uses the backlog-aware renderEveryN unconditionally (no catch-up flag), and resets skipping completely when backlog reaches 0 - metrics/overlay: TickMetricsEvent now carries backlogTurns and renderEveryN; the performance overlay displays backlog and current “render every N frames” but no longer mentions catch-up or heartbeats Learnings during branch development leading to this Once the worker self-clocks, a separate “catch-up mode” and beats-per-frame knob don’t add real control; they just complicate the model. Backlog is still a valuable signal, but it’s more effective as a quantitative input (backlog size and whether it’s growing) than as a boolean mode toggle. Frame skipping should be driven by actual backlog pressure plus frame cost: throttle only while backlog is growing and frames are heavy, and automatically relax back to full-rate rendering once the simulation catches up. --- src/client/ClientGameRunner.ts | 60 +++++++------------ src/client/InputHandler.ts | 3 - .../graphics/layers/PerformanceOverlay.ts | 25 +------- src/core/worker/WorkerClient.ts | 6 -- src/core/worker/WorkerMessages.ts | 6 -- 5 files changed, 23 insertions(+), 77 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 348e80d6d..8a92444ce 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -212,15 +212,12 @@ export class ClientGameRunner { private serverTurnHighWater: number = 0; private lastProcessedTick: number = 0; private backlogTurns: number = 0; - - private catchUpMode: boolean = false; - private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up - private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up + private backlogGrowing: boolean = false; private pendingUpdates: GameUpdateViewData[] = []; private isProcessingUpdates = false; - // Adaptive rendering during catch-up: render at most once every N frames. + // Adaptive rendering when frames are heavy: render at most once every N frames. private renderEveryN: number = 1; private renderSkipCounter: number = 0; private lastFrameTime: number = 0; @@ -311,7 +308,7 @@ export class ClientGameRunner { return; } this.pendingUpdates.push(gu); - if (!this.catchUpMode) { + if (this.renderEveryN === 1) { this.processPendingUpdates(); } }); @@ -326,13 +323,14 @@ export class ClientGameRunner { // Decide whether to render (and thus process pending updates) this frame. let shouldRender = true; - if (this.catchUpMode && this.renderEveryN > 1) { - if (this.renderSkipCounter < this.renderEveryN - 1) { - shouldRender = false; - this.renderSkipCounter++; - } else { - this.renderSkipCounter = 0; - } + if ( + this.renderEveryN > 1 && + this.renderSkipCounter < this.renderEveryN - 1 + ) { + shouldRender = false; + this.renderSkipCounter++; + } else if (this.renderEveryN > 1) { + this.renderSkipCounter = 0; } if (shouldRender) { @@ -528,7 +526,6 @@ export class ClientGameRunner { lastTickDuration, this.currentTickDelay, this.backlogTurns, - this.catchUpMode, this.renderEveryN, ), ); @@ -539,28 +536,26 @@ export class ClientGameRunner { } private adaptRenderFrequency(frameDuration: number) { - if (!this.catchUpMode) { - // Back to normal rendering. + // Frameskip only matters while we have a backlog; otherwise stay at 1. + if (this.backlogTurns === 0) { this.renderEveryN = 1; this.renderSkipCounter = 0; return; } - const HIGH_BACKLOG = 200; - const LOW_BACKLOG = 50; const HIGH_FRAME_MS = 25; const LOW_FRAME_MS = 18; - if (this.backlogTurns > HIGH_BACKLOG && frameDuration > HIGH_FRAME_MS) { - // We are far behind and frames are heavy → render less often. + // Only throttle rendering if backlog is still growing; otherwise drift back toward 1. + if (this.backlogGrowing && frameDuration > HIGH_FRAME_MS) { if (this.renderEveryN < this.MAX_RENDER_EVERY_N) { this.renderEveryN++; } } else if ( - this.backlogTurns < LOW_BACKLOG || - (frameDuration > 0 && frameDuration < LOW_FRAME_MS) + !this.backlogGrowing && + frameDuration > 0 && + frameDuration < LOW_FRAME_MS ) { - // Either mostly caught up or frames are cheap again → move back toward normal. if (this.renderEveryN > 1) { this.renderEveryN--; } @@ -609,27 +604,12 @@ export class ClientGameRunner { private updateBacklogMetrics(processedTick: number) { this.lastProcessedTick = processedTick; + const previousBacklog = this.backlogTurns; this.backlogTurns = Math.max( 0, this.serverTurnHighWater - this.lastProcessedTick, ); - - const wasCatchUp = this.catchUpMode; - if (!this.catchUpMode && this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG) { - this.catchUpMode = true; - } else if ( - this.catchUpMode && - this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG - ) { - this.catchUpMode = false; - } - if (wasCatchUp !== this.catchUpMode) { - console.log( - `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${ - this.backlogTurns - } turns)`, - ); - } + this.backlogGrowing = this.backlogTurns > previousBacklog; } private inputEvent(event: MouseUpEvent) { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 2cfa212cb..abe01cac3 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -131,10 +131,7 @@ export class TickMetricsEvent implements GameEvent { public readonly tickDelay?: number, // Number of turns the client is behind the server (if known) public readonly backlogTurns?: number, - // Whether the client is currently in catch-up mode - public readonly inCatchUpMode?: boolean, public readonly renderEveryN?: number, - public readonly beatsPerFrame?: number, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index bd101f80d..183c4fa44 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -233,9 +233,7 @@ export class PerformanceOverlay extends LitElement implements Layer { event.tickExecutionDuration, event.tickDelay, event.backlogTurns, - event.inCatchUpMode, event.renderEveryN, - event.beatsPerFrame, ); }); } @@ -425,25 +423,17 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - @state() - private backlogTurns: number = 0; - - @state() - private inCatchUpMode: boolean = false; - @state() private renderEveryN: number = 1; @state() - private beatsPerFrame: number | null = null; + private backlogTurns: number = 0; updateTickMetrics( tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, - inCatchUpMode?: boolean, renderEveryN?: number, - beatsPerFrame?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -484,15 +474,9 @@ export class PerformanceOverlay extends LitElement implements Layer { if (backlogTurns !== undefined) { this.backlogTurns = backlogTurns; } - if (inCatchUpMode !== undefined) { - this.inCatchUpMode = inCatchUpMode; - } if (renderEveryN !== undefined) { this.renderEveryN = renderEveryN; } - if (beatsPerFrame !== undefined) { - this.beatsPerFrame = beatsPerFrame ?? null; - } this.requestUpdate(); } @@ -642,13 +626,10 @@ export class PerformanceOverlay extends LitElement implements Layer {
Backlog turns: ${this.backlogTurns} - ${this.inCatchUpMode ? html` (catch-up)` : html``}
- ${this.inCatchUpMode + ${this.renderEveryN > 1 ? html`
- Render every ${this.renderEveryN} frame(s), - heartbeats per frame: - ${this.beatsPerFrame ?? "auto"} + Render every ${this.renderEveryN} frame(s)
` : html``} ${this.layerBreakdown.length diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index bde436f39..4edc97dee 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -100,12 +100,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 a8d30e9b1..0c5344da1 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,10 +30,6 @@ interface BaseWorkerMessage { id?: string; } -export interface HeartbeatMessage extends BaseWorkerMessage { - type: "heartbeat"; -} - // Messages from main thread to worker export interface InitMessage extends BaseWorkerMessage { type: "init"; @@ -114,7 +109,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage { // Union types for type safety export type MainThreadMessage = - | HeartbeatMessage | InitMessage | TurnMessage | PlayerActionsMessage From 3cc6243bf4f1a06282093ea24c9207af96b14ab9 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:40:11 +0100 Subject: [PATCH 06/27] Implemented time-sliced catch-up on the main thread to keep input responsive. src/client/ClientGameRunner.ts now drains pending game updates in small chunks (max 100 updates or ~8ms per slice) via requestAnimationFrame, merging and rendering per slice, and only clears the processing flag when the queue is empty. --- src/client/ClientGameRunner.ts | 73 ++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 8a92444ce..cd59e80b5 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -477,23 +477,25 @@ export class ClientGameRunner { } private processPendingUpdates() { - if (this.isProcessingUpdates) { - return; - } - if (this.pendingUpdates.length === 0) { + if (this.isProcessingUpdates || this.pendingUpdates.length === 0) { return; } this.isProcessingUpdates = true; - const batch = this.pendingUpdates.splice(0); + const processSlice = () => { + const SLICE_BUDGET_MS = 8; // keep UI responsive while catching up + const MAX_PER_SLICE = 100; + const sliceStart = performance.now(); + const batch: GameUpdateViewData[] = []; - let processedCount = 0; - let lastTickDuration: number | undefined; - let lastTick: number | undefined; + let processedCount = 0; + let lastTickDuration: number | undefined; + let lastTick: number | undefined; - try { - for (const gu of batch) { + while (this.pendingUpdates.length > 0) { + const gu = this.pendingUpdates.shift() as GameUpdateViewData; processedCount++; + batch.push(gu); this.transport.turnComplete(); gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { @@ -509,30 +511,41 @@ export class ClientGameRunner { lastTickDuration = gu.tickExecutionDuration; } lastTick = gu.tick; - } - } finally { - this.isProcessingUpdates = false; - } - if (processedCount > 0 && lastTick !== undefined) { - const combinedGu = this.mergeGameUpdates(batch); - if (combinedGu) { - this.gameView.update(combinedGu); + const elapsed = performance.now() - sliceStart; + if (processedCount >= MAX_PER_SLICE || elapsed >= SLICE_BUDGET_MS) { + break; + } } - this.renderer.tick(); - this.eventBus.emit( - new TickMetricsEvent( - lastTickDuration, - this.currentTickDelay, - this.backlogTurns, - this.renderEveryN, - ), - ); + if (processedCount > 0 && lastTick !== undefined) { + const combinedGu = this.mergeGameUpdates(batch); + if (combinedGu) { + this.gameView.update(combinedGu); + } - // Reset tick delay for next measurement - this.currentTickDelay = undefined; - } + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + this.renderEveryN, + ), + ); + + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } + + if (this.pendingUpdates.length > 0) { + requestAnimationFrame(processSlice); + } else { + this.isProcessingUpdates = false; + } + }; + + requestAnimationFrame(processSlice); } private adaptRenderFrequency(frameDuration: number) { From 5c99ef5092df7f4e4a5f8f6180d6b217a743adee Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:59:10 +0100 Subject: [PATCH 07/27] Refactor slice budget calculation in ClientGameRunner to improve backlog handling. Introduced dynamic slice budget scaling based on backlog size, allowing for longer processing times when necessary while maintaining UI responsiveness. --- src/client/ClientGameRunner.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index cd59e80b5..2294755c7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -483,8 +483,24 @@ export class ClientGameRunner { this.isProcessingUpdates = true; const processSlice = () => { - const SLICE_BUDGET_MS = 8; // keep UI responsive while catching up + 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 = 1000; // MAX_SLICE_BUDGET_MS is reached at this many turns const MAX_PER_SLICE = 100; + + 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 sliceStart = performance.now(); const batch: GameUpdateViewData[] = []; @@ -513,7 +529,7 @@ export class ClientGameRunner { lastTick = gu.tick; const elapsed = performance.now() - sliceStart; - if (processedCount >= MAX_PER_SLICE || elapsed >= SLICE_BUDGET_MS) { + if (processedCount >= MAX_PER_SLICE || elapsed >= sliceBudgetMs) { break; } } From 73f47fe997c8b1345a76c5ed040d68ceca2f4841 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:54:09 +0100 Subject: [PATCH 08/27] ClientGameRunner: simplify catch-up loop with indexed queue process updates in a single budgeted loop per RAF and render once track queue head with pendingStart and compact to avoid array shifts --- src/client/ClientGameRunner.ts | 37 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2294755c7..a864b75aa 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -215,6 +215,7 @@ export class ClientGameRunner { private backlogGrowing: boolean = false; private pendingUpdates: GameUpdateViewData[] = []; + private pendingStart = 0; private isProcessingUpdates = false; // Adaptive rendering when frames are heavy: render at most once every N frames. @@ -477,12 +478,13 @@ export class ClientGameRunner { } private processPendingUpdates() { - if (this.isProcessingUpdates || this.pendingUpdates.length === 0) { + const pendingCount = this.pendingUpdates.length - this.pendingStart; + if (this.isProcessingUpdates || pendingCount <= 0) { return; } this.isProcessingUpdates = true; - const processSlice = () => { + 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 @@ -501,15 +503,16 @@ export class ClientGameRunner { BASE_SLICE_BUDGET_MS + backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS); - const sliceStart = performance.now(); + const frameStart = performance.now(); const batch: GameUpdateViewData[] = []; - - let processedCount = 0; let lastTickDuration: number | undefined; let lastTick: number | undefined; - while (this.pendingUpdates.length > 0) { - const gu = this.pendingUpdates.shift() as GameUpdateViewData; + 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++; batch.push(gu); @@ -528,13 +531,23 @@ export class ClientGameRunner { } lastTick = gu.tick; - const elapsed = performance.now() - sliceStart; + const elapsed = performance.now() - frameStart; if (processedCount >= MAX_PER_SLICE || elapsed >= sliceBudgetMs) { break; } } - if (processedCount > 0 && lastTick !== undefined) { + // 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; + } + + if (batch.length > 0 && lastTick !== undefined) { const combinedGu = this.mergeGameUpdates(batch); if (combinedGu) { this.gameView.update(combinedGu); @@ -554,14 +567,14 @@ export class ClientGameRunner { this.currentTickDelay = undefined; } - if (this.pendingUpdates.length > 0) { - requestAnimationFrame(processSlice); + if (this.pendingStart < this.pendingUpdates.length) { + requestAnimationFrame(processFrame); } else { this.isProcessingUpdates = false; } }; - requestAnimationFrame(processSlice); + requestAnimationFrame(processFrame); } private adaptRenderFrequency(frameDuration: number) { From d501d0e2d98a1c5d2848730724eafe7c3b3d8230 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:40:39 +0100 Subject: [PATCH 09/27] remove redundant logic --- src/client/ClientGameRunner.ts | 96 ++++--------------- src/client/InputHandler.ts | 1 - .../graphics/layers/PerformanceOverlay.ts | 13 --- 3 files changed, 17 insertions(+), 93 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index a864b75aa..2f3641b22 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -218,12 +218,6 @@ export class ClientGameRunner { private pendingStart = 0; private isProcessingUpdates = false; - // Adaptive rendering when frames are heavy: render at most once every N frames. - private renderEveryN: number = 1; - private renderSkipCounter: number = 0; - private lastFrameTime: number = 0; - private readonly MAX_RENDER_EVERY_N = 5; - constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -309,39 +303,8 @@ export class ClientGameRunner { return; } this.pendingUpdates.push(gu); - if (this.renderEveryN === 1) { - this.processPendingUpdates(); - } + this.processPendingUpdates(); }); - const keepWorkerAlive = () => { - if (this.isActive) { - const now = performance.now(); - let frameDuration = 0; - if (this.lastFrameTime !== 0) { - frameDuration = now - this.lastFrameTime; - } - this.lastFrameTime = now; - - // Decide whether to render (and thus process pending updates) this frame. - let shouldRender = true; - if ( - this.renderEveryN > 1 && - this.renderSkipCounter < this.renderEveryN - 1 - ) { - shouldRender = false; - this.renderSkipCounter++; - } else if (this.renderEveryN > 1) { - this.renderSkipCounter = 0; - } - - if (shouldRender) { - this.processPendingUpdates(); - } - this.adaptRenderFrequency(frameDuration); - requestAnimationFrame(keepWorkerAlive); - } - }; - requestAnimationFrame(keepWorkerAlive); const onconnect = () => { console.log("Connected to game server!"); @@ -488,8 +451,8 @@ export class ClientGameRunner { 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 = 1000; // MAX_SLICE_BUDGET_MS is reached at this many turns - const MAX_PER_SLICE = 100; + 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, @@ -532,7 +495,7 @@ export class ClientGameRunner { lastTick = gu.tick; const elapsed = performance.now() - frameStart; - if (processedCount >= MAX_PER_SLICE || elapsed >= sliceBudgetMs) { + if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) { break; } } @@ -553,18 +516,20 @@ export class ClientGameRunner { this.gameView.update(combinedGu); } - this.renderer.tick(); - this.eventBus.emit( - new TickMetricsEvent( - lastTickDuration, - this.currentTickDelay, - this.backlogTurns, - this.renderEveryN, - ), - ); + // Only emit metrics when ALL processing is complete + if (this.pendingStart >= this.pendingUpdates.length) { + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + ), + ); - // Reset tick delay for next measurement - this.currentTickDelay = undefined; + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } } if (this.pendingStart < this.pendingUpdates.length) { @@ -577,33 +542,6 @@ export class ClientGameRunner { requestAnimationFrame(processFrame); } - private adaptRenderFrequency(frameDuration: number) { - // Frameskip only matters while we have a backlog; otherwise stay at 1. - if (this.backlogTurns === 0) { - this.renderEveryN = 1; - this.renderSkipCounter = 0; - return; - } - - const HIGH_FRAME_MS = 25; - const LOW_FRAME_MS = 18; - - // Only throttle rendering if backlog is still growing; otherwise drift back toward 1. - if (this.backlogGrowing && frameDuration > HIGH_FRAME_MS) { - if (this.renderEveryN < this.MAX_RENDER_EVERY_N) { - this.renderEveryN++; - } - } else if ( - !this.backlogGrowing && - frameDuration > 0 && - frameDuration < LOW_FRAME_MS - ) { - if (this.renderEveryN > 1) { - this.renderEveryN--; - } - } - } - private mergeGameUpdates( batch: GameUpdateViewData[], ): GameUpdateViewData | null { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index abe01cac3..e18e616e8 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -131,7 +131,6 @@ export class TickMetricsEvent implements GameEvent { public readonly tickDelay?: number, // Number of turns the client is behind the server (if known) public readonly backlogTurns?: number, - public readonly renderEveryN?: number, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 183c4fa44..8bc8f4a6a 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -233,7 +233,6 @@ export class PerformanceOverlay extends LitElement implements Layer { event.tickExecutionDuration, event.tickDelay, event.backlogTurns, - event.renderEveryN, ); }); } @@ -423,9 +422,6 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - @state() - private renderEveryN: number = 1; - @state() private backlogTurns: number = 0; @@ -433,7 +429,6 @@ export class PerformanceOverlay extends LitElement implements Layer { tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, - renderEveryN?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -474,9 +469,6 @@ export class PerformanceOverlay extends LitElement implements Layer { if (backlogTurns !== undefined) { this.backlogTurns = backlogTurns; } - if (renderEveryN !== undefined) { - this.renderEveryN = renderEveryN; - } this.requestUpdate(); } @@ -627,11 +619,6 @@ export class PerformanceOverlay extends LitElement implements Layer { Backlog turns: ${this.backlogTurns}
- ${this.renderEveryN > 1 - ? html`
- Render every ${this.renderEveryN} frame(s) -
` - : html``} ${this.layerBreakdown.length ? html`
From f0e05c66b8556686d49e064356b724f742ab518a Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:44:54 +0100 Subject: [PATCH 10/27] add "ticks per render" metric --- src/client/ClientGameRunner.ts | 8 ++++++++ src/client/InputHandler.ts | 2 ++ src/client/graphics/layers/PerformanceOverlay.ts | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2f3641b22..ce88d861a 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -213,6 +213,7 @@ export class ClientGameRunner { private lastProcessedTick: number = 0; private backlogTurns: number = 0; private backlogGrowing: boolean = false; + private lastRenderedTick: number = 0; private pendingUpdates: GameUpdateViewData[] = []; private pendingStart = 0; @@ -518,12 +519,19 @@ export class ClientGameRunner { // Only emit metrics when ALL processing is complete if (this.pendingStart >= this.pendingUpdates.length) { + const ticksPerRender = + this.lastRenderedTick === 0 + ? lastTick + : lastTick - this.lastRenderedTick; + this.lastRenderedTick = lastTick; + this.renderer.tick(); this.eventBus.emit( new TickMetricsEvent( lastTickDuration, this.currentTickDelay, this.backlogTurns, + ticksPerRender, ), ); diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index e18e616e8..bd95e7201 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -131,6 +131,8 @@ export class TickMetricsEvent implements GameEvent { public readonly tickDelay?: number, // Number of turns the client is behind the server (if known) public readonly backlogTurns?: number, + // Number of ticks applied since last render + public readonly ticksPerRender?: number, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 8bc8f4a6a..531f19f0b 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -233,6 +233,7 @@ export class PerformanceOverlay extends LitElement implements Layer { event.tickExecutionDuration, event.tickDelay, event.backlogTurns, + event.ticksPerRender, ); }); } @@ -425,10 +426,14 @@ export class PerformanceOverlay extends LitElement implements Layer { @state() private backlogTurns: number = 0; + @state() + private ticksPerRender: number = 0; + updateTickMetrics( tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, + ticksPerRender?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -470,6 +475,10 @@ export class PerformanceOverlay extends LitElement implements Layer { this.backlogTurns = backlogTurns; } + if (ticksPerRender !== undefined) { + this.ticksPerRender = ticksPerRender; + } + this.requestUpdate(); } @@ -615,6 +624,10 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms)
+
+ Ticks per render: + ${this.ticksPerRender} +
Backlog turns: ${this.backlogTurns} From 59ff42e52ba1988a0edd03d098ac2d26b84e0484 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:46:25 +0100 Subject: [PATCH 11/27] Refactor rendering and throttle based on backlog - Refactor rendering and metrics emission in ClientGameRunner to ensure updates occur only after all processing is complete - Throttle renderGame() based on the current backlog --- src/client/ClientGameRunner.ts | 46 ++++++++++++++++------------- src/client/InputHandler.ts | 7 +++++ src/client/graphics/GameRenderer.ts | 34 ++++++++++++++++++++- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ce88d861a..4d70a3f3e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -28,6 +28,7 @@ import { UserSettings } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; import { AutoUpgradeEvent, + BacklogStatusEvent, DoBoatAttackEvent, DoGroundAttackEvent, InputHandler, @@ -511,33 +512,35 @@ export class ClientGameRunner { this.pendingStart = 0; } - if (batch.length > 0 && lastTick !== undefined) { + // Only update view and render when ALL processing is complete + if ( + this.pendingStart >= this.pendingUpdates.length && + batch.length > 0 && + lastTick !== undefined + ) { const combinedGu = this.mergeGameUpdates(batch); if (combinedGu) { this.gameView.update(combinedGu); } - // Only emit metrics when ALL processing is complete - if (this.pendingStart >= this.pendingUpdates.length) { - const ticksPerRender = - this.lastRenderedTick === 0 - ? lastTick - : lastTick - this.lastRenderedTick; - this.lastRenderedTick = lastTick; + const ticksPerRender = + this.lastRenderedTick === 0 + ? lastTick + : lastTick - this.lastRenderedTick; + this.lastRenderedTick = lastTick; - this.renderer.tick(); - this.eventBus.emit( - new TickMetricsEvent( - lastTickDuration, - this.currentTickDelay, - this.backlogTurns, - ticksPerRender, - ), - ); + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + ticksPerRender, + ), + ); - // Reset tick delay for next measurement - this.currentTickDelay = undefined; - } + // Reset tick delay for next measurement + this.currentTickDelay = undefined; } if (this.pendingStart < this.pendingUpdates.length) { @@ -598,6 +601,9 @@ export class ClientGameRunner { this.serverTurnHighWater - this.lastProcessedTick, ); this.backlogGrowing = this.backlogTurns > previousBacklog; + this.eventBus.emit( + new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing), + ); } private inputEvent(event: MouseUpEvent) { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bd95e7201..85039015d 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -136,6 +136,13 @@ export class TickMetricsEvent implements GameEvent { ) {} } +export class BacklogStatusEvent implements GameEvent { + constructor( + public readonly backlogTurns: number, + public readonly backlogGrowing: boolean, + ) {} +} + export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd..97e4ad909 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 = 20; + 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 From e74dbe64fa017137d1008b686043fb9a01f98926 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 25 Nov 2025 19:21:33 +0100 Subject: [PATCH 12/27] Add performance metrics for worker and render ticks - Introduced new metrics in ClientGameRunner to track worker simulation ticks and render tick calls per second. - Updated TickMetricsEvent to include these new metrics. - Enhanced PerformanceOverlay to display worker and render ticks per second, improving performance monitoring capabilities. - Adjusted minimum FPS in GameRenderer --- src/client/ClientGameRunner.ts | 24 +++++++++++++++++ src/client/InputHandler.ts | 6 ++++- src/client/graphics/GameRenderer.ts | 2 +- .../graphics/layers/PerformanceOverlay.ts | 26 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 4d70a3f3e..2e6a4f108 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -215,6 +215,9 @@ export class ClientGameRunner { 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; @@ -479,6 +482,7 @@ export class ClientGameRunner { while (this.pendingStart < this.pendingUpdates.length) { const gu = this.pendingUpdates[this.pendingStart++]; processedCount++; + this.workerTicksSinceSample++; batch.push(gu); this.transport.turnComplete(); @@ -529,6 +533,24 @@ export class ClientGameRunner { : 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( @@ -536,6 +558,8 @@ export class ClientGameRunner { this.currentTickDelay, this.backlogTurns, ticksPerRender, + workerTicksPerSecond, + renderTicksPerSecond, ), ); diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 85039015d..dbae066ee 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -131,8 +131,12 @@ export class TickMetricsEvent implements GameEvent { public readonly tickDelay?: number, // Number of turns the client is behind the server (if known) public readonly backlogTurns?: number, - // Number of ticks applied since last render + // 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, ) {} } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 97e4ad909..c99a46014 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -358,7 +358,7 @@ export class GameRenderer { if (this.backlogTurns > 0) { const BASE_FPS = 60; - const MIN_FPS = 20; + const MIN_FPS = 10; const BACKLOG_MAX_TURNS = 50; const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS); diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 531f19f0b..64499024f 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -234,6 +234,8 @@ export class PerformanceOverlay extends LitElement implements Layer { event.tickDelay, event.backlogTurns, event.ticksPerRender, + event.workerTicksPerSecond, + event.renderTicksPerSecond, ); }); } @@ -429,11 +431,19 @@ export class PerformanceOverlay extends LitElement implements Layer { @state() private ticksPerRender: number = 0; + @state() + private workerTicksPerSecond: number = 0; + + @state() + private renderTicksPerSecond: number = 0; + updateTickMetrics( tickExecutionDuration?: number, tickDelay?: number, backlogTurns?: number, ticksPerRender?: number, + workerTicksPerSecond?: number, + renderTicksPerSecond?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -479,6 +489,14 @@ export class PerformanceOverlay extends LitElement implements Layer { this.ticksPerRender = ticksPerRender; } + if (workerTicksPerSecond !== undefined) { + this.workerTicksPerSecond = workerTicksPerSecond; + } + + if (renderTicksPerSecond !== undefined) { + this.renderTicksPerSecond = renderTicksPerSecond; + } + this.requestUpdate(); } @@ -624,6 +642,14 @@ 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} From fa6d445f465d1e729e928de75c3b72f54f856a88 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 25 Nov 2025 21:22:13 +0100 Subject: [PATCH 13/27] SAB+Atomics refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added src/core/worker/SharedTileRing.ts, which defines a SharedArrayBuffer-backed ring buffer (SharedTileRingBuffers/SharedTileRingViews) and helpers pushTileUpdate (worker-side writer) and drainTileUpdates (main-thread reader) using Atomics. Extended GameRunner (src/core/GameRunner.ts) with an optional tileUpdateSink?: (update: bigint) => void; when provided, tile updates are sent to the sink instead of being packed into GameUpdateViewData.packedTileUpdates (those become an empty BigUint64Array in this mode). Extended the worker protocol (src/core/worker/WorkerMessages.ts) so the init message can optionally carry sharedTileRingHeader and sharedTileRingData (the two SABs for the ring). Updated WorkerClient (src/core/worker/WorkerClient.ts) to accept optional SharedTileRingBuffers in its constructor and, during initialize(), include those SABs in the init message sent to Worker.worker.ts. Updated the worker implementation (src/core/worker/Worker.worker.ts) to build sharedTileRing views from the SABs on "init" and pass a (update: bigint) => pushTileUpdate(...) sink into createGameRunner, so every tile update from the simulation goes into the shared ring instead of the message payload. Updated the client side (src/client/ClientGameRunner.ts): In createClientGame, when SharedArrayBuffer/Atomics are available and crossOriginIsolated is true, it allocates the ring (createSharedTileRingBuffers with capacity 262144), creates views, passes buffers into WorkerClient, and keeps the views in ClientGameRunner. ClientGameRunner now has a tileRingViews field and, in mergeGameUpdates, if the ring is present it ignores gu.packedTileUpdates and instead drains up to MAX_TILE_UPDATES_PER_RENDER (100 000) tile updates from the SAB ring into combinedPackedTileUpdates before constructing the merged GameUpdateViewData passed to GameView.update. If SABs are unavailable, it falls back to the original behaviour (packing gu.packedTileUpdates from messages), so the feature is opt-in and backward compatible. Net effect: when the page is cross-origin isolated, the worker no longer sends per-tick BigUint64Array tile payloads via postMessage; it streams tile updates into a shared ring buffer, and the main thread pulls and applies them at its own pace while still batching non-tile updates and rendering exactly as before. If you’d like, I can next run the existing tests/build in your environment to sanity-check everything. --- src/client/ClientGameRunner.ts | 50 +++++++++++++++++-- src/core/GameRunner.ts | 20 ++++++-- src/core/worker/SharedTileRing.ts | 79 +++++++++++++++++++++++++++++++ src/core/worker/Worker.worker.ts | 18 +++++++ src/core/worker/WorkerClient.ts | 4 ++ src/core/worker/WorkerMessages.ts | 2 + 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/core/worker/SharedTileRing.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2e6a4f108..eb1088c66 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -25,6 +25,13 @@ 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, +} from "../core/worker/SharedTileRing"; import { WorkerClient } from "../core/worker/WorkerClient"; import { AutoUpgradeEvent, @@ -162,9 +169,30 @@ async function createClientGame( mapLoader, ); } + + let sharedTileRingBuffers: SharedTileRingBuffers | undefined; + let sharedTileRingViews: SharedTileRingViews | 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; + + if (canUseSharedBuffers) { + // Capacity is number of tile updates that can be queued. + // This is a compromise between memory usage and backlog tolerance. + const TILE_RING_CAPACITY = 262144; + sharedTileRingBuffers = createSharedTileRingBuffers(TILE_RING_CAPACITY); + sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers); + } + const worker = new WorkerClient( lobbyConfig.gameStartInfo, lobbyConfig.clientID, + sharedTileRingBuffers, ); await worker.initialize(); const gameView = new GameView( @@ -191,6 +219,7 @@ async function createClientGame( transport, worker, gameView, + sharedTileRingViews, ); } @@ -222,6 +251,7 @@ export class ClientGameRunner { private pendingUpdates: GameUpdateViewData[] = []; private pendingStart = 0; private isProcessingUpdates = false; + private tileRingViews: SharedTileRingViews | null; constructor( private lobby: LobbyConfig, @@ -231,8 +261,10 @@ export class ClientGameRunner { private transport: Transport, private worker: WorkerClient, private gameView: GameView, + tileRingViews: SharedTileRingViews | null, ) { this.lastMessageTime = Date.now(); + this.tileRingViews = tileRingViews; } private saveGame(update: WinUpdate) { @@ -603,9 +635,21 @@ export class ClientGameRunner { const updatesForType = gu.updates[type] as unknown as any[]; (combinedUpdates[type] as unknown as any[]).push(...updatesForType); } - gu.packedTileUpdates.forEach((tu) => { - combinedPackedTileUpdates.push(tu); - }); + } + + if (this.tileRingViews) { + const MAX_TILE_UPDATES_PER_RENDER = 100000; + drainTileUpdates( + this.tileRingViews, + MAX_TILE_UPDATES_PER_RENDER, + combinedPackedTileUpdates, + ); + } else { + for (const gu of batch) { + gu.packedTileUpdates.forEach((tu) => { + combinedPackedTileUpdates.push(tu); + }); + } } return { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 514ed2758..34577f759 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -37,6 +37,7 @@ export async function createGameRunner( clientID: ClientID, mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + tileUpdateSink?: (update: bigint) => void, ): Promise { const config = await getConfig(gameStart.config, null); const gameMap = await loadGameMap( @@ -85,6 +86,7 @@ export async function createGameRunner( game, new Executor(game, gameStart.gameID, clientID), callBack, + tileUpdateSink, ); gr.init(); return gr; @@ -101,6 +103,7 @@ export class GameRunner { public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + private tileUpdateSink?: (update: bigint) => void, ) {} init() { @@ -175,13 +178,24 @@ 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) { + this.tileUpdateSink(u.update); + } + 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, diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts new file mode 100644 index 000000000..0d8d0c331 --- /dev/null +++ b/src/core/worker/SharedTileRing.ts @@ -0,0 +1,79 @@ +export interface SharedTileRingBuffers { + header: SharedArrayBuffer; + data: SharedArrayBuffer; +} + +export interface SharedTileRingViews { + header: Int32Array; + buffer: BigUint64Array; + 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, +): SharedTileRingBuffers { + const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT); + const data = new SharedArrayBuffer( + capacity * BigUint64Array.BYTES_PER_ELEMENT, + ); + return { header, data }; +} + +export function createSharedTileRingViews( + buffers: SharedTileRingBuffers, +): SharedTileRingViews { + const header = new Int32Array(buffers.header); + const buffer = new BigUint64Array(buffers.data); + return { + header, + buffer, + capacity: buffer.length, + }; +} + +export function pushTileUpdate( + views: SharedTileRingViews, + value: bigint, +): 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: bigint[], +): 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 a6bb92510..3c1164849 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -2,6 +2,11 @@ import version from "../../../resources/version.txt"; import { createGameRunner, GameRunner } from "../GameRunner"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; +import { + createSharedTileRingViews, + pushTileUpdate, + SharedTileRingViews, +} from "./SharedTileRing"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -17,6 +22,7 @@ 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; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -62,11 +68,23 @@ ctx.addEventListener("message", async (e: MessageEvent) => { switch (message.type) { case "init": try { + if (message.sharedTileRingHeader && message.sharedTileRingData) { + sharedTileRing = createSharedTileRingViews({ + header: message.sharedTileRingHeader, + data: message.sharedTileRingData, + }); + } else { + sharedTileRing = null; + } + gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, + sharedTileRing + ? (update: bigint) => pushTileUpdate(sharedTileRing!, update) + : undefined, ).then((gr) => { sendMessage({ type: "initialized", diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 4edc97dee..6df22a933 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,7 @@ export class WorkerClient { constructor( private gameStartInfo: GameStartInfo, private clientID: ClientID, + private sharedTileRingBuffers?: SharedTileRingBuffers, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -70,6 +72,8 @@ export class WorkerClient { id: messageId, gameStartInfo: this.gameStartInfo, clientID: this.clientID, + sharedTileRingHeader: this.sharedTileRingBuffers?.header, + sharedTileRingData: this.sharedTileRingBuffers?.data, }); // Add timeout for initialization diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 0c5344da1..23a5ead5d 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -35,6 +35,8 @@ export interface InitMessage extends BaseWorkerMessage { type: "init"; gameStartInfo: GameStartInfo; clientID: ClientID; + sharedTileRingHeader?: SharedArrayBuffer; + sharedTileRingData?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage { From 15531806faad1fd7318f155ab7325e37ec5b552c Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:45:06 +0100 Subject: [PATCH 14/27] Use SharedArrayBuffer tile state and ring buffer for worker updates - Share GameMapImpl tile state between worker and main via SharedArrayBuffer - Add SAB-backed tile update ring buffer to stream tile changes instead of postMessage payloads - Wire shared state/ring through WorkerClient, Worker.worker, GameRunner, and ClientGameRunner - Update GameView to skip updateTile when shared state is enabled and consume tile refs from the ring --- src/client/ClientGameRunner.ts | 7 ++++++ src/core/GameRunner.ts | 2 ++ src/core/game/GameMap.ts | 13 +++++++++- src/core/game/GameView.ts | 16 +++++++++--- src/core/game/TerrainMapLoader.ts | 42 +++++++++++++++++++++++++++---- src/core/worker/Worker.worker.ts | 1 + src/core/worker/WorkerClient.ts | 2 ++ src/core/worker/WorkerMessages.ts | 1 + 8 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index eb1088c66..5b2e0960e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -180,6 +180,11 @@ async function createClientGame( typeof SharedArrayBuffer !== "undefined" && typeof Atomics !== "undefined" && isIsolated; + const sharedStateBuffer = + canUseSharedBuffers && gameMap.sharedStateBuffer + ? gameMap.sharedStateBuffer + : undefined; + const usesSharedTileState = !!sharedStateBuffer; if (canUseSharedBuffers) { // Capacity is number of tile updates that can be queued. @@ -193,6 +198,7 @@ async function createClientGame( lobbyConfig.gameStartInfo, lobbyConfig.clientID, sharedTileRingBuffers, + sharedStateBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -202,6 +208,7 @@ async function createClientGame( lobbyConfig.clientID, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, + usesSharedTileState, ); const canvas = createCanvas(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 34577f759..d6840e806 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -38,12 +38,14 @@ export async function createGameRunner( mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, tileUpdateSink?: (update: bigint) => 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)); diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 7a3bd8e6d..41368abac 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 471555751..6f306e6c7 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 >> 16n); + 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 e11dd7131..9b39f1137 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -6,6 +6,7 @@ export type TerrainMapData = { nations: Nation[]; gameMap: GameMap; miniGameMap: GameMap; + sharedStateBuffer?: SharedArrayBuffer; }; const loadedMaps = new Map(); @@ -35,15 +36,37 @@ 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; + if (useCache) { + const cached = loadedMaps.get(map); + if (cached !== undefined) return cached; + } const mapFiles = terrainMapFileLoader.getMapData(map); const manifest = await mapFiles.manifest(); + const stateBuffer = + sharedStateBuffer ?? + (typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + // crossOriginIsolated is only defined in browser contexts + typeof (globalThis as any).crossOriginIsolated === "boolean" && + (globalThis as any).crossOriginIsolated === true + ? 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 +86,26 @@ 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, }; - loadedMaps.set(map, result); + if (useCache) { + 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 +118,6 @@ export async function genTerrainFromBin( mapData.height, data, mapData.num_land_tiles, + stateBuffer, ); } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 3c1164849..104ebcab2 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -85,6 +85,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { sharedTileRing ? (update: bigint) => pushTileUpdate(sharedTileRing!, update) : undefined, + message.sharedStateBuffer, ).then((gr) => { sendMessage({ type: "initialized", diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 6df22a933..4fd110732 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -24,6 +24,7 @@ export class WorkerClient { private gameStartInfo: GameStartInfo, private clientID: ClientID, private sharedTileRingBuffers?: SharedTileRingBuffers, + private sharedStateBuffer?: SharedArrayBuffer, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -74,6 +75,7 @@ export class WorkerClient { clientID: this.clientID, sharedTileRingHeader: this.sharedTileRingBuffers?.header, sharedTileRingData: this.sharedTileRingBuffers?.data, + sharedStateBuffer: this.sharedStateBuffer, }); // Add timeout for initialization diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 23a5ead5d..8dbb45f33 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -37,6 +37,7 @@ export interface InitMessage extends BaseWorkerMessage { clientID: ClientID; sharedTileRingHeader?: SharedArrayBuffer; sharedTileRingData?: SharedArrayBuffer; + sharedStateBuffer?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage { From 1f655618ffe39474d06f135366a75f135aea2958 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:36:11 +0100 Subject: [PATCH 15/27] Change the ring buffer to Uint32Array Store only TileRef instead of packed tile+state values --- src/client/ClientGameRunner.ts | 6 +++++- src/core/GameRunner.ts | 7 ++++--- src/core/game/GameView.ts | 2 +- src/core/worker/SharedTileRing.ts | 14 +++++++------- src/core/worker/Worker.worker.ts | 3 ++- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5b2e0960e..57dd751c6 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -646,11 +646,15 @@ export class ClientGameRunner { if (this.tileRingViews) { const MAX_TILE_UPDATES_PER_RENDER = 100000; + const tileRefs: TileRef[] = []; drainTileUpdates( this.tileRingViews, MAX_TILE_UPDATES_PER_RENDER, - combinedPackedTileUpdates, + tileRefs, ); + for (const ref of tileRefs) { + combinedPackedTileUpdates.push(BigInt(ref)); + } } else { for (const gu of batch) { gu.packedTileUpdates.forEach((tu) => { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index d6840e806..552a15a9e 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -37,7 +37,7 @@ export async function createGameRunner( clientID: ClientID, mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, - tileUpdateSink?: (update: bigint) => void, + tileUpdateSink?: (tile: TileRef) => void, sharedStateBuffer?: SharedArrayBuffer, ): Promise { const config = await getConfig(gameStart.config, null); @@ -105,7 +105,7 @@ export class GameRunner { public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, - private tileUpdateSink?: (update: bigint) => void, + private tileUpdateSink?: (tile: TileRef) => void, ) {} init() { @@ -186,7 +186,8 @@ export class GameRunner { const tileUpdates = updates[GameUpdateType.Tile]; if (this.tileUpdateSink !== undefined) { for (const u of tileUpdates) { - this.tileUpdateSink(u.update); + const tileRef = Number(u.update >> 16n) as TileRef; + this.tileUpdateSink(tileRef); } packedTileUpdates = new BigUint64Array(); } else { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 6f306e6c7..a6188b532 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -515,7 +515,7 @@ export class GameView implements GameMap { this.updatedTiles = []; if (this.usesSharedTileState) { this.lastUpdate.packedTileUpdates.forEach((tu) => { - const tileRef = Number(tu >> 16n); + const tileRef = Number(tu); this.updatedTiles.push(tileRef); }); } else { diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts index 0d8d0c331..4ddf9403d 100644 --- a/src/core/worker/SharedTileRing.ts +++ b/src/core/worker/SharedTileRing.ts @@ -1,3 +1,5 @@ +import { TileRef } from "../game/GameMap"; + export interface SharedTileRingBuffers { header: SharedArrayBuffer; data: SharedArrayBuffer; @@ -5,7 +7,7 @@ export interface SharedTileRingBuffers { export interface SharedTileRingViews { header: Int32Array; - buffer: BigUint64Array; + buffer: Uint32Array; capacity: number; } @@ -18,9 +20,7 @@ export function createSharedTileRingBuffers( capacity: number, ): SharedTileRingBuffers { const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT); - const data = new SharedArrayBuffer( - capacity * BigUint64Array.BYTES_PER_ELEMENT, - ); + const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT); return { header, data }; } @@ -28,7 +28,7 @@ export function createSharedTileRingViews( buffers: SharedTileRingBuffers, ): SharedTileRingViews { const header = new Int32Array(buffers.header); - const buffer = new BigUint64Array(buffers.data); + const buffer = new Uint32Array(buffers.data); return { header, buffer, @@ -38,7 +38,7 @@ export function createSharedTileRingViews( export function pushTileUpdate( views: SharedTileRingViews, - value: bigint, + value: TileRef, ): void { const { header, buffer, capacity } = views; @@ -60,7 +60,7 @@ export function pushTileUpdate( export function drainTileUpdates( views: SharedTileRingViews, maxItems: number, - out: bigint[], + out: TileRef[], ): void { const { header, buffer, capacity } = views; diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 104ebcab2..586f77e66 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,6 +1,7 @@ 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, @@ -83,7 +84,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { mapLoader, gameUpdate, sharedTileRing - ? (update: bigint) => pushTileUpdate(sharedTileRing!, update) + ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) : undefined, message.sharedStateBuffer, ).then((gr) => { From e4178d3dc6e96e400bb899af8cffa45e183c4e34 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:46:05 +0100 Subject: [PATCH 16/27] add more stats to perf overlay --- src/client/ClientGameRunner.ts | 65 ++++++++++++++--- src/client/InputHandler.ts | 5 ++ .../graphics/layers/PerformanceOverlay.ts | 73 +++++++++++++++++++ 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 57dd751c6..da4cd22e6 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -31,6 +31,7 @@ import { drainTileUpdates, SharedTileRingBuffers, SharedTileRingViews, + TILE_RING_HEADER_OVERFLOW, } from "../core/worker/SharedTileRing"; import { WorkerClient } from "../core/worker/WorkerClient"; import { @@ -561,7 +562,8 @@ export class ClientGameRunner { batch.length > 0 && lastTick !== undefined ) { - const combinedGu = this.mergeGameUpdates(batch); + const { gameUpdate: combinedGu, tileMetrics } = + this.mergeGameUpdates(batch); if (combinedGu) { this.gameView.update(combinedGu); } @@ -599,6 +601,10 @@ export class ClientGameRunner { ticksPerRender, workerTicksPerSecond, renderTicksPerSecond, + tileMetrics.count, + tileMetrics.utilization, + tileMetrics.overflow, + tileMetrics.drainTime, ), ); @@ -616,9 +622,15 @@ export class ClientGameRunner { requestAnimationFrame(processFrame); } - private mergeGameUpdates( - batch: GameUpdateViewData[], - ): GameUpdateViewData | null { + private mergeGameUpdates(batch: GameUpdateViewData[]): { + gameUpdate: GameUpdateViewData | null; + tileMetrics: { + count: number; + utilization: number; + overflow: number; + drainTime: number; + }; + } { if (batch.length === 0) { return null; } @@ -644,31 +656,60 @@ export class ClientGameRunner { } } + 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; + + // Calculate ring buffer utilization and overflow + const TILE_RING_CAPACITY = 262144; + const utilization = (tileRefs.length / TILE_RING_CAPACITY) * 100; + const overflow = Atomics.load( + this.tileRingViews.header, + TILE_RING_HEADER_OVERFLOW, + ); + + tileMetrics = { + count: tileRefs.length, + utilization, + overflow, + drainTime, + }; + for (const ref of tileRefs) { combinedPackedTileUpdates.push(BigInt(ref)); } } else { + // Non-SAB mode: count tile updates from batch + let totalTileUpdates = 0; for (const gu of batch) { - gu.packedTileUpdates.forEach((tu) => { - combinedPackedTileUpdates.push(tu); - }); + totalTileUpdates += gu.packedTileUpdates.length; } + tileMetrics.count = totalTileUpdates; } return { - tick: last.tick, - updates: combinedUpdates, - packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates), - playerNameViewData: last.playerNameViewData, - tickExecutionDuration: last.tickExecutionDuration, + gameUpdate: { + tick: last.tick, + updates: combinedUpdates, + packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates), + playerNameViewData: last.playerNameViewData, + tickExecutionDuration: last.tickExecutionDuration, + }, + tileMetrics, }; } diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index dbae066ee..bf2510e4d 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -137,6 +137,11 @@ export class TickMetricsEvent implements GameEvent { 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, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 64499024f..6c1de8cd7 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -319,6 +319,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(); }; @@ -437,6 +445,24 @@ export class PerformanceOverlay extends LitElement implements Layer { @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, @@ -444,6 +470,10 @@ export class PerformanceOverlay extends LitElement implements Layer { ticksPerRender?: number, workerTicksPerSecond?: number, renderTicksPerSecond?: number, + tileUpdatesCount?: number, + ringBufferUtilization?: number, + ringBufferOverflows?: number, + ringDrainTime?: number, ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -497,6 +527,26 @@ export class PerformanceOverlay extends LitElement implements Layer { 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) { + // Accumulate overflows (overflows is a flag, so add 1 if set) + this.ringBufferOverflows += ringBufferOverflows; + } + + if (ringDrainTime !== undefined) { + this.ringDrainTime = Math.round(ringDrainTime * 100) / 100; + } + this.requestUpdate(); } @@ -527,6 +577,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 })), }; } @@ -658,6 +716,21 @@ export class PerformanceOverlay extends LitElement implements Layer { 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`
From 43774615f0f7db5b5b79f1ef69b224c20912b79b Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:57:52 +0100 Subject: [PATCH 17/27] mergeGameUpdates fix batch.length === 0 return case --- src/client/ClientGameRunner.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index da4cd22e6..83c4e5f40 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -632,7 +632,15 @@ export class ClientGameRunner { }; } { if (batch.length === 0) { - return null; + return { + gameUpdate: null, + tileMetrics: { + count: 0, + utilization: 0, + overflow: 0, + drainTime: 0, + }, + }; } const last = batch[batch.length - 1]; From 8d72632f79449e32607011b111881c20f070b0fc Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:22:34 +0100 Subject: [PATCH 18/27] fix sab detection --- src/core/game/TerrainMapLoader.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 9b39f1137..2f6757630 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -39,7 +39,16 @@ export async function loadTerrainMap( sharedStateBuffer?: SharedArrayBuffer, ): Promise { const useCache = sharedStateBuffer === undefined; - if (useCache) { + 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; } @@ -96,9 +105,8 @@ export async function loadTerrainMap( ? stateBuffer : undefined, }; - if (useCache) { - loadedMaps.set(map, result); - } + // Always cache the result, but only use cache when appropriate + loadedMaps.set(map, result); return result; } From 40e7394e58d934921c5ad59ca69b16b363827972 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:56:34 +0100 Subject: [PATCH 19/27] fix performance overlay --- src/client/graphics/layers/PerformanceOverlay.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 6c1de8cd7..b63310f10 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -236,6 +236,10 @@ export class PerformanceOverlay extends LitElement implements Layer { event.ticksPerRender, event.workerTicksPerSecond, event.renderTicksPerSecond, + event.tileUpdatesCount, + event.ringBufferUtilization, + event.ringBufferOverflows, + event.ringDrainTime, ); }); } From 45971d3c36f478aa630da0cc53f9a0bebd7b6d8a Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:41:27 +0100 Subject: [PATCH 20/27] dedup tileRef for tileUpdateSink(tileRef) --- src/core/GameRunner.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 552a15a9e..2fca55402 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -185,9 +185,13 @@ export class GameRunner { let packedTileUpdates: BigUint64Array; const tileUpdates = updates[GameUpdateType.Tile]; if (this.tileUpdateSink !== undefined) { + const seenTiles = new Set(); for (const u of tileUpdates) { const tileRef = Number(u.update >> 16n) as TileRef; - this.tileUpdateSink(tileRef); + if (!seenTiles.has(tileRef)) { + seenTiles.add(tileRef); + this.tileUpdateSink(tileRef); + } } packedTileUpdates = new BigUint64Array(); } else { From 02db840d3c1b716dc8c0f871e79c05628ed884b8 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:12:24 +0100 Subject: [PATCH 21/27] Revert "dedup tileRef for tileUpdateSink(tileRef)" This reverts commit 08a2ff906b3ca833cc3babb026432fdf4fe4ce53. --- src/core/GameRunner.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 2fca55402..552a15a9e 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -185,13 +185,9 @@ export class GameRunner { let packedTileUpdates: BigUint64Array; const tileUpdates = updates[GameUpdateType.Tile]; if (this.tileUpdateSink !== undefined) { - const seenTiles = new Set(); for (const u of tileUpdates) { const tileRef = Number(u.update >> 16n) as TileRef; - if (!seenTiles.has(tileRef)) { - seenTiles.add(tileRef); - this.tileUpdateSink(tileRef); - } + this.tileUpdateSink(tileRef); } packedTileUpdates = new BigUint64Array(); } else { From 5a8c0b636d193087b4257669fb451c802e8fa3a8 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:31:36 +0100 Subject: [PATCH 22/27] Use dirty flags to coalesce tile updates in SAB ring - Extend SharedTileRing to include a shared dirtyFlags buffer alongside header and data - Pass shared dirty buffer through WorkerClient/WorkerMessages and initialize views in Worker.worker - In SAB mode, mark tiles dirty via Atomics.compareExchange before enqueuing to ensure each tile is queued at most once until processed - On the main thread, clear dirty flags when draining the ring and build packedTileUpdates from distinct tile refs - Keep non-SAB behaviour unchanged while reducing ring pressure and making overflows reflect true backlog, not duplicate updates --- src/client/ClientGameRunner.ts | 30 ++++++++++++++++++++++++++---- src/core/game/TerrainMapLoader.ts | 2 ++ src/core/worker/SharedTileRing.ts | 8 +++++++- src/core/worker/Worker.worker.ts | 23 ++++++++++++++++++++--- src/core/worker/WorkerClient.ts | 2 ++ src/core/worker/WorkerMessages.ts | 1 + 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 83c4e5f40..d5b535500 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -173,6 +173,8 @@ async function createClientGame( 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 @@ -191,8 +193,14 @@ async function createClientGame( // Capacity is number of tile updates that can be queued. // This is a compromise between memory usage and backlog tolerance. const TILE_RING_CAPACITY = 262144; - sharedTileRingBuffers = createSharedTileRingBuffers(TILE_RING_CAPACITY); + const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height(); + sharedTileRingBuffers = createSharedTileRingBuffers( + TILE_RING_CAPACITY, + numTiles, + ); sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers); + sharedDirtyBuffer = sharedTileRingBuffers.dirty; + sharedDirtyFlags = sharedTileRingViews.dirtyFlags; } const worker = new WorkerClient( @@ -200,6 +208,7 @@ async function createClientGame( lobbyConfig.clientID, sharedTileRingBuffers, sharedStateBuffer, + sharedDirtyBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -228,6 +237,7 @@ async function createClientGame( worker, gameView, sharedTileRingViews, + sharedDirtyFlags, ); } @@ -260,6 +270,7 @@ export class ClientGameRunner { private pendingStart = 0; private isProcessingUpdates = false; private tileRingViews: SharedTileRingViews | null; + private dirtyFlags: Uint8Array | null; constructor( private lobby: LobbyConfig, @@ -270,9 +281,11 @@ export class ClientGameRunner { 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) { @@ -682,22 +695,31 @@ export class ClientGameRunner { ); 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 const TILE_RING_CAPACITY = 262144; - const utilization = (tileRefs.length / TILE_RING_CAPACITY) * 100; + const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100; const overflow = Atomics.load( this.tileRingViews.header, TILE_RING_HEADER_OVERFLOW, ); tileMetrics = { - count: tileRefs.length, + count: uniqueTiles.size, utilization, overflow, drainTime, }; - for (const ref of tileRefs) { + for (const ref of uniqueTiles) { + if (this.dirtyFlags) { + Atomics.store(this.dirtyFlags, ref, 0); + } combinedPackedTileUpdates.push(BigInt(ref)); } } else { diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 2f6757630..3ea90cbf2 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -7,6 +7,7 @@ export type TerrainMapData = { gameMap: GameMap; miniGameMap: GameMap; sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; }; const loadedMaps = new Map(); @@ -104,6 +105,7 @@ export async function loadTerrainMap( stateBuffer instanceof SharedArrayBuffer ? stateBuffer : undefined, + sharedDirtyBuffer: undefined, // populated by consumer when needed }; // Always cache the result, but only use cache when appropriate loadedMaps.set(map, result); diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts index 4ddf9403d..328def730 100644 --- a/src/core/worker/SharedTileRing.ts +++ b/src/core/worker/SharedTileRing.ts @@ -3,11 +3,13 @@ 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; } @@ -18,10 +20,12 @@ 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); - return { header, data }; + const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT); + return { header, data, dirty }; } export function createSharedTileRingViews( @@ -29,9 +33,11 @@ export function createSharedTileRingViews( ): 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, }; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 586f77e66..3bdd356ca 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -24,6 +24,7 @@ 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 @@ -73,19 +74,35 @@ ctx.addEventListener("message", async (e: MessageEvent) => { sharedTileRing = createSharedTileRingViews({ header: message.sharedTileRingHeader, data: message.sharedTileRingData, + dirty: message.sharedDirtyBuffer!, }); + dirtyFlags = sharedTileRing.dirtyFlags; } else { sharedTileRing = null; + dirtyFlags = null; } + console.log("[Worker.worker] init", { + hasSharedStateBuffer: !!message.sharedStateBuffer, + hasRingHeader: !!message.sharedTileRingHeader, + hasRingData: !!message.sharedTileRingData, + hasDirtyBuffer: !!message.sharedDirtyBuffer, + }); + gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, - sharedTileRing - ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) - : undefined, + 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({ diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 4fd110732..1d824546d 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -25,6 +25,7 @@ export class WorkerClient { 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(); @@ -76,6 +77,7 @@ export class WorkerClient { sharedTileRingHeader: this.sharedTileRingBuffers?.header, sharedTileRingData: this.sharedTileRingBuffers?.data, sharedStateBuffer: this.sharedStateBuffer, + sharedDirtyBuffer: this.sharedDirtyBuffer, }); // Add timeout for initialization diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 8dbb45f33..c6b811418 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -38,6 +38,7 @@ export interface InitMessage extends BaseWorkerMessage { sharedTileRingHeader?: SharedArrayBuffer; sharedTileRingData?: SharedArrayBuffer; sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage { From 75a9465f5a1cd0552b8090d3f7c87501030c7308 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:41:47 +0100 Subject: [PATCH 23/27] Size SAB ring buffer by world tile count --- src/client/ClientGameRunner.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index d5b535500..09f1d21e7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -190,10 +190,9 @@ async function createClientGame( const usesSharedTileState = !!sharedStateBuffer; if (canUseSharedBuffers) { - // Capacity is number of tile updates that can be queued. - // This is a compromise between memory usage and backlog tolerance. - const TILE_RING_CAPACITY = 262144; 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, @@ -701,8 +700,8 @@ export class ClientGameRunner { uniqueTiles.add(ref); } - // Calculate ring buffer utilization and overflow - const TILE_RING_CAPACITY = 262144; + // 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, From c1bc4a7d1cf3af61d62dbe7126d76b3b2f671484 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:57:36 +0100 Subject: [PATCH 24/27] removed console.log --- src/core/worker/Worker.worker.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 3bdd356ca..aae5a69ef 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -82,13 +82,6 @@ ctx.addEventListener("message", async (e: MessageEvent) => { dirtyFlags = null; } - console.log("[Worker.worker] init", { - hasSharedStateBuffer: !!message.sharedStateBuffer, - hasRingHeader: !!message.sharedTileRingHeader, - hasRingData: !!message.sharedTileRingData, - hasDirtyBuffer: !!message.sharedDirtyBuffer, - }); - gameRunner = createGameRunner( message.gameStartInfo, message.clientID, From 81b0d36c38bfc3727e98734913eaeabce521ec97 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:53:49 +0100 Subject: [PATCH 25/27] disable TerrainMapData cache for SAB path --- src/core/game/TerrainMapLoader.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 3ea90cbf2..e3f0a0635 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -107,8 +107,10 @@ export async function loadTerrainMap( : undefined, sharedDirtyBuffer: undefined, // populated by consumer when needed }; - // Always cache the result, but only use cache when appropriate - loadedMaps.set(map, result); + // Only cache the result when caching is actually used (non-SAB path) + if (shouldUseCache) { + loadedMaps.set(map, result); + } return result; } From 7e2784eee2e5a8972a4910446663f3ff8ac0bacb Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:55:37 +0100 Subject: [PATCH 26/27] refactored loadTerrainMap to reuse the existing canUseSharedBuffers --- src/core/game/TerrainMapLoader.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index e3f0a0635..3f7e52774 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -58,11 +58,7 @@ export async function loadTerrainMap( const stateBuffer = sharedStateBuffer ?? - (typeof SharedArrayBuffer !== "undefined" && - typeof Atomics !== "undefined" && - // crossOriginIsolated is only defined in browser contexts - typeof (globalThis as any).crossOriginIsolated === "boolean" && - (globalThis as any).crossOriginIsolated === true + (canUseSharedBuffers ? new SharedArrayBuffer( manifest.map.width * manifest.map.height * From 1e3d2e10f3f3fc98b2a1c3b25054d2486ccb1da7 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:02:12 +0100 Subject: [PATCH 27/27] overflows field now acts as a bool --- src/client/graphics/layers/PerformanceOverlay.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index b63310f10..6c53bb5b0 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -542,9 +542,9 @@ export class PerformanceOverlay extends LitElement implements Layer { Math.round(ringBufferUtilization * 100) / 100; } - if (ringBufferOverflows !== undefined) { - // Accumulate overflows (overflows is a flag, so add 1 if set) - this.ringBufferOverflows += ringBufferOverflows; + if (ringBufferOverflows !== undefined && ringBufferOverflows !== 0) { + // Remember that an overflow has occurred at least once this run. + this.ringBufferOverflows = 1; } if (ringDrainTime !== undefined) {