diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a..8a92444ce 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 { @@ -208,6 +208,21 @@ 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 backlogGrowing: boolean = false; + + private pendingUpdates: GameUpdateViewData[] = []; + private isProcessingUpdates = false; + + // 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; + private readonly MAX_RENDER_EVERY_N = 5; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -292,29 +307,36 @@ 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(); - - // Emit tick metrics event for performance overlay - this.eventBus.emit( - new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), - ); - - // 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.renderEveryN === 1) { + this.processPendingUpdates(); } }); - const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { - worker.sendHeartbeat(); + const now = performance.now(); + let frameDuration = 0; + if (this.lastFrameTime !== 0) { + frameDuration = now - this.lastFrameTime; + } + this.lastFrameTime = now; + + // Decide whether to render (and thus process pending updates) this frame. + let shouldRender = true; + if ( + this.renderEveryN > 1 && + this.renderSkipCounter < this.renderEveryN - 1 + ) { + shouldRender = false; + this.renderSkipCounter++; + } else if (this.renderEveryN > 1) { + this.renderSkipCounter = 0; + } + + if (shouldRender) { + this.processPendingUpdates(); + } + this.adaptRenderFrequency(frameDuration); requestAnimationFrame(keepWorkerAlive); } }; @@ -363,6 +385,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 +441,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}`, @@ -445,6 +476,142 @@ 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.renderEveryN, + ), + ); + + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } + } + + private adaptRenderFrequency(frameDuration: number) { + // 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_FRAME_MS = 25; + const LOW_FRAME_MS = 18; + + // 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.backlogGrowing && + frameDuration > 0 && + frameDuration < LOW_FRAME_MS + ) { + if (this.renderEveryN > 1) { + this.renderEveryN--; + } + } + } + + 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; + const previousBacklog = this.backlogTurns; + this.backlogTurns = Math.max( + 0, + this.serverTurnHighWater - this.lastProcessedTick, + ); + this.backlogGrowing = this.backlogTurns > previousBacklog; + } + private inputEvent(event: MouseUpEvent) { if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27..abe01cac3 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -129,6 +129,9 @@ 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, + public readonly renderEveryN?: number, ) {} } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index fb744d4a0..183c4fa44 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.renderEveryN, + ); }); } @@ -418,7 +423,18 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { + @state() + private renderEveryN: number = 1; + + @state() + private backlogTurns: number = 0; + + updateTickMetrics( + tickExecutionDuration?: number, + tickDelay?: number, + backlogTurns?: number, + renderEveryN?: number, + ) { 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 (renderEveryN !== undefined) { + this.renderEveryN = renderEveryN; + } + this.requestUpdate(); } @@ -600,6 +623,15 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms) +
+ Backlog turns: + ${this.backlogTurns} +
+ ${this.renderEveryN > 1 + ? html`
+ Render every ${this.renderEveryN} frame(s) +
` + : html``} ${this.layerBreakdown.length ? html`
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; 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