State and time
How state() gives a script memory across frames while scrubbing, replaying and farm renders all stay deterministic.
Ordinary expressions are pure: the same frame always computes the same value from nothing but the document. state() adds the one thing purity cannot do - memory - without giving up determinism. This page explains the model; the state and memo reference sections carry worked examples (a gravity bounce and a frame counter you can rebuild).
state(): memory that folds
export function frame({ dt }) {
const store = state({ y: 60, vy: 0 });
store.vy += 900 * dt;
store.y += store.vy * dt;
return [320, store.y];
}
state(initial) answers a per-script store. The first evaluated frame sees initial; every later frame sees whatever the PREVIOUS frame left in it. Mutate it freely - it is your object for the frame.
The store advances along the comp's integer frame grid as a FOLD from the epoch (frame 0): the state at frame n is defined as frame n minus one's state pushed through your frame(). Jumping the playhead to frame 200 shows exactly what playing 0 through 200 would have shown, because that is literally how the value is defined - scrubbing equals playing, always. The engine keeps periodic checkpoints so seeking stays fast, and a document edit invalidates them and refolds; none of that machinery changes the value, only how quickly it is reached.
state(initial, { key: "..." }) names additional stores when one script wants several. A top-level const store = state({...}) binds the same per-frame store as calling it inside frame().
Plain data only
Stores are serialised between frames, so they hold PLAIN DATA: numbers, strings, booleans, arrays, plain objects. A function, BigInt or circular structure reports the typed state_unserialisable; a store growing past 256 KB reports state_too_large. Keep the working set, not a history of everything.
Reads inside the fold are pinned
During a fold your CLOCK walks the grid - time, frame and dt take each folded frame's values - but PROPERTY and CONTROL reads always answer the frame being rendered, not each intermediate step. So fold from your own store and the clock (velocities, counters, phases), and treat scene reads as "where things are NOW". A trail of a layer's past positions is therefore not built by reading the layer during the fold - integrate your own copy of the motion instead, as the gravity example does.
Errors do not advance the fold
A frame whose evaluation fails leaves the store exactly as the previous frame serialised it and the outputs at their base values - the fold skips the failed step rather than recording half of it.
memo(): compute once per document
memo(key, fn) runs fn once and caches the result until the DOCUMENT changes - derived constants, lookup tables, parsed data. The body must not read time or frame (the typed memo_time_access enforces it): memo is for things that change with edits, never with playback. For per-frame memory use state(); for per-frame values just compute them - evaluation is cheap and budgeted.
Time, spelled twice
frame is the canonical clock - an integer on the comp's exact rational grid - and time is derived seconds (frame / fps). Write cues in whichever reads best and convert with timeToFrames() / framesToTime(); dt (one frame in seconds) is the natural step for integration, as in the bounce above. Remember the one shadowing rule: inside export function frame() {} destructure { frame } from the argument, because the bare word names the function.