Skip to content
Open

Sab #2519

Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e31ac7f
Add client catch-up mode
scamiv Nov 23, 2025
d5f53af
Batch worker updates in client catch-up mode to reduce render cost
scamiv Nov 23, 2025
b6515d4
frameskip
scamiv Nov 23, 2025
dcd5b55
Worker now self-clocks; no heartbeats needed
scamiv Nov 23, 2025
8e0a42c
Clean up previous implementations
scamiv Nov 24, 2025
3cc6243
Implemented time-sliced catch-up on the main thread to keep input res…
scamiv Nov 24, 2025
5c99ef5
Refactor slice budget calculation in ClientGameRunner to improve back…
scamiv Nov 24, 2025
73f47fe
ClientGameRunner: simplify catch-up loop with indexed queue
scamiv Nov 24, 2025
d501d0e
remove redundant logic
scamiv Nov 25, 2025
f0e05c6
add "ticks per render" metric
scamiv Nov 25, 2025
59ff42e
Refactor rendering and throttle based on backlog
scamiv Nov 25, 2025
e74dbe6
Add performance metrics for worker and render ticks
scamiv Nov 25, 2025
fa6d445
SAB+Atomics refactor
scamiv Nov 25, 2025
1553180
Use SharedArrayBuffer tile state and ring buffer for worker updates
scamiv Nov 25, 2025
1f65561
Change the ring buffer to Uint32Array
scamiv Nov 26, 2025
e4178d3
add more stats to perf overlay
scamiv Nov 26, 2025
4377461
mergeGameUpdates fix batch.length === 0 return case
scamiv Nov 26, 2025
8d72632
fix sab detection
scamiv Nov 26, 2025
40e7394
fix performance overlay
scamiv Nov 26, 2025
45971d3
dedup tileRef for tileUpdateSink(tileRef)
scamiv Nov 26, 2025
02db840
Revert "dedup tileRef for tileUpdateSink(tileRef)"
scamiv Nov 26, 2025
5a8c0b6
Use dirty flags to coalesce tile updates in SAB ring
scamiv Nov 26, 2025
75a9465
Size SAB ring buffer by world tile count
scamiv Nov 26, 2025
c1bc4a7
removed console.log
scamiv Nov 26, 2025
81b0d36
disable TerrainMapData cache for SAB path
scamiv Nov 26, 2025
7e2784e
refactored loadTerrainMap to reuse the existing canUseSharedBuffers
scamiv Nov 26, 2025
1e3d2e1
overflows field now acts as a bool
scamiv Nov 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 337 additions & 27 deletions src/client/ClientGameRunner.ts

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/client/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
public readonly tickDelay?: number,
// Number of turns the client is behind the server (if known)
public readonly backlogTurns?: number,
// Number of simulation ticks applied since last render
public readonly ticksPerRender?: number,
// Approximate worker simulation ticks per second
public readonly workerTicksPerSecond?: number,
// Approximate render tick() calls per second
public readonly renderTicksPerSecond?: number,
// Tile update metrics
public readonly tileUpdatesCount?: number,
public readonly ringBufferUtilization?: number,
public readonly ringBufferOverflows?: number,
public readonly ringDrainTime?: number,
Comment on lines +132 to +144
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this data relevant to InputHandler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantically, no, but it does extend TickMetricsEvent which was here before.

Most of this is only for the perf overlay and can be removed after testing.

) {}
}

export class BacklogStatusEvent implements GameEvent {
constructor(
public readonly backlogTurns: number,
public readonly backlogGrowing: boolean,
) {}
Comment on lines +148 to 152
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this belongs in InputHandler either

}

Expand Down
34 changes: 33 additions & 1 deletion src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -344,6 +354,28 @@ export class GameRenderer {
}

renderGame() {
const now = performance.now();

if (this.backlogTurns > 0) {
const BASE_FPS = 60;
const MIN_FPS = 10;
const BACKLOG_MAX_TURNS = 50;

const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS);
const targetFps = BASE_FPS - scale * (BASE_FPS - MIN_FPS);
const minFrameInterval = 1000 / targetFps;

if (this.lastRenderTime !== 0) {
const sinceLast = now - this.lastRenderTime;
if (sinceLast < minFrameInterval) {
requestAnimationFrame(() => this.renderGame());
return;
}
}
}

this.lastRenderTime = now;

FrameProfiler.clear();
const start = performance.now();
// Set background
Expand Down
139 changes: 137 additions & 2 deletions src/client/graphics/layers/PerformanceOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,18 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
this.updateTickMetrics(
event.tickExecutionDuration,
event.tickDelay,
event.backlogTurns,
event.ticksPerRender,
event.workerTicksPerSecond,
event.renderTicksPerSecond,
event.tileUpdatesCount,
event.ringBufferUtilization,
event.ringBufferOverflows,
event.ringDrainTime,
);
});
}

Expand Down Expand Up @@ -312,6 +323,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerStats.clear();
this.layerBreakdown = [];

// reset tile metrics
this.tileUpdatesPerRender = 0;
this.tileUpdatesPeak = 0;
this.ringBufferUtilization = 0;
this.ringBufferOverflows = 0;
this.ringDrainTime = 0;
this.totalTilesUpdated = 0;

this.requestUpdate();
};

Expand Down Expand Up @@ -418,7 +437,48 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerBreakdown = breakdown;
}

updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
@state()
private backlogTurns: number = 0;

@state()
private ticksPerRender: number = 0;

@state()
private workerTicksPerSecond: number = 0;

@state()
private renderTicksPerSecond: number = 0;

@state()
private tileUpdatesPerRender: number = 0;

@state()
private tileUpdatesPeak: number = 0;

@state()
private ringBufferUtilization: number = 0;

@state()
private ringBufferOverflows: number = 0;

@state()
private ringDrainTime: number = 0;

@state()
private totalTilesUpdated: number = 0;

updateTickMetrics(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be an interface, it's too easy to mix up arguments with this many parameters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most of these can/should be removed after evaluation

tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
ticksPerRender?: number,
workerTicksPerSecond?: number,
renderTicksPerSecond?: number,
tileUpdatesCount?: number,
ringBufferUtilization?: number,
ringBufferOverflows?: number,
ringDrainTime?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;

// Update tick execution duration stats
Expand Down Expand Up @@ -455,6 +515,42 @@ export class PerformanceOverlay extends LitElement implements Layer {
}
}

if (backlogTurns !== undefined) {
this.backlogTurns = backlogTurns;
}

if (ticksPerRender !== undefined) {
this.ticksPerRender = ticksPerRender;
}

if (workerTicksPerSecond !== undefined) {
this.workerTicksPerSecond = workerTicksPerSecond;
}

if (renderTicksPerSecond !== undefined) {
this.renderTicksPerSecond = renderTicksPerSecond;
}

if (tileUpdatesCount !== undefined) {
this.tileUpdatesPerRender = tileUpdatesCount;
this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount);
this.totalTilesUpdated += tileUpdatesCount;
}

if (ringBufferUtilization !== undefined) {
this.ringBufferUtilization =
Math.round(ringBufferUtilization * 100) / 100;
}

if (ringBufferOverflows !== undefined) {
// 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();
}

Expand Down Expand Up @@ -485,6 +581,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
tiles: {
updatesPerRender: this.tileUpdatesPerRender,
peakUpdates: this.tileUpdatesPeak,
ringBufferUtilization: this.ringBufferUtilization,
ringBufferOverflows: this.ringBufferOverflows,
ringDrainTimeMs: this.ringDrainTime,
totalTilesUpdated: this.totalTilesUpdated,
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
Expand Down Expand Up @@ -600,6 +704,37 @@ export class PerformanceOverlay extends LitElement implements Layer {
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
<div class="performance-line">
Worker ticks/s:
<span>${this.workerTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Render ticks/s:
<span>${this.renderTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Ticks per render:
<span>${this.ticksPerRender}</span>
</div>
<div class="performance-line">
Backlog turns:
<span>${this.backlogTurns}</span>
</div>
<div class="performance-line">
Tile updates/render:
<span>${this.tileUpdatesPerRender}</span>
(peak: <span>${this.tileUpdatesPeak}</span>)
</div>
<div class="performance-line">
Ring buffer:
<span>${this.ringBufferUtilization}%</span>
(${this.totalTilesUpdated} total, ${this.ringBufferOverflows}
overflows)
</div>
<div class="performance-line">
Ring drain time:
<span>${this.ringDrainTime.toFixed(2)}ms</span>
</div>
Comment on lines +707 to +737
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing translations for new metric labels.

The new performance lines use hardcoded English strings like "Worker ticks/s:", "Backlog turns:", etc. Other labels in this file use translateText() for localization.

Consider adding translation keys:

        <div class="performance-line">
-         Worker ticks/s:
+         ${translateText("performance_overlay.worker_ticks")}
          <span>${this.workerTicksPerSecond.toFixed(1)}</span>
        </div>

This keeps consistency with the rest of the overlay.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/client/graphics/layers/PerformanceOverlay.ts around lines 707 to 737,
several new performance label strings are hardcoded in English (e.g., "Worker
ticks/s:", "Render ticks/s:", "Ticks per render:", "Backlog turns:", "Tile
updates/render:", "Ring buffer:", "Ring drain time:") instead of using the
existing translateText() localization helper; replace each hardcoded label with
a call to translateText() using appropriate translation keys (add new keys to
the i18n resource files if needed), keep any punctuation/formatting the same,
and ensure the translated strings are used consistently with surrounding labels
so the overlay supports localization.

${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
Expand Down
27 changes: 24 additions & 3 deletions src/core/GameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ export async function createGameRunner(
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
tileUpdateSink?: (tile: TileRef) => void,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<GameRunner> {
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));

Expand Down Expand Up @@ -85,6 +88,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
tileUpdateSink,
);
gr.init();
return gr;
Expand All @@ -101,6 +105,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
private tileUpdateSink?: (tile: TileRef) => void,
) {}

init() {
Expand Down Expand Up @@ -175,13 +180,25 @@ export class GameRunner {
});
}

// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
// Many tiles are updated; either publish them via a shared sink or pack
// them into the view data.
let packedTileUpdates: BigUint64Array;
const tileUpdates = updates[GameUpdateType.Tile];
if (this.tileUpdateSink !== undefined) {
for (const u of tileUpdates) {
const tileRef = Number(u.update >> 16n) as TileRef;
this.tileUpdateSink(tileRef);
}
packedTileUpdates = new BigUint64Array();
} else {
const raw = tileUpdates.map((u) => u.update);
packedTileUpdates = new BigUint64Array(raw);
}
updates[GameUpdateType.Tile] = [];

this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
packedTileUpdates,
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
Expand Down Expand Up @@ -272,4 +289,8 @@ export class GameRunner {
}
return player.bestTransportShipSpawn(targetTile);
}

public hasPendingTurns(): boolean {
return this.currTurn < this.turns.length;
}
}
13 changes: 12 additions & 1 deletion src/core/game/GameMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
Loading
Loading