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.
This commit is contained in:
scamiv
2025-11-23 14:07:12 +01:00
parent 8dde30ebb6
commit 5e264a54bc
3 changed files with 94 additions and 4 deletions
+60 -2
View File
@@ -226,6 +226,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,
@@ -317,9 +327,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
@@ -332,7 +376,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);
}
};
@@ -380,6 +429,10 @@ export class ClientGameRunner {
}
for (const turn of message.turns) {
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
turn.turnNumber,
);
if (turn.turnNumber < this.turnsSeen) {
continue;
}
@@ -428,6 +481,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}`,
+4
View File
@@ -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,
) {}
}
@@ -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 {
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
<div class="performance-line">
Backlog turns:
<span>${this.backlogTurns}</span>
${this.inCatchUpMode ? html`<span> (catch-up)</span>` : html``}
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">