---
title: How it works
description: Scene code runs once at compile time or once per frame at resolve time, and a renderer only reads the result.
mode: wide
---

Code in a MotionActor scene runs at one of two times. Generator code runs once, when the scene
compiles. `derived` bodies and channel reads run once per frame, when a frame resolves. A
renderer runs after both and only reads what they produced. Most questions about the runtime
come down to which of these a line belongs to.

## Compile time

`createScene` returns a definition. `scene.build({ viewport, fps })` runs its generator to the
end and returns a `CompiledScene`. While it runs, the runtime keeps a cursor in frames and records every operation at the cursor's
position. `yield*` on a tween or a wait moves the cursor forward; `set` writes at the cursor and
moves nothing.

<CursorScene />

Durations are seconds. Each one is multiplied by the build's `fps`, which the host passes, and
rounded to the nearest whole frame, so the last wait, 7.5 frames at 30 fps,
becomes 8. Authoring in seconds keeps a scene's timing when its frame rate changes.

The record is ordinary data:

```ts twoslash
// @filename: cursor.scene.ts
import {
  createScene,
  LayoutActor,
} from "@motionactor/core";

export class Title extends LayoutActor {}

export const scene = createScene(function* (ctx) {
  const title = ctx.spawn(Title, {
    layout: { x: 480, y: 160, width: 320, height: 160 },
  });
  title.appearance.opacity.set(0);
  yield* title.appearance.opacity.tween(1, 0.2);
  yield* ctx.wait(1);
  yield* title.layout.y.tween(240, 0.4);
  yield* ctx.wait(0.25);

  return { title };
});
// @filename: main.ts
// ---cut---
import { scene } from "./cursor.scene";

using compiled = scene.build({ viewport: { width: 1280, height: 720 }, fps: 30 });
compiled.fps; // 30
compiled.totalDuration; // 56
compiled.operations.map((operation) => operation.kind);
// ["spawn", "set", "tween", "wait", "tween", "wait"]
```

`createScene` returns a definition and never compiles by itself. Each
`build({ viewport, fps, inputs })` compiles a fresh record for those values.

## Resolve time

`compiled.at(frame)` rebuilds the state of every actor at one frame from the record. Signals
take their tweened values, `derived` bodies run, channels supply their current values and layout
boxes are computed. The result is a `FrameState`, with typed scene exports and lookups by
class.

A channel is how a value the scene cannot know gets in, such as a measured width. Here a meter's fill depends on its level, which the scene tweens, and on a width the host
supplies:

```ts twoslash
import {
  channel,
  createScene,
  derived,
  LayoutActor,
  signal,
} from "@motionactor/core";

class Meter extends LayoutActor {
  level = signal(0);
  width = channel({ default: 400 });
  fill = derived(() => this.level() * this.width());
}

const compiled = createScene(function* (ctx) {
  const meter = ctx.spawn(Meter);
  yield* meter.level.tween(1, 1);
  return { meter };
}).build({ viewport: { width: 1920, height: 1080 }, fps: 30 });

const meter = compiled.at(15).scene.meter!;
meter.fill; // 200

meter.channels.width.write(800);
compiled.at(15).scene.meter?.fill; // 400
```

The host publishes through the resolved actor's handle, and every later read sees the value.

## Render time

A renderer receives the resolved actors of one frame and draws them. It has no clock and no way
to change what the scene authored; a value it measures goes back in through a channel. The same
resolved frame can be drawn by the DOM, Pixi, Three.js or Skia backend, inside Remotion,
HyperFrames, the native preview or the exporter.

| | Compile time | Resolve time | Render time |
| --- | --- | --- | --- |
| Runs | once per scene, or per build | once per `at(frame)` | once per drawn frame |
| Code | the generator, commands, `set`, `ctx.spawn` | `derived` bodies, channel reads, layout | actor renderers, backend hosts |
| Moves time | `yield*` | never | never |

## What a frame depends on

`at(frame, options)` depends on three things: the frame number, the values of the channels it
reads, and the options passed to it (`viewport`). Anything else that changes
the result is a bug in the scene. `Date.now()`, `Math.random()` or a module-level counter inside
a `derived` body will look right during the first playthrough and drift once someone seeks.

Because the inputs are known, frames cache exactly. `at()` keeps each actor's resolved values and
revisits an actor only when a timeline it reads moves, a channel it read is published, or an
effect on it is active. Asking for the same frame again with nothing changed returns the same
object; a call with `cache: false` resolves afresh.

```ts twoslash
// @filename: cursor.scene.ts
import {
  createScene,
  LayoutActor,
} from "@motionactor/core";

export class Title extends LayoutActor {}

export const scene = createScene(function* (ctx) {
  const title = ctx.spawn(Title, {
    layout: { x: 480, y: 160, width: 320, height: 160 },
  });
  title.appearance.opacity.set(0);
  yield* title.appearance.opacity.tween(1, 0.2);
  yield* ctx.wait(1);
  yield* title.layout.y.tween(240, 0.4);
  yield* ctx.wait(0.25);

  return { title };
});
// @filename: main.ts
// ---cut---
import { scene } from "./cursor.scene";

using compiled = scene.build({ viewport: { width: 1280, height: 720 }, fps: 30 });
compiled.at(40) === compiled.at(40); // true
compiled.at(40, { cache: false }) === compiled.at(40); // false
```
