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)
6f
ctx.wait(1)
30f
layout.y.tween(80, 0.4)
12f
30 fps
0s·0f
1s·30f
2s·60f

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 camera

Renderers 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 actor
  • yield* tween(...), yield* ctx.wait(...) — consume authored time
  • signal.set(...) — write at the current cursor
  • child(...), hostInSlot(...) — declare structure
  • watch(...) — register a reactive response

Determinism is a contract, not a goal

at(frame) is pure in three inputs and nothing else:

  1. the frame number,
  2. the values of the channel publications it reads,
  3. 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 frames

Use { 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.

On this page