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 andsubstrate_harnessrun aTick → Render → Presentframe; headless stays tick-only; theInitCaps/InitComponentsboot stages exist in the kind vocabulary but aren't yet wired into a shipped graph. Build on the stage kinds and the subscribe surface; readaether-lifecycleand 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 kind | Wire name | Role |
|---|---|---|
Tick | aether.lifecycle.tick | per-frame step carrying the frame's microseconds and the total since boot |
Render | aether.lifecycle.render | submit geometry after the whole Tick chain settles |
Present | aether.lifecycle.present | post-render ordering point and the graceful-quit drain edge |
Shutdown | aether.lifecycle.shutdown | terminal; graceful cleanup with the mail surface still live |
InitCaps | aether.lifecycle.init_caps | capability boot pass (in the vocabulary; not yet in a shipped graph) |
InitComponents | aether.lifecycle.init_components | component boot pass (likewise) |
Quit | aether.lifecycle.quit | the 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 eachTickadds 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_harnessrunframe_lifecycle_params—Tick → Render → Present → Tick, looping, with theQuitescape to aShutdownterminal onPresent. A fullTick → Render → Presentcycle runs per frame;Renderbroadcasts only after theTickchain settles, and GPU submit/present runs afterRendersettles, so a submission integrates the fully-settled state of the frame. - Headless runs
tick_only_lifecycle_params—Tick → Tick, looping, with theQuitescape toShutdownonTick. Headless composes no render actor, so a component that depends on render is refused at load there; one that subscribesRenderwithout depending on render gets the lifecycle'sErrfor a stage the graph omits, while itsTickpath 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
LifecycleAdvanceuntil the reply'snextreaches 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 byKindId, 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, andWindowSizepublished by the window actor — Input streams. - The
wirehook,initversuswire, and writing handlers — Components & lifecycle.