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:
- Application code creates ordinary Lustre elements with typed Tiramisu values.
- Those values are serialized into declarative DOM data.
- One
tiramisu-rendererreads its nested scene and reconciles it. - Components own entity-local state and behavior.
- Systems own renderer-local indexes and coordination.
- Commands give the runtime explicit ownership of objects and asynchronous settlements.
- 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:
| Constructor | Components injected automatically |
|---|---|
tiramisu.scene | Scene environment |
tiramisu.entity | None |
tiramisu.empty | None |
tiramisu.camera | Camera |
tiramisu.light | Light |
tiramisu.primitive | Primitive geometry and material |
tiramisu.mesh | Async 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:
- plugin, component, and system names must be unique;
- field names must be unique within a component;
- names must follow the supported lowercase-hyphen format; and
- built-ins always precede user plugins in stable order.
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:
- a Savoiardi scene;
- a Savoiardi renderer and canvas;
- one runtime generation;
- one instance of every registered system;
- an entity registry and stable preorder;
- component, object-slot, resource, and task ownership state; and
- a mutation observer for its own nested scene tree.
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:
- a non-empty id unique within that renderer;
- a monotonically assigned incarnation;
- a stable root
Object3D; - an optional parent entity;
- an ordered list of mounted components;
- named object slots; and
- managed resources.
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:
- Validate non-empty, renderer-unique entity ids.
- Compute the new entity preorder.
- Reparent retained roots before removing old parents.
- Remove absent entities in descendants-first order.
- Mount new entities and reconcile retained entities in preorder.
- For each entity, unmount absent component identities.
- Mount or update the requested components in declaration order.
- Store the final component and entity order.
- 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:
- invalidates the component’s pending task settlements;
- removes its DOM event listeners;
- runs its unmount callback;
- executes allowed teardown commands;
- removes and disposes its object slots; and
- 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:
- an empty slot attaches the object and records its owner;
- the same owner replaces the object, after which the old object is disposed;
- a different owner is rejected, the incoming object is disposed, and a
slot_ownedcomponent error is emitted.
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:
- camera arbitration;
- physics or collision worlds;
- spatial indexes;
- batched animation; or
- shared renderer resources.
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:
- renderer generation;
- command origin;
- entity id and incarnation when entity-local;
- component name and optional instance key when component-local; and
- caller-provided task key and monotonically assigned version.
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:
- stop observing DOM mutations;
- remove entities descendants-first, running component cleanup;
- invalidate remaining system tasks and run system stop callbacks;
- stop and dispose the renderer animation loop;
- dispose the Savoiardi renderer; and
- 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:
| Module | Responsibility |
|---|---|
tiramisu | Registration and declarative renderer/entity constructors |
tiramisu/renderer | Renderer attributes and public tick event |
tiramisu/scene | Scene environment values |
tiramisu/camera | Camera values |
tiramisu/light | Light values |
tiramisu/primitive | Primitive geometry values |
tiramisu/mesh | Async mesh source values |
tiramisu/material | Material and texture values |
tiramisu/transform | Transform values |
tiramisu/dev/extension | Typed 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.