API reference · layer 1

@latticekit/loop

Time. A wall-clock game loop with fixed-step simulation and interpolated rendering, plus scheduling, tweens and frame statistics.

exports38 symbols in 9 modules — start with createLoop, browserFrames, createTweens, replay
depends on@latticekit/core
environmentisomorphic (host clock is injected)
gzipped6.57 kB against a 12 kB budget
sourcepackages/loop · README · index.d.ts

@latticekit/loop — the only part of the kit that knows what time it is.

It advances a game's rules at a fixed rate off an injected wall clock whether or not anything is being painted, and hands the renderer a blend factor so the pictures can run at whatever rate the display manages.

import { createLoop, browserFrames } from '@latticekit/loop';

const loop = createLoop({
  clock: { now: () => performance.now() },        // the one global clock read in the whole app
  frames: browserFrames(),                        // rAF paints; an interval ticks when hidden
  update: (dt) => world.step(dt),                 // exactly 1/60 s, 0–15 times per pump
  render: (alpha) => world.draw(surface, alpha),  // never mutates; blends previous → current
});
loop.start();

Read those five option lines as the five promises this package makes.

linepromise
clocktime is a parameter. The kit never reads a global clock, so lint can ban Date.now() in every src/ and mean it.
frameswhen to run is a parameter too, and the browser adapter is deliberately not one source but two.
updatedt is the same number every call, forever. Nothing else here matters as much.
renderalpha ∈ [0, 1] blends the last step into the next. Render is told more about time than update is, and allowed to do less with it.
start()nothing runs on import. No ambient loop, no singleton, no autostart.

Saying "this must keep running when nobody is looking"

It is expressed by choosing what you attach the work to — not by a flag, and not by hoping.

attach it toruns hidden?truthful about wall time?use it for
render(alpha, time)no — rAF is 0 Hznopixels, and nothing else
update(dt, tick)yes, on 'tick' pumpsno — clamped, ~¼ speed hiddenrules, HUD data, anything that must not freeze
loop.real.every(s, fn)yesyes — unclamped, unpausedautosave, telemetry, "has the day rolled over?"
a timestamp in state, integrated on readyes, on the first read after resumeyes, exactlythe economy, and any long duration

Never accumulate dt into anything that has to be right. A day/night phase is phaseAt(epochNow()) — a pure function of the calendar, sampled in update and drawn in render. Accumulating it makes the night shorter for the player who looked away, which is the offline-earnings bug wearing a nicer hat.

Every symbol a consumer may use is re-exported here and nowhere else.

What it promises

  • Simulation advances on the wall clock, never on frame deltas — rAF is 0 Hz in a hidden tab.
  • Catch-up is clamped at 250ms per pump and the excess is dropped, not deferred. The loop advances callbacks; sim advances value.
  • loop.time deliberately drifts below real time while hidden. Anything that must be truthful about the player's wall clock is a timestamp in state, never a duration accumulated on the fixed step.
  • The clock and the frame source are both injected, so every test runs at whatever speed it likes with no timers.
  • This package has no epoch and stamps nothing. The calendar is one game-owned function, injected.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

clock3 symbols

Time as a parameter, and the two units this package speaks.

Nothing here reads a clock. That is the whole point: lint bans Date.now() and performance.now() inside every package's src/, so somebody outside the kit reads the host clock exactly once and hands the reading down. A game passes { now: () => performance.now() }; a test passes manualClock and runs a simulated hour in a microsecond with no fake-timer library anywhere.

The unit boundary is here and nowhere else: options in milliseconds, callbacks in seconds. A game's own constants read as "0.4 s of hop" and "12 s to build", and writing those in milliseconds is how a duration gets typo'd by a factor of a thousand; a host clock, meanwhile, is milliseconds on every platform that has one. Converting at the boundary costs one divide and removes the ambiguity from every call site inside.

Both units are a plain number, and the parameter name is the entire defense. This package deliberately exports no Millis or Seconds alias: an alias over number checks nothing, and a name in the manifest that looks like a guarantee is read as one — Millis sat beside Loop and Scheduler in .lattice/kit.json and was twice mistaken for a brand that would refuse a hand-typed step. A brand separates two kinds of value; a duration has one kind and only a wrong number of them, so there is nothing here for a type to separate. Write after(3000, …) against a callback measured in seconds and you have written fifty minutes; the last word of the parameter name is what tells you, and nothing else can. docs/rfc/durations.md has the reasoning and the three tiers.

Tier A: + - * / and comparison only. No transcendentals, no platform, no allocation after construction.

Clock interface ↳ src/clock.ts:47

interface Clock {

The host's clock, injected.

Must be monotonic: two calls in a row must never go backwards. performance.now() qualifies; Date.now() does not — an NTP correction or a user changing the system clock moves it backwards, and a loop that accumulates a negative delta stops firing timers for however long the jump was. If you inject Date.now() anyway, the loop clamps negative deltas to zero (invariant I-6) and you lose that much game time rather than the loop.

This clock is not the calendar and must never be used as one. It may or may not advance while the machine is asleep — that is platform-dependent — which is precisely why @latticekit/sim keeps its own epoch timestamp and why this package credits nothing. There is deliberately no second method here and no loop.epoch: the moment this package can tell you the date, half the kit starts asking it and the determinism rule becomes advisory.

1 member
now(): number

ManualClock interface ↳ src/clock.ts:58

interface ManualClock extends Clock {

A clock a test owns outright.

Exists so that no test in this kit ever imports a fake-timer library. A test that wants an hour of game says clock.advance(3_600_000) and gets it in a microsecond — which is also why the whole loop suite runs in milliseconds and cannot flake on a loaded CI box.

2 members
advance(ms: number): void

Move forward.

Throws

RangeError on a negative or non-finite amount. A negative advance is always a bug and never a rewind you meant: the loop clamps a backwards clock to a zero delta (I-6), so a test that "rewound" here would be asserting on a code path the loop deletes. Use ManualClock.set if you genuinely want to reproduce a clock that jumped back.

set(ms: number): void

Jump to an absolute reading.

For reproducing a captured trace — including one that goes backwards, which is the only way to test a loop against an NTP correction. Nothing here stops you; the loop is what refuses to accumulate the negative delta.

Throws

RangeError if the reading is not finite. NaN would poison the loop's accumulator permanently and silently: while (NaN >= step) is false forever, so the game would stop stepping with no exception anywhere.

manualClock function ↳ src/clock.ts:91

function manualClock(startMs?: number): ManualClock

A clock that only moves when a test moves it.

Parameters
startMs

the initial reading. Defaults to 0. The origin is arbitrary — a monotonic clock has no epoch — so this exists only for reproducing a trace that started somewhere else.

Throws

RangeError if startMs is not finite.

frames9 symbols

@browser-only — the cadence adapter. The one module in this package that names a host global, and the reason every other module runs unchanged in Node with no shims.

A loop that reaches for requestAnimationFrame itself is wrong 100% of the time in a hidden tab, so when to run is injected exactly as the clock is. browserFrames() is the two-source pump: rAF for 'paint', a plain interval for 'tick'. Neither call tells you what time it is, which is the seam — the kit ships the cadence, the game ships the clock — and it is why the determinism rule is untouched by this file.

Everything else here is pure and Node-safe: the PumpKind/Pump/FrameSource vocabulary is three type declarations, and manualFrames touches nothing at all. They share the file because they are one concept and because the linter's adapter count should read "loop has exactly one module that can see a browser", not two. Nothing in this module runs at import; a Node build tree-shakes browserFrames out entirely.

The trap this file exists to make un-steppable-in

rAF is 0 Hz in a background tab. A loop built on rAF alone stops advancing the game the moment the player looks at another tab — and because the canvas keeps showing its last painted frame, it looks alive. The report that eventually arrives is "my production resets when I switch tabs", weeks later, from a player.

And it would stop the kit saving. @latticekit/persist schedules its debounced autosave through loop.real, and every timer in this package advances only when a pump arrives. The interval half of this function is therefore what keeps autosave alive in a hidden tab — which is precisely when tabs get closed. A one-line "simplification" down to rAF alone passes every test that runs in the foreground and silently stops saving on the one code path nobody watches. That is invariant I-23, and it is a separate invariant for that reason.

DEFAULT_IDLE_PUMP_MS const ↳ src/frames.ts:45

const DEFAULT_IDLE_PUMP_MS = 1000

Period of the non-painting pump, in milliseconds. Default for BrowserFramesOptions.idleMs.

One second, because browsers clamp background intervals to roughly that and Chrome throttles harder still after five minutes. This is a floor on how stale a hidden game is allowed to get, not a frame rate, and lowering it buys nothing the platform will honor. It is also the granularity a hidden-tab timer actually has, which is why a sub-second debounce in @latticekit/persist is meaningless in the background.

PumpKind type ↳ src/frames.ts:59

type PumpKind = 'paint' | 'tick'

Why a pump happened.

  • 'paint' — the host is about to display a frame. render may run.
  • 'tick' — the host is not painting (hidden tab, occluded window, minimized), but time has still passed. update runs; render does not.

A boolean was rejected: pump(true) at a call site says nothing, and this distinction is the single most important one in the package. Get it backwards and either the game freezes whenever it is not visible, or it repaints a canvas nobody is looking at sixty times a second.

Pump type ↳ src/frames.ts:69

type Pump = (kind: PumpKind) => void

A callback the loop hands to its frame source. Calling it runs one pump, synchronously.

Synchronous matters: the loop reads the clock once inside the pump and everything in that pump is accounted against that one reading. A frame source that deferred the call — through a promise, a microtask, a setTimeout(0) — would move the work away from the reading that paid for it, and the elapsed time would be attributed to the wrong pump.

FrameSource interface ↳ src/frames.ts:80

interface FrameSource {

Where pumps come from. The loop never schedules anything itself.

start must be safe to call after stop — a loop can be restarted, and Vite's HMR restarts one on every save. stop must cancel everything it registered: an rAF chain that re-arms unconditionally survives stop() and keeps the whole object graph alive with it, canvas, world, audio and all. Two live chains driving one canvas is a real failure mode and it presents as a game running at double speed for no reason anyone can find.

2 members
start(pump: Pump): void
stop(): void

FrameHost interface ↳ src/frames.ts:93

interface FrameHost {

Just enough of window to drive a loop. Structural, so a real window satisfies it and so does a fifteen-line fake in a test.

This is the seam that keeps this file testable at 100% coverage in Node: nothing here ever has to touch a real browser to be exercised, and a game that wants a frame-rate cap or a fixed-cadence recorder writes one of these instead of asking for an option.

4 members
requestAnimationFrame(cb: (t: number) => void): number
cancelAnimationFrame(handle: number): void
setInterval(cb: () => void, ms: number): number
clearInterval(handle: number): void

BrowserFramesOptions interface ↳ src/frames.ts:101

interface BrowserFramesOptions {

Options for browserFrames.

2 members
readonly idleMs?: number

Period of the non-painting pump. Default DEFAULT_IDLE_PUMP_MS.

Do not lower it hoping for a faster hidden tab: browsers clamp background intervals to roughly one second, and Chrome throttles harder still after five minutes. This number is a floor on how stale a hidden game is allowed to get, not a frame rate.

Throws

RangeError at construction if it is not a finite number greater than zero. A zero period is a busy loop wearing a timer's clothes.

readonly host?: FrameHost

Injected for tests. Defaults to globalThis.

If you pass nothing in an environment with no requestAnimationFrame — Node, a worker, an SSR pass — start() throws a TypeError naming the missing method rather than failing later with undefined is not a function inside a callback three frames deep.

browserFrames functionstart here ↳ src/frames.ts:139

function browserFrames(options?: BrowserFramesOptions): FrameSource

Browser only. The two-source pump: requestAnimationFrame for paints, a plain interval for everything else.

Both pumps run while the tab is visible — the extra 'tick' pumps cost one clock read and an empty accumulator check, and only 'paint' pumps ever render, so nothing is drawn twice. When the tab is hidden rAF stops and the interval is all that is left, which is exactly the arrangement that keeps update, the schedulers and therefore autosave alive.

The rAF chain re-arms before the pump runs, so a game that stops itself from inside a callback has its pending handle canceled by the stop() it just called, rather than arming a fresh frame on the way out of a fatal error.

Throws

RangeError if idleMs is not a finite number greater than zero.

ManualFrames interface ↳ src/frames.ts:218

interface ManualFrames extends FrameSource {

A frame source a test drives by hand.

Together with manualClock this is the entire testing story for the package: no fake timers, no await, no flake, and a simulated hour in a microsecond. It is also what replay() drives, which is why it lives in the shipped surface rather than in a test helper file.

2 members
pump(kind?: PumpKind): void

Run one pump synchronously. Defaults to 'paint', because that is the pump a test usually means; pass 'tick' to reproduce a hidden tab.

A pump on a stopped source is a silent no-op, not an error. That is deliberate: the assertion "no further callbacks arrive after stop()" (I-17) is written by pumping a stopped source and checking a counter, and a throw would make that test assert on the exception instead of on the thing that matters.

readonly started: boolean

true between start() and stop(). The cheapest check that loop.stop() released.

loop11 symbols

The fixed step and the blend factor — the only part of the kit that knows what time it is.

A loop advances a game's rules at a fixed rate off an injected wall clock whether or not anything is being painted, and hands the renderer a blend factor so the pictures can run at whatever rate the display manages. Two consequences fall out of that sentence and most of this file is their bookkeeping: a test runs a simulated hour in a millisecond with no timers and no flake, and a backgrounded tab keeps its books straight instead of quietly deleting every minute the player spent elsewhere.

One pump, in order

1  nowMs = clock.now(); elapsed = max(nowMs - last, 0); last = nowMs   ← one accounting read
2  realTime += elapsed;  real.advance(elapsed)                        ← unclamped, unpaused
3  run queued jobs, in creation order, each at most once              ← off the paint path
4  accumulator += elapsed * speed
5  if accumulator > maxCatchUp: dropped += excess; accumulator = maxCatchUp; onStall(excess)
6  while accumulator >= step:
       sim.advance(step)                                              ← timers before the step
       for each update subscriber, in order: fn(stepSeconds, tick)
       tick++; accumulator -= step
7  if kind === 'paint':
       alpha = paused ? 1 : accumulator / step
       for each render subscriber: fn(alpha, time + alpha * stepSeconds, nowMs)
8  stats

Control calls take effect at the next pump boundary — pause() from inside update does not truncate the pump it was called from — except stop(), which takes effect immediately, because a game stopping itself on a fatal error must not be updated again.

Integer microseconds, and why 60 Hz reads 16.667

accumulator -= 1 / 60 ten thousand times does not land where the arithmetic says, and at 60 Hz there is no whole number of milliseconds in a step to hide behind. So the accumulator is integer microseconds: stepUs = round(1e6 / hz) — 16,667 at 60 Hz, 20,000 at 50 — and elapsed enters as round((now - last) * 1000). Every add and subtract is integer arithmetic that cannot drift, which is what makes every dt bit-identical and both stepSeconds and stepMs stable enough to compare against a recorded log.

Two things follow. hz must be a positive integer, because it divides that constant. And 60 Hz is really 59.9988 Hz — a step of 16,667 µs rather than 16,666.67 — which is 0.002% and matters to nobody, but it is why stepMs reads 16.667 rather than 16.666666666666668. A reviewer who expected the second number should read this paragraph rather than file a bug.

What this package will never do

It credits nothing. There is no offlineSeconds, no "welcome back" event, no awayMs on any callback, and it will not grow one. The clamp in step 5 does not defer the excess to a later frame — that only moves the spiral one frame along — and it does not hand it to anybody either: it is dropped, counted in stats.droppedSeconds, and reported to onStall for diagnostics. An hour in a background tab arrives as one enormous elapsed, becomes 250 ms of ticks, and the other 3,599.75 seconds cease to exist as far as this file is concerned. They were never its to lose — @latticekit/sim has already integrated the same interval from its own stored epoch timestamp. The loop advances callbacks; sim advances value.

It also has no epoch. Clock is monotonic and performance.now() counts from an arbitrary per-document origin, so clock.now() is meaningless the moment it crosses a reload boundary — which is the only boundary a save exists to cross. persist stamps saves; this file contributes the cadence and nothing else.

DEFAULT_HZ const ↳ src/loop.ts:73

const DEFAULT_HZ = 60

Fixed steps per second when hz is not given. 60 is what the interpolation hides best.

DEFAULT_MAX_CATCH_UP_MS const ↳ src/loop.ts:90

const DEFAULT_MAX_CATCH_UP_MS = 250

How much real time one pump may spend catching up, in ms. Default 250 — about fifteen steps at 60 Hz.

This is not an optimization; it is the termination condition. Without it a pump that takes longer than the steps it produces makes more steps than the next pump can afford, and the loop accelerates into a locked tab. Its disguise: with the clamp in place a game that is far too slow degrades — sim time simply falls behind real time — so it looks like a game running in slow motion. stats.stepsLastPump sustained above 1 and a growing realTime - time are the tells.

Do not raise it to "fix" the drift a hidden tab accumulates. The ceiling is what stops a restored tab spending four seconds inside one frame while the browser paints nothing. A game that wants faithful sim time in the background wants timestamps, not more catch-up.

DEFAULT_WINDOW_MS const ↳ src/loop.ts:111

const DEFAULT_WINDOW_MS = 10000

How far back the rolling worst-frame figures look, in ms. Default 10,000.

Ten seconds because that is the window docs/GALLERY.md § Scale gates every exhibit on, and because five separate exhibits hand-rolled exactly this before it existed here. Resolved into ten buckets, so the reported worst covers between nine and ten seconds at any instant — never more than the window, occasionally a tenth less, and never a stale number from four minutes ago the way a high-water mark is.

The window is the loop's, not the caller's, and that is the point. The only way to roll a high-water mark from outside is a timer calling resetStats(), which zeroes fps and frameMs for every other reader — the city exhibit's control panel read 0.0ms · 0fps one second in five, and it resolved that by deleting a shared panel knob rather than shipping two readouts that disagreed. A rolling figure that costs another consumer its reading is not a rolling figure, it is a shared mutable clock.

DEFAULT_WARMUP_FRAMES const ↳ src/loop.ts:128

const DEFAULT_WARMUP_FRAMES = 10

Paint intervals discarded at the start of a run, before FrameStats.worstGapMs begins recording. Default 10 — about a sixth of a second at 60 Hz.

The first frames of a page are its load: compilation, the first fill into a fresh canvas, the first layout. They are real and a visitor does feel them, and they are excluded anyway because the number they otherwise poison is a gate on the scene's steady costcrowd read 16.3 ms on arrival against 12.0 ms thereafter, which meant every exhibit showed its worst number at the exact moment a visitor was forming an opinion.

It is a discard rather than a secret: it is readable off Loop.warmupFrames, stats.warmingUp is true while it is in force so a HUD can say so, and 0 here turns it off. Do turn it off to measure a page load, which is a different question with a different answer.

DEFAULT_ABSENCE_MS const ↳ src/loop.ts:144

const DEFAULT_ABSENCE_MS = 1000

A gap between paints at or above this is an absence, not a frame. Default 1,000 ms.

A hidden tab paints nothing — browserFrames keeps the 'tick' pump running and rAF stops entirely — so the gap across a tab switch is the visitor's absence measured in milliseconds. Counting one makes the readout report a 96-second worst frame, which is the first thing every hand-rolled version of this did.

One second rather than the 250 ms two exhibits picked, because 250 ms also discards a genuine quarter-second hitch, which is a catastrophic frame and precisely the thing worth reporting. The cost of the looser bound is that tabbing away for under a second reads as one bad frame; it ages out of the window in ten seconds and stats.absences is there to explain a quiet reading either way.

LoopPhase type ↳ src/loop.ts:162

type LoopPhase = 'update' | 'render' | 'job' | 'timer'

Where an exception came from, for onError.

'timer' is a callback on either timeline; 'update' covers the fixed-step subscribers and also onStall, which is reported there because it is part of the same accounting phase and a diagnostics callback that throws has still killed the pump.

Job interface ↳ src/loop.ts:174

interface Job {

A unit of work that must happen soon, at most once per pump, and never on the paint path — a navigation field rebuilt after the map changed, a spatial index reinserted, a layout recomputed after a resize.

after(0, fn) is the trap that looks like it does this: ten after(0) calls in one pump queue ten one-shots and run the sweep ten times, which is the bug with extra steps. The guarantee here is per pump, not per step — fifteen catch-up steps that each dirty a flow field still produce exactly one sweep.

3 members
request(): void

Mark the work as needed. Idempotent within a pump: ten requests are one run.

cancel(): void

Un-request it. A job that has already run this pump is unaffected.

readonly queued: boolean

Requested and not yet run.

LoopOptions interface ↳ src/loop.ts:184

interface LoopOptions {

Options for createLoop. Only clock and frames are required.

12 members
readonly clock: Clock

The host's clock. The one global clock reading in the whole application, injected.

Throws

TypeError at construction if it has no now(). Failing here rather than on the first pump is the difference between an error naming the option and undefined is not a function inside a frame callback.

readonly frames: FrameSource

Where pumps come from. browserFrames() in a game, manualFrames() in a test.

Throws

TypeError at construction if it has no start()/stop().

readonly update?: (dt: number, tick: number) => void

Advance the game by exactly dt seconds. Called 0..n times per pump, n bounded by maxCatchUpMs; called on 'tick' pumps as well, so this is where everything that is not painting belongs — economy, HUD data, autosave decisions, quest settlement. The source game's HUD updated only inside the frame callback and froze with the renderer in a background tab: stale prices, stale disabled buttons, a shop that would not open. Everything was working; only the painting had stopped.

tick is a non-negative integer, starts at 0, increments by exactly one per call, and never skips or repeats for the life of the loop. @latticekit/input keys its event buckets by it and @latticekit/persist keys its replay envelope by it: the index is the alignment between an input log and a session, so it is a guarantee, not a convenience.

Must not: read a clock, read live input listeners (sample them into a buffer instead), touch the canvas, allocate per entity, or assume it runs once per frame. Must: copy current to previous for anything the renderer interpolates, before moving it — and on a teleport, set previous = current as well, or the blend draws the entity sliding through everything between the two positions for one frame.

Optional only because it is shorthand: giving it here is exactly onUpdate(fn) called before start(), and it is therefore always the first subscriber.

readonly render?: (alpha: number, time: number, nowMs: number) => void

Draw the world as it stands alpha of the way from the last completed step to the next one. Called at most once per pump and only on 'paint' pumps — which means it may never be called at all, for minutes at a time.

time is the exact instant being drawn (loop.time + alpha * stepSeconds) so that everything sampled from a clock — bobbing, pulsing, shimmer — is sampled at one consistent moment rather than at whatever alpha happened to be per call site.

nowMs is this pump's single reading of the injected clock, handed over so that a frame-integrated presentation value can take a delta without reading a clock of its own. It is monotonic and has no epoch: it is not the calendar, cannot be stored, and must not be compared across a reload.

Must not: mutate simulation state, accumulate anything the simulation reads, step tweens, cache hit-boxes, or start timers.

readonly hz?: number

Fixed steps per second. Default DEFAULT_HZ. An idle game is happy at 20.

Throws

RangeError unless it is an integer in [1, 1_000_000]. It must be an integer because the accumulator is integer microseconds and hz is what divides it; the ceiling is where round(1e6 / hz) would reach zero and the step loop would never terminate.

Changing it changes stepMs, which is written into recorded input logs — see Loop.stepMs. It is a migration, not a tuning pass.

readonly maxCatchUpMs?: number

Catch-up ceiling in milliseconds. Default DEFAULT_MAX_CATCH_UP_MS.

Throws

RangeError if it is not a finite number greater than zero. Setting it below one step is legal and means the loop never steps at all — occasionally what a test wants, and never what a game does.

readonly budgetMs?: number

Pump cost above which stats.overBudget increments. Default DEFAULT_BUDGET_MS.

This is a work budget and it belongs to stats.overBudget alone. Do not compare it to stats.worstGapMs, which contains a whole display period that is not work.

Throws

RangeError if negative or not finite.

readonly windowMs?: number

How far back stats.worstGapMs looks, in ms. Default DEFAULT_WINDOW_MS.

Throws

RangeError if it is not a finite number greater than zero.

readonly warmupFrames?: number

Paint intervals to discard before stats.worstGapMs starts recording. Default DEFAULT_WARMUP_FRAMES; 0 measures the page load too.

Throws

RangeError unless it is a non-negative integer. Fractional frames do not exist and a fractional value here is always a millisecond figure written in the wrong unit.

readonly absenceMs?: number

A gap between paints at or above this is an absence rather than a frame. Default DEFAULT_ABSENCE_MS.

Throws

RangeError if it is not a finite number greater than zero.

readonly onStall?: (droppedSeconds: number) => void

Called once per pump that hit the catch-up ceiling, with the seconds thrown away.

Diagnostics and presentation only. It is not an offline-earnings feed: this number is monotonic-clock time, which may not include the machine's sleep, and crediting it would double-count against @latticekit/sim, which has already integrated the same interval from its own timestamp. Legitimate uses: a perf warning, deciding to skip an expensive re-layout, a "welcome back" panel that mentions no numbers.

readonly onError?: (error: unknown, phase: LoopPhase) => void

Called when anything the loop invoked throws — a subscriber, a timer, a job. The loop stops itself first, then calls this, then rethrows if this is absent.

A clock reading that is not a finite number does not come here: the loop cannot trust its own accounting at that point, so it stops and throws whether or not this is present.

A loop that swallowed an exception and kept pumping would produce the worst bug shape there is: the picture still moves, the state is frozen, and nothing in the console says so.

Loop interface ↳ src/loop.ts:326

interface Loop {

A running (or stopped, or paused) loop. Constructed by createLoop; never a singleton.

26 members
readonly running: boolean

Started and not stopped. Independent of paused.

readonly paused: boolean

speed === 0. Sim time is not advancing; real time still is.

readonly speed: number

Sim-time multiplier. 1 is normal, 2 is fast-forward, 0 is paused. Never negative.

readonly time: number

Sim 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: number

Real 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: number

Fixed 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: number

The dt every update is handed, forever. Computed once; see Loop.stepMs.

readonly stepMs: number

The 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: Scheduler

Timers 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: Scheduler

Timers 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: number

The 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: number

The catch-up ceiling in force. See LoopOptions.maxCatchUpMs.

readonly budgetMs: number

The work budget stats.overBudget counts against. See LoopOptions.budgetMs.

readonly windowMs: number

How far back the rolling worst figures look. See LoopOptions.windowMs.

readonly warmupFrames: number

How 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: number

The gap above which a paint interval is an absence. See LoopOptions.absenceMs.

readonly stats: FrameStats

Live figures. The same object every read — copy the fields you keep.

onUpdate(fn: (dt: number, tick: number) => void): Disposer

Attach 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): Disposer

Attach 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): Job

Create 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(): void

Begin 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(): void

Stop 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(): void

setSpeed(0), remembering the previous speed. A no-op if already paused.

resume(): void

Restore the speed from before the pause. A no-op if not paused.

setSpeed(multiplier: number): void

Set the sim-time multiplier.

Throws

RangeError 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(): void

Zero the counters and the smoothing window. Totals included; tick and time are not counters.

createLoop functionstart here ↳ src/loop.ts:534

function createLoop(options: LoopOptions): Loop

Build a loop. Nothing runs until start(): there is no ambient loop, no singleton and no autostart, because two live loops driving one canvas is a real failure mode — Vite's HMR produces it routinely — and it is much easier to notice when every loop has an owner that constructed it.

Throws

TypeError if clock or frames is missing or the wrong shape.

Throws

RangeError if hz, maxCatchUpMs or budgetMs is out of range, naming the option and the value.

scheduler4 symbols

One timer model, driven by somebody else.

A Timeline has no clock. It advances when it is advanced, which is what lets the loop own two of them — one on sim time, one on real time — with a single implementation, and what lets a test run a simulated hour in a microsecond. The rule the whole kit follows is here in miniature: a package that needs to advance something exposes a tick-shaped method and lets somebody drive it; it does not go and find a clock.

Integer microseconds, and why

Every instant and period in here is an integer number of microseconds. dueUs += periodUs ten thousand times is exact; due += 1 / 60 ten thousand times is not, and the drift shows up as a spawn wave that fires one step early after twenty minutes — reproducible, wrong, and invisible to any test that runs for a second. Seconds are the unit at the API boundary and microseconds are the unit inside; the conversion happens once per call, at the edge.

Coalescing: at most one call per advance(), carrying repeats

A timer that came due eight times during one advance produces one call with repeats === 8, never eight calls. An hour spent in a hidden tab arrives at loop.real as a single advance(3600), and a real.every(1, …) that fired 3,600 times inside one frame would lock the tab it was supposed to be keeping honest.

The window is one advance(), not one pump — and that distinction is deliberate. The loop advances real exactly once per pump, so for real timers the two are the same thing. It advances sim once per fixed step, so a sim timer coalesces per step instead. That is the stricter and more useful guarantee: coalescing sim timers per pump would make a spawn wave fire in a pattern that depended on how many catch-up steps the frame happened to contain, which is frame-rate-dependent behavior in the one timeline that has to replay identically (invariant I-10 asks for exactly this — the same call sequence under two different pump patterns). The bound sim timers get instead comes from the catch-up clamp: a pump can never advance more than maxCatchUpMs of sim time, so a sim burst is bounded at fifteen calls at the defaults where a real burst would be unbounded.

Ordering

Timers due in the same advance fire in due-time order, then registration order, and the comparator can never return 0 because registration sequence is unique. That is the Lattice ordering rule: anything that orders by a numeric key breaks ties by insertion sequence and exposes no comparator parameter, because a comparator that may return 0 reintroduces exactly the ambiguity the rule exists to remove.

Tier A throughout: + - * /, Math.round, Math.floor, comparison. No clock, no randomness, no platform.

TimerId type ↳ src/scheduler.ts:58

type TimerId = number

Opaque, never reused within a session, and cheap: a number, not an object.

The counter is shared by every timeline in the process, so an id from loop.sim handed to loop.real.cancel returns false instead of silently canceling a completely unrelated timer that happened to be allocated the same small integer. Per-timeline counters were the obvious design and would have made that collision certain.

Scheduler interface ↳ src/scheduler.ts:115

interface Scheduler {

The read-and-schedule half of a timeline. What loop.sim and loop.real hand out.

The loop keeps advance to itself deliberately: a game that could advance loop.sim directly would be the second clock that non-negotiable "one thing decides when work happens" exists to prevent, and it would desynchronize sim timers from the fixed step they are defined against.

6 members
readonly time: number

Current time on this timeline, in seconds since it was created.

readonly pending: number

Live timers. 0 is a fine assertion for "nothing is left running", and the cheapest leak detector this package offers: a scene torn down with pending > 0 left a callback holding its whole object graph alive.

after(delay: number, fn: () => void): TimerId

Fire once, at or after delay.

after(0, …) fires on the next advance, which makes it look like a way to defer work off the current pump. It is not the right tool for that: ten after(0) calls in one pump queue ten one-shots and run the work ten times. Use loop.coalesce when the work must happen at most once per pump.

Throws

RangeError if delay is negative, NaN, infinite, or too large to express in integer microseconds.

Throws

TypeError if fn is not a function.

every(period: number, fn: (repeats: number) => void): TimerId

Fire every period.

repeats is how many periods this one call stands for. A callback never runs more than once per advance of its timeline: an hour spent hidden gives one call with repeats === 3600, not 3,600 calls in one frame. Write the body so it is correct for any repeatscredit(perTick * repeats), not credit(perTick) — and make it idempotent, because a repeat callback that is not safe to run twice is not safe on a timer at all.

Scheduling is by absolute due time, so periods do not drift: the hundredth fire of a 30-second timer is at 3,000 s, not at 3,000 s plus a hundred roundings. That also makes a race between two periodic jobs reproducible rather than intermittent — which is a smaller mercy than it sounds, because the race is still there. If two periodic jobs must not interleave, they are one job, or one is an after re-armed from inside the other.

Throws

RangeError if period is not a finite number greater than zero — a zero period is an infinite loop, not a fast timer.

Throws

TypeError if fn is not a function.

cancel(id: TimerId): boolean

Remove a timer. true if a live one was removed.

Canceling twice is not an error, and canceling from inside a firing callback works: a timer canceled during an advance never runs in that advance, even if it was already collected as due.

cancelAll(): void

Remove every timer on this timeline. Anything already collected as due will not run.

Timeline interface ↳ src/scheduler.ts:181

interface Timeline extends Scheduler {

A scheduler somebody advances. The loop keeps its two to itself and exposes Scheduler.

A game that wants a third — a cutscene clock, a per-level timer that resets, a replay scrubber — makes one with createTimeline and calls advance from inside its own update. That keeps the count of things that decide when work happens at exactly one.

1 member
advance(dt: number): void

Move this timeline forward and fire whatever came due, once each, in due-time then registration order.

Throws

RangeError if dt is negative, NaN or infinite. A negative advance would run timers backwards, which has no meaning: a due time already passed cannot un-pass.

createTimeline function ↳ src/scheduler.ts:198

function createTimeline(): Timeline

A timeline starting at time zero with nothing scheduled.

Advance it from inside update (fixed step, replays identically) rather than from render, where a mutation is forbidden and the delta is frame-rate dependent.

tween4 symbols

Interpolation over a clock somebody else owns.

Tweens interpolate numbers. A position is two of them, or one driving a lerp inside onUpdate. There is no tween(sprite, 'pos.x', …): a path string is reflection — it costs a split and a walk every step, it defeats rename, and a typo fails silently forever.

The curve vocabulary is core's, whole

This package defines no easing curve and no easing name, and never will. Easing names get written into level data and save files, so data that names a curve must resolve it through the one table the whole kit shares — 'cubicOut' has to mean the same thing in ui, in draw and here, forever. An unknown name throws a RangeError listing the valid ones; it must never quietly fall back to linear, because a level file with a typo would then ship feeling wrong and passing its tests.

A corollary worth stating out loud: there is no easeInOutSine and no expo curve anywhere in Lattice. Math.cos and Math.pow are not required by ECMA-262 to be correctly rounded, so either one silently demotes every tween that uses it out of Tier A — and a tween drives a position, a position gets written to a save, and the save no longer replays.

Where step is called from, and why that is the caller's business

Tweens.step(dt) is called by the game, from wherever in its update the ordering should be. A camera tween stepped after the world lags it by a frame. A tween stepped in render is a mutation in the one callback that must not mutate, and it makes the animation frame-rate dependent, which is the whole reason the fixed step exists.

Because a curve out of EASINGS is Tier A, a tween stepped on the fixed dt is safe to have write simulation state. core's damp is the opposite — it is Tier B — so a damped camera position may reach a pixel and may never reach a save, a hash or a checksum.

Timing is integer microseconds, for the same reason the scheduler's is: elapsed += 1 / 60 ten thousand times does not land where the arithmetic says, and a tween that ends a ten-thousandth short never fires its "arrived" callback.

TweenOptions interface ↳ src/tween.ts:73

interface TweenOptions {

Options for Tweens.start.

8 members
readonly from: number

The value at t = 0. RangeError if it is not finite — a NaN here spreads silently.

readonly to: number

The value handed to onUpdate exactly once at the end, before onDone.

readonly seconds: number

Duration.

Throws

RangeError if it is not finite and greater than zero, or if it rounds to less than one microsecond. A zero-length tween is an assignment, and writing it as a tween hides the assignment behind a callback that fires on some later frame.

readonly onUpdate: (value: number) => void

Called with the eased value every step, and exactly once more with exactly to before onDone.

t is clamped to [0, 1], so a tween that overruns its duration by half a step still ends on to rather than extrapolating past it. The value may leave [from, to] in the middle if the curve overshoots — that is what backOut and bounceOut are for, and a consumer that cannot tolerate an overshoot should not name one of those curves.

readonly ease?: Easing | EasingName

A curve, or the name of one in core's EASINGS. Default is linear.

Throws

RangeError on a name that is not in the table, listing the valid ones. Falling back to linear would let a typo in a level file ship, feeling wrong and passing.

readonly delay?: number

Wait this long before the first onUpdate. This is the whole sequencing story: delay covers most of it and onDone covers the rest in three lines.

Throws

RangeError if negative or not finite.

readonly slot?: string

A slot, not a tag. Starting a tween with a slot cancels any live tween in the same slot, silently and without its onDone.

Two tweens writing one property is the commonest animation bug there is: each writes its own idea of the value on alternate steps and the thing shudders between two paths. slot: 'panel.y' makes re-targeting mid-flight the default behavior instead of a thing you remember to do.

readonly onDone?: () => void

Fires once, after the final onUpdate. Never fires for a canceled or slot-displaced tween — which is what makes it safe to put "the panel has arrived, enable the buttons" in here, because a re-target must not enable them halfway.

Tweens interface ↳ src/tween.ts:156

interface Tweens {

A bag of running tweens the game steps itself.

Not owned by the loop, deliberately: the loop would have to choose an ordering relative to update, and there is no right answer to that — it depends on whether the tween drives the world or follows it.

5 members
readonly active: number

How many tweens are live, including ones still inside their delay.

start(options: TweenOptions): TweenId

Begin a tween. Validates everything at this call, so a bad curve name or a zero duration fails at the line that wrote it rather than on some later frame.

cancel(id: TweenId): boolean

Stop one. true if a live tween was removed. onDone does not fire.

cancelAll(): void

Stop all of them. No onDone fires. The teardown call for a scene.

step(dt: number): void

Advance every live tween by dt seconds.

A tween started from inside an onUpdate or onDone does not step in the same pass, and one canceled from inside one never runs again — the classic mutation-during-iteration crash, and the canceled-callback-that-fires-once-anyway bug, both closed here rather than left to the caller.

Throws

RangeError if dt is negative, NaN or infinite.

createTweens functionstart here ↳ src/tween.ts:199

function createTweens(): Tweens

An empty set of tweens. One per scene, or one per game; they cost nothing when idle.

stats1 symbol

The frame budget, measured — with two instruments, because one of them is blind.

This module is one interface and no code, which is the point: the numbers are produced by the loop, in place, into a single object that never changes identity. A FrameStats that were built per frame would be an allocation on the one path where allocations are counted.

The measurement itself uses the injected clock, so a coarse clock gives coarse stats. That is a property of your clock, not a bug in this — and it is why a manual-clock test can assert an exact millisecond count instead of a tolerance.

The two instruments, and why neither one is enough

measuressees a GC pause between pumps?includes the display's cadence?
pump workframeMs, worstFrameMs, overBudgetthe wall time from the top of a pump to the bottom of itnono
the gapworstGapMs, cadenceMsthe wall time from one painted frame to the nextyes — it is the gap, so everything in the gap is in ityes

A pump reads the clock once on the way in and once on the way out, so a garbage collection, a style recalculation or a compositor stall that lands between two pumps is not inside either reading and never appears in the pump figures. That is not hypothetical: the crowd exhibit measured 23.1 ms worst on one machine and 13.1 ms on the other for the same build, because whether the pause lands inside a pump is machine-dependent and the pump figure is not. The terraces exhibit shipped a HUD reading 0.0 ms against a real worst gap of 9.2 ms. A measurement that always reports the reassuring answer is worse than no measurement, because it is trusted.

The gap catches all of it, and pays for that by measuring something the exhibit does not control. A frame gap is bounded below by the display: a scene pinned to cadence reads 16.7 ms on a 60 Hz panel and 8.3 ms on a 120 Hz one, and the first looks twice as bad while being exactly as healthy. So neither instrument dominates, and shipping one of them alone is shipping an incomplete picture by construction — which is why both are here and why the pump pair was not redefined out from under the HUDs already reading it.

This is why FrameStats.cadenceMs exists. A gap is only legible next to the period the display was actually running at, and the loop already knows it: the shortest gap in the window is the panel's period, because nothing can paint faster than the panel refreshes. Read the pair — 21.4 ms against a cadence of 16.7 is a dropped frame on a 60 Hz screen; 8.4 against 8.3 is a perfect one on a 120 Hz screen; and the same rule reads correctly on both.

What a HUD should show

worstGapMs and cadenceMs, and — if there is room — frameMs beside them so a reader can tell a slow scene from a slow machine. Do not compare worstGapMs against budgetMs: the budget is a work budget and a gap contains a whole display period that is not work. overBudget is the field that belongs to budgetMs, and it counts pumps.

FrameStats interface ↳ src/stats.ts:61

interface FrameStats {

Live frame figures.

loop.stats returns the same object on every read; the loop mutates it in place. That is what keeps reading it every frame free. If you need to keep a reading — for a graph, for a report — copy the fields you want.

The trap, and it is worth one more sentence because it costs an afternoon every time: const before = loop.stats; then comparing before.frameMs to loop.stats.frameMs later compares an object with itself and finds no difference, ever. Storing this object stores a live view that changes under you. Copy the number, not the object.

15 members
readonly fps: number

Paints per second, counted over the last completed second of real time.

A count over a window rather than a smoothed reciprocal of a frame interval, so the number is an integer a human can check against a devtools reading. It is 0 until the first full second has elapsed — truthful rather than flattering, and a test can rely on it.

readonly frameMs: number

Smoothed cost of a whole pump: jobs, update, render and the loop's own bookkeeping.

Smoothing is a one-eighth exponential moving average — a negative power of two, so the arithmetic is exact in binary and a test can assert an equality rather than a tolerance. The first sample after a resetStats() is taken whole, so the reading is never dragged up out of zero for the first dozen frames.

readonly updateMs: number

Smoothed cost of all update subscribers in a pump — the number that grows with entity count.

readonly renderMs: number

Smoothed cost of all render subscribers. Zero on a pump that did not paint.

readonly worstFrameMs: number

Worst frameMs sample since the last resetStats() — the pump's own work, high-water.

Averages hide exactly the frame a player feels. A game at a smooth 60 with one 90 ms hitch when the map loads has a perfect frameMs and a worstFrameMs that names the bug.

Two things it is not, both of which have already misled somebody. It is pump work, so a pause between two pumps is invisible to it — see this module's header, and read FrameStats.worstGapMs for the figure that catches one. And it never decays, so over a session it converges on the worst frame the page ever had, which is usually the one it loaded on; a HUD that wants "the worst frame lately" wants worstGapMs, which rolls a window of its own and needs no resetStats() on a timer to stay honest.

readonly worstGapMs: number

The honest cost figure: the worst gap between two painted frames in the rolling window.

This is what a player feels — the interval between two pictures, which contains the pump, the browser's compositing, the garbage collector, and every other thing the machine chose to do in between. It is measured from the loop's own single clock reading at the top of each 'paint' pump, so it needs no performance.now() anywhere and a manual-clock test can assert it exactly. Four exhibits built this by hand before it existed here, and the fourth asked for it under this name.

The window is windowMs, resolved into ten buckets, so this is the worst gap of the last 0.9 to 1.0 of it — never longer, occasionally a tenth shorter, and never a stale number from four minutes ago the way a high-water mark is.

Three things a reader has to know before quoting it:

  • It contains a display period. Compare it to FrameStats.cadenceMs, never to budgetMs. Sixty hertz pinned to cadence is 16.7 ms and is a pass.
  • It only counts 'paint' pumps. A hidden tab paints nothing, so the gap across a tab switch is an absence, not a frame: gaps of absenceMs or more are excluded and counted in FrameStats.absences instead. Without that rule the first thing this readout does is report a 96-second worst frame, which is exactly what the exhibit that hand-rolled it first saw. The excluded gap re-bases the next one, so the reading recovers on the following paint rather than sitting at 0.0 until the window turns over.
  • The first warmupFrames gaps are excluded, and FrameStats.warmingUp says while it is happening. See that field for why it is a choice and not a cover-up.

0 until the first gap is measured — truthful rather than flattering, and the same promise fps makes.

readonly cadenceMs: number

The display's period, as this loop actually observed it: the shortest paint-to-paint gap in the same window.

Nothing can paint faster than the panel refreshes, so the fastest frame in ten seconds is the panel. It exists because FrameStats.worstGapMs is otherwise unreadable across machines — 8.4 ms is healthy on the 120 Hz laptop it was measured on and would be a mystery on a 60 Hz one — and because "60 fps on a mid laptop" is a threshold that has to mean the same thing on both. The verdict a HUD wants is the ratio: a worst gap under about one and a half cadences dropped no frames.

0 until the first gap is measured.

readonly absences: number

Gaps between paints of absenceMs or more, since the last resetStats().

The tab was hidden, the window was dragged between monitors, the machine slept. These are excluded from FrameStats.worstGapMs because counting one turns the gate into a report of how long the visitor spent in another tab — and they are counted here rather than silently dropped, because a discard nobody can see is how a measurement starts lying. A suspiciously calm window with a non-zero absences is a window with a hole in it.

readonly warmingUp: boolean

true while the opening warmupFrames gaps are still being discarded.

The choice this field exists to keep visible. The first painted frames of a page include its load: crowd reported ~16.3 ms on arrival and ~12.0 ms from the second window on, which was truthful and meant every exhibit in the gallery displayed its worst number at the exact moment a visitor was deciding what they thought of it. Three answers were available — discard N frames, start the window at the first steady frame, or label it — and this package takes the first and the third: the prefix is discarded, warmupFrames is readable off the loop so the size of the discard is never a secret, and this flag lets a HUD show rather than a confident 0.0 ms while it is in force.

Set warmupFrames: 0 for the unfiltered figure, which is what a benchmark of page load wants and what a scene's steady cost does not.

readonly overBudget: number

Pumps that cost more than budgetMs. The number a benchmark should assert on.

readonly stepsLastPump: number

Fixed steps run in the most recent pump.

Sustained above 1 means the game cannot keep up: the clamp turns that into a game running in slow motion rather than a locked tab, so this figure and a growing realTime - time are the only tells that the degradation is happening at all.

readonly ticks: number

Fixed steps run since the last resetStats().

readonly renders: number

Paint pumps that ran the render subscribers since the last resetStats().

readonly pumps: number

Pumps of either kind since the last resetStats().

readonly droppedSeconds: number

Total sim seconds discarded by the catch-up clamp.

Diagnostics only. This is monotonic-clock time, which may not include the machine's sleep, and crediting it would double-count against @latticekit/sim, which has already integrated the same interval from its own stored epoch timestamp. The loop advances callbacks; sim advances value. Legitimate uses: a perf warning, a "welcome back" panel that says nothing about numbers, deciding to skip an expensive re-layout.

replay4 symbols

The replay driver — what makes non-negotiable #1 falsifiable rather than aspirational.

@latticekit/input produces a log keyed by tick, @latticekit/persist stores and verifies it, and nothing else in the kit can press play. This module can, and everything it needs is already here: the fixed step is what makes a tick index mean anything, stepMs is what makes a log comparable, and tick is the join.

How it stays inside the DAG. loop is layer 1 and cannot import persist, so the driver never sees a ReplayLog. It is defined against a structural ReplaySource that persist's cursor satisfies — exactly as ui declares its Driveable structurally in the other direction. Nobody imports upward and nobody duplicates a format.

What a green replay proves, and what it does not

It proves the fixed step's prohibitions were obeyed. A game that reads a clock inside update, derives from a frame delta, or lets a render pass mutate state cannot pass, because none of those inputs exist here: there is no wall clock, no variable delta and nothing is painted. It is the one test in the kit that fails when someone adds Math.random() to a system months from now.

It does not prove the picture matched, and two caveats are carried openly:

  • The camera is outside the contract, deliberately. @latticekit/input runs two clocks — gestures deliver on ticks, the camera integrates on frames, which is what keeps a drag under the finger when a step is long. So a log reproduces the same world and the same tiles, not the same glide. The rule that keeps that safe is the Tier B rule: a frame-integrated camera may reach pixels and must never reach a hash. Hashing one makes every replay fail for a reason that is not a bug, on a machine the author does not have.
  • A replay is not a save. It reconstructs a session from its start; it does not resume one. persist owns resuming.

Four ways to build one that reports a confident wrong answer

All four are closed here rather than left to a caller.

mistakewhat it produces
applying a tick's inputs after its updateevery tick one late, and a report that blames the game for the driver
rendering during a replaytwo orders of magnitude slower, frame-rate dependent, and able to hide a divergence if any render pass mutates
hashing a Tier B valuetwo correct engines disagree in the last bits and the replay fails forever
replaying at a different hztick indices still line up and mean something completely different

ReplaySource interface ↳ src/replay.ts:65

interface ReplaySource {

A recorded session, seen from here: a length, inputs addressable by tick, and optional checkpoints.

@latticekit/persist's zero-allocation cursor satisfies this; so does an array in a test. This package never learns what a log looks like on disk, which is the whole reason the driver can live in layer 1.

4 members
readonly ticks: number

Total ticks recorded. The replay ends here, and ending is the point.

readonly stepMs?: number

The step length the log was recorded at, if the source knows it.

Optional because an array in a test does not have one. When it is present it is compared against the replay loop's stepMs and a mismatch throws rather than being reported as a divergence at tick 1 — a log recorded at 60 Hz and replayed at 50 has tick indices that still line up and mean something completely different, and the two failures deserve different words. @latticekit/persist refuses on the same comparison by name.

applyAt(tick: number): void

Apply everything recorded for tick to the live input state. Called exactly once per tick, in ascending order, before that tick's update.

Must allocate nothing and must not skip: a driver that applied inputs one tick late would produce a divergence report that blames the game for the driver's bug.

checkpointAt(tick: number): number | undefined

The checkpoint hash recorded at tick, or undefined if that tick carries none. Checked after that tick's update.

ReplayOptions interface ↳ src/replay.ts:97

interface ReplayOptions {

Options for replay.

6 members
readonly source: ReplaySource

The recording. See ReplaySource.

readonly update: (dt: number, tick: number) => void

The same update the live game runs. If it is not the same function, nothing is proven — a replay of a reimplementation tests the reimplementation.

readonly hash: () => number

The state hash the recording used.

Must be Tier A arithmetic: + - * /, Math.sqrt, Math.imul, bitwise. A hash built on Math.exp, or over a smoothed camera value, reports divergence between two correct engines and there is no way to tell that apart from a real one.

readonly hz?: number

Steps per second. Must equal the log's. Default DEFAULT_HZ.

Throws

RangeError unless it is an integer in [1, 1_000_000], and if ReplaySource.stepMs is present and disagrees with the step this hz produces.

readonly stopOnDivergence?: boolean

Stop at the first mismatch. Default true.

false runs to the end and still reports the first divergence, which is how you find out whether the drift stayed in one subsystem or spread.

readonly onProgress?: (tick: number) => void

Called every thousand completed ticks and once at the end, for a progress bar on a long log. Never called with a partial tick.

ReplayResult interface ↳ src/replay.ts:140

interface ReplayResult {

The verdict. See replay.

5 members
readonly ticks: number

Ticks actually run — less than source.ticks only if it stopped at a divergence.

readonly checkpoints: number

Checkpoints compared. 0 means the log carried none and the run proved very little.

readonly divergedAt: number

-1 when the replay matched the recording all the way through.

readonly expected: number

The recorded and recomputed hashes at divergedAt. Both 0 when nothing diverged.

readonly actual: number

replay functionstart here ↳ src/replay.ts:167

function replay(options: ReplayOptions): ReplayResult

Replay a recorded session and report the first tick at which this build stopped agreeing with the recording.

Synchronous, allocation-free per tick, and as fast as the machine — there is no clock to wait for, because there is no clock: it builds its own manualClock and manualFrames, advances by exactly one step per pump (so the catch-up clamp is never in play and the arithmetic is identical to a live session), and pumps 'tick' only. Nothing is painted.

Throws

RangeError if source.ticks is not a non-negative integer, if hz is out of range, or if source.stepMs disagrees with the step hz produces.

Throws

TypeError if source, update or hash is missing or the wrong shape.

Throws

whatever update throws — the loop stops itself first, so a thrown replay leaves nothing running.

index1 symbol

dispose1 symbolre-exported from @latticekit/core

One teardown vocabulary for the whole kit.

Before this module, five packages had each invented their own: input returned disposers from a scope, ui from interactive, loop from subscriptions, persist from store handles, audio from buses. A game tearing down a scene had to remember all five, and the one it forgot was a listener that stayed live — invisible for an hour, then the tab is using two gigabytes. Everything in Lattice that binds something now hands back a Disposer, and anything with a lifetime owns a Scope.

There is exactly one ordering rule — reverse registration order — because child() registers the child's dispose into the parent's own list. "Children before parent" is not a second rule to remember; it falls out of the first.

Tier A: no clock, no platform, no allocation beyond the disposer list itself.

Disposer type ↳ src/dispose.ts:30

type Disposer = () => void

Undo one thing.

Idempotent by contract. Calling a disposer twice must be safe and must not undo something else. The failure this rule exists to prevent: a handle is released, its slot is reused by somebody else, and the second call to the stale disposer releases their handle — a bug that presents as a completely unrelated subsystem losing its subscription. Every disposer the kit returns satisfies it, and every disposer a game writes is expected to.

A Scope calls each disposer exactly once, so idempotence is not for the scope's benefit: it is for the caller that also holds the disposer directly and disposes early.