readonly running: booleanStarted and not stopped. Independent of paused.
readonly paused: booleanspeed === 0. Sim time is not advancing; real time still is.
readonly speed: numberSim-time multiplier. 1 is normal, 2 is fast-forward, 0 is paused. Never negative.
readonly time: numberSim seconds elapsed: tick * stepSeconds. Pauses, scales, and lags real time on
purpose.
A hidden tab pumps once a second and may advance at most maxCatchUpMs of sim per pump,
so sim time runs at roughly a quarter speed while hidden and realTime - time grows.
Anything that must be true against the player's wall clock — a build timer, a research
countdown, a daily reward — is either a loop.real timer or a timestamp in sim state.
Putting it on loop.sim gives a thirty-second build that takes two minutes if the player
looks away, which reads as a bug and is worse than one, because it is a bug you cannot
reproduce in the foreground.
readonly realTime: numberReal seconds the loop has been running. Never pauses, never scales, never clamped.
It counts only time between start() and stop(): a loop stopped for an hour comes back
owing nothing, which is the same promise start() makes about the wait before the first
pump.
readonly tick: numberFixed steps issued since construction. The replay cursor.
A non-negative integer that starts at 0 and increases by exactly one per update call,
for the life of the loop — including across a stop() and start(), because an index
that repeated would silently corrupt the join that @latticekit/input's event buckets and
@latticekit/persist's replay envelope are both keyed on.
readonly stepSeconds: numberThe dt every update is handed, forever. Computed once; see Loop.stepMs.
readonly stepMs: numberThe same step in milliseconds — stepUs / 1000, computed once and stable for the life of
the loop.
This number is a compatibility constant, not a detail. @latticekit/persist writes it
into a recorded input log and refuses to migrate a log whose stepMs differs from the
running loop's, because a log keyed by tick index means nothing if a tick is a different
length than it was when the log was made. Changing hz in a shipped game is therefore a
breaking change to every recorded session, exactly as changing a save schema is, and
it belongs in a migration note rather than in a tuning pass.
readonly sim: SchedulerTimers on sim time: they pause when the game pauses, scale with speed, are clamped
with the simulation, and fire at a deterministic tick regardless of frame rate. Use for
anything that is part of the game's fiction: a spawn wave, a cooldown, a patrol.
readonly real: SchedulerTimers on real time: they fire while paused, while hidden, and while the game runs at
4×. Use for anything that is about the player's world rather than the game's: autosave,
telemetry flush, a daily-reward check, an idle prompt.
This timeline is advanced from every pump, and in a hidden tab the only pumps are the
interval half of browserFrames — which is why that half is not optional and why a
hidden tab's timer granularity is idleMs (about a second, browser-clamped) rather than a
frame. A sub-second debounce is meaningless in the background. And loop.stop() stops the
pumps and therefore these timers, so a flush on visibilitychange is still necessary.
readonly hz: numberThe fixed rate this loop was built with. See LoopOptions.hz.
Baked, and the setter is new: stepMs is written into every recorded input log and
@latticekit/persist refuses to migrate a log whose step differs from the running loop's.
readonly maxCatchUpMs: numberThe catch-up ceiling in force. See LoopOptions.maxCatchUpMs.
readonly budgetMs: numberThe work budget stats.overBudget counts against. See LoopOptions.budgetMs.
readonly windowMs: numberHow far back the rolling worst figures look. See LoopOptions.windowMs.
readonly warmupFrames: numberHow many opening paint intervals are discarded. See LoopOptions.warmupFrames.
Readable because a HUD quoting worstGapMs is quoting a filtered number, and the size
of the filter is exactly the thing a reader is entitled to check.
readonly absenceMs: numberThe gap above which a paint interval is an absence. See LoopOptions.absenceMs.
readonly stats: FrameStatsLive figures. The same object every read — copy the fields you keep.
onUpdate(fn: (dt: number, tick: number) => void): DisposerAttach state work to the fixed step. Runs on 'tick' pumps too — this is the callback
that keeps running when nobody is looking.
Subscribers run in registration order, and LoopOptions.update is registered first, so an
overlay attached later always sees a world that has already moved this step. An overlay
wired before the game would see last step's world, one step stale, forever.
The returned disposer removes exactly this subscription and is safe to call twice.
onRender(fn: (alpha: number, time: number, nowMs: number) => void): DisposerAttach painting to the paint pump. Every subscriber gets the same alpha, time and
nowMs, computed once for the pump.
Same prohibitions as LoopOptions.render: a render subscriber may not mutate
simulation state.
coalesce(fn: () => void): JobCreate a coalescing job: work that must happen soon, at most once per pump, and off the
paint path. Jobs run before the step loop, so a rebuild is always visible to the updates
that follow it, and they run on 'tick' pumps and while paused — a hidden tab still
rebuilds, because pathfinding is a rule and rules do not stop when the painting does.
Requests made during a step or a render are serviced next pump, which is the one-pump
latency the word "soon" is buying.
start(): voidBegin pumping. Records the clock now, so a loop constructed before a four-second asset
wait owes nothing for the wait. A no-op if already running.
stop(): voidStop the frame source and abandon the rest of the current pump. Restartable.
Timers survive deliberately: sim.pending and real.pending are untouched, so a loop
stopped for a scene transition comes back with its cooldowns intact. Nothing is called
after this returns.
pause(): voidsetSpeed(0), remembering the previous speed. A no-op if already paused.
resume(): voidRestore the speed from before the pause. A no-op if not paused.
setSpeed(multiplier: number): voidSet the sim-time multiplier.
ThrowsRangeError on a negative, NaN or infinite multiplier. A negative speed would run
the accumulator backwards, which is not slow motion — it is a loop that never steps again.
resetStats(): voidZero the counters and the smoothing window. Totals included; tick and time are not counters.