Skip to content
Open

Sab #2519

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5e264a5
Add client catch-up mode
scamiv Nov 23, 2025
a31a6a0
Batch worker updates in client catch-up mode to reduce render cost
scamiv Nov 23, 2025
e3a6e0b
frameskip
scamiv Nov 23, 2025
7e6717c
Worker now self-clocks; no heartbeats needed
scamiv Nov 23, 2025
f710c78
Clean up previous implementations
scamiv Nov 24, 2025
bd30070
Implemented time-sliced catch-up on the main thread to keep input res…
scamiv Nov 24, 2025
a04abbe
Refactor slice budget calculation in ClientGameRunner to improve back…
scamiv Nov 24, 2025
bd6dbd3
ClientGameRunner: simplify catch-up loop with indexed queue
scamiv Nov 24, 2025
0ffe9c3
remove redundant logic
scamiv Nov 25, 2025
2dde262
add "ticks per render" metric
scamiv Nov 25, 2025
391f19d
Refactor rendering and throttle based on backlog
scamiv Nov 25, 2025
2f2a12e
Add performance metrics for worker and render ticks
scamiv Nov 25, 2025
05181d7
SAB+Atomics refactor
scamiv Nov 25, 2025
314d8ef
Use SharedArrayBuffer tile state and ring buffer for worker updates
scamiv Nov 25, 2025
37315e5
Change the ring buffer to Uint32Array
scamiv Nov 26, 2025
0da975f
add more stats to perf overlay
scamiv Nov 26, 2025
19e5046
mergeGameUpdates fix batch.length === 0 return case
scamiv Nov 26, 2025
b312aa3
fix sab detection
scamiv Nov 26, 2025
09afa11
fix performance overlay
scamiv Nov 26, 2025
e387820
dedup tileRef for tileUpdateSink(tileRef)
scamiv Nov 26, 2025
99b01d8
Revert "dedup tileRef for tileUpdateSink(tileRef)"
scamiv Nov 26, 2025
430d856
Use dirty flags to coalesce tile updates in SAB ring
scamiv Nov 26, 2025
fad87a9
Size SAB ring buffer by world tile count
scamiv Nov 26, 2025
9892537
removed console.log
scamiv Nov 26, 2025
2d63dcf
disable TerrainMapData cache for SAB path
scamiv Nov 26, 2025
8bc140e
refactored loadTerrainMap to reuse the existing canUseSharedBuffers
scamiv Nov 26, 2025
a4094de
overflows field now acts as a bool
scamiv Nov 26, 2025
fe927b9
Fix fallback, Merge packed tile updates in non-SAB mode
scamiv Dec 8, 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
367 changes: 340 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?.());

// only append the canvas if it's not already in the document to avoid reparenting side-effects
Expand Down Expand Up @@ -348,6 +358,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 && ringBufferOverflows !== 0) {
// Remember that an overflow has occurred at least once this run.
this.ringBufferOverflows = 1;
}

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

this.requestUpdate();
}

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.

🛠️ Refactor suggestion | 🟠 Major

Use translateText() for new metric labels.

The new performance lines (707-737) use hardcoded English strings like "Worker ticks/s:", "Render ticks/s:", etc. For consistency with the rest of the overlay, use translateText() with keys added to resources/lang/en.json:

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

Add corresponding keys to en.json:

"performance_overlay": {
  "worker_ticks": "Worker ticks/s:",
  "render_ticks": "Render ticks/s:",
  "ticks_per_render": "Ticks per render:",
  "backlog_turns": "Backlog turns:",
  "tile_updates": "Tile updates/render:",
  "ring_buffer": "Ring buffer:",
  "ring_drain_time": "Ring drain time:"
}
🤖 Prompt for AI Agents
In src/client/graphics/layers/PerformanceOverlay.ts around lines 707 to 737 the
new performance lines use hardcoded English labels; replace each literal label
with a call to translateText() using the suggested keys
(performance_overlay.worker_ticks, performance_overlay.render_ticks,
performance_overlay.ticks_per_render, performance_overlay.backlog_turns,
performance_overlay.tile_updates, performance_overlay.ring_buffer,
performance_overlay.ring_drain_time), keeping the existing span/value markup and
punctuation intact. Also add the corresponding entries under
"performance_overlay" in resources/lang/en.json with the provided translations
so the UI uses the localization system.

${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