From d2a950660573199d54bcbc08a4ea5c165c397b0a Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Sun, 23 Nov 2025 14:07:12 +0100
Subject: [PATCH 1/5] Add client catch-up mode
Increase worker heartbeats per frame when far behind server to fast-forward simulation.
Track backlog and expose catch-up status via TickMetricsEvent.
Extend performance overlay to display backlog turns and indicate active catch-up mode.
---
src/client/ClientGameRunner.ts | 62 ++++++++++++++++++-
src/client/InputHandler.ts | 4 ++
.../graphics/layers/PerformanceOverlay.ts | 32 +++++++++-
3 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 575977f9a..c7c95a24b 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -208,6 +208,16 @@ export class ClientGameRunner {
private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
+ // Track how far behind the client simulation is compared to the server.
+ private serverTurnHighWater: number = 0;
+ private lastProcessedTick: number = 0;
+ private backlogTurns: number = 0;
+
+ private catchUpMode: boolean = false;
+ private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up
+ private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
+ private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5;
+
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -299,9 +309,43 @@ export class ClientGameRunner {
this.gameView.update(gu);
this.renderer.tick();
+ // Update tick / backlog metrics
+ if (!("errMsg" in gu)) {
+ this.lastProcessedTick = gu.tick;
+ this.backlogTurns = Math.max(
+ 0,
+ this.serverTurnHighWater - this.lastProcessedTick,
+ );
+
+ const wasCatchUp = this.catchUpMode;
+ if (
+ !this.catchUpMode &&
+ this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG
+ ) {
+ this.catchUpMode = true;
+ } else if (
+ this.catchUpMode &&
+ this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
+ ) {
+ this.catchUpMode = false;
+ }
+ if (wasCatchUp !== this.catchUpMode) {
+ console.log(
+ `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
+ this.backlogTurns
+ } turns)`,
+ );
+ }
+ }
+
// Emit tick metrics event for performance overlay
this.eventBus.emit(
- new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
+ new TickMetricsEvent(
+ gu.tickExecutionDuration,
+ this.currentTickDelay,
+ this.backlogTurns,
+ this.catchUpMode,
+ ),
);
// Reset tick delay for next measurement
@@ -314,7 +358,12 @@ export class ClientGameRunner {
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
- worker.sendHeartbeat();
+ const beatsPerFrame = this.catchUpMode
+ ? this.CATCH_UP_HEARTBEATS_PER_FRAME
+ : 1;
+ for (let i = 0; i < beatsPerFrame; i++) {
+ worker.sendHeartbeat();
+ }
requestAnimationFrame(keepWorkerAlive);
}
};
@@ -363,6 +412,10 @@ export class ClientGameRunner {
}
for (const turn of message.turns) {
+ this.serverTurnHighWater = Math.max(
+ this.serverTurnHighWater,
+ turn.turnNumber,
+ );
if (turn.turnNumber < this.turnsSeen) {
continue;
}
@@ -415,6 +468,11 @@ export class ClientGameRunner {
}
this.lastTickReceiveTime = now;
+ this.serverTurnHighWater = Math.max(
+ this.serverTurnHighWater,
+ message.turn.turnNumber,
+ );
+
if (this.turnsSeen !== message.turn.turnNumber) {
console.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 26d8f6c27..5ecc23915 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -129,6 +129,10 @@ export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
public readonly tickDelay?: number,
+ // Number of turns the client is behind the server (if known)
+ public readonly backlogTurns?: number,
+ // Whether the client is currently in catch-up mode
+ public readonly inCatchUpMode?: boolean,
) {}
}
diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index fb744d4a0..0fc50ffdf 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -229,7 +229,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
- this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
+ this.updateTickMetrics(
+ event.tickExecutionDuration,
+ event.tickDelay,
+ event.backlogTurns,
+ event.inCatchUpMode,
+ );
});
}
@@ -418,7 +423,18 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerBreakdown = breakdown;
}
- updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
+ @state()
+ private backlogTurns: number = 0;
+
+ @state()
+ private inCatchUpMode: boolean = false;
+
+ updateTickMetrics(
+ tickExecutionDuration?: number,
+ tickDelay?: number,
+ backlogTurns?: number,
+ inCatchUpMode?: boolean,
+ ) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
// Update tick execution duration stats
@@ -455,6 +471,13 @@ export class PerformanceOverlay extends LitElement implements Layer {
}
}
+ if (backlogTurns !== undefined) {
+ this.backlogTurns = backlogTurns;
+ }
+ if (inCatchUpMode !== undefined) {
+ this.inCatchUpMode = inCatchUpMode;
+ }
+
this.requestUpdate();
}
@@ -600,6 +623,11 @@ export class PerformanceOverlay extends LitElement implements Layer {
${this.tickDelayAvg.toFixed(2)}ms
(max: ${this.tickDelayMax}ms)
+
+ Backlog turns:
+ ${this.backlogTurns}
+ ${this.inCatchUpMode ? html` (catch-up)` : html``}
+
${this.layerBreakdown.length
? html`
From f8ce8d71c0b7a51fae9132042eb0ca6886ad53b2 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Sun, 23 Nov 2025 19:28:34 +0100
Subject: [PATCH 2/5] Batch worker updates in client catch-up mode to reduce
render cost
- Refactor worker update handling into processPendingUpdates so multiple GameUpdateViewData objects are batched per frame.
- Combine all tick updates in a batch into a single GameUpdateViewData before applying it to GameView, while still running per-tick side effects (turnComplete, hashes, backlog metrics, win saving).
- Ensure layers using updatesSinceLastTick and recentlyUpdatedTiles see all events in a batch, fixing visual artifacts during fast-forward resync.
---
src/client/ClientGameRunner.ts | 183 +++++++++++++++++++++++----------
1 file changed, 131 insertions(+), 52 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index c7c95a24b..dcf769b8b 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -12,7 +12,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
-import { PlayerActions, UnitType } from "../core/game/Game";
+import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -218,6 +218,9 @@ export class ClientGameRunner {
private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5;
+ private pendingUpdates: GameUpdateViewData[] = [];
+ private isProcessingUpdates = false;
+
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -302,57 +305,9 @@ export class ClientGameRunner {
this.stop();
return;
}
- this.transport.turnComplete();
- gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
- this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
- });
- this.gameView.update(gu);
- this.renderer.tick();
-
- // Update tick / backlog metrics
- if (!("errMsg" in gu)) {
- this.lastProcessedTick = gu.tick;
- this.backlogTurns = Math.max(
- 0,
- this.serverTurnHighWater - this.lastProcessedTick,
- );
-
- const wasCatchUp = this.catchUpMode;
- if (
- !this.catchUpMode &&
- this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG
- ) {
- this.catchUpMode = true;
- } else if (
- this.catchUpMode &&
- this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
- ) {
- this.catchUpMode = false;
- }
- if (wasCatchUp !== this.catchUpMode) {
- console.log(
- `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
- this.backlogTurns
- } turns)`,
- );
- }
- }
-
- // Emit tick metrics event for performance overlay
- this.eventBus.emit(
- new TickMetricsEvent(
- gu.tickExecutionDuration,
- this.currentTickDelay,
- this.backlogTurns,
- this.catchUpMode,
- ),
- );
-
- // Reset tick delay for next measurement
- this.currentTickDelay = undefined;
-
- if (gu.updates[GameUpdateType.Win].length > 0) {
- this.saveGame(gu.updates[GameUpdateType.Win][0]);
+ this.pendingUpdates.push(gu);
+ if (!this.catchUpMode) {
+ this.processPendingUpdates();
}
});
const worker = this.worker;
@@ -364,6 +319,7 @@ export class ClientGameRunner {
for (let i = 0; i < beatsPerFrame; i++) {
worker.sendHeartbeat();
}
+ this.processPendingUpdates();
requestAnimationFrame(keepWorkerAlive);
}
};
@@ -503,6 +459,129 @@ export class ClientGameRunner {
}
}
+ private processPendingUpdates() {
+ if (this.isProcessingUpdates) {
+ return;
+ }
+ if (this.pendingUpdates.length === 0) {
+ return;
+ }
+
+ this.isProcessingUpdates = true;
+ const batch = this.pendingUpdates.splice(0);
+
+ let processedCount = 0;
+ let lastTickDuration: number | undefined;
+ let lastTick: number | undefined;
+
+ try {
+ for (const gu of batch) {
+ processedCount++;
+
+ this.transport.turnComplete();
+ gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
+ this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
+ });
+ this.updateBacklogMetrics(gu.tick);
+
+ if (gu.updates[GameUpdateType.Win].length > 0) {
+ this.saveGame(gu.updates[GameUpdateType.Win][0]);
+ }
+
+ if (gu.tickExecutionDuration !== undefined) {
+ lastTickDuration = gu.tickExecutionDuration;
+ }
+ lastTick = gu.tick;
+ }
+ } finally {
+ this.isProcessingUpdates = false;
+ }
+
+ if (processedCount > 0 && lastTick !== undefined) {
+ const combinedGu = this.mergeGameUpdates(batch);
+ if (combinedGu) {
+ this.gameView.update(combinedGu);
+ }
+
+ this.renderer.tick();
+ this.eventBus.emit(
+ new TickMetricsEvent(
+ lastTickDuration,
+ this.currentTickDelay,
+ this.backlogTurns,
+ this.catchUpMode,
+ ),
+ );
+ // Reset tick delay for next measurement
+ this.currentTickDelay = undefined;
+ }
+ }
+
+ private mergeGameUpdates(
+ batch: GameUpdateViewData[],
+ ): GameUpdateViewData | null {
+ if (batch.length === 0) {
+ return null;
+ }
+
+ const last = batch[batch.length - 1];
+ const combinedUpdates: GameUpdates = {} as GameUpdates;
+
+ // Initialize combinedUpdates with empty arrays for each existing key
+ for (const key in last.updates) {
+ const type = Number(key) as GameUpdateType;
+ combinedUpdates[type] = [];
+ }
+
+ const combinedPackedTileUpdates: bigint[] = [];
+
+ for (const gu of batch) {
+ for (const key in gu.updates) {
+ const type = Number(key) as GameUpdateType;
+ // We don't care about the specific update subtype here; just treat it
+ // as an array we can concatenate.
+ const updatesForType = gu.updates[type] as unknown as any[];
+ (combinedUpdates[type] as unknown as any[]).push(...updatesForType);
+ }
+ gu.packedTileUpdates.forEach((tu) => {
+ combinedPackedTileUpdates.push(tu);
+ });
+ }
+
+ return {
+ tick: last.tick,
+ updates: combinedUpdates,
+ packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
+ playerNameViewData: last.playerNameViewData,
+ tickExecutionDuration: last.tickExecutionDuration,
+ };
+ }
+
+ private updateBacklogMetrics(processedTick: number) {
+ this.lastProcessedTick = processedTick;
+ this.backlogTurns = Math.max(
+ 0,
+ this.serverTurnHighWater - this.lastProcessedTick,
+ );
+
+ const wasCatchUp = this.catchUpMode;
+ if (!this.catchUpMode && this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG) {
+ this.catchUpMode = true;
+ } else if (
+ this.catchUpMode &&
+ this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
+ ) {
+ this.catchUpMode = false;
+ }
+ if (wasCatchUp !== this.catchUpMode) {
+ console.log(
+ `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
+ this.backlogTurns
+ } turns)`,
+ );
+ }
+ }
+
private inputEvent(event: MouseUpEvent) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
From ddbd2d7b40bc42ff3c6c20a87c2aca9c22f70d4a Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Sun, 23 Nov 2025 21:02:22 +0100
Subject: [PATCH 3/5] frameskip
---
src/client/ClientGameRunner.ts | 66 ++++++++++++++++++-
src/client/InputHandler.ts | 2 +
.../graphics/layers/PerformanceOverlay.ts | 22 +++++++
3 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index dcf769b8b..e0804b885 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -216,11 +216,18 @@ export class ClientGameRunner {
private catchUpMode: boolean = false;
private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up
private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
- private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5;
+ private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; //upper bound on heartbeats per frame when in catch-up mode
private pendingUpdates: GameUpdateViewData[] = [];
private isProcessingUpdates = false;
+ // Adaptive rendering during catch-up: render at most once every N frames.
+ private renderEveryN: number = 1;
+ private renderSkipCounter: number = 0;
+ private lastFrameTime: number = 0;
+ private readonly MAX_RENDER_EVERY_N = 60;
+ private lastBeatsPerFrame: number = 1;
+
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -313,13 +320,36 @@ export class ClientGameRunner {
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
+ const now = performance.now();
+ let frameDuration = 0;
+ if (this.lastFrameTime !== 0) {
+ frameDuration = now - this.lastFrameTime;
+ }
+ this.lastFrameTime = now;
+
const beatsPerFrame = this.catchUpMode
? this.CATCH_UP_HEARTBEATS_PER_FRAME
: 1;
+ this.lastBeatsPerFrame = beatsPerFrame;
for (let i = 0; i < beatsPerFrame; i++) {
worker.sendHeartbeat();
}
- this.processPendingUpdates();
+
+ // Decide whether to render (and thus process pending updates) this frame.
+ let shouldRender = true;
+ if (this.catchUpMode && this.renderEveryN > 1) {
+ if (this.renderSkipCounter < this.renderEveryN - 1) {
+ shouldRender = false;
+ this.renderSkipCounter++;
+ } else {
+ this.renderSkipCounter = 0;
+ }
+ }
+
+ if (shouldRender) {
+ this.processPendingUpdates();
+ }
+ this.adaptRenderFrequency(frameDuration);
requestAnimationFrame(keepWorkerAlive);
}
};
@@ -510,13 +540,45 @@ export class ClientGameRunner {
this.currentTickDelay,
this.backlogTurns,
this.catchUpMode,
+ this.renderEveryN,
+ this.lastBeatsPerFrame,
),
);
+
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
}
}
+ private adaptRenderFrequency(frameDuration: number) {
+ if (!this.catchUpMode) {
+ // Back to normal rendering.
+ this.renderEveryN = 1;
+ this.renderSkipCounter = 0;
+ return;
+ }
+
+ const HIGH_BACKLOG = 200;
+ const LOW_BACKLOG = 50;
+ const HIGH_FRAME_MS = 25;
+ const LOW_FRAME_MS = 18;
+
+ if (this.backlogTurns > HIGH_BACKLOG && frameDuration > HIGH_FRAME_MS) {
+ // We are far behind and frames are heavy → render less often.
+ if (this.renderEveryN < this.MAX_RENDER_EVERY_N) {
+ this.renderEveryN++;
+ }
+ } else if (
+ this.backlogTurns < LOW_BACKLOG ||
+ (frameDuration > 0 && frameDuration < LOW_FRAME_MS)
+ ) {
+ // Either mostly caught up or frames are cheap again → move back toward normal.
+ if (this.renderEveryN > 1) {
+ this.renderEveryN--;
+ }
+ }
+ }
+
private mergeGameUpdates(
batch: GameUpdateViewData[],
): GameUpdateViewData | null {
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 5ecc23915..2cfa212cb 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -133,6 +133,8 @@ export class TickMetricsEvent implements GameEvent {
public readonly backlogTurns?: number,
// Whether the client is currently in catch-up mode
public readonly inCatchUpMode?: boolean,
+ public readonly renderEveryN?: number,
+ public readonly beatsPerFrame?: number,
) {}
}
diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index 0fc50ffdf..3cd688449 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -234,6 +234,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
event.tickDelay,
event.backlogTurns,
event.inCatchUpMode,
+ event.renderEveryN,
+ event.beatsPerFrame,
);
});
}
@@ -429,11 +431,19 @@ export class PerformanceOverlay extends LitElement implements Layer {
@state()
private inCatchUpMode: boolean = false;
+ @state()
+ private renderEveryN: number = 1;
+
+ @state()
+ private beatsPerFrame: number = 1;
+
updateTickMetrics(
tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
inCatchUpMode?: boolean,
+ renderEveryN?: number,
+ beatsPerFrame?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
@@ -477,6 +487,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (inCatchUpMode !== undefined) {
this.inCatchUpMode = inCatchUpMode;
}
+ if (renderEveryN !== undefined) {
+ this.renderEveryN = renderEveryN;
+ }
+ if (beatsPerFrame !== undefined) {
+ this.beatsPerFrame = beatsPerFrame;
+ }
this.requestUpdate();
}
@@ -628,6 +644,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
${this.backlogTurns}
${this.inCatchUpMode ? html` (catch-up)` : html``}
+ ${this.inCatchUpMode
+ ? html`
+ Render every ${this.renderEveryN} frame(s),
+ heartbeats per frame: ${this.beatsPerFrame}
+
`
+ : html``}
${this.layerBreakdown.length
? html`
From b458d00157061b9dac2957f00caeb59008964c84 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Sun, 23 Nov 2025 21:03:22 +0100
Subject: [PATCH 4/5] Worker now self-clocks; no heartbeats needed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GameRunner exposes pending work via a new hasPendingTurns() so the worker can check whether more ticks need to be processed.
Worker auto-runs ticks: as soon as it initializes or receives a new turn, it calls processPendingTurns() and loops executeNextTick() while hasPendingTurns() is true. No more "heartbeat" message type; the worker no longer depends on the main thread’s RAF loop to advance the simulation.
Client main thread simplified:
Removed CATCH_UP_HEARTBEATS_PER_FRAME, the heartbeat loop, and the lastBeatsPerFrame tracking.
keepWorkerAlive now just manages frame skipping + draining. When it decides to render (based on renderEveryN), it drains pendingUpdates, merges them, updates GameView, and runs renderer.tick().
Because rendering a batch always implies draining, we restored the invariant that every GameView.update is paired with a layer tick() (no more lost incremental updates).
MAX_RENDER_EVERY_N is now 5 to keep the queue from growing too large while the worker sprints.
---
src/client/ClientGameRunner.ts | 14 +--------
.../graphics/layers/PerformanceOverlay.ts | 7 +++--
src/core/GameRunner.ts | 4 +++
src/core/worker/Worker.worker.ts | 29 +++++++++++++++++--
4 files changed, 35 insertions(+), 19 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index e0804b885..348e80d6d 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -216,7 +216,6 @@ export class ClientGameRunner {
private catchUpMode: boolean = false;
private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up
private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
- private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5; //upper bound on heartbeats per frame when in catch-up mode
private pendingUpdates: GameUpdateViewData[] = [];
private isProcessingUpdates = false;
@@ -225,8 +224,7 @@ export class ClientGameRunner {
private renderEveryN: number = 1;
private renderSkipCounter: number = 0;
private lastFrameTime: number = 0;
- private readonly MAX_RENDER_EVERY_N = 60;
- private lastBeatsPerFrame: number = 1;
+ private readonly MAX_RENDER_EVERY_N = 5;
constructor(
private lobby: LobbyConfig,
@@ -317,7 +315,6 @@ export class ClientGameRunner {
this.processPendingUpdates();
}
});
- const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
const now = performance.now();
@@ -327,14 +324,6 @@ export class ClientGameRunner {
}
this.lastFrameTime = now;
- const beatsPerFrame = this.catchUpMode
- ? this.CATCH_UP_HEARTBEATS_PER_FRAME
- : 1;
- this.lastBeatsPerFrame = beatsPerFrame;
- for (let i = 0; i < beatsPerFrame; i++) {
- worker.sendHeartbeat();
- }
-
// Decide whether to render (and thus process pending updates) this frame.
let shouldRender = true;
if (this.catchUpMode && this.renderEveryN > 1) {
@@ -541,7 +530,6 @@ export class ClientGameRunner {
this.backlogTurns,
this.catchUpMode,
this.renderEveryN,
- this.lastBeatsPerFrame,
),
);
diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index 3cd688449..bd101f80d 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -435,7 +435,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
private renderEveryN: number = 1;
@state()
- private beatsPerFrame: number = 1;
+ private beatsPerFrame: number | null = null;
updateTickMetrics(
tickExecutionDuration?: number,
@@ -491,7 +491,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.renderEveryN = renderEveryN;
}
if (beatsPerFrame !== undefined) {
- this.beatsPerFrame = beatsPerFrame;
+ this.beatsPerFrame = beatsPerFrame ?? null;
}
this.requestUpdate();
@@ -647,7 +647,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
${this.inCatchUpMode
? html`
Render every ${this.renderEveryN} frame(s),
- heartbeats per frame: ${this.beatsPerFrame}
+ heartbeats per frame:
+ ${this.beatsPerFrame ?? "auto"}
`
: html``}
${this.layerBreakdown.length
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index a78e39699..d6de468fb 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -268,4 +268,8 @@ export class GameRunner {
}
return player.bestTransportShipSpawn(targetTile);
}
+
+ public hasPendingTurns(): boolean {
+ return this.currTurn < this.turns.length;
+ }
}
diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts
index 1014968fb..a6bb92510 100644
--- a/src/core/worker/Worker.worker.ts
+++ b/src/core/worker/Worker.worker.ts
@@ -16,6 +16,7 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise
| null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
+let isProcessingTurns = false;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -32,13 +33,33 @@ function sendMessage(message: WorkerMessage) {
ctx.postMessage(message);
}
+async function processPendingTurns() {
+ if (isProcessingTurns) {
+ return;
+ }
+ if (!gameRunner) {
+ return;
+ }
+
+ const gr = await gameRunner;
+ if (!gr || !gr.hasPendingTurns()) {
+ return;
+ }
+
+ isProcessingTurns = true;
+ try {
+ while (gr.hasPendingTurns()) {
+ gr.executeNextTick();
+ }
+ } finally {
+ isProcessingTurns = false;
+ }
+}
+
ctx.addEventListener("message", async (e: MessageEvent) => {
const message = e.data;
switch (message.type) {
- case "heartbeat":
- (await gameRunner)?.executeNextTick();
- break;
case "init":
try {
gameRunner = createGameRunner(
@@ -51,6 +72,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => {
type: "initialized",
id: message.id,
} as InitializedMessage);
+ processPendingTurns();
return gr;
});
} catch (error) {
@@ -67,6 +89,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => {
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
+ processPendingTurns();
} catch (error) {
console.error("Failed to process turn:", error);
throw error;
From 8508baee84d206d3b78915084e2c4fdfd8176a39 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Mon, 24 Nov 2025 15:01:17 +0100
Subject: [PATCH 5/5] Clean up previous implementations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
removed:
- catchUpMode and its CATCH_UP_ENTER/EXIT thresholds in ClientGameRunner
- tick metrics fields and overlay UI for inCatchUpMode and beatsPerFrame
- leftover worker heartbeat plumbing (message type + WorkerClient.sendHeartbeat) that was no longer used after self-clocking
changed:
- backlog tracking: keep serverTurnHighWater / lastProcessedTick / backlogTurns, but simplify it to just compute backlog and a backlogGrowing flag instead of driving a dedicated catch-up mode
- frame skip: adaptRenderFrequency now only increases renderEveryN when backlog > 0 and still growing; when backlog is stable/shrinking or zero, it decays renderEveryN back toward 1
- render loop: uses the backlog-aware renderEveryN unconditionally (no catch-up flag), and resets skipping completely when backlog reaches 0
- metrics/overlay: TickMetricsEvent now carries backlogTurns and renderEveryN; the performance overlay displays backlog and current “render every N frames” but no longer mentions catch-up or heartbeats
Learnings during branch development leading to this
Once the worker self-clocks, a separate “catch-up mode” and beats-per-frame knob don’t add real control; they just complicate the model.
Backlog is still a valuable signal, but it’s more effective as a quantitative input (backlog size and whether it’s growing) than as a boolean mode toggle.
Frame skipping should be driven by actual backlog pressure plus frame cost: throttle only while backlog is growing and frames are heavy, and automatically relax back to full-rate rendering once the simulation catches up.
---
src/client/ClientGameRunner.ts | 60 +++++++------------
src/client/InputHandler.ts | 3 -
.../graphics/layers/PerformanceOverlay.ts | 25 +-------
src/core/worker/WorkerClient.ts | 6 --
src/core/worker/WorkerMessages.ts | 6 --
5 files changed, 23 insertions(+), 77 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 348e80d6d..8a92444ce 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -212,15 +212,12 @@ export class ClientGameRunner {
private serverTurnHighWater: number = 0;
private lastProcessedTick: number = 0;
private backlogTurns: number = 0;
-
- private catchUpMode: boolean = false;
- private readonly CATCH_UP_ENTER_BACKLOG = 120; // turns behind to enter catch-up
- private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
+ private backlogGrowing: boolean = false;
private pendingUpdates: GameUpdateViewData[] = [];
private isProcessingUpdates = false;
- // Adaptive rendering during catch-up: render at most once every N frames.
+ // Adaptive rendering when frames are heavy: render at most once every N frames.
private renderEveryN: number = 1;
private renderSkipCounter: number = 0;
private lastFrameTime: number = 0;
@@ -311,7 +308,7 @@ export class ClientGameRunner {
return;
}
this.pendingUpdates.push(gu);
- if (!this.catchUpMode) {
+ if (this.renderEveryN === 1) {
this.processPendingUpdates();
}
});
@@ -326,13 +323,14 @@ export class ClientGameRunner {
// Decide whether to render (and thus process pending updates) this frame.
let shouldRender = true;
- if (this.catchUpMode && this.renderEveryN > 1) {
- if (this.renderSkipCounter < this.renderEveryN - 1) {
- shouldRender = false;
- this.renderSkipCounter++;
- } else {
- this.renderSkipCounter = 0;
- }
+ if (
+ this.renderEveryN > 1 &&
+ this.renderSkipCounter < this.renderEveryN - 1
+ ) {
+ shouldRender = false;
+ this.renderSkipCounter++;
+ } else if (this.renderEveryN > 1) {
+ this.renderSkipCounter = 0;
}
if (shouldRender) {
@@ -528,7 +526,6 @@ export class ClientGameRunner {
lastTickDuration,
this.currentTickDelay,
this.backlogTurns,
- this.catchUpMode,
this.renderEveryN,
),
);
@@ -539,28 +536,26 @@ export class ClientGameRunner {
}
private adaptRenderFrequency(frameDuration: number) {
- if (!this.catchUpMode) {
- // Back to normal rendering.
+ // Frameskip only matters while we have a backlog; otherwise stay at 1.
+ if (this.backlogTurns === 0) {
this.renderEveryN = 1;
this.renderSkipCounter = 0;
return;
}
- const HIGH_BACKLOG = 200;
- const LOW_BACKLOG = 50;
const HIGH_FRAME_MS = 25;
const LOW_FRAME_MS = 18;
- if (this.backlogTurns > HIGH_BACKLOG && frameDuration > HIGH_FRAME_MS) {
- // We are far behind and frames are heavy → render less often.
+ // Only throttle rendering if backlog is still growing; otherwise drift back toward 1.
+ if (this.backlogGrowing && frameDuration > HIGH_FRAME_MS) {
if (this.renderEveryN < this.MAX_RENDER_EVERY_N) {
this.renderEveryN++;
}
} else if (
- this.backlogTurns < LOW_BACKLOG ||
- (frameDuration > 0 && frameDuration < LOW_FRAME_MS)
+ !this.backlogGrowing &&
+ frameDuration > 0 &&
+ frameDuration < LOW_FRAME_MS
) {
- // Either mostly caught up or frames are cheap again → move back toward normal.
if (this.renderEveryN > 1) {
this.renderEveryN--;
}
@@ -609,27 +604,12 @@ export class ClientGameRunner {
private updateBacklogMetrics(processedTick: number) {
this.lastProcessedTick = processedTick;
+ const previousBacklog = this.backlogTurns;
this.backlogTurns = Math.max(
0,
this.serverTurnHighWater - this.lastProcessedTick,
);
-
- const wasCatchUp = this.catchUpMode;
- if (!this.catchUpMode && this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG) {
- this.catchUpMode = true;
- } else if (
- this.catchUpMode &&
- this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
- ) {
- this.catchUpMode = false;
- }
- if (wasCatchUp !== this.catchUpMode) {
- console.log(
- `Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
- this.backlogTurns
- } turns)`,
- );
- }
+ this.backlogGrowing = this.backlogTurns > previousBacklog;
}
private inputEvent(event: MouseUpEvent) {
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 2cfa212cb..abe01cac3 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -131,10 +131,7 @@ export class TickMetricsEvent implements GameEvent {
public readonly tickDelay?: number,
// Number of turns the client is behind the server (if known)
public readonly backlogTurns?: number,
- // Whether the client is currently in catch-up mode
- public readonly inCatchUpMode?: boolean,
public readonly renderEveryN?: number,
- public readonly beatsPerFrame?: number,
) {}
}
diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index bd101f80d..183c4fa44 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -233,9 +233,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
event.tickExecutionDuration,
event.tickDelay,
event.backlogTurns,
- event.inCatchUpMode,
event.renderEveryN,
- event.beatsPerFrame,
);
});
}
@@ -425,25 +423,17 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerBreakdown = breakdown;
}
- @state()
- private backlogTurns: number = 0;
-
- @state()
- private inCatchUpMode: boolean = false;
-
@state()
private renderEveryN: number = 1;
@state()
- private beatsPerFrame: number | null = null;
+ private backlogTurns: number = 0;
updateTickMetrics(
tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
- inCatchUpMode?: boolean,
renderEveryN?: number,
- beatsPerFrame?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
@@ -484,15 +474,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (backlogTurns !== undefined) {
this.backlogTurns = backlogTurns;
}
- if (inCatchUpMode !== undefined) {
- this.inCatchUpMode = inCatchUpMode;
- }
if (renderEveryN !== undefined) {
this.renderEveryN = renderEveryN;
}
- if (beatsPerFrame !== undefined) {
- this.beatsPerFrame = beatsPerFrame ?? null;
- }
this.requestUpdate();
}
@@ -642,13 +626,10 @@ export class PerformanceOverlay extends LitElement implements Layer {
Backlog turns:
${this.backlogTurns}
- ${this.inCatchUpMode ? html` (catch-up)` : html``}
- ${this.inCatchUpMode
+ ${this.renderEveryN > 1
? html`
- Render every ${this.renderEveryN} frame(s),
- heartbeats per frame:
- ${this.beatsPerFrame ?? "auto"}
+ Render every ${this.renderEveryN} frame(s)
`
: html``}
${this.layerBreakdown.length
diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts
index bde436f39..4edc97dee 100644
--- a/src/core/worker/WorkerClient.ts
+++ b/src/core/worker/WorkerClient.ts
@@ -100,12 +100,6 @@ export class WorkerClient {
});
}
- sendHeartbeat() {
- this.worker.postMessage({
- type: "heartbeat",
- });
- }
-
playerProfile(playerID: number): Promise {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts
index a8d30e9b1..0c5344da1 100644
--- a/src/core/worker/WorkerMessages.ts
+++ b/src/core/worker/WorkerMessages.ts
@@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
export type WorkerMessageType =
- | "heartbeat"
| "init"
| "initialized"
| "turn"
@@ -31,10 +30,6 @@ interface BaseWorkerMessage {
id?: string;
}
-export interface HeartbeatMessage extends BaseWorkerMessage {
- type: "heartbeat";
-}
-
// Messages from main thread to worker
export interface InitMessage extends BaseWorkerMessage {
type: "init";
@@ -114,7 +109,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
// Union types for type safety
export type MainThreadMessage =
- | HeartbeatMessage
| InitMessage
| TurnMessage
| PlayerActionsMessage