The frame
How Khora turns the CLAD layering into motion — the per-frame descent, the Substrate Pass, and the fixed-timestep model that keeps the simulation deterministic.
Two clocks, one channel
Khora has two clocks. The hot path runs every frame on the main thread, at 60 Hz or higher, and must never block. The cold path runs at ~20 Hz on a background thread, watches what just happened, and decides what should happen next. They communicate through a single channel: budgets flow from the cold path to the hot path; telemetry flows back.
sequenceDiagram
participant OS as OS / winit
participant SDK as EngineCore
participant App as EngineApp
participant GW as GameWorld
participant RS as RenderSystem
participant Sch as Scheduler
participant FG as FrameGraph
participant DCC as DCC (~20 Hz thread)
OS->>SDK: redraw requested
SDK->>SDK: drain_inputs()
SDK->>SDK: run_app_update (Pre/Post-Sim + Pre-Extract DataSystems around app.update)
SDK->>App: app.update(world, inputs)
SDK->>RS: begin_frame() → ColorTarget, DepthTarget
SDK->>Sch: run_frame()
Sch->>Sch: budget_channel.sync()
Sch->>Sch: run Flows → publish Views into LaneBus
Sch->>Sch: per phase: plugins, topo sort, execute agents
Note over Sch: Agents record GPU passes into FrameGraph; lanes fill the OutputDeck
SDK->>FG: drain + submit (topological pass order)
SDK->>RS: end_frame(presents) → swapchain present
SDK->>SDK: run_maintenance (drain OutputDeck, EcsMaintenance compaction)
DCC-->>Sch: budgets via BudgetChannel
The cold path is the subject of The big idea and the GORNA reference. This page is about the hot path — the six stages a frame walks through, and the timing model underneath them.
Startup, once
Before the first frame, the engine boots through the SDK entry point. It opens a
window, runs your bootstrap closure (which typically registers the renderer),
constructs your app, registers the default services, the DCC, and the agents,
then calls setup once so you can spawn initial entities and cache service
handles. Finally the DCC walks every registered agent and lets each cache its
services exactly once. Nothing in setup is ever re-run; after it, the engine
enters the frame loop and stays there.
The details of bootstrapping an app are a how-to, not a concept — see the SDK quickstart how-to for the actual steps.
The six-stage descent
Each frame runs six stages in order. Every stage is a public method on the engine core, so drivers (the editor’s overlay and shell) can interleave hooks between them:
1. drain_inputs ← Pop queued InputEvents, tick telemetry
2. run_app_update ← Substrate invariants (Pre/Post-Sim, Pre-Extract) around app.update
3. begin_render_frame ← RenderSystem::begin_frame, swapchain acquire
4. run_scheduler ← Substrate Pass (Flows publish Views) + phase-by-phase agent execution
5. end_render_frame ← submit FrameGraph + RenderSystem::end_frame
6. run_maintenance ← Maintenance DataSystems drain the OutputDeck; EcsMaintenance compacts
A few stages reward a closer look, because they are where the architecture’s discipline shows.
Stage 2 — run_app_update runs the data layer’s invariants around your
game logic: pre-simulation systems (input-driven mutations the app will see),
then app.update, then post-simulation systems (hierarchy fix-ups such as
transform propagation), then pre-extract systems (GPU mesh sync). Notice what is
not here: scene projection. That happens in Stage 4.
Stage 4 — run_scheduler is the heart of the descent. It runs in two parts.
First the Substrate Pass runs every registered projection
Flow, publishing each domain’s typed View into
the LaneBus for lanes to consume. Then the
Scheduler runs every active execution phase in order — INIT, OBSERVE,
TRANSFORM, MUTATE, OUTPUT, FINALIZE — syncing budgets from the DCC,
running plugin hooks, topologically sorting the phase’s agents by their hard
dependencies, and executing them. This is the Control → Agent → Lane → Data
descent made concrete: the Scheduler is Control, it dispatches agents, agents
invoke lanes, lanes read the Views the Substrate just published.
Stage 6 — run_maintenance is where the data layer does its own
housekeeping. The maintenance systems drain the
OutputDeck the lanes filled — audio and physics
write-backs land on ECS components here — and the ECS maintenance pass compacts
storage pages and prunes orphaned data. This pass is Data-owned and
self-budgeted: it is not negotiated with the DCC. It is the home of the data
layer’s self-optimization, the same “Data adapts itself” principle from
CLAD.
Two output channels. Lanes feed two sinks. The
FrameGraphcarries recorded GPU render passes, drained and submitted in Stage 5. The typedOutputDeckcarries cross-domain results (audio, physics, …), drained by maintenance systems in Stage 6. The inputs to lanes are the typed Views the projection Flows publish into theLaneBusin Stage 4.
The six stages are the single most important sequence in Khora. Everything performance-critical happens here, in this order. To watch a real frame walk through them, see Debug a frame.
The Substrate Pass
The Substrate is the data layer’s per-tick self-presentation: the invariants that keep the World consistent (Stage 2) and the projection Flows that publish read-only Views for the lanes (Stage 4). The Scheduler invokes the Substrate because it owns the tick ordering, but it does not decide how the data is laid out — that remains the data layer’s own concern.
The crucial property: Flows are read-only projectors. A Flow reads the World and publishes a typed View; it never mutates game state and never changes which components an entity has. This is the frame-level expression of adapt the HOW, never the WHAT. Lanes consume those Views from the bus rather than querying the World directly — which is what lets the data layer change its internal representation freely without any lane noticing.
Fixed-timestep simulation and render interpolation
Rendering runs at the display’s variable rate, but the simulation must advance in fixed increments to stay frame-rate independent and deterministic. A physics step that depended on a variable frame delta would produce different results on a 30 Hz machine and a 144 Hz machine — unacceptable for physics, replays, or networked play. Khora reconciles the two with a single time accumulator, owned by the Scheduler.
The model, per frame:
- Measure and clamp. Take the real wall-clock delta since the previous
frame and clamp it to
MAX_FRAME_DELTA_SECONDS(0.25 s). A longer real gap — a debugger break, an asset hitch, a window drag — is truncated so the accumulator never demands an unbounded catch-up. This is the classic spiral-of-death guard. - Accumulate. Add the clamped delta to the accumulator.
- Consume whole steps. With the fixed step
fixed_delta(the smallestfixed_timestepany agent declares — in practice the physics agent’s, default 1/60 s), computesteps = floor(accumulator / fixed_delta), capped atMAX_SIM_STEPS(5). The remainder carries over to the next frame. - Derive alpha. The leftover fraction —
remainder / fixed_delta, always in[0, 1)— becomes the renderinterpolation_alpha. - Run. Step the fixed-timestep agents exactly
stepstimes (a fixed-update sub-loop, each iteration a full agent invocation), then run the regular phase loop once, excluding those agents so they are not stepped twice. So physics integrates N discrete sub-steps while the render fires exactly once.
sequenceDiagram
participant Loop as Frame
participant Acc as sim_accumulator
participant Sim as Fixed agents (physics)
participant Render as Render (once/frame)
Loop->>Acc: += min(real_dt, 0.25 s)
Note over Acc: steps = floor(acc / fixed_delta), capped at 5
loop steps times
Acc->>Sim: step(fixed_delta)
end
Acc->>Loop: alpha = remainder / fixed_delta (in [0, 1))
Loop->>Render: render once, blend by alpha
The step arithmetic is small and pure — its essence is:
#![allow(unused)]
fn main() {
let acc = accumulator + dt; // dt already clamped to 0.25 s
let steps = (acc / fixed_delta).floor(); // whole sim steps this frame
// ... capped at MAX_SIM_STEPS, the excess dropped so the accumulator stays bounded
let alpha = (remainder / fixed_delta).clamp(0.0, 1.0); // render blend factor
}
The determinism guarantee is the payoff: the same total elapsed time produces the same number of sim steps regardless of frame cadence. A 144 Hz burst and a 30 Hz stutter that span the same wall-clock interval run identical step counts. Game logic that must be deterministic therefore never sees a variable step. Variable-rate game code instead reads the real delta; render smoothing reads the interpolation alpha.
On saturation — when more than MAX_SIM_STEPS would be required — the excess
time is dropped rather than queued. Under sustained overload the simulation runs
in slow motion rather than freezing in an ever-deepening catch-up, and the
accumulator stays bounded. This is a deliberate trade: a brief slowdown is
recoverable; a spiral of death is not.
All of this timing state lives in one place — the engine’s
Time resource (khora_core::time::Time),
republished fresh into the runtime each frame before the render phase reads it. It
carries the real delta_seconds, the fixed_delta_seconds, the
interpolation_alpha, and a monotonic frame counter.
Why interpolation is render-only
Because the simulation steps at a fixed cadence but the screen refreshes at a
different one, the most recent simulated pose rarely lands exactly on a frame
boundary. Rendering the raw current pose would judder. Instead the render path
blends the previous and current poses by interpolation_alpha, producing
smooth motion at any refresh rate.
Here is the architecturally important part: the previous-pose store for
interpolation is an engine-internal resource
(khora_core::interpolation::TransformInterpolation), not an ECS component.
A post-simulation system snapshots each simulated body’s world transform into
that store before propagation overwrites it; the render path reads it to blend.
This is adapt the HOW, never the WHAT in its purest form. Interpolation is a render representation: it carries no game meaning, so it must never appear as a component in the editor inspector or in a saved scene. Only simulated entities (those carrying a rigid body) are snapshotted; everything else simply renders at its current transform. The store is pruned each pass so a despawned body leaves no stale entry. Keeping it out of the ECS is what guarantees the smoothing can never leak into simulation semantics — a saved scene is identical whether or not the renderer ever interpolated it.
Next steps
- Debug a frame — walk a live frame through the six stages and inspect a GORNA decision.
- The big idea — the cold path that feeds budgets into Stage 4, and the philosophy the frame serves.
- Glossary — every proprietary term in one place.