// runs the game at a fixed rate. a slow tick makes the next ones run back to back until the game catches up, // so game time keeps up with real time. if it falls too far behind, it skips ahead instead and says so import { TICKS_PER_SECOND } from "$/common/constants.ts"; // at most this many ticks in a row to catch up, then skip the rest const MAX_CATCH_UP_TICKS = 10; // don't repeat the can't keep up warning more often than this const WARN_INTERVAL_MS = 15_000; export interface LoopClock { now(): number; // run fn after ms, returns something clear() takes schedule(fn: () => void, ms: number): unknown; clear(handle: unknown): void; } const real_clock: LoopClock = { now: () => performance.now(), schedule: (fn, ms) => setTimeout(fn, ms), clear: (handle) => clearTimeout(handle as number), }; export interface LoopStats { // ticks that ran in the last second tps: number; // average milliseconds a tick took over the last second mspt: number; // ticks skipped because the game couldn't keep up, since it started skipped: number; } export class GameLoop { readonly tick_ms: number; #tick: () => void; #clock: LoopClock; #timer: unknown; #running = false; #next_tick = 0; #last_warning = -Infinity; // ticks in the last second, as [when it started, how long it took] #recent: [number, number][] = []; #skipped = 0; constructor(tick: () => void, tps = TICKS_PER_SECOND, clock: LoopClock = real_clock) { this.#tick = tick; this.tick_ms = 1000 / tps; this.#clock = clock; } start() { if (this.#running) return; this.#running = true; this.#next_tick = this.#clock.now(); this.#run(); } stop() { this.#running = false; this.#clock.clear(this.#timer); } get stats(): LoopStats { const now = this.#clock.now(); const recent = this.#recent.filter(([at]) => now - at < 1000); const total = recent.reduce((sum, [, took]) => sum + took, 0); return { tps: recent.length, mspt: recent.length ? total / recent.length : 0, skipped: this.#skipped }; } #run() { if (!this.#running) return; let ran = 0; while (this.#clock.now() >= this.#next_tick && ran < MAX_CATCH_UP_TICKS) { const started = this.#clock.now(); try { this.#tick(); } catch (e) { // one bad tick shouldn't stop the world console.error("Tick failed:", e); } const took = this.#clock.now() - started; this.#recent.push([started, took]); this.#next_tick += this.tick_ms; ran++; } const now = this.#clock.now(); this.#recent = this.#recent.filter(([at]) => now - at < 1000); const behind = Math.floor((now - this.#next_tick) / this.tick_ms); if (behind > 0) { // catching up would mean running the game in fast forward for a while, skip instead this.#skipped += behind; this.#next_tick += behind * this.tick_ms; if (now - this.#last_warning > WARN_INTERVAL_MS) { this.#last_warning = now; console.warn( `Can't keep up, skipped ${behind} ticks (${Math.round(behind * this.tick_ms)} ms). ` + `Ticks take ${this.stats.mspt.toFixed(1)} ms on average, ${this.tick_ms} ms is the budget.`, ); } } this.#timer = this.#clock.schedule(() => this.#run(), Math.max(0, this.#next_tick - this.#clock.now())); } }