From 6ab7136c3c835b039a97c52df0179ffbe4b41f44 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/13] 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 7cf024fb6eb75ebc4612990b11c91eb0339c1a33 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/13] 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 97e7dfc0f2b0053255f2f571562679f1a3b95c5f 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/13] 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 cb7c94efb8a9c908ca72662d29e042c55b790507 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/13] 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 d6de468fb..dc1b03b51 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(
@@ -81,6 +82,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
+ tileUpdateSink,
);
gr.init();
return gr;
@@ -97,6 +99,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
+ private tileUpdateSink?: (update: bigint) => void,
) {}
init() {
@@ -171,13 +174,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 {