Worker now self-clocks; no heartbeats needed

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.
This commit is contained in:
scamiv
2025-11-23 21:03:22 +01:00
parent b6515d4366
commit dcd5b550cf
4 changed files with 35 additions and 19 deletions
+1 -13
View File
@@ -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,
),
);
@@ -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`<div class="performance-line">
Render every <span>${this.renderEveryN}</span> frame(s),
heartbeats per frame: <span>${this.beatsPerFrame}</span>
heartbeats per frame:
<span>${this.beatsPerFrame ?? "auto"}</span>
</div>`
: html``}
${this.layerBreakdown.length
+4
View File
@@ -272,4 +272,8 @@ export class GameRunner {
}
return player.bestTransportShipSpawn(targetTile);
}
public hasPendingTurns(): boolean {
return this.currTurn < this.turns.length;
}
}
+26 -3
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);
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<MainThreadMessage>) => {
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<MainThreadMessage>) => {
type: "initialized",
id: message.id,
} as InitializedMessage);
processPendingTurns();
return gr;
});
} catch (error) {
@@ -67,6 +89,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
processPendingTurns();
} catch (error) {
console.error("Failed to process turn:", error);
throw error;