How Tiramisu Works

Tiramisu is a typed declarative bridge between a Lustre view tree and a renderer-local Three.js object graph. Lustre decides what entities and component values should exist. Tiramisu reconciles that declaration into stateful components, systems, owned objects, async work, and a Savoiardi renderer.

This document describes the runtime architecture in Tiramisu 9. For extension authoring, see Writing Extensions.

The short version

Each renderer host owns an isolated world:

The important boundaries are:

  1. Application code creates ordinary Lustre elements with typed Tiramisu values.
  2. Those values are serialized into declarative DOM data.
  3. One tiramisu-renderer reads its nested scene and reconciles it.
  4. Components own entity-local state and behavior.
  5. Systems own renderer-local indexes and coordination.
  6. Commands give the runtime explicit ownership of objects and asynchronous settlements.
  7. Disconnecting the renderer tears down that whole world.

There is no global scene runtime shared by renderer hosts.

Declarative authoring

A typical view is still plain Lustre code:

tiramisu.renderer(
  "main-renderer",
  [renderer.width(1280), renderer.height(720)],
  [
    tiramisu.scene(
      "main-scene",
      [scene.background_color(0x0F172A)],
      [
        tiramisu.camera(
          "camera",
          [
            camera.active(True),
            transform.position(vec3.Vec3(x: 0.0, y: 2.0, z: 6.0)),
          ],
          [],
        ),
        tiramisu.primitive(
          "cube",
          [
            primitive.box(vec3.Vec3(x: 1.0, y: 1.0, z: 1.0)),
            material.color(0x38BDF8),
          ],
          [],
        ),
      ],
    ),
  ],
)

The public constructors are conveniences over one uniform model:

ConstructorComponents injected automatically
tiramisu.sceneScene environment
tiramisu.entityNone
tiramisu.emptyNone
tiramisu.cameraCamera
tiramisu.lightLight
tiramisu.primitivePrimitive geometry and material
tiramisu.meshAsync mesh and material

The scene is emitted as tiramisu-scene. Every non-scene node is emitted as tiramisu-entity; cameras, lights, meshes, primitives, and extension-defined behavior are component values rather than separate runtime node kinds.

Typed values are folded in list order and encoded into a data-tiramisu-components JSON attribute. Later values for the same component field win. This keeps the DOM transport uniform while application and extension code continue to use concrete Gleam types.

Registration compiles the runtime vocabulary

tiramisu.register() registers the built-in plugin. Use tiramisu.register_with([my_plugin]) to append user plugins.

Registration performs validation before installing the renderer custom element:

A component definition retains typed schema and lifecycle closures behind an erased extension.Component. A system does the same behind extension.System. This is the only heterogeneous boundary: extension code never needs to decode Dynamic values or cast component state.

Registration is browser lifecycle work. On Erlang, register and register_with return Ok(Nil) without starting a runtime. The authoring modules remain available so Lustre server components can render the same markup before browser hydration.

One renderer is one world

When a tiramisu-renderer connects, it creates:

The renderer then parses the first nested tiramisu-scene, reconciles it, and starts the animation loop. The observer schedules another reconciliation when entity children, ids, or encoded component values change.

Two renderer elements may use the same entity ids because their registries, systems, objects, cameras, tasks, and generations are independent.

Entities have stable roots and incarnations

Every valid scene node becomes an entity with:

The root represents hierarchy and identity. Components generally mutate the root for transforms and attach replaceable objects to named slots. A loaded mesh, for example, can replace the mesh slot without invalidating the root used by transforms, children, or systems.

An incarnation distinguishes two entities that reuse the same id at different times. Events and async settlements carry the incarnation that created them, so late work cannot target a newly mounted entity merely because its string id matches.

Reconciliation

On each DOM mutation, the renderer parses the scene into a uniform node tree and performs a deterministic reconciliation:

  1. Validate non-empty, renderer-unique entity ids.
  2. Compute the new entity preorder.
  3. Reparent retained roots before removing old parents.
  4. Remove absent entities in descendants-first order.
  5. Mount new entities and reconcile retained entities in preorder.
  6. For each entity, unmount absent component identities.
  7. Mount or update the requested components in declaration order.
  8. Store the final component and entity order.
  9. Drain async settlements queued while reconciliation was active.

A component identity is its component name plus an optional instance key. Changing component data updates the existing mounted instance. Removing that identity unmounts it. Removing and later recreating the entity creates a new incarnation and new component state.

Invalid initial component data emits an error and mounts the schema default. Invalid update data emits an error and preserves the previous valid state. Unknown components and components placed on the wrong scope are rejected without corrupting the rest of the entity.

Component lifecycle

A mounted component contains its decoded data, private state, event listeners, object-change subscriptions, and optional tick hooks.

The lifecycle is:

mount -> zero or more updates/events/object changes/ticks -> unmount

Mount state is stored before event listeners are attached, and listeners are attached before mount commands execute. This means a mount command may safely emit an event handled by the same component.

Lifecycle callbacks do not mutate the runtime value directly. They return commands. This lets the runtime associate every effect with the component and entity incarnation that produced it.

Unmount is ordered and guaranteed. The runtime:

  1. invalidates the component’s pending task settlements;
  2. removes its DOM event listeners;
  3. runs its unmount callback;
  4. executes allowed teardown commands;
  5. removes and disposes its object slots; and
  6. releases its managed resources.

Removing an entity unmounts all components before disposing the entity root. Descendants are removed first, so recursive Three.js disposal cannot dispose an object that the runtime will later treat as independently live.

Object slots and ownership

A named object slot is a replaceable child of an entity root. The component or system command origin that first sets the slot owns it.

set_object has three outcomes:

remove_object follows the same ownership rule. Object-change subscribers are notified before the removed or replaced object is disposed, allowing dependent components to update their own state safely.

This model prevents two unrelated components from silently overwriting or leaking the same Three.js resource.

Systems

A system is instantiated once per renderer. It has private state and can subscribe to typed component mounts, updates, and unmounts. Subscriptions are decoded using the component’s schema before the callback runs.

Systems are appropriate for behavior that needs a renderer-wide view, such as:

A system receives an opaque SystemContext exposing only its renderer’s scene, renderer, and entity lookup. It cannot accidentally query another renderer.

The built-in camera system demonstrates renderer-wide arbitration. Active camera components are considered in current entity order. The first active camera is selected; additional active cameras produce a multiple_active_cameras error. Reordering entities can therefore change the selected camera without remounting their components.

Frame ordering

Each animation frame has two explicit phases:

BeforeRender systems
BeforeRender components in entity/component order
Render the selected active camera
AfterRender systems
AfterRender components in entity/component order

Systems run before components within each phase. Systems retain plugin registration order. Components retain scene preorder and component declaration order.

Use BeforeRender for mutations that must be visible in the current frame. AfterRender is for work that intentionally occurs after drawing. Components and systems opt into phases; there is no implicit per-component tick cost.

If no active camera is selected, the lifecycle still ticks but no scene render call is issued for that frame.

Async work and stale-result safety

Async commands are lazy: their start closure runs only after the command is accepted by a connected browser runtime.

Every task is keyed by:

Starting the same key again from the same origin invalidates the older version. The same key from a different component, named instance, entity incarnation, or renderer remains independent.

A settlement is accepted only if its generation, owner, key, and version are still current. Settlements arriving during reconciliation are queued, then checked after reconciliation completes. This avoids applying work against a partially updated entity graph.

task discards a stale success. resource_task additionally receives a disposer for a successful value rejected before acceptance. Accepted resources must transfer to one runtime owner, commonly an object slot. Tiramisu does not claim to abort the underlying JavaScript Promise; cancellation means that its settlement can no longer mutate this world.

Events and diagnostics

Components may emit typed JSON detail through DOM events and subscribe to named DOM events with a decoder. Runtime event dispatch includes entity incarnation, component name, and instance key, so an event queued for an old component instance cannot reach its replacement.

Authoring and reconciliation failures bubble from the affected scene/entity element as tiramisu:component-error. The detail contains:

{
  "component": "component-name-or-null",
  "instance": "instance-key-or-null",
  "reason": "machine-readable-reason"
}

Reasons currently include invalid or duplicate entity ids, unknown components, invalid scope or data, slot ownership conflicts, and multiple active cameras. Applications can listen for this event during development without coupling to internal runtime state.

Disconnect and reconnect

Renderer connection is an incarnation with one idempotent disposer. Disconnect performs the following cleanup:

  1. stop observing DOM mutations;
  2. remove entities descendants-first, running component cleanup;
  3. invalidate remaining system tasks and run system stop callbacks;
  4. stop and dispose the renderer animation loop;
  5. dispose the Savoiardi renderer; and
  6. remove its canvas from the shadow root.

A later reconnect increments the generation and creates a fresh scene, renderer, canvas, systems, entities, and ownership state. Late events or tasks from the previous generation fail their identity checks and cannot affect the new world.

Module boundaries

The public surface is intentionally smaller than the runtime:

ModuleResponsibility
tiramisuRegistration and declarative renderer/entity constructors
tiramisu/rendererRenderer attributes and public tick event
tiramisu/sceneScene environment values
tiramisu/cameraCamera values
tiramisu/lightLight values
tiramisu/primitivePrimitive geometry values
tiramisu/meshAsync mesh source values
tiramisu/materialMaterial and texture values
tiramisu/transformTransform values
tiramisu/dev/extensionTyped extension authoring API
tiramisu/internal/*Reconciliation, ownership, renderer lifecycle, and DOM bridge

Application and extension code should not import tiramisu/internal modules. The extension API exposes capabilities rather than runtime storage so the renderer can preserve isolation, ordering, and cleanup invariants.

Search Document