The frame lifecycle

Governing ADR: ADR-0082 (application-declared lifecycle sequence). The model — a declared graph of stages, settlement-gated advance, per-stage subscription on aether.lifecycle — is stable, and the stage-kind vocabulary (Tick, Render, Present, Shutdown, Quit) ships today. The per-chassis graphs are still being built out: desktop and substrate_harness run a Tick → Render → Present frame; headless stays tick-only; the InitCaps / InitComponents boot stages exist in the kind vocabulary but aren't yet wired into a shipped graph. Build on the stage kinds and the subscribe surface; read aether-lifecycle and the ADR for the graph a given chassis declares.

A component never runs a loop. The substrate owns the frame, and an actor reaches the frame the same way it reaches anything else — by mail. Each binary that hosts the engine (the desktop chassis, the headless chassis, a future replay or fixed-timestep host) declares its lifecycle as an ordered graph of stages, and the substrate ships one capability, LifecycleCapability at the aether.lifecycle mailbox, that walks the graph and broadcasts each stage in turn. An actor that wants a stage — Tick to step, Render to submit geometry — subscribes to it; the cap fans the stage out to every subscriber. This is the same publish/subscribe shape as input streams, on a different mailbox.

Why it exists

Every chassis runs the same skeleton — a cadence source, a per-frame broadcast, the drain that waits for the frame's work to finish, and a present or capture at the end — and each one drives a different cadence: the desktop chassis paces to vsync, the headless chassis to a fixed timer, the substrate harness steps one frame at a time. Encoding that skeleton as hand-written loop code in each binary forces the same structure to be re-derived per chassis, and the only thing that actually differs between them is the cadence and which stages they run.

Declaring the lifecycle as data collapses the loop to one shape. The application states what stages exist and in what order; the driver owns how to walk them; the chassis main loop owns only the cadence — when to ask for the next step. Adding or reordering a stage is an edit to one graph, not four loops. The cadence difference lives in the chassis's advance loop and the stages it declares, not in duplicated frame structure. And because the driver is a normal actor at a real mailbox, every frame-stage chain has a labelled root in the trace tree — "what triggered this mail?" walks straight up to aether.lifecycle's broadcast of the stage.

What it does

One mailbox, a graph of stages. Everything addresses aether.lifecycle, owned by the LifecycleCapability actor — the sole owner of the compiled graph, the subscriber table (one typed set per stage K, holding each subscriber as a ProtocolRef<Subscriber<K>>), the fan-out, and the settlement gating. The cap is a bridged singleton, so a wasm guest that declares depends(LifecycleCapability) names it by type: ctx.subscribe::<LifecycleCapability, Tick>().

Stages are signals; Tick carries time. Tick is the one stage that carries time, in two fields. delta_micros: u32 is the game time this frame adds, as the chassis driver states it, and elapsed_micros: u64 is the game time since boot with this frame included, so the frame covers (elapsed_micros - delta_micros, elapsed_micros]. The lifecycle capability owns the total and adds each frame's delta to it exactly once, so delta_micros is always the growth of elapsed_micros. A subscriber integrates a rate in units per second with tick.delta_seconds(), so slowing or speeding the frame cadence does not slow or speed authored motion, and logic that counts in whole steps reads them from tick.steps(length) (below). Every other stage is a zero-sized signal. Application state still rides its own mail — the camera computes a view-projection matrix on Tick and publishes it to aether.render, rather than threading it through a stage.

Tick time is game time; ctx.now() is measured time. The two Tick fields say how far the game has advanced, as the driver states it. An actor that wants to know how long its own work took reads the engine's actor clock, with the same call on a guest ctx and a native one:

#![allow(unused)]
fn main() {
let started = ctx.now();
// ... the work being measured ...
let took = ctx.now().since(started); // core::time::Duration
}

ctx.now() returns an aether_actor::Instant. Within one engine a later reading is never less than an earlier one, and only the difference between two readings means anything: it is not wall-clock time, not game time, and it means nothing in another engine or after a restart. init can take a reading too (WasmInitCtx::now, NativeInitCtx::now), so an actor that keeps an Instant in its state builds it there. See Guest, native, and wire boundaries for what an Instant may not do.

The stage-kind vocabulary:

Stage kindWire nameRole
Tickaether.lifecycle.tickper-frame step carrying the frame's microseconds and the total since boot
Renderaether.lifecycle.rendersubmit geometry after the whole Tick chain settles
Presentaether.lifecycle.presentpost-render ordering point and the graceful-quit drain edge
Shutdownaether.lifecycle.shutdownterminal; graceful cleanup with the mail surface still live
InitCapsaether.lifecycle.init_capscapability boot pass (in the vocabulary; not yet in a shipped graph)
InitComponentsaether.lifecycle.init_componentscomponent boot pass (likewise)
Quitaether.lifecycle.quitthe one escape signal, mailed in to request shutdown

Two more kinds are the cadence wire, not stage broadcasts: LifecycleAdvance (aether.lifecycle.advance) is what the chassis main loop sends to ask for the next step. Its delta_micros is copied into Tick and joins the total on the advance that broadcasts Tick; other stages ignore it. LifecycleAdvanceComplete (aether.lifecycle.advance_complete) is the reply the chassis waits on.

The graph is a builder over kind types. A chassis builds its graph with LifecycleGraphData::builder(), naming each stage by its kind and the edge out of it. The desktop and substrate-harness frame:

#![allow(unused)]
fn main() {
LifecycleGraphData::builder()
    .state::<Tick>()    .next::<Render>()
    .state::<Render>()  .next::<Present>()
    .state::<Present>() .next::<Tick>()    .quit::<Shutdown>()
    .terminal::<Shutdown>()
    .start::<Tick>()
    .build()
}

The builder's type-state enforces at compile time that every .state::<S>() is followed by a .next::<T>() before the next state, and that exactly one .start::<S>() is set. .build() returns a BuildError if an edge targets an unregistered kind, a kind is registered twice, or the graph has no terminal — so a malformed lifecycle fails at chassis-build, not at runtime.

Settlement gates each advance. The chassis main loop drives cadence by mailing LifecycleAdvance to the cap once per step, stating the frame's elapsed time, and what a driver states is what game time means on its chassis:

  • Desktop measures wall time between frames. It is the one driver that measures, so it is the one a stall can reach, and it holds each frame's share to a limit before stating it: AETHER_DESKTOP_MAX_FRAME_DELTA_MICROS (--desktop-max-frame-delta-micros, [desktop] max_frame_delta_micros), default 250,000. On a limited frame game time slows, no step is skipped, every actor sees the same slowed clock, and the driver warns of the microseconds it did not count, at most once per cooldown. A limit of zero is refused at boot.
  • Headless states its timer period on every advance, so game time is the number of Ticks broadcast times the period. A frame that overruns is not made up, so headless game time runs slow against the wall clock then. A slow tick rate is honoured whole: at two ticks a second each Tick adds 500,000 microseconds, with no limit.
  • A harness states the duration the test wrote, and game time is the sum of those durations exactly.

The cap adds the delta it is told and applies no limit of its own. On each advance the cap broadcasts the current stage to its subscribers, subscribes settlement on that broadcast's chain root, and waits: it advances the state pointer along the resolved edge and replies LifecycleAdvanceComplete only once that stage's whole chain has settled. Cadence couples to actual work completion — Render broadcasts only after every actor's Tick handler has finished, so a render producer submits against fully-settled cross-actor state, never a half-updated frame. A chassis that overruns its cadence and sends a second LifecycleAdvance while one is still in flight sees it warn-drop rather than skip a stage. (When no settlement registry is wired — a bare test harness — the cap falls back to fire-and-advance, replying immediately.)

Quit is one signal with one consumption point. A chassis bridges OS-level termination — ctrlc, the window's close button, a future hub-shutdown mail — to a single Quit mail. The cap sets a quit_pending flag on receipt and consumes it at the next state whose graph declares a quit edge. With the quit edge on Present, a quit requested mid-frame lets the in-flight Tick → Render → Present cycle finish before the lifecycle advances to Shutdown — the frame drains before exit. Placing the quit edge elsewhere expresses a different shutdown policy; the topology spells out the answer.

Shutdown and unwire are different teardowns. The Shutdown broadcast arrives as mail with the actor's full mail surface still operational — work that needs to talk to peers (save state, flush a write, post a metric) belongs in a Shutdown handler, and the driver waits for the Shutdown chain to settle before the loop exits. Then the chassis tears actors down, running each actor's unwire finaliser (release native handles, drop GPU resources). Shutdown is the graceful "everything still works" cleanup; unwire is the post-lifecycle "the world is going away" finaliser. The two aren't interchangeable.

Teardown closes actors in dependency order. Every actor is closed, an idle one included: the chassis tells it the engine is going, wakes it, and waits for its close, which drains its inbox, runs unwire, and ends its name. The order is fixed. Instanced actors go first, loaded components among them. The chassis's composed capabilities follow in reverse boot order. The pumped capabilities a driver owns (aether.window and aether.render on the desktop chassis) go last, render before window, because the others depend on them. A departing actor's MonitorNotice, and what a component sends from unwire (a texture it destroys, say), is therefore on the inbox of a capability that is still open, and that capability's own close dispatches it.

The interrupt/stage split. Key presses, mouse movement, and resizes are asynchronous interrupts — they arrive whenever the platform produces them, on the window actor's selector-aware streams. Tick, Render, and Present are frame stages — they fire in declared order, paced by the chassis, on aether.lifecycle. Both reach an actor as ordinary mail in arrival order on its single dispatcher thread, and both use typed subscription helpers, but they have different owners: lifecycle owns the frame graph; window owns events originating from each window.

How to use it

Subscribe a stage in wire. init can't send mail (its context is resolver-only), so a stage subscription goes in the wire hook, which runs post-init with mail allowed — the same site as an input subscribe, addressing a different cap. wire receives a WireCtx, the window-bearing context that also serves the assets a component ships in aether.asset.<path> sections (ADR-0163); it Derefs to WasmCtx, so every send and subscribe verb reads the same as in a handler. wire returns Result<(), ActorInitError> and ends in Ok(()); an Err fails the load (see the actor model). The wire context is typed by your actor like a handler's: WireCtx<'_, '_> reads as WireCtx<'_, '_, Self>. Declare the lifecycle cap as a dependency and name the publisher and the stage:

#![allow(unused)]
fn main() {
#[actor(root, depends(LifecycleCapability))]
impl WasmActor for Camera {
    fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> {
        ctx.subscribe::<LifecycleCapability, Tick>();
        ctx.subscribe::<LifecycleCapability, Render>();
        Ok(())
    }
}
}

A native actor's wire and unwire hooks are typed the same way: NativeCtx<'_> reads as NativeCtx<'_, Self>, so they reach their declared dependencies.

ctx.subscribe::<P, K>() subscribes the calling actor — the cap reads the subscriber off the inbound's host-stamped Source, so you name neither the stage id nor your own mailbox. It is checked at compile time three ways: P must publish K (LifecycleCapability publishes the stage kinds), the actor must declare depends(P), and the actor's handler for K must not declare a reply (a -> () handler, or an unchecked one), because a broadcast stage has no one waiting for a reply. Any one of them missing is an error at the call. The cap checks the last one again at run time: it refuses a sender whose published rows lack a silent or unchecked handler for the stage. ctx.unsubscribe::<P, K>() is the teardown twin, with the first two checks. You don't unsubscribe on the way out — the host clears your subscriptions when the component drops.

To subscribe another actor — an operator over MCP, or a test — send aether.lifecycle.subscribe with a subscription naming the stage and the subscriber's canonical path, as in {"Tick": "aether.kit.camera:main"}. The path decodes only when the actor there handles the stage silently, and the cap proves it live at receipt. A path that fails either proof is answered {"Err": {"Subscriber": {"path": …, "reason": …}}} naming it and why; a stage the graph does not declare is {"Err": {"Stage": {"stage": …, "error": …}}}.

Then handle each stage as its kind, like any other mail:

#![allow(unused)]
fn main() {
#[handler::event]
fn on_tick(&mut self, ctx: &mut WasmCtx<'_>, _tick: Tick) { /* advance one frame */ }
}

Logic that counts in whole steps takes them from the tick. The step length is the game's own constant, a StepLength that cannot be zero; the engine has no step length of its own:

#![allow(unused)]
fn main() {
const STEP: StepLength = StepLength::from_micros(20_000).expect("a nonzero step");

#[handler::event]
fn on_tick(&mut self, _ctx: &mut WasmCtx<'_>, tick: Tick) {
    let steps = tick.steps(STEP);
    for index in steps.indices() {
        self.step(index);                 // each whole step, exactly once, numbered from 0 at boot
    }
    self.blend = steps.fraction();        // the part of the next step already elapsed, in [0, 1)
}
}

steps is a pure function of the one mail and the length, so every actor that asks with the same length gets the same step indices, whenever it was loaded, and over any run of frames each step is handed out exactly once. A frame may hold zero steps or several. Each actor counts a frame's steps inside its own Tick handler: nothing settles between step N and step N+1, so actors that must mail each other inside one step are outside what steps provides.

A ctx that omits its actor is typed by it: the macro reads WasmCtx<'_> as WasmCtx<'_, Self>, so the ctx reaches only the actors the component declares with depends(R). The ctx's type arguments are receiver, sender, mode: the actor first, the sender the handler requires second (Anyone when it states none), and the reply mode third (WasmCtx<'_, Self, Anyone, Unchecked>); spell WasmCtx<'_, Erased> for the untyped view.

aether-kit's camera export subscribes Tick and Render — it computes its camera matrix on Tick and publishes it to aether.render on Render; its MeshViewer export (aether.kit.mesh) subscribes Render to replay its mesh each frame.

A stage your chassis doesn't declare fails fast. Subscribing to a stage the local graph doesn't carry replies aether.lifecycle.subscribe_result with an Err — an actor authored against a stage its chassis omits learns at boot, not by silently never firing. On the fire-and-forget subscribe path the error warn-drops, so a component that subscribes Render runs unchanged on a chassis that lacks the stage: it simply never receives Render.

From an agent over MCP. Ticks are the substrate's own cadence — you don't pump them by hand. The lifecycle advances under the chassis loop, so the way you observe it is through a subscribed component's behavior or its logs. Mailing a stage kind to aether.lifecycle yourself bypasses the driver and isn't the way to step the engine; let the chassis drive the frame.

The per-chassis graphs today

Which stages a chassis declares is its own choice; the driver walks whatever graph it's handed. The shipped graphs:

  • Desktop and substrate_harness run frame_lifecycle_params — Tick → Render → Present → Tick, looping, with the Quit escape to a Shutdown terminal on Present. A full Tick → Render → Present cycle runs per frame; Render broadcasts only after the Tick chain settles, and GPU submit/present runs after Render settles, so a submission integrates the fully-settled state of the frame.
  • Headless runs tick_only_lifecycle_params — Tick → Tick, looping, with the Quit escape to Shutdown on Tick. Headless composes no render actor, so a component that depends on render is refused at load there; one that subscribes Render without depending on render gets the lifecycle's Err for a stage the graph omits, while its Tick path runs unchanged.
  • The hub is a coordinator, not a frame-driven host — it runs no lifecycle graph.

The InitCaps / InitComponents boot stages are declared in the kind vocabulary and the ADR's stage model, but the shipped graphs start at Tick; a chassis that needs a two-pass boot broadcast adds them to its own graph.

How to extend or reuse it

  • A new chassis declares its own graph and drives its own cadence. A replay host that steps to recorded frame times, a fixed-timestep physics host, or a test harness that steps once all drop in by building a graph and looping LifecycleAdvance until the reply's next reaches a terminal — the driver is unchanged.
  • A new stage is a kind plus a .state::<K>().next::<…>() edge in the chassis graph; actors that want it subscribe by type. There's no driver code to widen — the cap fans out by KindId, and a chassis that omits the stage simply doesn't declare it.
  • Any actor can subscribe, not just components — a native capability that needs the tick subscribes through the same mail. What an actor receives is exactly the stages it asked for on a chassis that declares them.

Where to read more

  • The declared-graph decision, the builder type-state, the quit-edge semantics, and the per-chassis realizations — ADR-0082.
  • How a stage advance waits on its whole chain, and what "settled" means — Tracing & settlement.
  • The interrupt side of the split — Key, MouseMove, and WindowSize published by the window actor — Input streams.
  • The wire hook, init versus wire, and writing handlers — Components & lifecycle.