/** * Fixed-timestep tick loop with an accumulator. * * The engine advances in whole ticks of `tickMs`. `advance(loop, now)` folds the * elapsed wall-clock time into an accumulator and drains it one tick at a time, * keeping the sub-tick remainder so results are independent of how the caller * chunks calls (determinism — see the test suite). * * Offline catch-up uses this same path: a large `now` delta owes many ticks. * `maxTicks` caps work per `advance()` call; the accumulator retains the * remainder so no ticks are lost across calls. * * The save layer (`save.ts`) separately clamps how much *wall-clock* elapsed * time is credited on load via `DEFAULT_MAX_OFFLINE_MS`. Tick loop batching * and offline credit clamping are independent concerns. */ export const TICK_HZ = 10; export const TICK_MS = 1000 / TICK_HZ; export const DEFAULT_MAX_TICKS_PER_ADVANCE = 100_000; export interface TickLoop { /** Milliseconds of simulated time per tick. */ readonly tickMs: number; /** Total ticks run over this loop's lifetime. */ tickCount: number; /** Sub-tick wall-clock remainder, in milliseconds. */ accumulatorMs: number; /** Last timestamp seen, or null until the first `advance` establishes a baseline. */ lastNow: number | null; } export function createTickLoop(opts?: { tickMs?: number; startNow?: number }): TickLoop { const tickMs = opts?.tickMs ?? TICK_MS; if (!(tickMs > 0)) { throw new RangeError(`tickMs must be > 0, got ${tickMs}`); } return { tickMs, tickCount: 0, accumulatorMs: 0, lastNow: opts?.startNow ?? null, }; } /** * Advance the loop to `now`, running owed ticks (capped at `maxTicks`). * Returns the number of ticks run this call. */ export function advance( loop: TickLoop, now: number, onTick?: (tickIndex: number) => void, maxTicks: number = DEFAULT_MAX_TICKS_PER_ADVANCE, ): number { if (loop.lastNow === null) { loop.lastNow = now; return 0; } const elapsed = now - loop.lastNow; loop.lastNow = now; if (elapsed > 0) { loop.accumulatorMs += elapsed; } let ran = 0; while (loop.accumulatorMs >= loop.tickMs && ran < maxTicks) { loop.accumulatorMs -= loop.tickMs; loop.tickCount += 1; ran += 1; onTick?.(loop.tickCount); } return ran; }