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