How it works
Compile records authored intent once. Resolution reconstructs actor state for any frame from that record. Nothing else may influence a frame.
MotionActor has exactly two stages, and almost every question about the runtime — why a value did not update, why a seek produced the wrong result, why a renderer should not own a timer — is answered by knowing which stage you are in.
Stage 1: compile
createScene(generator) runs your generator once, immediately, to completion. It does
not render anything and it does not advance a clock. What it does is keep a cursor of
authored time and write down every authored event it passes.
const compiled = createScene(function* (ctx) {
const title = ctx.spawn(TextBlock, { id: "title" });
yield* title.appearance.opacity.tween(1, 0.2); // cursor 0.0 → 0.2
yield* ctx.wait(1); // cursor 0.2 → 1.2
yield* title.layout.y.tween(80, 0.4); // cursor 1.2 → 1.6
return { title };
});The result is a CompiledScene: a record of spawns, tween ranges, structural hosting and
scene exports. yield* is the only thing that moves the cursor — a plain set(...) writes
at the current cursor and takes no time.
opacity.tween(1, 0.2)ctx.wait(1)layout.y.tween(80, 0.4)Authored seconds become integer frames at compile time using the scene's fps. At 30 fps
this scene is 48 frames long.
Stage 2: resolution
compiled.at(frame) reconstructs every actor's state at that frame from the record. It is
a pure function and it is random access — frame 400 costs the same whether or not you have
asked for frame 399.
const state = compiled.at(60);
state.scene.title; // typed scene export
state.findActor(TextBlock); // typed lookup by blueprint
state.camera.zoom; // resolved cameraRenderers read resolved state and draw it. They never own animation truth, never hold a timer, and never decide what the next frame looks like.
What separates them
Runs once, in the generator.
ctx.spawn(...)— create an actoryield* tween(...),yield* ctx.wait(...)— consume authored timesignal.set(...)— write at the current cursorchild(...),hostInSlot(...)— declare structurewatch(...)— register a reactive response
Determinism is a contract, not a goal
at(frame) is pure in three inputs and nothing else:
- the frame number,
- the values of the channel publications it reads,
- the explicit options you pass (
viewport,channelValues).
Anything else that changes the answer is a bug in the scene, not a feature of it.
Date.now(), Math.random(), module-level mutable counters and closed-over state inside
derived, transition or watch bodies all break seeking and caching — silently, because
the first playthrough looks fine and only scrubbing backwards reveals it.
Where host values enter
Anything observed rather than authored — a measured DOM box, a stepped physics body, a
fetched value — enters through a channel. Channel
publications are versioned, so cached frames invalidate precisely: publishing to channel
A does not bust a frame that only read channel B.
Why this shape
Authored time
Authored durations are seconds. The runtime converts to integer frames using the
scene's fps, which defaults to 30.
type Duration =
| number // seconds
| { frames: number }; // explicit frames, when you really mean framesUse { frames: n } only when a duration is genuinely frame-quantized (a one-frame flash,
a sprite step). Everything else should be authored in seconds so the scene survives an
fps change.