Spectate catchup (#3012)

## Description:


https://github.com/user-attachments/assets/dc118d5f-3b7f-4ccb-8579-5b0d8c73fe8e

Catchup mechanic for live games and changes replays to have a backlog
for more "max" speed

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-01-27 23:15:35 +00:00
committed by GitHub
parent 1dac7bd2e8
commit da4b8aa5e1
3 changed files with 38 additions and 8 deletions
+15 -2
View File
@@ -20,7 +20,13 @@ import {
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "./utilities/ReplaySpeedMultiplier";
// build a small backlog so MAX can catch up.
const MAX_REPLAY_BACKLOG_TURNS = 60;
export class LocalServer {
// All turns from the game record on replay.
@@ -64,9 +70,16 @@ export class LocalServer {
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
this.replaySpeedMultiplier;
const backlog = Math.max(0, this.turns.length - this.turnsExecuted);
const allowReplayBacklog =
this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest &&
this.lobbyConfig.gameRecord !== undefined;
const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0;
const canQueueNextTurn =
backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog);
if (
this.turnsExecuted === this.turns.length &&
canQueueNextTurn &&
Date.now() > this.turnStartTime + turnIntervalMs
) {
this.turnStartTime = Date.now();
+10 -4
View File
@@ -112,12 +112,12 @@ export class GameRunner {
this.turns.push(turn);
}
public executeNextTick() {
public executeNextTick(): boolean {
if (this.isExecuting) {
return;
return false;
}
if (this.currTurn >= this.turns.length) {
return;
return false;
}
this.isExecuting = true;
@@ -144,7 +144,8 @@ export class GameRunner {
} else {
console.error("Game tick error:", error);
}
return;
this.isExecuting = false;
return false;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
@@ -177,6 +178,11 @@ export class GameRunner {
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
return true;
}
public pendingTurns(): number {
return Math.max(0, this.turns.length - this.currTurn);
}
public playerActions(
+13 -2
View File
@@ -16,6 +16,7 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
const MAX_TICKS_PER_HEARTBEAT = 4;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case "heartbeat":
(await gameRunner)?.executeNextTick();
case "heartbeat": {
const gr = await gameRunner;
if (!gr) {
break;
}
const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT);
for (let i = 0; i < ticksToRun; i++) {
if (!gr.executeNextTick()) {
break;
}
}
break;
}
case "init":
try {
gameRunner = createGameRunner(