From 11df9d8f46e6d8b4d9b9cca563fa7cbb007e6193 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/28] 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 26a1bc21ef..9a65ead02a 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -223,6 +223,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;
@@ -528,12 +529,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 e18e616e80..bd95e7201c 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 8bc8f4a6a5..531f19f0b3 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 fef47cb2069e66e8256667004e252531352ae4a7 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/28] 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 | 48 ++++++++++++++++-------------
src/client/InputHandler.ts | 7 +++++
src/client/graphics/GameRenderer.ts | 34 +++++++++++++++++++-
3 files changed, 67 insertions(+), 22 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 9a65ead02a..5b8868619a 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,
@@ -521,33 +522,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;
-
- this.renderer.tick();
- this.eventBus.emit(
- new TickMetricsEvent(
- lastTickDuration,
- this.currentTickDelay,
- this.backlogTurns,
- ticksPerRender,
- ),
- );
+ const ticksPerRender =
+ this.lastRenderedTick === 0
+ ? lastTick
+ : lastTick - this.lastRenderedTick;
+ this.lastRenderedTick = lastTick;
- // Reset tick delay for next measurement
- this.currentTickDelay = undefined;
- }
+ this.renderer.tick();
+ this.eventBus.emit(
+ new TickMetricsEvent(
+ lastTickDuration,
+ this.currentTickDelay,
+ this.backlogTurns,
+ ticksPerRender,
+ ),
+ );
+
+ // Reset tick delay for next measurement
+ this.currentTickDelay = undefined;
}
if (this.pendingStart < this.pendingUpdates.length) {
@@ -608,6 +611,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 bd95e7201c..85039015d6 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 50911c8dbc..94ad937853 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?.());
// only append the canvas if it's not already in the document to avoid reparenting side-effects
@@ -348,6 +358,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 a950b76c3f2730046c044a905a9d0f9ccedf44af 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/28] 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 5b8868619a..9b030404a9 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -225,6 +225,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;
@@ -489,6 +492,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();
@@ -539,6 +543,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(
@@ -546,6 +568,8 @@ export class ClientGameRunner {
this.currentTickDelay,
this.backlogTurns,
ticksPerRender,
+ workerTicksPerSecond,
+ renderTicksPerSecond,
),
);
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 85039015d6..dbae066eea 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 94ad937853..d36ce81178 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -362,7 +362,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 531f19f0b3..64499024fa 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 c102cbb0e767b37ef507986b90daa0052d577a9c 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/28] 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 9b030404a9..a1d59fca49 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,
@@ -172,9 +179,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(
@@ -201,6 +229,7 @@ async function createClientGame(
transport,
worker,
gameView,
+ sharedTileRingViews,
);
}
@@ -232,6 +261,7 @@ export class ClientGameRunner {
private pendingUpdates: GameUpdateViewData[] = [];
private pendingStart = 0;
private isProcessingUpdates = false;
+ private tileRingViews: SharedTileRingViews | null;
constructor(
private lobby: LobbyConfig,
@@ -241,8 +271,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) {
@@ -613,9 +645,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 514ed27589..34577f7593 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 0000000000..0d8d0c3310
--- /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 a6bb925103..3c11648497 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 4edc97dee4..6df22a9331 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 0c5344da14..23a5ead5dc 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 854951c567e10808ae00402dc4a6b7e4bf3f5164 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/28] 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 a1d59fca49..20561f1b2c 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -190,6 +190,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.
@@ -203,6 +208,7 @@ async function createClientGame(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
+ sharedStateBuffer,
);
await worker.initialize();
const gameView = new GameView(
@@ -212,6 +218,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 34577f7593..d6840e8064 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 7a3bd8e6d5..41368abac5 100644
--- a/src/core/game/GameMap.ts
+++ b/src/core/game/GameMap.ts
@@ -80,6 +80,7 @@ export class GameMapImpl implements GameMap {
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
+ stateBuffer?: ArrayBufferLike,
) {
if (terrainData.length !== width * height) {
throw new Error(
@@ -89,7 +90,17 @@ export class GameMapImpl implements GameMap {
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
- this.state = new Uint16Array(width * height);
+ if (stateBuffer !== undefined) {
+ const state = new Uint16Array(stateBuffer);
+ if (state.length !== width * height) {
+ throw new Error(
+ `State buffer length ${state.length} doesn't match dimensions ${width}x${height}`,
+ );
+ }
+ this.state = state;
+ } else {
+ this.state = new Uint16Array(width * height);
+ }
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index c525129d10..0046bb367c 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -474,6 +474,7 @@ export class GameView implements GameMap {
private _cosmetics: Map = new Map();
private _map: GameMap;
+ private readonly usesSharedTileState: boolean;
constructor(
public worker: WorkerClient,
@@ -482,8 +483,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(
@@ -512,9 +515,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 e11dd7131b..9b39f11379 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 3c11648497..104ebcab21 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 6df22a9331..4fd110732f 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 23a5ead5dc..8dbb45f333 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 ddca7f165d024fa4dd14ca53b24c95c53dff2260 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/28] 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 20561f1b2c..ce07163f78 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -656,11 +656,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 d6840e8064..552a15a9eb 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 0046bb367c..c1661c7a34 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -517,7 +517,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 0d8d0c3310..4ddf9403d9 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 104ebcab21..586f77e668 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 6865d01a8d503fc51acc0db56fe4dca1b65c093e 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/28] 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 ce07163f78..a97f7699c3 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 {
@@ -571,7 +572,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);
}
@@ -609,6 +611,10 @@ export class ClientGameRunner {
ticksPerRender,
workerTicksPerSecond,
renderTicksPerSecond,
+ tileMetrics.count,
+ tileMetrics.utilization,
+ tileMetrics.overflow,
+ tileMetrics.drainTime,
),
);
@@ -626,9 +632,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;
}
@@ -654,31 +666,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 dbae066eea..bf2510e4df 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 64499024fa..6c1de8cd7d 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 e9c81fa449dd84745ce1ced7ebfdc291506898ba 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/28] 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 a97f7699c3..fdf9426d5c 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -642,7 +642,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 d4df68714c2722ad42cf27b6b02d72dae9bdbcbb 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/28] 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 9b39f11379..2f67576304 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 507933f2e8ce6f553278ea797f2e851d6126bb76 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/28] 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 6c1de8cd7d..b63310f106 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 8f0e8feec3dae307767d073d1e18275c3e588425 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/28] 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 552a15a9eb..2fca554023 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 7d51c4c10c94557a7acd30e4152704f1106ff07c 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/28] 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 2fca554023..552a15a9eb 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 4b0025ea40b8e860b910c729be97c917fe439a8c 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/28] 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 fdf9426d5c..36c4d820ed 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -183,6 +183,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
@@ -201,8 +203,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(
@@ -210,6 +218,7 @@ async function createClientGame(
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
+ sharedDirtyBuffer,
);
await worker.initialize();
const gameView = new GameView(
@@ -238,6 +247,7 @@ async function createClientGame(
worker,
gameView,
sharedTileRingViews,
+ sharedDirtyFlags,
);
}
@@ -270,6 +280,7 @@ export class ClientGameRunner {
private pendingStart = 0;
private isProcessingUpdates = false;
private tileRingViews: SharedTileRingViews | null;
+ private dirtyFlags: Uint8Array | null;
constructor(
private lobby: LobbyConfig,
@@ -280,9 +291,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) {
@@ -692,22 +705,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 2f67576304..3ea90cbf29 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 4ddf9403d9..328def7306 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 586f77e668..3bdd356ca7 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 4fd110732f..1d824546d7 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 8dbb45f333..c6b8114188 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 d5cf22a1fcb42a01c78d42110c0a8bcfc3330b0b 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/28] 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 36c4d820ed..cbf3e994b8 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -200,10 +200,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,
@@ -711,8 +710,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 20d2ea18a09f7862a63909441586e430eef82f91 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/28] 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 3bdd356ca7..aae5a69efd 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 30d2879b7de3c95f2c399719fd2745bb75bd78e2 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/28] 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 3ea90cbf29..e3f0a0635e 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 b150e6fb979b6ed97030e36a948f4ec02951035d 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/28] 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 e3f0a0635e..3f7e527744 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 5fdf152ac7f09a28a90fbdf1a06e1ee825220f8b 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/28] 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 b63310f106..6c53bb5b00 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) {
From 74dbec8c724a16c096fbbaf7bcaab6f9c3dda954 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Mon, 8 Dec 2025 19:12:05 +0100
Subject: [PATCH 28/28] Fix fallback, Merge packed tile updates in non-SAB mode
---
src/client/ClientGameRunner.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index cbf3e994b8..aa2ab08f36 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -732,10 +732,13 @@ export class ClientGameRunner {
combinedPackedTileUpdates.push(BigInt(ref));
}
} else {
- // Non-SAB mode: count tile updates from batch
+ // Non-SAB mode: merge packed tile updates from batch
let totalTileUpdates = 0;
for (const gu of batch) {
totalTileUpdates += gu.packedTileUpdates.length;
+ for (const tu of gu.packedTileUpdates) {
+ combinedPackedTileUpdates.push(tu);
+ }
}
tileMetrics.count = totalTileUpdates;
}