Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Khora Engine

An engine that thinks.

Khora is an experimental real-time game engine, written in Rust, built on a Symbiotic Adaptive Architecture: every major subsystem is an agent that knows its own cost, weighs its options, and negotiates for a slice of the frame budget — each tick. A central observer watches thermal headroom, frame-time stutter, battery, and GPU pressure, and trades budgets with the agents through a protocol called GORNA. The agents adapt; the work continues.

Most engines decide at compile time. Khora decides at runtime, every tick.


Choose your path

This documentation is organized around what you came to do. Pick a starting point — each path is a guided thread, not a pile of chapters.

🎮 I want to build a game

You want a window, a camera, and something moving on screen — fast. The engine handles the performance problem; you focus on the creative one.

Start with Your first game. It takes you from a clean clone to a running scene you can fly around in, step by step. From there, the How-to guides are task-sized recipes (“load a mesh”, “play a 3D sound”, “save a scene”), and the SDK overview maps the public surface.

🧠 I want to understand the engine

You want the ideas: why an engine would negotiate with itself, how the layers fit, what CRPECS and GORNA and AGDF actually are.

Start with The big idea. The Concepts section is the “why” — the philosophy, the architecture, and one explanation page per subsystem. Read it in order, or jump to the Glossary when a term is unfamiliar.

🔧 I want to contribute

You want to extend the engine — a custom agent, a new lane, a backend — or work on the internals.

Start with Get set up, then take the Architecture tour for a guided reading order. Extending the engine is a worked tutorial; the conventions and rules are the constraints we hold to.


In a hurry?

git clone https://github.com/eraflo/KhoraEngine.git
cd KhoraEngine
cargo build
cargo run -p sandbox        # the demo — a scene you can fly around

Then open Your first game to build your own.


Status

Khora is experimental. The foundational architecture, the CRPECS ECS, the GORNA negotiation loop, six intelligent agents, an editor with play mode, and a large workspace test suite are operational. The SDK surface is intentionally narrow and grows as the engine matures. The Roadmap lays out the multi-year path; the Open questions chapter is honest about what is still undecided.

When the engine changes, this book changes in the same commit.

An engine that thinks. A book that says so plainly.

Your first game

By the end of this lesson you will have a running Khora window showing a lit scene: a grey ground plane, a coloured sphere, and a free-fly camera you steer with the mouse and WASD. You will write every line yourself, run it, and see the result.

This is a lesson, not a reference. Follow the steps in order. When you might wonder why, there is a one-line answer with a link — keep moving.

Prerequisites

  • Rust 1.91+ (edition 2024) and a GPU with Vulkan, Metal, or DX12. Full setup is in Contributing → Setup.
  • A clone of the engine you can build:
git clone https://github.com/eraflo/KhoraEngine
cd KhoraEngine
cargo build

If cargo build finishes, you are ready.

Step 1 — Create the game crate

Add a binary crate that depends on the SDK. The SDK is the only crate a game needs — everything else is an implementation detail.

Cargo.toml:

[package]
name = "my-first-game"
version = "0.1.0"
edition = "2024"

[dependencies]
khora-sdk = { path = "crates/khora-sdk" }
anyhow = "1"
env_logger = "0.11"

Why the SDK only? See The SDK is the API.

Step 2 — Write the skeleton

Open src/main.rs. Every Khora game is a struct that implements three traits: EngineApp (lifecycle), AgentProvider (custom subsystems — none yet), and PhaseProvider (custom frame phases — none yet). Start with the empty shell:

#![allow(unused)]
fn main() {
use anyhow::Result;
use khora_sdk::prelude::math::{Quaternion, Vec3};
use khora_sdk::prelude::*;
use khora_sdk::run_winit;
use khora_sdk::winit_adapters::WinitWindowProvider;
use khora_sdk::{
    AgentProvider, DccService, EngineApp, GameWorld, PhaseProvider, RenderSystem,
    Runtime, WgpuRenderSystem, WindowConfig,
};
use std::sync::{Arc, Mutex};

#[global_allocator]
static GLOBAL: SaaTrackingAllocator = SaaTrackingAllocator::new(std::alloc::System);

struct MyGame;

impl EngineApp for MyGame {
    fn window_config() -> WindowConfig {
        WindowConfig {
            title: "My First Khora Game".to_owned(),
            ..WindowConfig::default()
        }
    }

    fn new() -> Self {
        MyGame
    }

    fn setup(&mut self, world: &mut GameWorld, _runtime: &Runtime) {
        // Step 3 fills this in.
    }

    fn update(&mut self, _world: &mut GameWorld, _inputs: &[InputEvent]) {
        // The next tutorial fills this in.
    }
}

impl AgentProvider for MyGame {
    fn register_agents(&self, _dcc: &DccService, _runtime: &mut Runtime) {}
}

impl PhaseProvider for MyGame {}
}

The #[global_allocator] line installs SaaTrackingAllocator, which feeds the engine’s memory heuristics. It is optional but recommended — see Telemetry.

Why three traits? setup/update is for game logic; engine subsystems live in agents. See Agents and Lanes.

Step 3 — Spawn the scene

Fill in setup. It runs once, after engine initialisation, and gives you a mutable [GameWorld]. You spawn entities through Vessel, a builder that guarantees every entity has a Transform and GlobalTransform.

Replace the body of setup with:

#![allow(unused)]
fn main() {
fn setup(&mut self, world: &mut GameWorld, _runtime: &Runtime) {
    // A perspective camera, pulled back and turned to look into the scene.
    let camera = ecs::Camera::new_perspective(
        std::f32::consts::FRAC_PI_4, // 45° vertical FOV
        16.0 / 9.0,                  // aspect ratio
        0.1,                         // near plane
        1000.0,                      // far plane
    );
    khora_sdk::Vessel::at(world, Vec3::new(0.0, 2.0, 10.0))
        .with_component(camera)
        .with_rotation(Quaternion::from_axis_angle(Vec3::Y, std::f32::consts::PI))
        .build();

    // A matte grey ground plane. A mesh needs a material to be drawn, so we
    // register one and attach its reference.
    let ground_mat = materials::StandardMaterial {
        base_color: math::LinearRgba::new(0.5, 0.5, 0.5, 1.0),
        roughness: 0.9,
        ..Default::default()
    };
    let ground_handle = world.add_material(ground_mat);
    khora_sdk::spawn_plane(world, 20.0, 0.0)
        .with_component(ground_handle)
        .build();

    // A directional "sun" light that casts shadows.
    let mut sun = ecs::Light::directional();
    if let ecs::LightType::Directional(ref mut d) = sun.light_type {
        d.intensity = 2.5;
        d.shadow_enabled = true;
    }
    khora_sdk::Vessel::at(world, Vec3::new(0.0, 20.0, 5.0))
        .with_component(sun)
        .with_rotation(Quaternion::from_axis_angle(
            Vec3::X,
            -std::f32::consts::FRAC_PI_2 * 0.8,
        ))
        .build();

    // A glossy red sphere in front of the camera.
    let sphere_mat = materials::StandardMaterial {
        base_color: math::LinearRgba::RED,
        roughness: 0.2,
        ..Default::default()
    };
    let sphere_handle = world.add_material(sphere_mat);
    khora_sdk::spawn_sphere(world, 0.75, 32, 16)
        .at_position(Vec3::new(0.0, 0.5, -5.0))
        .with_component(sphere_handle)
        .build();
}
}

Why an explicit material? The render projection has no default-material fallback, so a mesh with no material reference is skipped. See Materials and lighting.

spawn_plane, spawn_sphere, and spawn_cube_at return a Vessel you keep building on; add_material returns a reference you attach with .with_component(...).

Step 4 — Wire the backends and run

The engine is backend-agnostic: the renderer, physics, audio, and so on are your choice, registered once in the bootstrap closure you pass to run_winit. A first game needs only the renderer.

Add main:

fn main() -> Result<()> {
    env_logger::init();

    run_winit::<WinitWindowProvider, MyGame>(|window, runtime, _event_loop| {
        let mut rs = WgpuRenderSystem::new();
        rs.init(window).expect("renderer init failed");
        // The render agent reads the graphics device directly, so register it
        // before boxing the system.
        runtime.backends.insert(rs.graphics_device());
        let rs: Box<dyn RenderSystem> = Box::new(rs);
        runtime.backends.insert(Arc::new(Mutex::new(rs)));
    })?;
    Ok(())
}

run_winit is generic over a window provider (WinitWindowProvider, the default) and your app type. The closure’s runtime is where backends and resources are registered before the frame loop starts.

Why register the renderer yourself? Backends are swappable — lanes hold an abstract device and never know which one is underneath. See Agents and Lanes.

Now run it:

cargo run --release -p my-first-game

You should now see a window titled My First Khora Game containing a grey ground plane lit from above, with a glossy red sphere resting on it and a soft shadow beneath. The terminal logs the engine starting up. The camera does not move yet — that is the next lesson.

If the window is black, the most common cause is a missing material (the sphere or plane will simply not draw); double-check Step 3. More fixes are in Troubleshooting.

Next steps

You have a running, rendered scene. Now make it interactive:

Adding behaviour

In Your first game you built a static lit scene. Now you will make the camera move: by the end you steer it forward, back, left, and right with WASD, and each step is scaled by the real frame time so movement is smooth on any machine. You will write the input handling, read the per-frame delta, and mutate a transform.

Start from the project you finished in the previous lesson.

Step 1 — Hold some state

update runs every frame, but it needs to remember which entity to move and where to read input and time from. Give the game struct fields for those, and record the camera entity in setup.

Replace struct MyGame; with:

#![allow(unused)]
fn main() {
use khora_sdk::khora_core::platform::{InputBinding, InputMap};

struct MyGame {
    /// The entity we drive with the keyboard.
    player: Option<khora_sdk::prelude::ecs::EntityId>,
    /// Handle to the engine's input map, cached at setup.
    input_map: Option<Arc<Mutex<InputMap>>>,
    /// Handle to the engine's per-frame time, cached at setup.
    time: Option<khora_sdk::prelude::SharedTime>,
}
}

Update new:

#![allow(unused)]
fn main() {
fn new() -> Self {
    MyGame {
        player: None,
        input_map: None,
        time: None,
    }
}
}

The InputMap maps named actions (like "player.forward") to keys, so your game logic asks “is forward pressed?” instead of testing raw key codes. SharedTime is the engine’s per-frame clock — it publishes the real frame delta each tick.

Why an action map instead of raw keys? It lets players rebind controls and lets several keys share one action. See Input mapping.

Step 2 — Bind actions and cache handles in setup

At the end of your existing setup, capture the camera entity and grab the two resource handles from the Runtime. The runtime carries every engine service; setup receives it by reference.

Change the camera spawn to store the returned entity, and add the caching block:

#![allow(unused)]
fn main() {
fn setup(&mut self, world: &mut GameWorld, runtime: &Runtime) {
    // ... camera, ground, sun, sphere as in the previous lesson ...

    // Remember the camera as the controllable entity. (Build it with `.build()`
    // and keep the returned EntityId.)
    self.player = Some(
        khora_sdk::Vessel::at(world, Vec3::new(0.0, 2.0, 10.0))
            .with_component(ecs::Camera::new_perspective(
                std::f32::consts::FRAC_PI_4,
                16.0 / 9.0,
                0.1,
                1000.0,
            ))
            .with_rotation(Quaternion::from_axis_angle(Vec3::Y, std::f32::consts::PI))
            .build(),
    );

    // Bind movement actions to keys, then cache the InputMap handle so `update`
    // can query it every frame.
    if let Some(map_arc) = runtime.resources.get::<Arc<Mutex<InputMap>>>() {
        if let Ok(mut map) = map_arc.lock() {
            map.bind("player.forward", InputBinding::Key(KeyCode::KeyW));
            map.bind("player.backward", InputBinding::Key(KeyCode::KeyS));
            map.bind("player.left", InputBinding::Key(KeyCode::KeyA));
            map.bind("player.right", InputBinding::Key(KeyCode::KeyD));
        }
        self.input_map = Some(map_arc.clone());
    }

    // Cache the per-frame Time handle so `update` reads the real frame delta.
    self.time = runtime
        .resources
        .get::<khora_sdk::prelude::SharedTime>()
        .cloned();
}
}

update doesn’t receive the Runtime, so we cache the handles here. That is the standard pattern for any per-frame resource a game needs.

Step 3 — Read input and the frame delta in update

Now the payoff. Each frame: read the real delta_seconds from SharedTime, ask the InputMap which movement keys are held, and nudge the camera transform. Movement is variable-rate, so it scales by dt (not a hardcoded step) — every machine moves the same distance per second.

Replace update with:

#![allow(unused)]
fn main() {
fn update(&mut self, world: &mut GameWorld, _inputs: &[InputEvent]) {
    // Real wall-clock delta from the engine's Time resource. Falls back to a
    // 60 Hz step if the resource isn't available yet.
    let dt = self
        .time
        .as_ref()
        .and_then(|t| t.read().ok().map(|t| t.delta_seconds))
        .unwrap_or(1.0 / 60.0);

    // Resolve movement axes from the action map.
    let (mut forward, mut right) = (0.0_f32, 0.0_f32);
    if let Some(map_arc) = &self.input_map {
        if let Ok(map) = map_arc.lock() {
            if map.is_pressed("player.forward") {
                forward += 1.0;
            }
            if map.is_pressed("player.backward") {
                forward -= 1.0;
            }
            if map.is_pressed("player.left") {
                right -= 1.0;
            }
            if map.is_pressed("player.right") {
                right += 1.0;
            }
        }
    }

    // Move the camera along the world axes, scaled by frame time.
    const SPEED: f32 = 5.0; // units per second
    let velocity = SPEED * dt;
    if let Some(player) = self.player {
        world.update_transform(player, |t| {
            t.translation = t.translation
                + Vec3::Z * (-forward) * velocity
                + Vec3::X * right * velocity;
        });
    }
}
}

update_transform applies your change and syncs the GlobalTransform in one call, so the renderer sees the new pose immediately.

Why delta_seconds and not a fixed step? Gameplay movement is variable-rate; only the simulation runs on a fixed timestep. See The frame.

Step 4 — Run it

cargo run --release -p my-first-game

You should now see the same lit scene, but pressing W glides the camera toward the sphere, S pulls it back, and A/D strafe sideways. Movement speed stays constant whether the game runs at 30 or 300 frames per second.

If nothing moves, confirm you stored the camera in self.player (Step 2) and that the action names in bind match the names in is_pressed exactly.

Next steps

You can now spawn a scene and drive it with input. From here:

  • How-to recipes — full input mapping (including mouse look), spawning and transforming entities, parenting.
  • Want the full free-fly controller with mouse look? Read examples/sandbox/src/main.rs, which this lesson is a trimmed slice of.
  • Curious why movement uses delta_seconds while physics doesn’t? Read The frame — fixed timestep and interpolation.
  • Ready to write your own engine subsystem? Continue to Extending the engine.

Extending the engine

So far you have written game logic. This lesson crosses into engine work: you will add your own subsystem — a custom agent that runs every frame and a lane that does its work — register it, and watch the scheduler run it. By the end the engine’s frame loop will dispatch your code and you will see it log each frame.

The example is deliberately tiny: a “heartbeat” subsystem that logs a message at a steady cadence. The mechanics are exactly the ones a real subsystem (AI, scripting, networking) uses.

Start from a working Khora app — the project from Your first game is fine.

What you are building

Two pieces, in the order the engine uses them:

  • A lane — the executor. It implements the [Lane] trait and contains the actual work. Lanes are swappable strategies; an agent picks one per frame.
  • An agent — the strategist. It implements the [Agent] trait, owns one LaneKind, negotiates a budget through GORNA, and dispatches its lane.

Why two pieces? The agent decides, the lane does. This split is what lets the engine swap quality strategies under load. See Agents and Lanes.

Step 1 — Define the lane

Create src/heartbeat.rs. The lane implements Lane: a name, a LaneKind, and an execute that does the work. Note execute takes &self and returns Result<(), LaneError>.

#![allow(unused)]
fn main() {
use khora_sdk::khora_core::lane::{Lane, LaneContext, LaneError, LaneKind};

#[derive(Default)]
pub struct HeartbeatLane;

impl Lane for HeartbeatLane {
    fn strategy_name(&self) -> &'static str {
        "Heartbeat"
    }

    fn lane_kind(&self) -> LaneKind {
        LaneKind::Ecs
    }

    fn execute(&self, _ctx: &mut LaneContext) -> Result<(), LaneError> {
        log::info!("heartbeat: tick");
        Ok(())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
        self
    }
}
}

A real lane reads its inputs from the LaneContext (ctx.get::<T>()) — for hot-path work, that data comes from Views the agent reads off the LaneBus, never by querying the world directly. Our heartbeat needs no input, so it just logs.

Why read Views, not the world? It keeps lanes decoupled and lets the data layer adapt its layout underneath. See conventions.

Step 2 — Define the agent

In the same file, add the agent. It implements [Agent] and Default — and nothing else. No start, no builders, no accessors: an agent’s only jobs are lane selection, GORNA negotiation, and lane dispatch.

#![allow(unused)]
fn main() {
use std::any::Any;
use std::time::Duration;

use khora_sdk::khora_core::agent::{
    Agent, AgentImportance, ExecutionPhase, ExecutionTiming,
};
use khora_sdk::khora_core::control::gorna::{
    AgentId, AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget,
    StrategyId, StrategyOption,
};
use khora_sdk::khora_core::EngineContext;
use khora_sdk::khora_core::lane::{Lane, LaneContext};

#[derive(Default)]
pub struct HeartbeatAgent {
    /// The lane chosen for this frame, set by `apply_budget`.
    lane: Option<Box<dyn Lane>>,
    /// The strategy GORNA last issued us.
    current_strategy: StrategyId,
}

impl Agent for HeartbeatAgent {
    fn id(&self) -> AgentId {
        // Reuse an existing slot — `AgentId` is a fixed enum, so a custom
        // subsystem borrows the kind closest to its work.
        AgentId::Ecs
    }

    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
        // We offer a single, cheap strategy. A richer subsystem would offer
        // several (e.g. Balanced vs LowPower) with different cost estimates.
        NegotiationResponse {
            strategies: vec![StrategyOption {
                id: StrategyId::Balanced,
                estimated_time: Duration::from_micros(10),
                estimated_vram: 0,
            }],
            timing_adjustment: None,
        }
    }

    fn apply_budget(&mut self, budget: ResourceBudget) {
        // Pick the lane that matches the issued strategy. With one strategy,
        // there is one lane.
        self.current_strategy = budget.strategy_id;
        self.lane = Some(Box::new(HeartbeatLane::default()));
    }

    fn execute(&mut self, _context: &mut EngineContext<'_>) {
        if let Some(lane) = self.lane.as_ref() {
            let mut ctx = LaneContext::new();
            if let Err(e) = lane.execute(&mut ctx) {
                log::error!("heartbeat lane failed: {e}");
            }
        }
    }

    fn report_status(&self) -> AgentStatus {
        AgentStatus {
            agent_id: self.id(),
            health_score: 1.0,
            current_strategy: self.current_strategy,
            is_stalled: false,
            message: "heartbeat ok".to_owned(),
        }
    }

    fn execution_timing(&self) -> ExecutionTiming {
        ExecutionTiming {
            // TRANSFORM is the per-frame logic/simulation phase.
            allowed_phases: vec![ExecutionPhase::TRANSFORM],
            default_phase: ExecutionPhase::TRANSFORM,
            priority: 0.5,
            importance: AgentImportance::Optional,
            fixed_timestep: None,
            dependencies: Vec::new(),
        }
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }
}
}

Two things to internalise:

  • The agent owns exactly one LaneKind and only ever selects a lane, negotiates, and dispatches. Any other method on an agent struct is a rule violation. See conventions.
  • The lane it dispatches is chosen in apply_budget from the strategy GORNA issued — that is how a subsystem scales itself under load. See GORNA.

Step 3 — Register the agent

Custom agents are registered through the AgentProvider trait your app already implements. The DCC calls register_agents once at boot. An agent is registered as an Arc<Mutex<dyn Agent>> with a priority.

In main.rs, add mod heartbeat; at the top, then fill in the (currently empty) register_agents:

#![allow(unused)]
fn main() {
mod heartbeat;

use heartbeat::HeartbeatAgent;
// (Arc and Mutex are already imported from std::sync in your main.rs.)

impl AgentProvider for MyGame {
    fn register_agents(&self, dcc: &DccService, _runtime: &mut Runtime) {
        dcc.register_agent(Arc::new(Mutex::new(HeartbeatAgent::default())), 0.5);
    }
}
}

No special bootstrap is needed — the same run_winit::<WinitWindowProvider, MyGame>(...) call from the first lesson picks up the registration.

To restrict an agent to certain engine modes, use dcc.register_agent_for_mode(agent, priority, modes) instead.

Step 4 — Run it

cargo run -p my-first-game

You should now see the lit scene from the first lesson, and — once the engine enters the simulation loop — a steady stream of heartbeat: tick lines in the terminal, one per simulation frame. Your subsystem is now part of the frame.

If you see no ticks, confirm mod heartbeat; is declared in main.rs and that register_agents is the one being compiled (Rust will warn about an unused agent otherwise).

Next steps

You have added a real subsystem to the engine’s frame loop. To go further:

  • How-to recipes — add a lane to an existing agent, add an agent, add an ECS component, add a shader.
  • Agents and Lanes — the full contract: why agents stay strategists and lanes stay executors.
  • GORNA — how budgets are negotiated, so your negotiate and apply_budget offer meaningful strategies.

How-to guides

Task-sized recipes. Each page solves one goal with real, verified APIs and a way to check it worked. These assume you’ve done the Your first game tutorial (for game tasks) or Extending the engine (for engine tasks) — they don’t re-teach the basics, they link to them.

Looking for the “why” behind a recipe? Follow the links into Concepts. Looking for the exact signature? See the API reference.

Building a game

I want to…Recipe
Spawn entities and move themSpawn and transform entities
Give surfaces materials and light a sceneMaterials and lighting
Load a mesh, texture, or soundLoad assets
Play positional 3D audioPlay 3D audio
Lay out a UIBuild a UI
Save and restore the worldSave and load scenes
Bind input to actionsMap input

Extending the engine

I want to…Recipe
Define and register an ECS componentAdd a component
Add a hot-path strategy to an agentAdd a lane
Add a new strategist subsystemAdd an agent
Add a .wgsl shader and a pipelineAdd a shader
Decode a new asset formatAdd an asset decoder
Project the World into a View for lanesAdd a flow
Pin or bound an agent’s adaptationControl GORNA adaptation

Operating and tuning

I want to…Recipe
Find and fix a problemTroubleshoot
Measure and tune performanceProfile performance
Investigate one frame or a GORNA decisionDebug a frame

New here? Start with a tutorial instead — how-to guides assume you already know the ropes.

Spawn entities and move them

This guide shows you how to spawn entities, read and update their Transform, and build a parent–child hierarchy — the everyday building blocks of a Khora scene.

Prerequisites. You have completed Your first game and have an EngineApp with a setup / update loop and a GameWorld.

Spawn an entity with the Vessel builder

Vessel spawns an entity already carrying a Transform and a GlobalTransform, then lets you configure it with chained calls and finalize with build().

#![allow(unused)]
fn main() {
use khora_sdk::Vessel;
use khora_sdk::prelude::ecs::Name;
use khora_sdk::prelude::math::{Quaternion, Vec3};

let crate_entity = Vessel::at(world, Vec3::new(1.0, 0.0, -3.0))
    .with_scale(Vec3::ONE * 2.0)
    .with_rotation(Quaternion::from_axis_angle(Vec3::Y, std::f32::consts::FRAC_PI_2))
    .with_component(Name::new("crate"))
    .build();
}

The available builder steps are at(world, pos) / new(world) to create it, at_position, with_rotation, with_scale, with_transform, and with_component for any extra component. build() returns the EntityId.

For built-in shapes use the procedural helpers — they attach the mesh for you and return a Vessel you can keep configuring:

#![allow(unused)]
fn main() {
use khora_sdk::{spawn_cube_at, spawn_plane, spawn_sphere};

let ground = spawn_plane(world, 20.0, 0.0).build();          // size, y
let ball   = spawn_sphere(world, 0.75, 32, 16)               // radius, segments, rings
    .at_position(Vec3::new(0.0, 0.5, -5.0))
    .build();
let block  = spawn_cube_at(world, Vec3::new(2.0, 0.5, -4.0), 1.0).build();
}

Spawn from a component bundle

When you want full control over which components an entity starts with, spawn a tuple directly. Pair every Transform with a GlobalTransform so the renderer sees the pose.

#![allow(unused)]
fn main() {
use khora_sdk::prelude::ecs::{GlobalTransform, Transform};

let entity = world.spawn((
    Transform::from_translation(Vec3::new(0.0, 1.0, -2.0)),
    GlobalTransform::default(),
));
}

Read and update a Transform

Transform exposes translation, rotation, and scale directly. Read with get_transform, mutate with update_transform (which re-syncs GlobalTransform for you):

#![allow(unused)]
fn main() {
// Read
if let Some(t) = world.get_transform(entity) {
    let _pos = t.translation;
}

// Update + auto-sync the GlobalTransform the renderer reads
world.update_transform(entity, |t| {
    t.translation = t.translation + Vec3::Y;
});
}

If you mutate a transform through get_transform_mut instead, call world.sync_global_transform(entity) afterwards so the change reaches the renderer — this is what the sandbox’s player controller does each frame.

To touch many entities at once, query mutably:

#![allow(unused)]
fn main() {
use khora_sdk::prelude::ecs::Transform;

for (t,) in world.query_mut::<(&mut Transform,)>() {
    t.translation = t.translation + Vec3::Y * 0.01;
}
}

Why two transforms? Transform is the local pose you author; GlobalTransform is the world-space matrix the render and audio paths consume. See Data and the ECS for the full story.

Build a parent–child hierarchy

Reparent with set_parent. It maintains both the Parent component on the child and the Children list on the parent, and refuses cycles silently.

#![allow(unused)]
fn main() {
let turret = spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0), 0.5).build();
let barrel = spawn_cube_at(world, Vec3::new(0.0, 1.2, 0.5), 0.2).build();

world.set_parent(barrel, Some(turret)); // attach
world.set_parent(barrel, None);         // detach back to a root
}

Expected result

build() / spawn return a live EntityId; get_transform(entity) returns the pose you set, and updated transforms become visible to the renderer after the sync. Parented entities appear in their parent’s Children list.

Apply materials and light a scene

This guide shows you how to give a mesh a PBR material and light it with directional and point lights.

Prerequisites. You can spawn meshes (Spawn entities and move them). Every lit mesh needs an explicit material — the projection has no default-material fallback, so a mesh without a material handle is skipped (and logged).

Create a StandardMaterial and attach it

StandardMaterial is the metallic-roughness PBR material. Build one with the fields you care about and fall back to Default for the rest, then turn it into a handle with world.add_material and attach it to a mesh entity with with_component.

#![allow(unused)]
fn main() {
use khora_sdk::prelude::materials::StandardMaterial;
use khora_sdk::prelude::math::LinearRgba;

let red_metal = StandardMaterial {
    base_color: LinearRgba::new(0.9, 0.1, 0.1, 1.0),
    metallic: 1.0,
    roughness: 0.2,
    ..Default::default()
};
let handle = world.add_material(red_metal);

khora_sdk::spawn_sphere(world, 0.75, 32, 16)
    .at_position(khora_sdk::prelude::math::Vec3::new(0.0, 0.5, -5.0))
    .with_component(handle)
    .build();
}

add_material returns a MaterialRef::Inline that embeds the material in the scene; the resolver and GPU projection turn it into a runtime material. To reference a packaged .kmat asset instead, attach MaterialRef::Asset(uuid) — see Load and reference assets.

The PBR knobs

FieldRangeEffect
base_colorLinearRgbaAlbedo (diffuse for dielectrics, reflectance for metals).
metallic0.0 / 1.0Dielectric vs. metal. Intermediate values are non-physical.
roughness0.01.0Mirror-sharp (0.0) to fully diffuse (1.0) reflections.
emissiveLinearRgbaSelf-illumination, added after lighting.
base_color_textureOption<AssetUUID>Albedo map, multiplied with base_color.
double_sidedboolRender back faces instead of culling them.

To texture the albedo, set base_color_texture: Some(uuid) — covered in Load and reference assets.

Add a directional light (the sun)

Light::directional() makes a sun-like light. Its direction comes from the entity’s rotation; tilt the entity to aim it. Reach into light_type to set intensity and enable shadows.

#![allow(unused)]
fn main() {
use khora_sdk::prelude::ecs::{Light, LightType};
use khora_sdk::prelude::math::{Quaternion, Vec3};

let mut sun = Light::directional();
if let LightType::Directional(ref mut d) = sun.light_type {
    d.intensity = 2.5;
    d.shadow_enabled = true;
    d.shadow_bias = 0.005;
    d.shadow_normal_bias = 0.02;
}

khora_sdk::Vessel::at(world, Vec3::new(0.0, 20.0, 5.0))
    .with_component(sun)
    .with_rotation(Quaternion::from_axis_angle(
        Vec3::X,
        -std::f32::consts::FRAC_PI_2 * 0.8,
    ))
    .build();
}

Add a point light

Light::point() is an omni light positioned by the entity’s transform, with a color, intensity, and range. intensity is a direct linear multiplier paired with a windowed distance attenuation, so a few units is already bright.

#![allow(unused)]
fn main() {
use khora_sdk::prelude::ecs::{GlobalTransform, Light, LightType, Transform};
use khora_sdk::prelude::math::{LinearRgba, Vec3};

let mut lamp = Light::point();
if let LightType::Point(ref mut p) = lamp.light_type {
    p.intensity = 5.0;
    p.color = LinearRgba::new(0.8, 0.9, 1.0, 1.0);
    p.range = 15.0;
}

world.spawn((
    Transform::from_translation(Vec3::new(0.0, 1.0, -2.0)),
    GlobalTransform::default(),
    lamp,
));
}

Light::spot() works the same way, exposing inner_cone_angle / outer_cone_angle through LightType::Spot.

Expected result

The sphere renders shaded by the sun and tinted by the point light. With shadow_enabled = true on the directional light, meshes cast shadows onto the ground plane. A mesh with no material is silently skipped — if a shape is invisible, check that you attached a handle.

Load and reference assets

This guide shows you how to reference a mesh, a texture, and a sound from your project’s assets — and how the same code works for loose files in development and a packed archive in a release build.

Prerequisites. You can spawn entities and attach components (Spawn entities and move them).

How assets are identified

Every asset has a stable AssetUUID, decoupled from its file path. By default the asset index derives that UUID from the asset’s forward-slash relative path with AssetUUID::new_v5:

#![allow(unused)]
fn main() {
use khora_sdk::prelude::AssetUUID;

let wood_uuid = AssetUUID::new_v5("textures/wood.png");
}

The default is only a starting point. The moment you rename or move an asset in the editor, its identity is frozen in the project’s identity registry (<project>/.khora/asset-registry.ron), so the UUID stays put even though the path changed — every MeshRef::Asset, MaterialRef, and texture slot that referenced it keeps resolving, with nothing to rewrite. See File formats — asset identity registry.

Either way the resolved UUID is identical in development (loose files via the FileLoader) and in release (a single data.pack via the PackLoader), because both resolve through the same registry. Game code that carries a UUID or an AssetHandle<T> does not change between the two — see Assets and the VFS for the pipeline.

Reference a mesh

The quickest path is a procedural primitive — spawn_plane / spawn_cube_at / spawn_sphere attach a content-derived MeshRef::Procedural for you (identical primitives dedup to one GPU mesh):

#![allow(unused)]
fn main() {
let ball = khora_sdk::spawn_sphere(world, 0.75, 32, 16).build();
}

To reference an imported mesh asset (glTF / OBJ) by UUID, attach MeshRef::Asset:

#![allow(unused)]
fn main() {
use khora_sdk::prelude::AssetUUID;
use khora_sdk::khora_data::ecs::MeshRef;

let ship = khora_sdk::Vessel::new(world)
    .with_component(MeshRef::Asset(AssetUUID::new_v5("models/ship.gltf")))
    .build();
}

The resolver loads and uploads the referenced asset before the GPU mesh projection runs.

Reference a texture on a material

A material references a texture by UUID. Set base_color_texture (or metallic_roughness_texture, normal_map, emissive_texture) on a StandardMaterial:

#![allow(unused)]
fn main() {
use khora_sdk::prelude::AssetUUID;
use khora_sdk::prelude::materials::StandardMaterial;
use khora_sdk::prelude::math::LinearRgba;

let mat = StandardMaterial {
    base_color: LinearRgba::WHITE,
    base_color_texture: Some(AssetUUID::new_v5("textures/wood.png")),
    roughness: 0.6,
    ..Default::default()
};
let handle = world.add_material(mat);
khora_sdk::spawn_cube_at(world, khora_sdk::prelude::math::Vec3::ZERO, 1.0)
    .with_component(handle)
    .build();
}

Load a sound

Sounds decode to SoundData (khora_sdk::SoundData). An AudioSource component holds an AssetHandle<SoundData>; how you obtain that handle and play it spatially is covered in Play 3D audio.

Load through the AssetService (VFS-backed)

For assets registered in the project’s VFS, the AssetService resolves a UUID through VFS → IO → decode → store and returns a reference-counted handle. It is registered as an engine resource — cache it in setup:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use khora_sdk::AssetService;
use khora_sdk::prelude::AssetUUID;
use khora_sdk::renderer::scene::Mesh;

// In `setup`, with `runtime: &Runtime`:
if let Some(service) = runtime.resources.get::<Arc<std::sync::Mutex<AssetService>>>() {
    if let Ok(mut svc) = service.lock() {
        let mesh = svc.load::<Mesh>(&AssetUUID::new_v5("models/ship.gltf"));
        // `mesh` is `Result<AssetHandle<Mesh>>`; handle the error, don't unwrap on IO.
    }
}
}

load::<A>(&uuid) is synchronous and caches: a second load of the same UUID returns the cached handle. AssetHandle<T> is cheap to clone (an Arc), so many entities can share one asset without duplicating GPU memory.

Dev vs. packed. The editor’s Build Game bundles assets/ into data.pack + index.bin. The runtime opens the pack instead of loose files; UUIDs and handles are unchanged, so no game code edits are needed for a release build.

Expected result

A mesh referenced by UUID renders once its asset resolves; a material’s texture UUID is uploaded and sampled as the albedo map. A missing UUID is reported (the mesh or texture is skipped) rather than crashing the frame.

Play 3D positional audio

This guide shows you how to put a listener on the camera, emit a positional sound from an entity, and control its volume and looping.

Prerequisites. Your app wires the audio backend in the run_winit bootstrap (the CPAL device + mix bus, as the sandbox does) so the mixer has somewhere to send samples. You have a SoundData handle (Load and reference assets).

Mark the listener

The listener is the entity whose GlobalTransform defines where the ears are — usually the camera. Add an AudioListener (a marker component) alongside the camera’s transform. There should be exactly one; if several exist, the first encountered wins.

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ecs::AudioListener;
use khora_sdk::prelude::ecs::Camera;
use khora_sdk::prelude::math::Vec3;

let camera = Camera::new_perspective(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 1000.0);
khora_sdk::Vessel::at(world, Vec3::new(0.0, 2.0, 10.0))
    .with_component(camera)
    .with_component(AudioListener)
    .build();
}

Emit a positional sound

An AudioSource carries an AssetHandle<SoundData> plus playback flags. Build one with AudioSource::new(handle) (which sets autoplay = true) and place it in the world with a Transform + GlobalTransform. Spatialization is automatic: a source that has a GlobalTransform is panned and attenuated relative to the listener.

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ecs::AudioSource;
use khora_sdk::prelude::ecs::{GlobalTransform, Transform};
use khora_sdk::prelude::math::Vec3;

// `clip: AssetHandle<SoundData>` from the asset service or a decoded buffer.
let mut footstep = AudioSource::new(clip);
footstep.volume = 0.7;

world.spawn((
    Transform::from_translation(Vec3::new(3.0, 0.0, -6.0)),
    GlobalTransform::default(),
    footstep,
));
}

Control volume and looping

AudioSource fields are plain data — set them at spawn time, or mutate them later through a query:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ecs::AudioSource;

let mut music = AudioSource::new(theme);
music.looping = true;   // restart on end
music.volume = 0.5;     // linear gain; 1.0 is unattenuated
music.autoplay = true;  // begin on first encounter
}

To change a live source, query it mutably in update:

#![allow(unused)]
fn main() {
for (source,) in world.query_mut::<(&mut AudioSource,)>() {
    source.volume = 0.2;
}
}

A source with no GlobalTransform is mixed as a 2D sound (UI clicks, music) — no distance attenuation or panning. To stop a sound, despawn its entity; to silence it, set volume to 0.0. Playback is tied to entity lifetime — there is no global “playing sounds” registry.

What the spatial mixer does

Each frame the SpatialMixingLane reads the audio view (listener pose + a snapshot of every source), computes distance attenuation and stereo pan, and mixes into the buffer the CPAL callback drains. The mix runs at frame rate; the callback runs at the device sample rate, decoupled through the mix bus. See Audio for the model.

Expected result

The footstep grows louder as the listener approaches and pans left/right as it moves past; a 2D music source plays at constant volume regardless of position.

Build a UI

This guide shows you how to lay out UI nodes with text and images using Khora’s Taffy-backed, ECS-driven UI components.

Prerequisites. You can spawn entities and parent them (Spawn entities and move them). UI runs through the LayoutSystem backend your app wired in the bootstrap (Taffy, as the sandbox does).

Note. The UiAgent runs in editor mode today; in-game (play-mode) UI is on the roadmap. The components below are the stable vocabulary either way.

Where the UI types live

UI is just ECS — entities with UI components, in the same World as everything else. The components are in khora_sdk::khora_data::ui:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ui::{
    UiNode, UiStyle, UiText, UiImage, UiBorder, UiVal, UiFlexDirection,
};
}

Layout uses UiNode (a flexbox node, like a CSS box); the layout system computes a screen-space UiTransform for each node. Hierarchy uses the same Parent / Children components as the 3D scene.

Lay out a panel

UiNode is built as a struct literal — set the sizing, padding, and flex fields you need and default the rest. UiStyle gives it a background and border. Spawn the panel as one entity:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ui::{UiNode, UiStyle, UiVal, UiFlexDirection};
use khora_sdk::prelude::math::Vec4;

let panel = world.spawn((
    UiNode {
        width: UiVal::Px(400.0),
        height: UiVal::Px(300.0),
        flex_direction: UiFlexDirection::Column,
        ..Default::default()
    },
    UiStyle {
        background_color: Vec4::new(0.1, 0.1, 0.12, 0.9),
        border_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
        border_width: 1.0,
        ..Default::default()
    },
));
}

UiVal expresses sizes as Px, percentages, or auto — see the UI reference for the full set. flex_direction, flex_grow, and flex_shrink on UiNode drive child arrangement, exactly like flexbox.

Add text

UiText holds the string, a font UUID, a pixel size, and an RGBA color. Spawn it as a child node and parent it under the panel:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ui::{UiNode, UiText};
use khora_sdk::prelude::math::Vec4;
use khora_sdk::prelude::AssetUUID;

let label = world.spawn((
    UiNode::default(),
    UiText {
        content: "Hello, world.".to_owned(),
        font: AssetUUID::new_v5("fonts/inter.ttf"),
        size: 14.0,
        color: Vec4::new(1.0, 1.0, 1.0, 1.0),
    },
));
world.set_parent(label, Some(panel));
}

Add an image

UiImage references a texture by UUID; the layout node sizes it:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ui::{UiImage, UiNode, UiVal};
use khora_sdk::prelude::AssetUUID;

let icon = world.spawn((
    UiNode {
        width: UiVal::Px(48.0),
        height: UiVal::Px(48.0),
        ..Default::default()
    },
    UiImage { texture: AssetUUID::new_v5("ui/coin.png") },
));
world.set_parent(icon, Some(panel));
}

Update UI each frame

For dynamic UI (a HUD counter, a health bar), mutate the components in update — the layout re-runs when components change:

#![allow(unused)]
fn main() {
use khora_sdk::khora_data::ui::UiText;

for (text,) in world.query_mut::<(&mut UiText,)>() {
    text.content = format!("Score: {score}");
}
}

What the UI agent and lane do

The UiAgent owns two lanes: a layout lane reads the UI components, runs Taffy, and produces a laid-out UiScene; a render lane rasterizes that scene over the 3D frame. Swapping the layout backend is a matter of implementing LayoutSystem — see UI.

Expected result

The panel lays out at its size with its background and border; the text and image appear inside it, arranged by the flex direction. Mutating UiText.content updates the rendered string on the next frame.

Save and load scenes

This guide shows you how to serialize the world to a .kscene file and load it back.

Prerequisites. You have a populated GameWorld (Spawn entities and move them).

Save the world

SerializationService saves a World to an in-memory SceneFile; call scene.to_bytes() to get the bytes and write them to disk. You serialize the GameWorld’s inner World via world.inner_world(). Never unwrap() the I/O — handle the Result.

#![allow(unused)]
fn main() {
use khora_sdk::{SceneFile, SerializationGoal, SerializationService};

let service = SerializationService::new();
match service.save_world(world.inner_world(), SerializationGoal::HumanReadableDebug) {
    Ok(scene) => {
        if let Err(e) = std::fs::write("levels/level_01.kscene", scene.to_bytes()) {
            log::error!("failed to write scene: {e}");
        }
    }
    Err(e) => log::error!("failed to serialize scene: {e:?}"),
}
}

You pick a goal (your intent), and the service picks the matching strategy: HumanReadableDebug / LongTermStability produce RON (readable, diffable); EditorInterchange / SmallestFileSize produce compact binary; FastestLoad produces the archetype layout; PortableBinary produces MessagePack. Choosing a goal is your decision; choosing the strategy is the engine’s — the .kscene header records which one, so loading is symmetric.

Load the world

Parse the bytes into a SceneFile, then populate the world’s inner World. Despawn the existing entities first so you replace rather than merge:

#![allow(unused)]
fn main() {
use khora_sdk::{SceneFile, SerializationService};

let bytes = match std::fs::read("levels/level_01.kscene") {
    Ok(b) => b,
    Err(e) => { log::error!("read failed: {e}"); return; }
};
let scene = match SceneFile::from_bytes(&bytes) {
    Ok(f) => f,
    Err(e) => { log::error!("invalid scene file: {e:?}"); return; }
};

// Replace the current scene.
let existing: Vec<_> = world.iter_entities().collect();
for entity in existing {
    world.despawn(entity);
}

let service = SerializationService::new();
if let Err(e) = service.load_world(&scene, world.inner_world_mut()) {
    log::error!("failed to load scene: {e:?}");
}
}

load_world reads the strategy id from the header and dispatches the right decoder; the goal you saved with does not need to be repeated.

The three strategies, in one sentence

Definition serializes to human-readable RON, Recipe to a compact binary command stream, and Archetype to a near-memcpy page layout — picked for you by the SerializationGoal you pass. See Serialization for the file format and the goal → strategy mapping.

Play-mode snapshot note

The editor uses EditorInterchange to snapshot the world on Play and restore it on Stop, so gameplay never mutates the authored scene. Physics state is not preserved across the snapshot — bodies rebuild from component data, and velocities and contacts reset to defaults.

Expected result

save_world produces a .kscene file (RON text under the debug goals, binary otherwise) beginning with the KHORASCN magic; loading it into a fresh world reproduces the same entities and components. A corrupt or truncated file is reported through the Result rather than panicking.

Map input to actions

This guide shows you how to bind keys and mouse buttons to named actions, read them each frame, and drive movement with the real frame delta — the same path the sandbox’s player controller uses.

Prerequisites. You have an EngineApp with setup(&mut self, world, runtime) and update(&mut self, world, inputs) (Your first game).

Bind actions in setup

The engine owns one InputMap, registered as a resource behind an Arc<Mutex<…>>. In setup, lock it and bind each action to one or more bindings — an action fires when any of its bindings fires (logical OR), so you can map both WASD and the arrow keys to the same action. Cache the handle so update can query it.

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use khora_sdk::khora_core::platform::{InputBinding, InputMap};
use khora_sdk::prelude::KeyCode;

const ACTION_FORWARD: &str = "player.forward";
const ACTION_JUMP: &str = "player.jump";

fn setup(&mut self, world: &mut GameWorld, runtime: &Runtime) {
    if let Some(map_arc) = runtime.resources.get::<Arc<Mutex<InputMap>>>() {
        if let Ok(mut map) = map_arc.lock() {
            map.bind(ACTION_FORWARD, InputBinding::Key(KeyCode::KeyW));
            map.bind(ACTION_FORWARD, InputBinding::Key(KeyCode::ArrowUp));
            map.bind(ACTION_JUMP, InputBinding::Key(KeyCode::Space));
        }
        self.input_map = Some(map_arc.clone()); // Option<Arc<Mutex<InputMap>>> field
    }
}
}

InputBinding is Key(KeyCode) or Mouse(MouseButton). The engine drains the input queue into the map once per frame, before your update runs.

Read actions in update

Lock the cached handle and query it. The map distinguishes held from edge:

#![allow(unused)]
fn main() {
if let Some(map_arc) = &self.input_map {
    if let Ok(map) = map_arc.lock() {
        if map.is_pressed(ACTION_FORWARD) {
            // held this frame — continuous movement
        }
        if map.just_pressed(ACTION_JUMP) {
            // fired only on the press edge — one-shot jump
        }
        // `map.just_released(action)` fires on the release edge.
    }
}
}

is_pressed is true every frame the action is held; just_pressed / just_released are true only on the transition frame and clear on the next update.

Use the real frame delta

For variable-rate movement, multiply by the wall-clock delta, not a constant. Cache the SharedTime handle in setup and read delta_seconds in update:

#![allow(unused)]
fn main() {
// setup:
self.time = runtime.resources.get::<khora_sdk::prelude::SharedTime>().cloned();

// update:
let dt = self
    .time
    .as_ref()
    .and_then(|t| t.read().ok().map(|t| t.delta_seconds))
    .unwrap_or(1.0 / 60.0); // fall back to a 60 Hz step if unavailable
}

Move an entity from input

Combine the three pieces — read the action, scale by dt, write the transform:

#![allow(unused)]
fn main() {
use khora_sdk::prelude::math::Vec3;

let speed = 5.0;
if let Some(player) = self.player {
    world.update_transform(player, |t| {
        if held_forward {
            t.translation = t.translation + Vec3::new(0.0, 0.0, -speed * dt);
        }
    });
}
}

Mouse motion is not an action — the InputMap models boolean inputs only. For look/drag, match InputEvent::MouseMoved { x, y } on the raw inputs slice passed to update, as the sandbox’s controller does.

Expected result

Holding W (or Up) moves the player smoothly at a frame-rate-independent speed; pressing Space triggers exactly one jump per press. Unbound keys do nothing.

Add a component

This guide shows you how to define a new ECS component, have it self-register for serialization and the editor inspector, and query it from a system.

Prerequisites: you can build the workspace and have read ECS — CRPECS.

Step 1 — Define the struct with #[derive(Component)]

Components are plain data. Deriving Component generates the Component impl, a SerializableX mirror with From conversions, and an inventory registration so the type wires itself into the scene pipeline and the editor inspector — no manual list to edit.

Declare the component’s semantic domain with #[component(domain = …)]. The domain drives change-epoch tracking (which Flows must re-project when this component changes). Valid domains: Spatial, Render, Audio, Physics, Ui.

#![allow(unused)]
fn main() {
use khora_macros::Component;
use khora_core::math::Vec3; // engine math types only — never raw glam

/// Per-entity wind influence, sampled by the foliage system.
#[derive(Debug, Clone, Copy, PartialEq, Default, Component)]
#[component(domain = Spatial)]
pub struct WindAffected {
    pub direction: Vec3,
    pub strength: f32,
}
}

A Default impl is required: the editor’s “add component” action and deserialization of skipped fields both rely on it.

Step 2 — Mark fields that must not be serialized

Use field attributes when a field cannot or should not round-trip through the serializer:

  • #[component(skip)] — exclude a single field (e.g. a GPU handle or runtime cache). It is omitted from the Serializable mirror and filled with Default::default() on load.
  • #[component(no_serializable)] — applied to the struct, suppresses the generated mirror entirely. Use this only when you hand-write the serialization yourself.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default, Component)]
#[component(domain = Render)]
pub struct DecalProjector {
    pub size: f32,
    /// Runtime-only GPU handle — never serialized.
    #[component(skip)]
    pub texture: Option<u64>,
}
}

Step 3 — Place the file and re-export it

Put the file next to the other components in crates/khora-data/src/ecs/components/ and add a pub mod/pub use line in that module’s mod.rs, matching the existing entries. The inventory registration fires at startup automatically — there is nothing else to wire.

Step 4 — Verify it works

Build, then confirm the component spawns and reads back:

#![allow(unused)]
fn main() {
let mut world = World::new();
let e = world.spawn(WindAffected { direction: Vec3::X, strength: 2.0 });
assert_eq!(world.get_component::<WindAffected>(e).unwrap().strength, 2.0);
}
cargo test --workspace

The component now appears in the editor inspector and survives scene save/load with no further code.

Add a lane

This guide shows you how to implement a Lane — a swappable hot-path strategy — have an agent select it, feed it a View from the LaneBus, and collect its output through the OutputDeck.

Prerequisites: you have completed Extending the engine and read Lanes.

Step 1 — Implement the Lane trait

A lane provides identity (strategy_name, lane_kind), an optional cost estimate, and the work in execute. Note execute takes &self (not &mut self) and returns Result<(), LaneError> — lanes hold no per-frame mutable state of their own; their inputs and outputs flow through the LaneContext.

#![allow(unused)]
fn main() {
use khora_core::lane::{Lane, LaneContext, LaneError, LaneKind};

#[derive(Default)]
pub struct LowDetailFoliageLane;

impl Lane for LowDetailFoliageLane {
    fn strategy_name(&self) -> &'static str {
        "LowDetailFoliage"
    }

    fn lane_kind(&self) -> LaneKind {
        LaneKind::Render
    }

    fn estimate_cost(&self, _ctx: &LaneContext) -> f32 {
        0.4 // cheaper than the full-detail strategy — GORNA can prefer it under load
    }

    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
        // Read the typed inputs the agent inserted; fail cleanly if absent.
        let view = ctx
            .get::<FoliageView>()
            .ok_or_else(|| LaneError::missing("FoliageView"))?;
        // … record draw commands from `view` …
        let _ = view;
        Ok(())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
        self
    }
}
}

FoliageView here is the typed projection a Flow publishes — see Add a flow. The agent reads it off the bus and threads it into the LaneContext; the lane never queries the World.

Step 2 — Feed the lane a View from the LaneBus

Lanes do not read the LaneBus directly. The owning agent reads the View off EngineContext::bus, then inserts it (by value, or borrowed via Ref for the frame) into a fresh LaneContext, and dispatches Lane::execute:

#![allow(unused)]
fn main() {
// inside the agent's `execute(&mut self, context: &mut EngineContext<'_>)`
let Some(view) = context.bus.get::<FoliageView>() else { return };

let mut ctx = LaneContext::new();
ctx.insert(view.clone()); // FoliageView: Clone, so insert by value
if let Some(lane) = self.lane.as_ref() {
    if let Err(e) = lane.execute(&mut ctx) {
        log::error!("foliage lane failed: {e}");
    }
}
}

This is the boundary the architecture enforces: lanes consume Views, the Flow produces them. Querying the World from a lane is a rule violation.

Step 3 — Write outputs to the OutputDeck

For outputs the engine drains at the I/O boundary (recorded command buffers, draw lists), write into a typed slot on the OutputDeck. The agent owns the deck via EngineContext::deck; thread a Slot into the LaneContext if the lane itself accumulates output. OutputDeck::slot::<T>() lazily creates a T::default() on first access:

#![allow(unused)]
fn main() {
// agent side
let draws: &mut Vec<DrawCommand> = context.deck.slot::<Vec<DrawCommand>>();
// … or hand the lane a Slot into it and let the lane push …
}

Step 4 — Register the lane on an agent

Lanes are held in the agent’s LaneRegistry (or a small set of Option<Box<dyn Lane>> fields, as the built-in agents do). The agent constructs its lanes in on_initialize, then selects one per frame in apply_budget based on the GORNA strategy. A lane runs its one-shot setup through Lane::on_initialize if it needs a device:

#![allow(unused)]
fn main() {
// inside the agent's on_initialize
self.lane = Some(Box::new(LowDetailFoliageLane::default()));
}

To add a whole new subsystem rather than a strategy for an existing one, see Add an agent.

Step 5 — Verify it works

cargo test --workspace
cargo run -p sandbox   # confirm no wgpu/Vulkan validation errors for a render lane

A unit test can exercise the lane directly by building a LaneContext, inserting a stub view, and asserting execute returns Ok(()).

  • Lanes — the lane lifecycle and the two-level trait hierarchy.
  • Add a flow — produce the View this lane consumes.
  • Add an agent — the strategist that selects this lane.

Add an agent

This guide shows you how to add a strategist agent — a subsystem that negotiates a budget through GORNA, selects one lane, and dispatches it every frame.

Prerequisites: you have completed Extending the engine and read Agents.

Use an agent only when the subsystem needs GORNA negotiation. Work that just runs deterministically each tick belongs in a direct service (AssetService, EcsMaintenance, a DataSystem), not an agent.

Step 1 — Implement Agent and Default — and nothing else

An agent struct implements exactly two traits: Agent and Default. No start/stop, no builders, no accessors — those would violate the agent contract. Construction is always Default::default(). The agent owns one LaneKind and stores only its own GORNA/strategy state; every shared service is looked up from the Runtime each frame.

#![allow(unused)]
fn main() {
use std::any::Any;
use std::time::Duration;

use khora_core::agent::{Agent, AgentImportance, ExecutionPhase, ExecutionTiming};
use khora_core::context::EngineContext;
use khora_core::control::gorna::{
    AgentId, AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget,
    StrategyId, StrategyOption,
};
use khora_core::lane::{Lane, LaneContext};

#[derive(Default)]
pub struct FoliageAgent {
    lane: Option<Box<dyn Lane>>,
    current_strategy: StrategyId,
}
}

Step 2 — Pick an AgentId

AgentId is a fixed enum — there is no Custom variant. A new subsystem reuses the slot closest to its work. Foliage rendering, for example, reuses AgentId::Renderer. The variants are: Renderer, ShadowRenderer, Overlay, Physics, Ecs, Ui, Audio, Asset.

#![allow(unused)]
fn main() {
impl Agent for FoliageAgent {
    fn id(&self) -> AgentId {
        AgentId::Renderer
    }
}

Step 3 — Offer strategies in negotiate, pick a lane in apply_budget

negotiate returns the strategies this agent can run with their estimated cost; the DCC issues one back. apply_budget records the chosen strategy and selects the matching lane — this is how the subsystem scales itself under load.

#![allow(unused)]
fn main() {
    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
        NegotiationResponse {
            strategies: vec![
                StrategyOption {
                    id: StrategyId::HighPerformance,
                    estimated_time: Duration::from_micros(800),
                    estimated_vram: 4 * 1024 * 1024,
                },
                StrategyOption {
                    id: StrategyId::LowPower,
                    estimated_time: Duration::from_micros(200),
                    estimated_vram: 1024 * 1024,
                },
            ],
            timing_adjustment: None,
        }
    }

    fn apply_budget(&mut self, budget: ResourceBudget) {
        self.current_strategy = budget.strategy_id;
        // Pick the lane matching the issued strategy.
        // self.lane = Some(Box::new(...));
    }
}

Step 4 — Dispatch the lane in execute

Read inputs from context.bus, build a LaneContext, dispatch the lane, and write outputs to context.deck. The agent does only lane selection and dispatch — all real work lives in the lane (see Add a lane).

#![allow(unused)]
fn main() {
    fn execute(&mut self, context: &mut EngineContext<'_>) {
        let Some(lane) = self.lane.as_ref() else { return };
        let mut ctx = LaneContext::new();
        // … insert Views read from context.bus into ctx …
        if let Err(e) = lane.execute(&mut ctx) {
            log::error!("foliage lane failed: {e}");
        }
        let _ = context;
    }

    fn report_status(&self) -> AgentStatus {
        AgentStatus {
            agent_id: self.id(),
            health_score: 1.0,
            current_strategy: self.current_strategy,
            is_stalled: false,
            message: "foliage ok".to_owned(),
        }
    }
}

Step 5 — Declare execution timing

execution_timing tells the scheduler when the agent runs. The real phases are INIT, OBSERVE, TRANSFORM, MUTATE, OUTPUT, FINALIZE. OUTPUT is the render/present phase; TRANSFORM is per-frame logic. Mark work the scheduler may drop under budget pressure as AgentImportance::Optional — only Optional is negotiable; Critical and Important always run.

#![allow(unused)]
fn main() {
    fn execution_timing(&self) -> ExecutionTiming {
        ExecutionTiming {
            allowed_phases: vec![ExecutionPhase::OUTPUT],
            default_phase: ExecutionPhase::OUTPUT,
            priority: 0.7,
            importance: AgentImportance::Optional,
            fixed_timestep: None,
            dependencies: Vec::new(),
        }
    }

    fn as_any(&self) -> &dyn Any { self }
    fn as_any_mut(&mut self) -> &mut dyn Any { self }
}
}

Step 6 — Register the agent via AgentProvider

Game and host code register agents through the AgentProvider::register_agents hook. Wrap the agent in Arc<Mutex<…>> and hand it to the DCC with a priority. Use register_agent_for_mode to restrict it to specific engine modes.

#![allow(unused)]
fn main() {
impl AgentProvider for MyGame {
    fn register_agents(&self, dcc: &DccService, _runtime: &mut Runtime) {
        dcc.register_agent(Arc::new(Mutex::new(FoliageAgent::default())), 0.7);
    }
}
}

Step 7 — Verify it works

cargo test --workspace
cargo run -p sandbox

Confirm the agent’s report_status message and its lane’s effect appear in the running frame. For render agents, check there are no wgpu/Vulkan validation errors.

  • Agents — the full agent contract and lifecycle.
  • GORNA — how budgets are negotiated and issued.
  • Control GORNA adaptation — pin or bound this agent’s strategy from host code.

Add a shader

This guide shows you how to add a .wgsl shader and wire a render pipeline for it through the PipelineSystem backend, composing reusable modules with naga_oil #import and staying within the four-bind-group budget.

Prerequisites: you have read the shader section of Rendering.

Shaders are files, never strings. Never inline WGSL as a Rust const/static. Every shader is a .wgsl file composed through the ShaderRegistry / PipelineSystem so it is reviewable and hot-reloadable.

Step 1 — Add the .wgsl file

Place the source under the backend’s shader tree:

  • A pipeline entry point (has @vertex / @fragment / @compute): crates/khora-infra/src/graphics/shader/shaders/pipelines/.
  • A reusable library module (shared structs / functions): crates/khora-infra/src/graphics/shader/shaders/lib/.

Import the shared modules you need with naga_oil #import. The standard library modules use the khora::… namespace (khora::std::camera, khora::std::model, khora::std::material, the khora::lighting::* and khora::shadow::* families):

// shaders/pipelines/my_pipeline.wgsl
#import khora::std::camera::camera
#import khora::std::model::model
#import khora::std::material::material

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) normal: vec3<f32>,
};

@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let world_pos = model.model_matrix * vec4<f32>(input.position, 1.0);
    out.clip_position = camera.view_projection * world_pos;
    out.normal = normalize((model.normal_matrix * vec4<f32>(input.normal, 0.0)).xyz);
    return out;
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return material.base_color;
}

Step 2 — Register the module with the backend

Add the file to the relevant table in crates/khora-infra/src/graphics/shader/system.rs so the composer knows it. A reusable module goes in LIB_MODULES (with a khora::… import path); an entry-point pipeline goes in PIPELINE_MODULES (keyed by its logical name):

#![allow(unused)]
fn main() {
const PIPELINE_MODULES: &[(&str, &str)] = &[
    // … existing …
    (
        "khora::pipelines::my_pipeline",
        include_str!("shaders/pipelines/my_pipeline.wgsl"),
    ),
];
}

The sources are embedded at compile time. The backend composes, validates with naga, and emits final WGSL per shader-def variant on first use, then caches it.

Step 3 — Respect the four-bind-group budget

A render pipeline may bind at most four bind groups. The engine reserves them by role — Camera, Model, Material, Lighting — resolved by the PipelineSystem from a LayoutKey. Build your pipeline by listing the bind-group layouts it needs in that fixed order. If your shader needs a fifth distinct resource set, fold it into an existing group rather than adding a fifth — exceeding four is a hard GPU limit on many targets.

A lane requests its pipeline by building a PipelineSpec (shader name, entry points, bind-group layouts, vertex buffers, targets) and calling PipelineSystem::pipeline(device, &spec), which returns a cached RenderPipelineId. Mirror an existing render lane for the exact spec shape.

Step 4 — Verify it composes

The backend has a boot smoke test that composes, validates, and emits every registered pipeline at the empty variant — it catches import-graph and binding drift without a GPU:

cargo test -p khora-infra composes_every_pipeline
cargo test --workspace
cargo run -p sandbox   # confirm no wgpu/Vulkan validation errors

If composition fails, the error names the offending module and the naga_oil diagnostic — usually a wrong #import path or a binding outside the four-group budget.

  • Rendering — the render lane / pipeline architecture.
  • Add a lane — the lane that requests and draws with this pipeline.

Add an asset decoder

This guide shows you how to add an asset decoder — turning raw bytes of a new format into a typed asset — and have it auto-register with the AssetService.

Prerequisites: you have read Assets and VFS.

Step 1 — Implement the AssetDecoder trait

A decoder is pure CPU work: it holds no GPU/IO state and implements AssetDecoder<A> for the asset type A it produces. load takes a byte slice and returns the typed asset or a boxed error. Validate the bytes at this boundary — decoders sit at a trust boundary (untrusted file input).

#![allow(unused)]
fn main() {
use khora_io::asset::{AssetDecoder, DecoderRegistration};

/// The asset type this decoder produces (must implement `Asset`).
use my_crate::HeightMap;

#[derive(Clone, Default)]
pub struct HeightMapDecoder;

impl AssetDecoder<HeightMap> for HeightMapDecoder {
    fn load(
        &self,
        bytes: &[u8],
    ) -> Result<HeightMap, Box<dyn std::error::Error + Send + Sync + 'static>> {
        let map = HeightMap::parse(bytes)?; // your format parsing
        Ok(map)
    }
}
}

Step 2 — Auto-register via DecoderRegistration

For a slot with a single canonical implementation, submit a DecoderRegistration through inventory. The type_name is the asset-type string the indexer derives from the file extension; the register function is a plain function pointer (no captures) that registers the decoder on the AssetService:

#![allow(unused)]
fn main() {
inventory::submit! {
    DecoderRegistration {
        type_name: "heightmap",
        register: |svc| {
            svc.register_decoder::<HeightMap>("heightmap", HeightMapDecoder);
        },
    }
}
}

Place the file next to the built-in decoders in crates/khora-io/src/asset/decoders/ and re-export it from that module’s mod.rs, matching the existing entries (e.g. texture, shader, font). Registration fires at startup — no manual wiring.

The type_name must match what IndexBuilder::asset_type_for_extension returns for your file extensions, or the loader will not route bytes to your decoder.

Slots where multiple backends compete (audio, mesh) deliberately stay registered explicitly at the call site instead of through inventory — follow the pattern in those modules if your format is one of those.

Step 3 — Reference the asset through a handle

Decoded assets are never stored inline on entities. Load through the asset service and reference the result with AssetHandle<T> / HandleComponent<T>; the engine resolves the handle to the decoded data when needed.

Step 4 — Verify it works

cargo test --workspace

A unit test can call decoder.load(&bytes) directly and assert the parsed asset, or load a sample file through the AssetService and confirm the handle resolves.

  • Assets and VFS — the asset service, VFS, and handle model.
  • Serialization — the three-strategy scene format (distinct from asset decoding).

Add a flow

This guide shows you how to implement a Flow — a read-only projector that turns the ECS World into a typed View for a domain’s lanes — and register it with one line.

Prerequisites: you have read Lanes and understand that lanes consume Views, not the World.

A Flow is read-only. It never mutates the World. Representation changes (AGDF memory layout) are the data layer’s own self-maintenance; semantic/gameplay changes are developer-authored systems — never a Flow. Adapt the HOW, never the WHAT.

Step 1 — Define the View type

The View is the typed payload published into the LaneBus. It must be Clone, Send + Sync, and 'staticClone lets the registration trampoline republish a cached view cheaply. Hold Arcs and plain data, not borrows of the World.

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct FoliageView {
    pub instances: Vec<FoliageInstance>,
}

#[derive(Clone)]
pub struct FoliageInstance {
    pub position: khora_core::math::Vec3,
    pub strength: f32,
}
}

Step 2 — Implement the Flow trait

A Flow declares its DOMAIN and NAME, then runs in two read-only stages: select narrows the relevant entities, project builds the View. Both receive the engine Runtime so the Flow can read services its domain needs.

#![allow(unused)]
fn main() {
use khora_core::Runtime;
use khora_data::ecs::{SemanticDomain, World};
use khora_data::flow::{Flow, Selection};

#[derive(Default)]
pub struct FoliageFlow;

impl Flow for FoliageFlow {
    type View = FoliageView;

    const DOMAIN: SemanticDomain = SemanticDomain::Render;
    const NAME: &'static str = "foliage";

    fn select(&mut self, world: &World, _runtime: &Runtime) -> Selection {
        // Pick the entities this domain cares about (read-only).
        let mut sel = Selection::new();
        // … push relevant entities …
        let _ = world;
        sel
    }

    fn project(&self, world: &World, sel: &Selection, _runtime: &Runtime) -> FoliageView {
        // Build the typed View from the selected entities (read-only).
        let _ = (world, sel);
        FoliageView { instances: Vec::new() }
    }
}
}

Step 3 — (Optional) add a cache_key for view reuse

If projection is expensive, override cache_key. When it returns Some(k) equal to the previous tick’s key, the trampoline republishes the cached View without re-running select/project. The default (None) re-projects every tick — always correct, just not cached.

A key must fold in every input the projection reads, or it serves stale views. Use combine_cache_key over World::instance_id, the relevant World::domain_epochs, and a bit-level hash of any runtime state consulted. An over-broad key only re-projects more often (safe); a key that misses an input is a bug.

#![allow(unused)]
fn main() {
fn cache_key(&self, world: &World, _runtime: &Runtime) -> Option<u64> {
    Some(khora_data::flow::combine_cache_key([
        world.instance_id(),
        world.domain_epoch(SemanticDomain::Render),
        world.domain_epoch(SemanticDomain::Spatial),
    ]))
}
}

Step 4 — Register the Flow

One line wires the Flow into the Substrate Pass. register_flow! generates the type-erased trampoline (single static instance, view cache included) and submits the FlowRegistration via inventory:

#![allow(unused)]
fn main() {
use khora_data::register_flow;

register_flow!(FoliageFlow);
}

The lane reads the View off the bus (ctx.get::<FoliageView>() after the agent inserts it) — see Add a lane.

Step 5 — Verify it works

cargo test --workspace

A unit test can drive run_flow_cached with a World and a LaneBus, then assert the published View via bus.get::<FoliageView>() — and that mutating the relevant domain invalidates the cache.

  • Lanes — the bus / deck contract the Flow feeds.
  • AGDF — representation adaptation, the HOW a Flow must not change.
  • Add a lane — consume the View this Flow produces.

Control GORNA adaptation

This guide shows you how to pin or bound an agent’s adaptation from game or host code, so GORNA’s automatic strategy switching stays within limits you choose — and how to record and replay the decisions it makes.

Prerequisites: you have read GORNA.

This is the developer-control surface over the adaptive core: the DCC observes and proposes, but you decide how much latitude it has. A safety stop can still force the lowest strategy in any mode — safety overrides developer control.

Step 1 — Choose an AdaptationMode

AdaptationMode sets how much freedom GORNA has for one agent:

ModeBehaviour
Learning (default)Full negotiation — GORNA freely picks the best-fitting strategy each tick.
Manual(StrategyId)Pinned — the agent stays on the given strategy; GORNA observes but never switches it.
StablePredictable — GORNA may downgrade under budget pressure but never makes an opportunistic upgrade, so the strategy doesn’t flap.
Bounded { min, max }Learning within limits — the chosen strategy is clamped to [min, max] (LowPower < Balanced < HighPerformance; Custom ranks above HighPerformance).

Step 2 — Apply it through DccService::set_adaptation_mode

Call set_adaptation_mode on the DccService with the target AgentId and the mode. It is thread-safe and takes effect on the next arbitration tick.

#![allow(unused)]
fn main() {
use khora_core::control::gorna::{AdaptationMode, AgentId, StrategyId};

// Pin the renderer to LowPower (e.g. on battery): GORNA will not upgrade it.
dcc.set_adaptation_mode(AgentId::Renderer, AdaptationMode::Manual(StrategyId::LowPower));

// Or let physics learn, but never below Balanced and never above HighPerformance:
dcc.set_adaptation_mode(
    AgentId::Physics,
    AdaptationMode::Bounded {
        min: StrategyId::Balanced,
        max: StrategyId::HighPerformance,
    },
);

// Or stop quality from flapping for the UI agent:
dcc.set_adaptation_mode(AgentId::Ui, AdaptationMode::Stable);
}

To return an agent to full autonomy, set it back to AdaptationMode::Learning.

Step 3 — (Optional) record and replay decisions

GORNA arbitration is deterministic (no RNG), so recording the strategy issued to each agent each tick and replaying it reproduces a session’s adaptation bit-for-bit — useful for QA, network lockstep, and bug reproduction.

#![allow(unused)]
fn main() {
// Start capturing per-tick decisions into a fresh trace.
dcc.start_decision_recording();

// … run the engine …

// Stop and take the captured trace (a `DecisionTrace`).
let trace = dcc.stop_decision_recording();

// Later: replay it. Each tick re-issues the recorded strategies in order,
// bypassing live fit and AdaptationMode, until the trace is exhausted.
dcc.replay_decisions(trace);

// Return to live arbitration at any point.
dcc.stop_replay();
}

recorded_decisions() returns a snapshot of the trace captured so far without stopping recording.

Step 4 — Verify it works

cargo test --workspace

With recording on, drive a few ticks, replay the captured trace, and assert the issued strategies match the original run. To confirm a mode took effect, read the agent’s reported current_strategy and check it respects the bound you set.

  • GORNA — the negotiation protocol and strategy fitting.
  • Add an agent — the agent whose adaptation you are controlling.

Troubleshoot a Khora project

Goal: find the cause of a concrete failure fast. Each entry below names a symptom, the likely cause, and the fix, grouped by subsystem. When you need the underlying mechanism rather than the recipe, follow the link out to the concept chapter — this page stays task-oriented.

Jump to the subsystem you are fighting:

  1. Build & toolchain
  2. Rendering / GPU (wgpu)
  3. Physics
  4. Audio
  5. Assets / VFS
  6. GORNA / adaptation

Build & toolchain

The toolchain is rejected — “package requires rustc 1.91” (or a similar MSRV error)

Cause. The workspace pins its minimum supported Rust version to 1.91 in the root Cargo.toml ([workspace.package] rust-version = "1.91"), the lowest stable toolchain that compiles the whole tree. An older toolchain cannot build it.

Fix. rustup update (or rustup install 1.91 && rustup default 1.91). The MSRV is verified with cargo +1.91 check --workspace --all-features --all-targets.

target/ grows very large

Cause. Expected for a workspace this size — but Khora already trims it: dev builds use debug = "line-tables-only" (stack traces with file + line, no per-symbol type tables) and strip debuginfo from dependencies, saving roughly 30–50% of target/ size per profile. You still get panic backtraces and RUST_BACKTRACE=1.

Fix. cargo clean reclaims everything. If you need to step through values in a debugger, temporarily set debug = 2 in a local Cargo.toml override rather than committing it. Release artifacts are larger because [profile.release] enables lto and codegen-units = 1.

cargo deny check fails in CI or locally

Cause. The supply-chain gate (deny.toml, EmbarkStudios cargo-deny) runs four checks: advisories (RustSec security advisories — real vulnerabilities fail), licenses (only the permissive licenses listed in deny.toml are allowed), bans (duplicate versions only warn; wildcard versions on external crates are denied), and sources (only crates.io and explicitly allowed sources are trusted).

How to read the output. cargo deny check prints one block per finding with the check name, the crate, and the advisory or license ID. An error breaks the gate; a warning does not.

Known, ignored advisory. RUSTSEC-2025-0141 (bincode 2.x flagged unmaintained) is acknowledged in deny.toml’s [advisories] ignore list. It is an unmaintained notice, not a security vulnerability, and the advisory itself states no safe upgrade exists — so it is expected to be silent. Never add an ID to ignore to mask a live vulnerability; each entry must carry a documented justification.

Platform note — the default UUID is path-derived, not OS-derived

The asset index normalizes relative paths to forward slashes before deriving the default UUID, so a path that hashes to one UUID on Windows (textures\wood.png) produces the same UUID on Linux/macOS — assets built on one OS resolve on another. (Frozen identities in the registry are OS-agnostic too: the stored path is always forward-slash.) See Assets / VFS. For the supported build/run targets, follow the SDK quickstart and the editor’s Build Game flow (Editor).


Rendering / GPU (wgpu)

The render system classifies every surface-acquire outcome and every device-health flag into a deliberate action, so most GPU hiccups are handled without a panic. The pure decision logic lives in crates/khora-infra/src/graphics/wgpu/resilience.rs; the side effects (reconfigure / skip / error) are applied in system.rs. Knowing which bucket a log line falls into tells you whether to act.

Brief flicker or a single dropped frame after an alt-tab, resolution change, or display switch — then it recovers on its own

Cause. The surface reported Lost or Outdated. With a valid (non-zero) window size the system reconfigures the swapchain and retries the acquire within the same frame; you may see at most a one-frame hitch.

Fix. None needed — this is the resilience path doing its job. If it logs at debug, that is expected.

Nothing renders while the window is minimized or occluded, then resumes when restored

Cause. A minimized window has a zero-size surface; a hidden window reports Occluded. Both, plus a transient Timeout, classify as skip this frame — the frame is dropped quietly (a debug-level line at most) and retried next frame, instead of spamming a hard error every tick.

Fix. None needed. The engine resumes presenting when the window is shown again.

A one-off Validation or unknown surface-acquire failure logs at error, but the loop keeps running

Cause. These classify as non-fatal: the frame is skipped and the error is reported upward, but the host stays alive. A validation error is also caught by the device error handler.

Fix. If it recurs every frame, treat it as a real bug — capture the validation message (see below) and inspect the offending pass.

EngineCore: begin_frame fatal error: … / … end_frame fatal error: … and drawing stops — but no panic

Cause. The wgpu device error callbacks raised a device lost (driver crash/reset/destroy or an internal device error) or out-of-memory flag. These flags are sticky — the device cannot recover them in place — so RenderError::DeviceLost / RenderError::DeviceOutOfMemory is classified as fatal (RenderError::is_fatal()). The render system checks device health before each submission and, once a fatal condition is observed, stops acquiring and submitting GPU work; the engine logs it loudly at error rather than crashing mid-frame so the host can tear down cleanly.

Fix. Device loss usually means a GPU driver crash, a driver update, or the GPU being reset by the OS — restart the application (and update the driver if it persists). Out-of-memory means a real VRAM/resource budget breach: reduce texture/buffer footprint or render at a lower resolution. Neither is a code panic to debug in a backtrace; read the fatal log line.

Vulkan validation errors in the console

Cause. The engine targets a clean frame loop: running cargo run -p sandbox should produce no Vulkan validation errors. A validation message names the failing object and the rule it broke; read it top-down.

Fix. Treat any validation error as a bug to fix, not noise. Reproduce under the same backend, capture the full message, and trace it to the pass that recorded the offending command. See Rendering.

Black screen / nothing renders, no errors

Cause. Common causes, in rough order of likelihood:

  • No active camera — without a camera there is no view/projection to draw from.
  • No light — under a lit strategy an unlit scene can read as black; confirm a light exists or switch to an unlit strategy to isolate it.
  • Mesh/material not uploaded or not referenced — entities must carry a valid mesh and material handle that resolved through the asset pipeline (see Assets / VFS).

Fix. Verify a camera, a light, and at least one entity with resolved mesh + material handles are present in the world. See Rendering.


Physics

The simulation runs on a fixed timestep decoupled from the display rate. The scheduler (crates/khora-control/src/scheduler.rs, compute_sim_steps) accumulates real frame time and runs whole fixed sub-steps; the PhysicsAgent (crates/khora-agents/src/physics_agent/agent.rs) owns the fixed step via its GORNA strategy. See GORNA and Profile performance.

A body does not move under gravity or forces

Cause. Either:

  • The entity has no RigidBody (or it is fixed/kinematic rather than dynamic), so the provider never integrates it.
  • The simulation is not stepping. Physics only steps in an engine mode that runs the PhysicsAgent. In the editor, physics runs in Playing mode, not while editing — see Editor — Play mode.

Fix. Confirm the entity carries a dynamic RigidBody, and that you are in a mode where the physics agent is active.

Fast bodies pass through thin colliders (tunneling)

Cause. A body moving far enough in one fixed step to skip over a thin collider is classic discrete-step tunneling, made worse by a coarse fixed step (the LowPower strategy steps at 30 Hz; Balanced 60 Hz; HighPerformance 120 Hz — see apply_budget in the physics agent).

Fix. Use thicker colliders for fast objects, or favour a finer fixed step. Continuous collision detection is a provider-level concern — see Physics.

Simulation slows to slow-motion under a heavy hitch (but never freezes)

Cause. The spiral-of-death guard, working as designed. The real frame delta fed into the accumulator is clamped to MAX_FRAME_DELTA_SECONDS (0.25 s), and the number of fixed sub-steps run in one frame is capped at MAX_SIM_STEPS (5). When a long stall (a debugger break, an asset hitch, a window drag) would demand more catch-up steps than the cap, the excess accumulated time is dropped: the simulation runs in slow-motion for a moment rather than trying to replay seconds of physics in one frame and freezing.

Fix. Nothing in normal operation — recovery is automatic once frame pacing returns. The two knobs (MAX_FRAME_DELTA_SECONDS, MAX_SIM_STEPS) are constants in scheduler.rs; raise them only if you deliberately want more catch-up at the cost of a worse worst-case frame.

NaN positions / the simulation explodes

Cause. Usually a degenerate input: a zero or non-finite mass, an inverted/degenerate collider, an enormous force, or a fixed step too large for the configured stiffness. A NaN propagates through the integrator and corrupts transforms.

Fix. Validate masses and collider extents at spawn, clamp applied forces, and prefer a finer fixed step for stiff constraints. See Physics.


Audio

Audio runs through the AudioDevice trait (CPAL backend) and the spatial mixing lane (crates/khora-lanes/src/audio_lane/). As with physics, the AudioAgent only runs in a mode that enables it (e.g. Playing in the editor).

No spatialization — sound plays but is not panned by position

Cause. The mixing lane only pans when a listener transform is present; with no listener the channels stay balanced (see spatial_mixing_lane.rs).

Fix. Ensure the scene has a listener (an AudioListener on an entity with a transform), typically on the active camera or the player.

No sound at all

Cause. Check, in order:

  • The audio agent is running — you are in a mode that enables audio (not editor-edit mode).
  • The source is actually playing — it has audio data and is in a playing state, not stopped or at zero gain.
  • A CPAL output device exists and opened — on a headless CI box or a machine with no default output, the device may fail to open. A stream-level failure is reported through the CPAL error callback as audio stream error: … (backends/cpal/device.rs).

Fix. Confirm a working default output device, a playing source with non-zero gain, and an active audio mode. See Audio.

A corrupt or unsupported audio file does not crash the engine

Cause. Decoders return a Result; a file that fails to decode is logged and skipped, not unwrapped. This is the same contract every decoder follows (see Assets / VFS).

Fix. If a clip is silent, check the logs for a decode failure and re-export the asset in a supported format.


Assets / VFS

Assets are identified by a stable AssetUUID: AssetUUID::new_v5(rel_path) derived from the forward-slash relative path under assets/ by default, or a value frozen in the project’s identity registry (<project>/.khora/asset-registry.ron) once the asset has been renamed/moved in the editor (see crates/khora-io/src/asset/index_builder.rs and File formats — asset identity registry). Both dev (FileLoader) and a release pack (PackLoader) resolve through the same registry, so a file yields the same UUID in either — that identity is what makes dev/release transparent.

“Asset not found” / a handle never resolves

Cause. For an asset that has never been renamed, its UUID is computed from the relative path with forward slashes, e.g. textures/wood.png. A mismatch is almost always a path/identity mismatch: the file is outside the project’s assets/ root, or a reference was authored against a different path. (Renaming or moving inside the editor does not cause this — the registry freezes the UUID; see the entry below for renames done outside the editor.) The default UUID is platform-agnostic — textures\wood.png on Windows and textures/wood.png on Linux hash to the same UUID — so a missing asset is a path/identity problem, not an OS path-separator problem.

Fix. Confirm the file lives under <project>/assets/, and that the reference resolves to an indexed asset — either at its original path (unfrozen default) or at whatever path the identity registry currently binds its UUID to.

A reference broke after renaming/moving an asset outside the editor (shell, git, another tool)

Cause. Stable identity relies on the editor mediating the file operation: it moves the file and freezes the UUID into .khora/asset-registry.ron in the same step. Renaming or moving an asset from a shell, git, or any external tool bypasses that freeze. The file now resolves to new_v5(new_path) (a different UUID), while scenes and materials still reference the old UUID — so the reference orphans, exactly as it would have before the registry existed.

Fix. Prefer doing renames/moves in the editor’s asset browser, which keeps references intact. If a file was already moved outside the editor, either move it back to its original path (restoring the default UUID) or add a matching entry to the registry so the old UUID binds to the new path.

An asset loads in the editor (FileLoader) but not in a built game (PackLoader), or vice versa

Cause. Both loaders key by the same UUID, so an asset that resolves in one should resolve in the other if it was included. The usual cause is that the file was not under assets/ when the pack was built, or it was a scratch/temporary file the scan skips. The index builder ignores OS/editor scratch files: names starting with . or ~, and files ending in .tmp, .swp, .bak, or ~.

Fix. Keep referenced assets under assets/ with non-scratch names, and rebuild the pack. See Editor — Build Game and Assets.

Hot-reload does not fire when I edit a file

Cause. The filesystem watcher is timing-dependent — it reacts to OS file events, which can be delayed, coalesced, or (for some editors that write via atomic-rename to a temp file) hidden behind a name the scan ignores.

Fix. Save again, or save in place rather than via a .tmp/.swp shadow file. Hot-reload is a convenience; a reload-on-demand always works.

A corrupt asset cannot crash the engine

Cause. Every decoder returns a Result, and the index builder reads file contents behind a fallible path. A malformed file yields an error (or empty dependencies for a corrupt material), not a panic — see the corrupt_material_still_builds_with_empty_deps behaviour in index_builder.rs.

Fix. Check the logs for the decode warning naming the file, then re-export it.


GORNA / adaptation

The DCC adapts each agent’s strategy every cold-path tick based on hardware and frame-time pressure. When quality changes unexpectedly, GORNA is usually responding to a real signal. The mechanism is in GORNA; the code is khora-control/src/analysis.rs (HeuristicEngine) and khora-control/src/service.rs (DccService). To investigate a single decision frame by frame, use Debug a frame.

“Why did quality suddenly drop?” (a strategy downgraded on its own)

Cause. The DCC’s heuristics shape a single frame-time target, and a PID controller drives the global_budget_multiplier that scales every agent’s budget. Any of these will lower the multiplier and push agents to cheaper strategies:

  • Thermal — a throttling GPU/CPU relaxes the target to 30 FPS; Critical triggers a hard ~20 FPS safety cap.
  • BatteryLow relaxes the target to 30 FPS and prefers LowPower; Critical caps to ~20 FPS.
  • Frame-time / stutter / trend — frames over target, high variance, or a degrading slope tighten the budget; the cost-model forecast can tighten it before a breach.
  • CPU / GPU / memory pressure — load above the critical threshold forces negotiation.
  • Death spiral — three or more independent pressure sources active at once trip an emergency stop.

Fix. This is adaptive behaviour, not a bug. To diagnose, watch the editor’s GORNA Stream panel — it prints the reason for each switch (e.g. “RenderAgent: LitForward → Forward+ — GPU pressure”). See Pin a strategy below.

Quality keeps oscillating, or upgrades right after a downgrade

Cause. The loop is closed in both directions: once measured frame time settles under the setpoint, the PID multiplier climbs back toward 1.0 and budgets are re-issued so agents upgrade again. Re-arbitration fires when the multiplier moves by more than PID_RENEGOTIATE_DELTA (0.05). Near a threshold this can cycle.

Fix. Pin or clamp the agent (below) to stop the oscillation while you investigate.

Pin a strategy so adaptation stops surprising you

Cause. You want to remove adaptation as a variable while reproducing a behaviour.

Fix. Set the agent’s AdaptationMode through the DCC service (DccService::set_adaptation_mode(agent_id, mode)):

  • Manual(strategy) — pin the agent to one strategy; GORNA will not move it (a death-spiral safety stop can still force a downgrade).
  • Stable — block opportunistic up-switches (it can still downgrade under pressure).
  • Bounded { min, max } — clamp the negotiated range.
  • Learning (default) — negotiate freely.

These are the four AdaptationMode variants enforced today. For the full recipe — including how to read which agent chose which strategy and why — see Debug a frame.

Reproduce a specific adaptation decision

Cause. You need the exact sequence of strategy choices to recur bit-for-bit (QA, a flaky bug, lockstep).

Fix. Pin the relevant agent with Manual(strategy) to remove adaptation as a variable, then read the live decisions in the GORNA Stream panel. For deterministic capture-and-replay of the whole arbitration trace, the DCC also exposes a decision recorder (DccService::start_decision_recordingstop_decision_recordingreplay_decisions) — see Debug a frame for how to drive it.


See also: GORNA, Telemetry, Profile performance, Debug a frame, Rendering, Physics, Audio, Assets, Editor.

Profile and tune performance

Goal: measure where a frame spends its time, read why GORNA is making the choices it makes, and pull the levers that actually move the needle. This page is task-oriented and honest about what is measurable today and what is not yet — for the why behind each mechanism, follow the links out.

  1. Read frame performance
  2. Understand GORNA’s decisions
  3. The fixed-timestep cost model
  4. AGDF / data layout
  5. Flow view caching
  6. Practical tips
  7. What is not yet measurable

Read frame performance

Telemetry is a first-class subsystem, not an afterthought — it is the engine’s nervous system and the input to every adaptive decision. See Telemetry.

What the engine collects.

  • Frame timing. The wgpu backend has a GPU timestamp profiler (crates/khora-infra/src/graphics/wgpu/profiler.rs) that measures the main pass and the frame total on the GPU, smoothed with an EMA. It uses a two-pass timestamp scheme with a frame-lag read model (timestamps written in frame N are read at frame N+2).
  • Per-agent cost. The scheduler times every agent.execute() and publishes a TelemetryEvent::AgentCost { id, n, time_ms } each frame, where n is the workload size. This is what feeds the cost model (§2§3).
  • Draw calls / triangles / render stats. Agents and lanes push named counters and gauges through the MetricsRegistry (e.g. render.draw_calls, physics.bodies_active). Names are dot-separated; the registry is concurrent.
  • Memory. SaaTrackingAllocator (installed as the global allocator in every binary) tracks every heap allocation into atomic counters; the MemoryMonitor surfaces them as memory.current_bytes, memory.bytes_allocated_lifetime, memory.net_allocations.
  • Hardware monitors. GpuMonitor, MemoryMonitor, VramMonitor poll GPU utilization, heap, and VRAM.

Where it surfaces. The editor’s Control Plane (the sixth Spine mode) is the primary surface — see Editor — The Control Plane:

  • Lane Timeline — per-subsystem execution windows.
  • GORNA Stream — the live negotiation feed (who switched, and why).
  • Meters Wall — frame time, GPU %, memory, agent budget, assets pending.

To read live metrics from your own game/UI, the well-known metric names are documented in crates/khora-telemetry/src/lib.rs under WELL_KNOWN_METRICS. To add your own, hold a Counter / Gauge handle in the agent or lane that owns it (do not look up by string in the hot path).

Honest gap. The GPU profiler measures main pass and frame total; there is no fine-grained per-stage GPU breakdown (per-pass, per-material) and no CPU tracing spans (Tracy-style) wired up yet. Per-agent CPU cost is available (from AgentCost), but it is not yet sliced per render stage. See What is not yet measurable.


Understand GORNA’s decisions

GORNA chooses how each agent runs every cold-path tick; the GORNA chapter is the full reference. For performance work, three pieces matter.

The frame budget. Heuristics (thermal, battery, phase, frame-time, stutter, trend, CPU pressure, GPU pressure, memory pressure, death-spiral) collapse into a single frame-time target. A PID controller (khora_core::control::pid) then drives the global_budget_multiplier applied to every agent’s budget, closing the loop on measured frame time versus that target. When frames run long the multiplier drops and agents pick cheaper strategies; when there is headroom it climbs back toward 1.0 and agents upgrade. A hard safety ceiling caps the multiplier immediately on Critical thermal/battery or near-budget memory pressure.

Why an agent up/downgraded. Read the GORNA Stream panel — each switch prints its reason (“RenderAgent: LitForward → Forward+ — GPU pressure”). Re-arbitration fires when the PID multiplier has moved by more than PID_RENEGOTIATE_DELTA (0.05) since budgets were last issued, so a strategy change always traces back to a measured target/multiplier move. To walk a single decision step by step, use Debug a frame.

Calibration anchors quotes to reality. Agents quote static cost estimates in negotiate(), but reality drifts per machine and per scene. The DCC fits each agent’s measured (n, time) samples into a CostModel (c·f(n) over {1, n, n·log n, n²}) and, during arbitration, rescales each agent’s strategy options so the option matching its current strategy equals the measurement (factor clamped to [0.25, 4.0], ordering preserved). The budget fitting therefore reasons about measured milliseconds, not worst-case quotes — a cold-start agent with no sample keeps its quote until it is measured. See AGDF — anticipatory budgeting.


The fixed-timestep cost model

Rendering runs at the display’s variable rate; the simulation advances in fixed increments so it stays frame-rate independent and deterministic. The scheduler (crates/khora-control/src/scheduler.rs) reconciles the two with an accumulator (compute_sim_steps).

Sim steps scale with real dt. Each frame the real wall-clock delta (clamped to MAX_FRAME_DELTA_SECONDS = 0.25 s) is added to an accumulator, and whole fixed steps are consumed: steps = floor(accumulator / fixed_delta), capped at MAX_SIM_STEPS (5). The leftover carries over and yields the render interpolation_alpha. So a slow frame costs more sub-steps (more sim work), and a sustained overload clamps to 5 steps and drops the excess (slow-motion, never a freeze — the spiral-of-death guard).

Picking the fixed step. The fixed step is the smallest fixed_timestep any registered agent declares; in practice physics owns it via its GORNA strategy:

StrategyFixed step
LowPower (Simplified)30 Hz (1.0 / 30.0)
Balanced (Standard)60 Hz (1.0 / 60.0)
HighPerformance (Standard)120 Hz (1.0 / 120.0)

(From PhysicsAgent::apply_budget.) With no fixed-timestep agent, the loop degrades to “everything once per frame” and DEFAULT_FIXED_DELTA_SECONDS (1/60) is reported.

Visual smoothness is decoupled from sim rate. Because the renderer interpolates using interpolation_alpha, a 30 Hz physics step still renders smoothly at the display rate. A finer fixed step buys simulation accuracy (stiffer constraints, less tunneling), not visual smoothness — pick it for correctness, and let the cheaper step be a valid GORNA downgrade under pressure. See Troubleshoot — Physics.


AGDF / data layout

AGDF adapts the representation of ECS data — never its meaning. See AGDF for the full model; for performance, two levers.

The layout advisor (glass-box, ships today). The DCC turns per-component access telemetry (query_count, rows_scanned, size_bytes) into a read-only recommendation per component, surfaced via DccService::layout_recommendations():

#![allow(unused)]
fn main() {
pub enum LayoutRecommendation {
    KeepSoa,        // default column is fine
    SimdFieldSoa,   // lean component swept in large batches → field-SoA + SIMD
    HotColdSplit,   // fat component → split hot fields from cold
}
}

The DCC observes and advises; it never repacks (Data owns its layout). Online repack is the deferred Layer 3 frontier.

The SIMD field-SoA lever. A large, compute-bound, all-f32 hot component can opt into a field-split column with #[component(layout = "soa")], then be processed by the engine’s wide-based f32x8 kernels (khora_core::math::simd). The benchmark crates/khora-data/examples/layout_bench.rs measured, on the author’s machine:

KernelLayoutResult
normalize_quat_batchin-place field-SoA4.25× vs scalar
compute-heavy normalizeSoA + explicit f32x84.1× vs SoA scalar
compute-heavy normalizeAoSoA auto-vec2.9× vs SoA scalar
compose_trs_to_mat4SoA in → AoS Mat4 out0.9× (a loss)

The lesson is a whole-pipeline property: the SIMD win holds only while the data stays field-SoA resident. The same f32x8 math wins big in place but loses the moment it must scatter back to an AoS result (the per-lane transpose dominates the cheap arithmetic). That is why persistent field-SoA storage — not a per-frame transpose — is the real lever. Numbers are machine-dependent; reproduce them with:

cargo run -p khora-data --example layout_bench --release

Rule of thumb. Most components should stay AoS (&T queries, zero-copy). Reach for layout = "soa" only for what the advisor flags as SimdFieldSoa.


Flow view caching

Flows are read-only projectors: each Substrate Pass they re-derive a View (RenderWorld, ShadowView, …) from the World and publish it to the LaneBus. Most frames nothing they read has changed, so the projection is pure waste — and a cached View is bit-identical to a re-projected one, so skipping the work changes only the HOW. See AGDF — Flow view caching and ECS — Semantic domains.

What makes a Flow cache-hit vs re-project. The World keeps a monotonic change epoch per SemanticDomain, bumped at every mutation that can affect that domain’s semantic content (spawn/despawn, component insert/remove, get_mut, mutable query construction, deserialization, compaction). A flow that can name all of its inputs returns a Flow::cache_key combining the world instance id, the relevant domain epochs, and any runtime fingerprints. The register_flow! trampoline compares the key against the previous View and, on a match, republishes it (a cheap clone) without re-running select/project. A None key disables (and clears) the cache.

Which flows opt in is an honest audit of their input signals:

FlowCached?Why
AudioFlowyesreads only Audio + Spatial epochs (+ instance id)
RenderFlowyesRender + Spatial epochs + a viewport-override fingerprint
ShadowFlowyessame inputs as RenderFlow
UiFlownodepends on surface size + hot-reloadable fonts (no epoch)
PhysicsFlownothe sim mutates Physics/Spatial every simulated frame — a cache would never hit

So a cache-hit means “this domain did not change this frame”: a static scene re-uses Views, while a moving simulation re-projects. Representation-only changes (an AGDF layout repack) do not bump an epoch, so they never invalidate a cached View.


Practical tips

  • Always measure in --release. Dev builds use opt-level = 1 and line-tables-only debuginfo; release enables lto and codegen-units = 1. Per-frame numbers from a dev build are not representative.
  • Pre-size allocations in lanes. Allocation churn is a real frame-hitch cause — the DCC raises a glass-box alert when resident-byte volatility is high (see Telemetry). Reserve buffers up front and reuse them across frames rather than allocating per frame.
  • Avoid per-frame churn in the hot path. Hold typed Counter / Gauge handles instead of looking metrics up by string; keep field-SoA data resident across a SIMD loop (§4); do not transpose in and out.
  • Let GORNA pick the cheap path under pressure instead of hard-coding a quality level — but pin it (AdaptationMode::Manual) while you A/B a specific change so adaptation is not a variable (see Debug a frame).
  • Criterion benches. The ECS query path has a criterion benchmark (crates/khora-data/benches/query_bench.rs): run it with cargo bench -p khora-data. The AGDF layout numbers come from the layout_bench example (§4), not a criterion harness.

What is not yet measurable

Being explicit about the gaps:

  • No fine-grained GPU stage breakdown. The profiler times the main pass and the frame total, not individual passes or materials.
  • No CPU tracing spans. There is no Tracy/tracing-span timeline yet; the telemetry pipeline is described as compatible with such a hookup, but it is not wired (Telemetry — open questions).
  • Coarse workload size. The cost model’s n is the global entity count, not a per-domain workload — per-domain refinement is an open GORNA item (GORNA — open questions).
  • No histogram export. Histograms collect, but a Prometheus/OpenMetrics exporter is not committed; metrics are read in-editor or in-process.

These are forward-looking: the observation tunnel and metric registry are built to absorb them when they land. (GORNA decision record/replay, once roadmap, is now available — see Debug a frame.)


See also: Telemetry, GORNA, AGDF, ECS, Editor, Troubleshoot, Debug a frame.

Debug a frame / a GORNA decision

Goal: investigate a single frame — figure out which agent chose which strategy, why GORNA made that call, and how to pin or replay the decision so it stops being a moving target. Assumes you know the GORNA model and have the editor’s Control Plane open.

When to reach for this page: a strategy switched and you want the reason; quality oscillates and you want to freeze it; or a bug only reproduces under a specific adaptation sequence and you need it to recur. For the symptom-level “why did quality drop?” entry, start at Troubleshoot — GORNA; come here to go deeper.


1 — Read the per-frame telemetry

Open the editor’s Control Plane (the sixth Spine mode). It is a first-class workspace, not a profiler popup — see Editor — The Control Plane. Three regions matter for a single frame:

RegionWhat it showsWhat to read it for
Lane Timeline (top)Per-subsystem execution windows as colored bandsWhich subsystem owns the frame’s cost this tick
GORNA Stream (bottom-left)Live negotiation feed: timestamp, subsystem, suggestion, accept/rejectWhy an agent up/downgraded
Meters Wall (bottom-right)Frame time, GPU %, memory, agent budget, assets pendingThe signals that drove the decision

The Lane Timeline shows execution windows per subsystem, not a per-render-stage GPU breakdown — that finer slice does not exist yet (see Profile performance — gaps). For the CPU side, the scheduler publishes a TelemetryEvent::AgentCost { id, n, time_ms } per agent per frame, surfaced as the agent.<Id>_time_ms metric — that is your per-agent cost.

If you are reading metrics from your own code rather than the editor, the well-known names live in crates/khora-telemetry/src/lib.rs under WELL_KNOWN_METRICS; the DCC also ingests renderer.frame_time (ms), renderer.draw_calls, and renderer.triangles_rendered from the GPU report.


2 — Recognize which agent chose which strategy, and why

Adaptation is two stages: the heuristics decide a target, then the PID + arbitrator turn that target into a strategy per agent. Reading a decision means tracing it back through both.

Step A — read the reason off the GORNA Stream

Each switch in the stream prints its reason, e.g. RenderAgent: LitForward → Forward+ — GPU pressure. That suffix is the heuristic that fired. The HeuristicEngine (crates/khora-control/src/analysis.rs, analyze) evaluates these pressure sources every cold-path tick, starting from a 60 FPS baseline target (16.66 ms):

Pressure sourceTriggerEffect on the target
ThermalThrottling / CriticalRelax to 33.33 ms (30 FPS) / 50 ms (~20 FPS)
BatteryLow / CriticalRelax to 33.33 ms / 50 ms
Frame timeavg over warn / critical thresholdForce negotiation (critical also counts as pressure)
Stutterframe-time variance over thresholdForce negotiation
Trendrising frame-time slope (MetricStore::get_trend)Preemptive negotiation before a breach
CPU / GPU loadabove critical (GPU also has a warn tier)Force negotiation, counts as pressure
Memory pressureresident RAM nears the developer-set budgetForce negotiation, counts as pressure

The logs mirror the stream: the DCC thread emits DCC Analysis: <alert> at info for each alert in the report, and DCC PID: multiplier=… (setpoint=…ms, measured=…ms, dt=…s) at debug. Raise the log level on khora_control to see them outside the editor.

Step B — understand the multiplier that scaled the budget

The heuristics produce a single suggested_latency_ms target. A PID controller then drives the global_budget_multiplier so measured frame time tracks that target (service.rs, the DccService cold-path loop). Key behaviours when reading a decision:

  • The multiplier only updates once there are at least FRAME_TIME_MIN_SAMPLES frame-time samples and a non-zero measurement; before that it holds the PID’s current output.
  • It is clamped to a hard safety ceiling on Critical thermal/battery or near-budget memory — that cap is applied after the PID, so a critical signal overrides loop convergence immediately.
  • A change in the multiplier of more than PID_RENEGOTIATE_DELTA (0.05) since budgets were last issued forces re-arbitration — in both directions. Pressure heuristics drive downgrades; this delta is what drives recovery (the multiplier climbing back toward 1.0 re-issues budgets so agents upgrade again). That is the mechanism behind “it upgraded right after a downgrade”.

Step C — recognize the death-spiral stop

If three or more independent pressure sources are active in the same tick, analyze sets death_spiral_detected and logs Heuristic: DEATH SPIRAL detected (N simultaneous pressure sources) at error. That is the emergency stop, not a per-signal downgrade — if you see it, the machine is genuinely saturated on several axes at once.

Step D — confirm the agent honoured (or overrode) the choice

Even with a target and a multiplier, the final strategy depends on the agent’s AdaptationMode. An agent pinned with Manual keeps its strategy regardless of the fit; a Stable agent refuses opportunistic upgrades; a Bounded agent clamps to its range. Check the mode before concluding “GORNA chose X” — the arbitrator may have wanted Y and been overridden. See the next section.


3 — Pin a strategy to isolate behaviour

To remove adaptation as a variable, set the agent’s AdaptationMode through the DCC service. There are exactly four modes (crates/khora-core/src/control/gorna.rs):

#![allow(unused)]
fn main() {
dcc.set_adaptation_mode(AgentId::Renderer, AdaptationMode::Manual(StrategyId::LowPower));
}
ModeEffect
Manual(strategy)Pin the agent to one strategy; GORNA will not move it (a death-spiral safety stop can still force a downgrade).
StableBlock opportunistic up-switches; the agent can still downgrade under pressure.
Bounded { min, max }Clamp the negotiated range.
Learning (default)Negotiate freely.

set_adaptation_mode is thread-safe and takes effect on the next arbitration tick. Pinning a single agent lets you isolate one subsystem’s contribution while the rest adapt normally. For the broader recipe and the trade-offs of each mode, see Control GORNA adaptation. For the symptom-driven version, see Troubleshoot — Pin a strategy.


4 — Record and replay a GORNA decision

When a bug only reproduces under a specific adaptation sequence, capture that sequence and replay it bit-for-bit. The DCC records its per-tick decisions into a DecisionTrace (a Vec<TickDecisions> in arbitration order, crates/khora-core/src/control/gorna.rs) and can drive arbitration from a trace:

#![allow(unused)]
fn main() {
dcc.start_decision_recording();      // begins a fresh trace; takes effect next tick
// … reproduce the scenario …
let trace = dcc.stop_decision_recording();   // returns the captured DecisionTrace

dcc.replay_decisions(trace);         // each subsequent tick issues the recorded
                                     // strategies in order, bypassing live fit +
                                     // AdaptationMode, until the trace is exhausted
assert!(dcc.is_replaying());         // true until the trace runs out
dcc.stop_replay();                   // return to live arbitration at any time
}

During replay, arbitration drives every tick from the recorded decisions in order until the trace is exhausted, then falls back to live negotiation — so a captured run reproduces exactly, independent of the machine’s current thermal/frame-time signals. recorded_decisions() returns a snapshot of the trace captured so far (safe to call any time, e.g. to inspect mid-recording).

Note on AdaptationMode::Replay. Replay is not an AdaptationMode variant — the four modes are Learning, Manual, Stable, Bounded. Record/replay is a separate DccService capability (the methods above), so you can record a run while individual agents still carry their own modes.


5 — Quick reference: what to look at, in order

  1. GORNA Stream — read the switch reason (the — <cause> suffix).
  2. Meters Wall + DCC Analysis: logs — confirm which pressure signal actually fired.
  3. DCC PID: debug log — check setpoint vs measured and the multiplier; a move > 0.05 explains a re-arbitration.
  4. Agent’s AdaptationMode — confirm the agent didn’t override the fit.
  5. Pin with Manual — freeze the variable while you reproduce.
  6. Record → replay — when you need the exact sequence to recur.

See also: GORNA, Profile performance, Troubleshoot, Control GORNA adaptation, Editor — The Control Plane, Telemetry.

Concepts

The “why” behind Khora. These pages explain the ideas and the architecture — they don’t walk you through tasks (that’s How-to guides) or list every type (that’s the API reference). Read them in order for the full picture, or jump to the one you need.

The core ideas

  1. The big idea (SAA) — why an engine would negotiate with itself.
  2. Architecture (CLAD) — the Control → Agent → Lane → Data descent.
  3. The frame — the per-frame loop, fixed timestep, and render interpolation.

The adaptive machinery

  1. Data and the ECS (CRPECS) — how entities and components are stored.
  2. Agents and Lanes — strategists that choose, executors that run.
  3. GORNA — how subsystems negotiate for the frame budget.
  4. AGDF — adapting data layout at runtime, without changing meaning.

The subsystems

Each is a trait in khora-core with a default backend in khora-infra:


Unfamiliar with a term? The Glossary defines the vocabulary (SAA, CLAD, GORNA, AGDF, CRPECS, Lane, Flow, …). Ready to build? Head to the tutorials.

The big idea

Why an engine would negotiate with itself.


The problem with rigid engines

Most game engines decide at compile time. The render pipeline is fixed, the physics tick rate is a constant, the resource budgets are baked in. This works beautifully on exactly one target — the machine the defaults were tuned for — and progressively worse everywhere else. The result is a familiar trio of problems:

ProblemImpact
Static resource allocationUnderutilization or bottlenecks
Manual per-platform tuningTedious, fragile, expensive
No contextual awarenessCannot prioritize what matters to the player right now

Hardware diversity makes this worse every year. A title that ships to high-end PCs, mobile, and VR cannot have one static configuration that is right for all three. The classic answer is per-platform tuning by hand: weeks of fiddling with quality presets, and a combinatorial explosion of edge cases. The engine never understands its situation — it only ever replays decisions a human made in advance.

The answer: an engine that thinks

Khora replaces the rigid orchestrator with a council of intelligent, collaborating agents. The framework has a name: the Symbiotic Adaptive Architecture, or SAA. The name is exact — subsystems live in symbiosis (neither commanding nor commanded), and the engine adapts to its environment as a continuous behavior, not as a configuration step.

The contrast with a traditional engine is sharp:

  • A traditional engine is a tree. A central runtime calls into renderers, physics, and audio in a fixed order, with budgets decided at compile time.
  • SAA is a council. A central observer watches; specialists negotiate; an arbitrator hands out budgets each tick. Decisions are revisited every frame.

Three properties fall out of this:

  • Automated self-optimization. The engine detects bottlenecks and reallocates resources autonomously — no MAX_LIGHTS constant, no preset to pick.
  • Strategic flexibility. Rendering switches techniques under load. Physics shrinks its tick rate when the GPU is starving. Audio sheds voices when memory tightens. None of it requires developer intervention.
  • Goal-oriented decisions. Every adaptation serves a high-level goal — maintain 90 fps in VR, conserve battery on mobile, prioritize physics in this volume.

This is not a research toy. The engine ships a working renderer, a working physics step, an editor with a play mode, a sandbox you can run today, and hundreds of workspace tests. SAA is a deliberate, opinionated answer to the rigidity problem — not an aspiration.

The seven pillars

SAA rests on seven pillars. Here is the idea of each; where each one lives in the codebase is the subject of Architecture (CLAD).

1. Dynamic Context Core — the central nervous system

The DCC is the engine’s center of awareness. It does not command subsystems directly; it maintains a constantly updated situational model of the whole application — hardware load (CPU cores, GPU utilization, VRAM, bandwidth), game state (scene complexity, entity counts, light count), and performance goals (target framerate, latency ceiling, power budget). It runs on a dedicated background thread at ~20 Hz, completely independent of the frame loop.

2. Intelligent Subsystem Agents — the specialists

Every major subsystem is an agent: not a passive library but a semi-autonomous component with deep understanding of its own domain. An agent measures its own cost, holds multiple strategies with different performance characteristics, and can estimate the resource cost of each. Five agents exist today — one per LaneKind: Render, Shadow, Physics, UI, Audio. The architecture is open; you can add your own.

3. GORNA — Goal-Oriented Resource Negotiation and Allocation

GORNA is the formal protocol the DCC and the agents use to allocate resources, replacing static budgets:

flowchart LR
    A[Agent requests] -->|needs + costs| B[DCC analysis]
    B -->|heuristics| C[Arbitration]
    C -->|budgets| D[Agent adaptation]
    D -->|telemetry| A

Agents submit desired needs with strategy costs; the DCC arbitrates against the global model and its goals; it grants each agent a budget (possibly less than requested); the agent then selects a strategy that fits. The DCC shapes a single frame-time target from a battery of heuristics each tick, and a closed-loop PID controller regulates the global budget so the measured frame time tracks that target.

4. Adaptive Game Data Flows — the living representation

AGDF is the principle that not only algorithms but also the in-memory representation of data should adapt to the hardware it runs on. The engine observes how each component is actually accessed and lays its storage out for the machine it is on right now:

SignalAGDF action (representation only)
A component is scanned in tight, vectorizable loopsRe-tile its column to a SIMD-friendly AoSoA layout
A component mixes hot and cold fieldsSplit storage so iteration touches only the hot cache lines
A layout stops paying off on this hardwareRepack back, gated by a cost/benefit test

This rearranges bytes, never the simulation outcome. It is the data-layer twin of GORNA — the same observe → decide → apply loop, applied to memory instead of strategy.

What AGDF is not. Removing an entity’s physics because it is far from the player changes the game, not just its representation. That is a gameplay decision and belongs to the developer. The engine may offer the mechanism, but it never applies it to an entity the developer has not opted into. See pillar 7.

5. Semantic interfaces and contracts — the common language

For intelligent negotiation to be possible, every agent must speak a common, unambiguous language. Khora’s contracts are formal Rust traits — capabilities (“I can render with Forward+ or Simple Unlit”), requirements (“I require all entity positions and meshes”), guarantees (“with 4 ms CPU budget I guarantee stable physics for 1000 rigid bodies”). These contracts are the seam through which the entire engine is reorganizable.

6. Observability and traceability — the glass box

An intelligent system risks becoming an indecipherable black box. Observability is therefore first-class. Every DCC decision is logged with complete context — telemetry, requests, final budget — so a developer can ask not just “what happened?” but “why did the engine choose that?”. A telemetry service surfaces real-time metrics for every subsystem.

7. Developer guidance and control — partnership, not autocracy

The engine’s autonomy serves the developer; it does not replace them. The developer can define constraints (“in this zone, physics > graphics”) and pick an adaptation mode (Learning / Manual / Stable / Bounded). The boundary is firm:

Automatic adaptation may change the how (strategy, quality, memory layout) but never the what (game semantics — which components an entity has, how the simulation behaves). Changing the what is always the developer’s call; the engine supplies the mechanism, the developer authors the policy.

This single rule — adapt the HOW, never the WHAT — recurs throughout the engine, and is the reason adaptation never becomes a correctness hazard.

Cold path and hot path

The clearest way to understand SAA is to see the two clocks it runs on. The hot path runs every frame on the main thread and must never block. The cold path runs slowly on a background thread, watches what just happened, and decides what should happen next. They communicate through exactly one channel: budgets flow down, telemetry flows up.

graph TD
    subgraph Cold["Cold path — background thread, ~20 Hz"]
        DCC[DCC service]
        Telemetry[Telemetry aggregation]
        Heuristics[Heuristics]
        GORNA[GORNA arbitration]
    end
    subgraph Hot["Hot path — main thread, 60+ Hz"]
        Scheduler[ExecutionScheduler]
        Agents[Agents: Render, Shadow, Physics, UI, Audio]
        Lanes[Lanes: pipelines]
    end
    Telemetry --> DCC
    DCC --> Heuristics
    Heuristics --> GORNA
    GORNA -->|BudgetChannel| Scheduler
    Scheduler --> Agents
    Agents --> Lanes
    Lanes -->|telemetry| Telemetry
AspectCold path (DCC)Hot path (Scheduler + agents)
ThreadBackgroundMain
Frequency~20 Hz60+ Hz (every frame)
ResponsibilityObserve, analyze, negotiateExecute agents, dispatch lanes, produce output
CommunicationUnidirectional budget channelAgents read budgets at frame start

The key insight: agents are not controllers, they are adapters. They receive budgets from GORNA and select the appropriate lane strategy. The DCC decides what resources are available; agents decide how to use them. The data layer plays by the same spirit on its own — it self-optimizes its memory layout internally, and the DCC only observes the result through a read-only telemetry tunnel. Only agents compete in the budget auction; the data layer never bids for frame time.

This cold/hot split is non-negotiable. The frame loop must never be blocked by analysis, and an agent must never wait synchronously on the DCC — the relationship is fire-and-forget through a channel, with last-wins semantics.

SAA is the why; CLAD is the how

SAA describes the philosophy. Its concrete implementation has another name: the CLAD layering — Control, Lanes, Agents, Data. The first describes the why; the second describes the how. They are two views of the same thing. Every abstract SAA concept has a direct, physical home within CLAD, and the dependency layering of the crates is the architecture.

Next steps

  • Architecture (CLAD) — where each of the seven pillars lives, and why the layering flows the way it does. Read this next.
  • The frame — the per-frame descent and the fixed-timestep model that turns SAA into motion.
  • Glossary — every proprietary term in one place.

Architecture (CLAD)

Where the SAA pillars live in the codebase, and why the layering flows the way it does. Pair with The big idea.


SAA, meet CLAD

Khora is built on two architectural concepts that are really two sides of one coin. SAA is the philosophical blueprint — self-optimizing, adaptive, symbiotic. CLAD is the concrete crate structure — Control / Lanes / Agents / Data — with strict dependency layering and well-defined data-flow patterns.

The whyThe how
NameSAA — Symbiotic Adaptive ArchitectureCLAD — Control / Lanes / Agents / Data
FormPhilosophical blueprintConcrete crate structure
ConcernSelf-optimizing, adaptive engineStrict dependency layering, data-flow patterns

Every abstract concept in SAA has a direct, physical home within CLAD. The split between agents and lanes mirrors the split between strategy and execution. The split between data and core mirrors the split between state and contract.

graph TD
    subgraph SAA["Symbiotic Adaptive Architecture (the why)"]
        DCC[Dynamic Context Core]
        ISA[Intelligent Subsystem Agents]
        GORNA[GORNA protocol]
        AGDF[Adaptive Game Data Flows]
        Contracts[Semantic interfaces]
        Obs[Observability]
    end
    subgraph CLAD["CLAD crate pattern (the how)"]
        Control[khora-control]
        Agents[khora-agents]
        Lanes[khora-lanes]
        Data[khora-data]
        Core[khora-core]
        IO[khora-io]
        Tele[khora-telemetry]
        Infra[khora-infra]
    end
    DCC --> Control
    GORNA --> Control
    ISA --> Agents
    AGDF --> Data
    Contracts --> Core
    Obs --> Tele
    Control -.->|budget auction| Agents
    Agents -.->|Switches| Lanes
    Control -.->|invokes substrate: invariants + projection| Data
    Data -.->|access telemetry; self-optimizes layout| Tele
    Tele -.->|observation tunnel| Control
    Lanes -.->|Uses traits| Core
    Data -.->|Uses traits| Core
    IO -.->|I/O services| Agents
    Infra -.->|Implements contracts| Core

The descent: Control → Agent → Lane → Data

The CLAD name spells out the path a frame’s command takes through the engine:

Control ──► Agent ──► Lane ──► Data
       budget   selects   reads bus / writes deck
  • Control (DCC + GORNA) is the strategic brain. It observes telemetry, arbitrates the agent budget auction, runs the Scheduler, and invokes the Substrate (data invariants + projection Flows). It sets a budget; it does not dictate strategy.
  • Agents are tactical managers. Each owns exactly one LaneKind. Given its budget, an agent picks a Lane and invokes it. It does no per-frame state-keeping of its own — it is a strategist, not a worker.
  • Lanes are the fast, deterministic workers — the actual algorithms an agent can choose between. A Lane reads typed Views from the bus and writes its results into the output deck.
  • Data is the foundation — the archetype ECS storage plus the adaptive memory layout. It is read-only projected into the hot path each frame, never mutated structurally from within a Lane.

This descent is the competitive path: agents negotiate, the winner descends to its Lane, the Lane touches Data. It is the single most important shape in the engine.

The mapping

Each SAA pillar lands in a specific crate:

SAA concept (the why)CLAD crate (the how)Role
Dynamic Context Core & GORNAkhora-controlStrategic brain — observes telemetry (incl. Data access patterns), arbitrates the agent budget auction, runs the Scheduler, invokes the Substrate. It never drives Data’s layout — Data self-optimizes
Intelligent Subsystem Agentskhora-agentsTactical managers — one per LaneKind (render, shadow, physics, audio, UI)
Multiple agent strategieskhora-lanesFast, deterministic workers — the algorithms an agent chooses from
Adaptive Game Data Flowskhora-dataFoundation — archetype storage + adaptive memory layout, self-optimized inside the Data layer; representation only, never game semantics
Semantic interfaces and contractskhora-coreUniversal language — traits, core types, math, GORNA types
I/O serviceskhora-ioAsset loading, VFS, serialization — on-demand services, not agents
Observability and telemetrykhora-telemetryNervous system — gathers performance data for the DCC
Hardware and OS interactionkhora-infraBridge to the outside world — wgpu, winit, Rapier3D, CPAL, Taffy

The full crate-by-crate map — folders, key files, what to read first — lives in the Crate map reference.

Dependencies flow downward only

The dependency graph is the architecture. If you change one, you change the other. Dependencies flow strictly downward; a cycle is a hard build error.

graph LR
    subgraph User
        SDK[khora-sdk]
        ED[khora-editor]
    end
    subgraph Engine
        CTRL[khora-control]
        AGT[khora-agents]
        LANE[khora-lanes]
        IO[khora-io]
        DATA[khora-data]
        CORE[khora-core]
        INFRA[khora-infra]
        TELE[khora-telemetry]
    end
    SDK --> CTRL
    SDK --> AGT
    SDK --> IO
    SDK --> INFRA
    SDK --> TELE
    SDK --> DATA
    CTRL --> CORE
    CTRL --> DATA
    AGT --> CORE
    AGT --> DATA
    AGT --> LANE
    AGT --> IO
    LANE --> CORE
    LANE --> DATA
    IO --> CORE
    IO --> DATA
    IO --> TELE
    DATA --> CORE
    INFRA --> CORE
    INFRA --> DATA
    TELE --> CORE
    ED --> SDK
    ED --> AGT
    ED --> IO

A handful of rules make the graph legible:

RuleWhy
No upward depskhora-core depends on nothing — it stays portable and trait-only
No lateral depsAgents never depend on Control; they talk down to lanes and across to a one-way channel
Traits in coreAbstract traits live in khora-core; implementations live in their own crates
Backends in infraPer-backend code lives under khora-infra/src/<area>/<backend>/

Two consequences are worth dwelling on, because they shape the whole engine.

khora-control depends on khora-data, but never commands it. The Scheduler invokes the Substrate — the data-layer invariants and the projection Flows — over the World because it owns the tick ordering. That is orchestration of when Data takes its turn, not control over how Data lays itself out. Layout is Data’s own self-optimization. Agents still never depend on Control.

khora-infra is one implementation, not the implementation. Every backend in khora-infra implements a trait that lives in khora-core. Swapping to a different graphics backend, physics solver, audio device, or UI layout engine means writing a new implementation of the trait — typically a new sibling folder under khora-infra/src/<area>/<new_backend>/. The rest of the engine never sees the change. This is the load-bearing reason backend code is segregated, and it is why the trait surface matters more than any single backend.

Two relationships of Control — and neither commands Data

A frequent misreading is that the DCC “commands” subsystems, or that the data layer “competes” for budget like an agent. Neither is true. Control relates to the rest of the engine in exactly two ways:

  1. The descent — the budget auction. Control → Agent → Lane → Data. Agents are the only parties that negotiate for the frame budget; the DCC arbitrates among agents. Once an agent’s budget is fixed, it picks a Lane and work descends. This is the competitive path.
  2. The observation tunnel — Data → Control. Telemetry flows up: hardware monitors, agent status, and data-layer access-pattern metrics feed the DCC’s situational model. This is read-only observation — the opposite of a command.

The data layer adapts itself. Its memory layout is a self-optimization internal to khora-data, decided locally from the access patterns it measures and self-bounded by a cost/benefit test. The DCC does not drive it; it only observes the result. Symmetrically, the DCC never dictates an agent’s strategy either — it sets a budget, the agent decides. This is precisely what keeps the architecture symbiotic rather than autocratic.

The trait surface

The contracts that hold the engine together are a small set of Rust traits. Reading them is reading the engine’s API — they are kept short, stable, and free of backend-specific types:

TraitDefined inImplemented by
Lanekhora-coreAll lane types in khora-lanes
Agentkhora-coreAll agent types in khora-agents
RenderSystemkhora-corewgpu backend in khora-infra
PhysicsProviderkhora-coreRapier3D backend in khora-infra
AudioDevicekhora-coreCPAL backend in khora-infra
LayoutSystemkhora-coreTaffy backend in khora-infra
Componentkhora-dataAll ECS components (via derive macro)

The presence of every seam as a trait — and the absence of string-keyed APIs or Box<dyn Any> downcasting in production paths — is what makes the engine reorganizable at runtime. For the exhaustive trait and type listing, defer to the rustdoc; the point here is the shape, not the catalog.

Next steps

  • The frame — the per-frame mechanics that bring this layering to life: the six-stage descent, the Substrate Pass, and the fixed-timestep simulation model. Read this next.
  • Crate map reference — the full crate-by-crate breakdown.
  • Glossary — every proprietary term in one place.

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 FrameGraph carries recorded GPU render passes, drained and submitted in Stage 5. The typed OutputDeck carries 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 the LaneBus in 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:

  1. 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.
  2. Accumulate. Add the clamped delta to the accumulator.
  3. Consume whole steps. With the fixed step fixed_delta (the smallest fixed_timestep any agent declares — in practice the physics agent’s, default 1/60 s), compute steps = floor(accumulator / fixed_delta), capped at MAX_SIM_STEPS (5). The remainder carries over to the next frame.
  4. Derive alpha. The leftover fraction — remainder / fixed_delta, always in [0, 1) — becomes the render interpolation_alpha.
  5. Run. Step the fixed-timestep agents exactly steps times (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.

Data and the ECS (CRPECS)

The data layer Khora is built on. This page explains why the engine carries its own ECS, the shape of its storage model, and how that shape pays off for the rest of the Symbiotic Adaptive Architecture. It is an explanation, not a recipe — for the steps to define and register a component, see How-to: add a component, and for the type-level facts, the rustdoc on khora_data::ecs.


Why a custom ECS

CRPECSChunked Relational Page ECS — exists because the SAA’s promise of Adaptive Game Data Flows (adapting the in-memory layout of data to the hardware it runs on) requires a storage model where structural change is cheap. The three letters of the name are load-bearing: storage is chunked into bounded pages, the relationship between an entity and its data is relational (entities are identifiers, not pointers), and the page is the unit at which everything — iteration, compaction, serialization — happens.

The consequence is that adding or removing a component is not an O(N) operation; whole pages are queryable in cache-friendly bursts; and bitset-guided queries keep sparse iteration fast. Off-the-shelf ECS libraries tend to optimise for one of those properties at the expense of the others — a sparse-set ECS is simpler but pays a query-time cost; a pure archetype store iterates fast but can make structural change expensive. CRPECS is built to do all three at once, because the SAA needs all three at once. That is the whole trade-off the design is organised around.

The storage model

The World owns three things: an entity store (sparse, generation-checked), a component registry (typed, inventory-driven), and a set of archetype pages (contiguous struct-of-arrays storage, one per component combination).

graph TD
    subgraph World
        ES[Entity store]
        R[Component registry]
        P[Pages]
    end
    subgraph Pages
        P1[Page 0: Transform + GlobalTransform]
        P2[Page 1: RigidBody + Collider]
        P3[Page 2: Camera + Light]
    end
    ES --> R
    R --> P
    P --> P1
    P --> P2
    P --> P3

Entities are lightweight identifiers carrying an index and a generation. The generation is what makes stale handles safe: when an entity is despawned and its slot is reused, the generation increments, so an old handle silently fails its lookups instead of pointing at whatever now lives in that slot.

Components are plain data tagged with #[derive(Component)]. The derive is where a lot of the ergonomics live — it generates the serialization mirror and its From conversions, and self-registers the type (via inventory) so World::new discovers it with no hand-maintained list. The point worth understanding is why the macro generates a mirror at all: maintaining two structs by hand (the live type and its serialized form) was a recurring source of drift, and runtime reflection would cost allocation on the hot path. The macro is the statically checked middle path.

Pages are the heart of it. Components are grouped by archetype — the exact set of component types an entity has — and each archetype’s data lives in one or more contiguous pages, stored struct-of-arrays. Adding a RigidBody to an entity moves it from its current page to the page whose archetype includes RigidBody; the cost is a bounded, component-by-component memcpy, not a world-wide event. A bitset on each page records which slots are live, so iteration walks set bits and indexes into the SoA arrays with no per-entity allocation and no per-entity branching.

Semantic domains

Every component declares a SemanticDomain (Spatial, Render, UI, Audio, …), encoded as a bitset on its registry entry. Domains exist so that a query can pre-filter pages by meaning: a render-extraction query hinted with the Render domain never touches UI pages. As the entity count grows, this is part of what keeps per-frame extraction from scaling with the whole world.

Domains are also the unit of change tracking, and this is where the ECS connects to the adaptive layer. The World keeps a monotonic counter — a change epoch — per domain, bumped in O(1) by every mutation entry point that can affect that domain’s semantic content: spawn/despawn, component insert/remove, get_mut, mutable query construction, deserialization, and compaction that reorders rows.

The contract is deliberately one-sided. Equal epochs across two reads guarantee the domain’s data — and its iteration order — is unchanged; a different value means only “possibly changed”. The bumps are conservative, so over-bumping is harmless while a missed bump would mean a stale consumer, which is the failure mode the design refuses to risk. Crucially, a representation-only change — an AGDF layout choice that rewrites how a column is stored — does not bump the epoch, because it changes the HOW, not the WHAT. Epochs are read via World::domain_epoch(domain) and paired with World::instance_id() so counters from two different World instances can never accidentally compare equal.

The first consumer of these epochs is Flow view caching: a Flow that can name all of its inputs folds the relevant domain epochs into a cache key, and on an unchanged frame republishes its previous View instead of re-projecting it. That mechanism — and why a cached View is bit-identical to a freshly projected one — is explained in AGDF.

Queries and SoA versus AoSoA

Queries are type-safe and borrow-checked at compile time. A query asks for a tuple of component references, and the planner picks every page whose archetype contains all of them, iterating in SoA order:

#![allow(unused)]
fn main() {
for (transform, mut global) in world.query::<(&Transform, &mut GlobalTransform)>() {
    global.0 = transform.compute_global();
}
}

A &mut Component in one query closes the door, at compile time, on any other query touching that component for the borrow’s duration — the same exclusivity rule that would later make parallel query execution sound, though the policy for that is not yet decided.

Underneath, a default column is a Vec<T>: struct-of-arrays across entities, but array-of-structures within a component — whole structs back to back. That is the right default and matches what every mainstream archetype ECS does. But it leaves the field level on the table: a loop touching one field of a fat component strides over all the others, wasting cache lines and defeating SIMD. A compute-heavy, all-f32 component can therefore opt into a field-split (SoA) column, where each field becomes its own contiguous stream — without changing its semantics or disturbing the rest of CRPECS. This is the substrate the adaptive- layout layer rides on; the trade-offs, the measured wins, and why the layout is chosen per component are covered in AGDF.

Maintenance is a service, not an agent

ECS maintenance — draining cleanup after component removal and compacting pages with too many holes — runs every frame between user logic and agent execution. It is deliberately not an agent. An agent exists to negotiate between competing strategies; maintenance has no strategies to negotiate, it does the same fixed work every frame. So it is a direct data-layer operation, owned by the world rather than scheduled through GORNA. Knowing where that line falls — strategy-bearing subsystems are agents, fixed operations are services — is the single most useful distinction for reading the data layer.

Next steps

  • How-to: add a component — define, tag with a domain, and register a component.
  • AGDF — how the field-SoA substrate and Flow view-caching ride on this storage model.
  • Agents and Lanes — what consumes the Views the data layer projects.
  • Glossary — CRPECS, SemanticDomain, change epoch, archetype page.

Agents and Lanes

This page explains the single most-confused boundary in Khora: the line between a strategist (an Agent) and an executor (a Lane). They are deliberately different kinds of object with different rules, and keeping them straight is the key to reading the per-frame descent. This is an explanation of why the split exists and how data flows across it — for the steps to add either one, see How-to: add a lane and How-to: add an agent; for the trait method tables, the rustdoc on khora_core::agent and khora_core::lane.


Two roles, one descent

The CLAD descent of a frame is Control → Agent → Lane → Data. The Control layer (the DCC, via GORNA) hands each agent a budget; the agent picks a strategy and dispatches it; the strategy — a lane — does the work against data the Data layer has projected. The two middle roles are not interchangeable:

  • An Agent is a tactical manager. It owns exactly one LaneKind, knows the lanes (strategies) available for that kind, exposes those strategies to GORNA, applies the budget GORNA returns, and dispatches the chosen lane each frame. It decides which lane runs.
  • A Lane is a hot-path worker. It does one thing — render a forward pass, step physics once, mix one audio frame, decode one glTF — deterministically, and it does it when an agent dispatches it. It decides nothing; it just executes.

The naming is symmetric on purpose. RenderAgent chooses between SimpleUnlitLane, LitForwardLane, and ForwardPlusLane; PhysicsAgent runs the standard physics lane; AudioAgent runs the spatial-mixing lane. The agent owns selection. The lane owns execution. Each lane is a strategy the owning agent can offer to GORNA.

Why agents are kept so thin

An agent implements only the Agent trait plus Default — no other methods. No start/stop, no builders, no accessors; construction goes through Default::default(). This is a hard rule, and it is worth understanding the reasoning rather than treating it as arbitrary:

  • One LaneKind per agent. An agent has exactly one negotiation surface. The canonical example is the split between RenderAgent and ShadowAgent: shadows and the main pass have different costs, different dependencies, and different per-frame roles, so they are two agents, not one agent with two jobs.
  • No per-frame state. An agent does not buffer outputs, own a Flow, or accumulate work between frames. If it needs to remember something across frames, that thing belongs in a lane (self), in the ECS, or in a tick-scoped context — not on the agent. State on an agent is the first symptom of lane logic leaking upward.
  • Strategist, never worker or controller. An agent does not contain pipeline code (that is the lane’s job) and does not decide global priorities (that is the DCC’s job). It sits precisely in between.

The motive is legibility. Every agent in the workspace has the same shape, so the boundary stays crisp and the slow drift toward god-object subsystems never starts. If you find yourself adding a method to an agent struct, you are almost certainly leaking lane work into the agent — the fix is to push it down into a lane.

A corollary worth stating: a subsystem that has no strategies to negotiate is not an agent at all. ECS maintenance, asset loading, and serialization are services, not agents, because there is nothing for GORNA to choose between. Reserve the agent abstraction for subsystems that genuinely have competing performance strategies.

How a lane gets its data: the bus and the deck

A lane never reaches into the ECS World directly. This is the other half of the discipline, and it is what makes lanes orthogonal — substitutable without touching their neighbours. Lanes read and write through two tick-scoped, typed channels, reached via the LaneContext the agent hands them:

  • The LaneBus — the read side. The domain Flow layer is the only legitimate producer of the Views a lane consumes. During the Substrate Pass each Flow projects a typed View (RenderWorld, ShadowView, …) from the World and publishes it into the bus. A lane only ever gets a View by type from the bus; it cannot publish, and it cannot query the World behind the bus’s back. The bus is constructed by the scheduler at frame start and dropped at frame end — outside lane execution it does not exist.
  • The OutputDeck — the write side. The symmetric counterpart: a lane writes its typed output (recorded GPU command buffers, draw lists) into a slot on the deck. The engine drains specific types at the I/O boundary — submit, present — by taking them out. A lane produces into the deck; it does not perform the final I/O itself.
flowchart LR
    World[(World — CRPECS)] -->|Substrate Pass| Flow[Flows]
    Flow -->|publish Views| Bus[LaneBus]
    DCC[DCC / GORNA] -->|budget| Agent
    Agent -->|dispatch chosen lane| Lane
    Bus -->|get View| Lane
    Lane -->|write output| Deck[OutputDeck]
    Deck -->|engine takes| IO[I/O boundary: submit / present]

So the full data flow is: World → Flow → LaneBus → Lane → OutputDeck → engine I/O, with the agent sitting above the lane choosing which lane runs and the DCC above the agent setting how much it may spend. Read-only Views in, typed outputs out, nothing shared by long-lived reference. A lane can be removed, replaced, or swapped for another strategy without disturbing any other lane, because everything crosses the boundary as a typed value in the bus or the deck rather than as a direct dependency between lanes.

That decoupling is the payoff of the whole arrangement. The LitForwardLane does not depend on a ShadowPassLane instance — it depends on the shadow View being present in the bus. The shadow lane can be rewritten, and as long as it still publishes the same View type, the forward lane never notices.

The three phases of a lane

A lane’s per-frame work is split into prepareexecutecleanup (with a one-shot on_initialize at boot). The split is load-bearing, not ceremony: prepare is read-only extraction (parallelizable across lanes in principle), execute is the mutating output (single-threaded), and cleanup is the seam where per-frame state is reset so nothing leaks into the next frame. A lane that leaves stale data behind in cleanup is a frame-leak waiting to happen. Errors surface as a LaneError that bubbles to the agent, which can log it and fall back to a cheaper strategy — the same LaneError is how a degraded frame stays a degraded frame rather than a crash.

Why this separation exists

Pulling the two roles apart buys three things the engine depends on:

  1. GORNA has something to negotiate. Because lanes are strategies and agents expose them, the cost estimates flow up to the DCC and budgets flow back down — adaptation has a surface to act on. A monolithic “render system” would have nothing to choose between.
  2. The hot path stays decoupled from the cold path. Agents apply budgets that the DCC computed off-thread; lanes execute against Views the Flow layer already projected. Neither blocks on the other.
  3. Subsystems stay swappable. Lanes communicate only through the bus and the deck, so a strategy can be added, removed, or replaced in isolation — which is exactly what an adaptive engine needs when it switches LitForward to Forward+ mid-session because GORNA detected too many lights.

Next steps

GORNA: resource negotiation

GORNAGoal-Oriented Resource Negotiation and Allocation — is the protocol that lets the engine’s agents and its central controller trade frame budgets in real time. This page explains why the engine negotiates budgets at all, the shape of the negotiation, and how the control loop closes. It is an explanation, not an operator’s manual — for pinning or bounding an agent in practice, see How-to: control GORNA adaptation, and for watching a live decision, How-to: debug a frame.


Why negotiate at all

A traditional engine assigns budgets at compile time: physics gets 4 ms, rendering gets 12 ms, audio gets the rest. Those numbers are correct on exactly one machine — the developer’s — and wrong everywhere else. A laptop on battery, a workstation, and a Steam Deck do not share a frame budget, and the same machine does not share one between a quiet menu and a crowded firefight.

GORNA replaces the fixed split with a per-tick negotiation. Agents declare what they can do at several cost points; the DCC (the Dynamic Control Core) observes the running system, applies a set of heuristics, and hands back a budget that reflects this hardware, this scene, this frame. The result is one binary that adapts its strategy each tick to hold the frame rate, instead of a binary tuned for one target.

A boundary worth stating up front: only agents negotiate in GORNA. There is one negotiation surface per LaneKind, and that is it. The Data layer does not compete here — it self-optimises its own memory layout (AGDF), observed by the DCC but never driven by it, and never bids for the frame budget. GORNA arbitrates strategy; AGDF arbitrates layout; they are siblings, not the same auction.

The negotiation, phase by phase

The loop runs on the cold path at roughly 20 Hz. The hot path never waits for it — if a budget is late, the previous one simply stays in effect.

flowchart LR
    A[Awareness] -->|collect metrics| B[Analysis]
    B -->|run heuristics| C[Negotiation]
    C -->|collect strategies| D[Arbitration]
    D -->|apply budgets| E[Application]
    E -->|next tick| A
  • Awareness gathers telemetry from agents and hardware monitors.
  • Analysis runs the heuristic engine over that telemetry, shaping a single frame-time target.
  • Negotiation asks each agent for its strategy options and their estimated costs.
  • Arbitration selects an optimal strategy per agent that fits the global frame budget, respecting priorities and constraints.
  • Application calls apply_budget on each agent and pushes the result through the BudgetChannel (one channel per agent, last-wins).

Agents declare what they offer as StrategyOptions and receive a ResourceBudget. The request and budget shapes are intentionally narrow — time, memory, VRAM, and a small extras map — so that adding a resource dimension is a considered change, not a free-form bag each subsystem invents its own dialect for. The exhaustive field lists live in the rustdoc on khora_core::control::gorna.

The cost model and the PID frame-budget controller

Two pieces turn raw telemetry into a budget, and the division of labour between them is the heart of the current design.

The heuristics shape a single frame-time target rather than scaling the budget directly. Thermal, battery, and engine-phase heuristics relax the target (a hot device aims for 30 FPS rather than 60); the cost-model forecast can tighten it pre-emptively. They answer the question “what frame time should we be aiming for?”.

The actual global_budget_multiplier — the scalar applied to every agent’s frame budget — is then driven by a PID controller that closes the loop on measured frame time versus that target. When frames run long the loop lowers the multiplier (agents pick cheaper strategies, frames speed up); when there is headroom it climbs back toward 1.0. This replaced an older static thermal/battery lookup that stepped the multiplier in coarse jumps and double-counted thermal and battery (once in the target, once in the multiplier). The controller uses the refinements a noisy, saturating, discrete-actuator plant needs — derivative-on-measurement with a low- pass filter, back-calculation anti-windup, setpoint weighting, and output clamping to [floor, 1.0]. On top of the loop sits a hard safety ceiling that caps the multiplier immediately on critical thermal/battery or near-budget memory pressure: the loop regulates the steady state, the ceiling handles emergencies.

The PID recovery loop is what makes this bidirectional, and it is a recently completed piece rather than a future one. Pressure heuristics drive the downgrades by tightening the target and flagging a renegotiation. Recovery is driven by the multiplier itself: the DCC remembers the multiplier in effect when budgets were last issued, and re-arbitrates whenever the PID has since moved it by more than a small fixed delta (0.05) in either direction. Once measured frame time settles back under the setpoint the multiplier climbs, budgets are re-issued, and agents upgrade again — instead of staying pinned at a degraded strategy forever.

Empirical cost calibration closes the other gap. Agents quote static estimates in negotiate(), but reality drifts from the quote per machine and per scene. The DCC fits each agent’s measured (n, time) samples to an empirical cost model (a constant factor times a complexity class f(n), the same shape a query optimiser uses), and GornaArbitrator::arbitrate feeds those measured costs back into the negotiation: every strategy option is rescaled so the one matching the agent’s current strategy equals the measurement. The rescale factor is clamped (to [0.25, 4.0]) so a single hitch or cold cache cannot swing the fit by orders of magnitude, and the relative ordering of options is preserved — only the absolute scale moves. The budget fitting therefore reasons about measured milliseconds, not static worst-case quotes. An agent with no measurement yet (cold start) keeps its quotes as-is. Together with the cost model’s forecast — which can tighten the target before a frame overruns — this is the “model proposes, measurement disposes” loop: the controller acts ahead of a breach, and the measurements keep its arithmetic honest.

The heuristics

The heuristic engine runs a battery of independent heuristics each tick, each one a near-pure function of telemetry that emits a target adjustment or a recommendation; the arbitrator combines them. Because they are independent, new heuristics grow the engine’s adaptive intelligence by accretion, without touching the existing ones.

HeuristicReadsEffect
Phasethe current engine phaserelaxes or tightens the target for that phase
ThermalGPU/CPU temperaturerelaxes the target when hot; hard safety cap on critical
Batterybattery level + AC staterelaxes the target on low battery; hard cap on critical
Frame timerecent frame durationstightens when frames run over target
Stutterframe-time variancepenalises strategies with inconsistent timing
Trendframe-time slopeanticipates degradation before it becomes a stutter
CPU pressureCPU utilisationrebalances toward CPU-light strategies
GPU pressureGPU utilisationrebalances toward GPU-light strategies
Death spiralconsecutive over-budget framesforces the cheapest strategy until recovery

The death spiral is a first-class concept: when the engine cannot hold its frame budget for several frames running, GORNA forces the cheapest strategy and monitors for recovery, returning agents to negotiated strategies once the spiral breaks. It overrides developer control — safety wins.

One firm constraint runs through all of this: GORNA can never force a phase. It can suggest an importance change, but agents always control which phases they run in. Adaptation changes the HOW, never the WHAT.

AdaptationMode — the developer-control surface

GORNA is a partnership, not an autocracy. How much latitude it has over a given agent is set per agent through AdaptationMode (via DccService::set_adaptation_mode, the DCC being a registered service). There are four modes:

  • Learning (default) — full negotiation; the DCC freely picks the best-fitting strategy each tick.
  • Manual(strategy) — the agent is pinned; GORNA observes and reports but never switches it.
  • Stable — GORNA may downgrade under pressure but never makes an opportunistic upgrade, so the strategy does not flap frame to frame.
  • Bounded { min, max } — the chosen strategy is clamped to a range.

All four are enforced today. A death-spiral safety stop can still force the cheapest strategy in any mode — safety overrides developer control. (A few finer modes — calibration, a game→engine hint channel, a spatial PriorityVolume constraint API — remain on the roadmap; they are not part of AdaptationMode.)

Separately, the DCC can record and replay its decision trace. Arbitration is deterministic (no RNG), so recording the strategy issued to each agent each tick and replaying it reproduces a session’s adaptation bit-for-bit — for QA, lockstep, and bug reproduction. This is a capability of the DCC service (start/stop recording, load a trace to replay), not an AdaptationMode variant, and it is implemented today rather than planned.

Cold path and hot path

graph TD
    subgraph Cold["Cold path — ~20 Hz"]
        DCC[DccService]
        Heur[HeuristicEngine]
        Arb[GornaArbitrator]
    end
    subgraph Hot["Hot path — 60+ Hz"]
        Sch[Scheduler]
        Agents[Agents]
        Lanes[Lanes]
    end
    DCC --> Heur --> Arb
    Arb -->|BudgetChannel last-wins| Sch
    Sch --> Agents --> Lanes
    Lanes -->|telemetry| DCC

The two paths touch only through the BudgetChannel, and the hot path never blocks on the cold path. Within each phase the scheduler currently executes agents sequentially, in priority order, so a GORNA budget is a per-agent exclusive time slice of the frame, not a concurrent allocation — which is exactly why the budget fitting sums the calibrated per-agent costs against the frame budget. Parallel agent execution is roadmap work; when it lands, the fitting must switch from sum-of-costs to a critical-path model.

Next steps

AGDF: adaptive data layout

AGDFAdaptive Game Data Flows — is how Khora adapts the representation of its ECS data to the hardware and the access pattern, without ever changing what the data means. This page opens with a short overview anyone can follow, then drops into a deep dive for engine contributors. For the steps to opt a component into a SoA layout, see How-to: add a component; for the storage model it builds on, Data and the ECS.


In one minute

AGDF is runtime data-layout adaptation. Component storage can be laid out for the access pattern and the hardware — a field-split SoA stream for a compute-bound batch, plain array-of-structures for everything else — instead of one fixed representation chosen once and forever.

It is governed by a single guarantee that runs through the whole Symbiotic Adaptive Architecture: adapt the HOW, never the WHAT. A layout change rewrites how a component’s bytes are stored; it never changes what components an entity has or how the simulation behaves. Switching a column from AoS to field-SoA produces a bit- identical observable result. Detaching a RigidBody, by contrast, changes the simulation — so that is the WHAT, and AGDF never touches it. (Distance-based gameplay gating, like dropping physics far from the camera, is not AGDF; it is opt-in, developer-authored policy.)

AGDF resolves into three layers, shipped in order. Layer 1 observes and advises: the DCC watches the data layer through a read-only telemetry tunnel and produces a per-component layout recommendation, but never mutates the data. Layer 2 is the field-SoA substrate: a component can opt into a field-split column at registration time and feed explicit-SIMD kernels — the performance lever, and what actually ships as adaptable storage today. Layer 3 is online repack — flipping a populated component’s layout at runtime — which is a documented frontier, deliberately deferred. That is the whole idea; the rest of this page is for contributors who need the mechanism.


Deep dive

The industry gap, and the three-layer model

Every mainstream archetype ECS (Unity DOTS, Unreal Mass, Bevy, flecs) stores components SoA-across-entities but AoS-within-the-component — one contiguous array of whole structs. The unexploited level is the field, and no shipping engine adapts layout online. AGDF operates exactly there, governed by the DCC and the same MAPE-K loop that governs GORNA: GORNA adapts the HOW for strategies, AGDF adapts the HOW for data layout.

flowchart TD
    subgraph L1["Layer 1 — Observe & advise (shipped, runtime)"]
        OT["Observation tunnel<br/>(telemetry → DCC)"]
        CM["Cost model c·f(n)<br/>anticipatory budgeting"]
        LA["Layout advisor<br/>(glass-box recommendation)"]
        MEM["Memory tracking<br/>pressure + churn"]
    end
    subgraph L2["Layer 2 — Field-SoA substrate (shipped, registration-time)"]
        SOA["FieldSoaColumn + SoaLayout<br/>opt-in: layout = soa"]
        SIMD["wide f32x8 kernels<br/>(khora_core::math::simd)"]
    end
    subgraph L3["Layer 3 — Online repack (deferred frontier)"]
        REPACK["Runtime structural repack<br/>gated by bandit + α-counter"]
    end
    L1 -->|recommends| L2
    L2 -.->|substrate for| L3
    L1 -.->|reward signal for| L3

Layer 1 — observe and advise

The DCC runs on a cold thread and never holds &World. Everything it learns about the data layer travels as telemetry into its metric store; budgets travel back through the last-wins channel. AGDF extends that existing tunnel with two events the scheduler publishes from the main thread by non-blocking try_send (telemetry never stalls the frame): a per-frame agent-cost sample { id, n, time_ms }, and a slow- moving per-component access snapshot { type_name, size_bytes, query_count, rows_scanned } emitted every 60 frames. The workload size n is the live entity count.

Three consumers ride that tunnel:

  • The cost model fits each agent’s measured (n, time) to a complexity class (1, n, n·log n, ) by least squares and forecasts the breach. When the combined forecast at the current workload exceeds the frame budget, the DCC tightens the target latency before the frame overruns, so GORNA selects cheaper strategies in advance, and the same measured costs calibrate agent quotes inside arbitration. This is the predictive half of the budget loop — see GORNA.
  • The layout advisor turns the live access counters into a read-only recommendation per component — KeepSoa, SimdFieldSoa, or HotColdSplit. Its thresholds encode the engine’s own benchmark findings: the field-SoA/SIMD lever pays on large, compute-bound batches; the hot/cold split pays on fat components; the lean built-ins want neither. This is the glass-box half of AGDF that works today with no repack — it advises, and a contributor or the editor’s control panel reads the advice. The CLAD invariant holds throughout: Control observes and advises, Data owns its layout, the DCC never repacks.
  • Memory tracking wraps the system allocator and exposes resident bytes, lifetime allocations, and net allocations as telemetry. With a budget configured, the DCC derives a memory_pressure signal that sits alongside thermal/CPU/GPU; at/above 0.95 it imposes the same hard safety cap the GORNA controller uses, and high allocation churn raises a glass-box alert flagging a likely per-frame allocation hotspot.

Flow view caching — per-domain change epochs

The first piece of data-flow adaptation that shipped at runtime is a literal application of the organizing rule. Flows are read-only projectors: every Substrate Pass they re-derive a View from the World and publish it to the bus. Most frames, nothing the projection reads has changed, so re-running it is pure waste — and a cached View is bit-identical to a freshly projected one, so skipping the work changes only the HOW, never the WHAT.

Two pieces close the gap. The World keeps a per-domain change epoch, a monotonic counter bumped O(1) at every mutation that can affect a domain’s semantic content; equal epochs guarantee “unchanged”, a bump means only “possibly changed” (conservative by design), and a representation-only AGDF layout change does not bump it. A flow that can name all of its inputs returns a cache_key combining the World’s instance id and the relevant domain epochs (plus any runtime fingerprint, like the editor viewport override, that has no ECS epoch); on a key match the runner republishes the previous View as a cheap clone instead of re-projecting. Which flows opt in is an honest audit of their inputs — AudioFlow, RenderFlow, and ShadowFlow cache; UiFlow and PhysicsFlow do not, because their inputs (surface size, hot-reloadable fonts, a simulation that mutates every frame) have no signal a key could fold in.

Layer 2 — the field-SoA substrate, and why it is the lever

A default CRPECS column is Vec<T> — array-of-structures within the component, so a loop over one field strides over all the others and wastes both cache and SIMD lanes. A field-SoA column instead stores one contiguous f32 stream per field, which tiles cleanly into f32x8 with no gather:

AoS column:        [ t0 r0 s0 | t1 r1 s1 | t2 r2 s2 | … ]   ← strides over fields
field-SoA column:  tx[ x0 x1 x2 … ]  ty[ … ]  tz[ … ] …     ← one stream per field

The engine ships wide-based f32x8 batch kernels (stable Rust) in khora_core::math::simd, with scalar twins. The shipped benchmark (crates/khora-data/examples/layout_bench.rs) measured, on the reference machine, a 4.25× speedup for an in-place field-SoA quaternion normalise — but a loss the moment the same math has to scatter its result back to an AoS Mat4. That is the key lesson: the SIMD win is a whole-pipeline property. It holds only while the data stays field-SoA resident; the per-lane transpose in and out dominates the cheap arithmetic otherwise. This is precisely why Layer 2 makes the field-SoA storage persistent rather than transposing per frame, and why auto-vectorisation alone caps near ~2.9× (the floating-point reduction in sqrt/normalize is non-associative, so the compiler may not lane it).

The substrate is opt-in per component (#[component(layout = "soa")], v1 requiring all-f32 fields) and integrated into CRPECS, not a parallel store. A component declares how its column is created, written, migrated, read, and overwritten through a small set of Component hooks that all default to the AoS path; the macro overrides them for an opt-in component and generates the per-type scatter/gather glue. Storage stays a type-erased column either way, so pages, domains, despawn, and serialization are untouched. The only visible difference is the access path: a field- SoA component can’t yield &T (its bytes are split across field arrays), so it is read by value or processed in bulk for SIMD, and a &T query against one panics with a clear message rather than silently misbehaving.

The decision core, and why Layer 3 is deferred

AGDF’s planning brain is a MAPE-K autonomic loop, and its primitives are built and tested: a textbook UCB1 bandit, a sliding-window variant for the non-stationary reward a game session produces (the best layout in a menu is not the best in a firefight), a decaying access counter, and net-reward / dwell-gate helpers that fold the repack cost into the decision so a learner can’t thrash. All are deterministic (no RNG), so decisions stay reproducible.

But these primitives are not wired to a physical runtime repack — that is the line to keep straight. Layer 3 — online repack — is consciously deferred, for a sound reason rather than lack of effort. The query contract is fixed at compile time: an AoS component is read as &T, a field-SoA component by value. Flipping a populated component’s layout at runtime would break every &T that referenced it. And a bandit cannot learn a layout online without trying it — which needs the very repack that isn’t there yet — so the learning loop is circular until the repack lands. The only sound runtime repack is within the SoA family (plain ↔ AoSoA tiling), whose benefit is marginal for pure streaming. So Layer 2’s registration-time choice — the whole industry’s approach — is what ships, the bandit gets its reward signal offline in the benchmark, and Layer 3 stays the documented frontier with its design anchors recorded (OREO’s α-counter for online reorganisation with a competitive bound, the speculate-guard-deopt safety pattern from V8/HotSpot, and reuse-distance / phase- detection cost models to warm-start from a playtest).

Prior art

AGDF’s contribution is unifying models proven elsewhere into a game ECS governed by the DCC, not inventing self-optimising memory. Layout and SIMD draw on LLAMA (layout as an exchangeable compile-time mapping), Cabana/Kokkos (AoSoA in HPC), and Chilimbi / Pettis–Hansen (hot/cold splitting). Online reorganisation draws on OREO (online reorg as a Metrical Task System), database cracking, and Just-in-Time Data Structures. The decision and control side draws on UCB1 and SW-UCB, switching-cost bandits, MAPE-K, and the speculate-guard-deopt pattern.

Next steps

  • Data and the ECS — the CRPECS storage model and change epochs AGDF builds on.
  • GORNA — the strategy-level sibling of AGDF and the cost-model loop they share.
  • How-to: add a component — opt a hot component into a SoA layout.
  • Glossary — AGDF, field-SoA, layout advisor, MAPE-K, change epoch, online repack.

Rendering

The subsystem that turns ECS components into pixels. This page explains why the renderer is shaped as a family of strategies behind a single trait, how it plugs into the CLAD descent and GORNA negotiation, and how it stays alive when the GPU does not. It is an explanation, not a recipe — for the steps to set up a camera, a light, or a material, see How-to: materials and lighting; for the exact types, the rustdoc on khora_core::renderer.


Why a family of strategies

A modern engine must render across hardware that varies by orders of magnitude — from a handheld to a workstation. Picking a single render path at compile time leaves performance on the table everywhere except the developer’s machine.

So Khora’s renderer is not one pipeline; it is a family of them behind one abstraction. The rendering surface is the RenderSystem trait in khora-core (renderer/traits/render_system.rs) — the only thing the rest of the engine knows about. Concrete GPU work lives in a backend under khora-infra; the default is wgpu. Above that trait, a strategy is a self-contained lane: each renders the same scene at a different cost/quality point, and the RenderAgent picks one per frame based on the budget GORNA approved.

Two principles fall out of this shape and they are worth holding onto:

  • GPU resources hide behind typed IDs. TextureId, BufferId, PipelineId, BindGroupId, SamplerId — never a raw wgpu handle in a public API. That seam is exactly what lets the backend be swapped without touching a single lane.
  • Shaders are files, never strings. Every shader is a .wgsl file on disk, composed through the shader registry. Inlining shader source as a Rust constant is forbidden, because files are editable, reviewable, and hot-reloadable.

The frame lifecycle

Rendering is one slice of the per-frame descent (Control → Agent → Lane → Data). The engine acquires the swapchain once, runs the scheduler, and presents once:

EngineCore::tick
  ├─ run_app_update                 # user logic + Substrate DataSystems
  │
  ├─ begin_render_frame             # RenderSystem::begin_frame
  │   └─ acquire swapchain texture; publish color/depth targets
  │
  ├─ run_scheduler
  │   ├─ Substrate Pass             # Flows project Views into the LaneBus
  │   ├─ OBSERVE phase
  │   │   ├─ ShadowAgent.execute    # encodes the shadow atlas
  │   │   └─ RenderAgent.execute    # records the main pass
  │   ├─ TRANSFORM / MUTATE phases  # physics, audio
  │   ├─ OUTPUT phase               # UiAgent records the UI overlay
  │   └─ FINALIZE phase             # telemetry, cleanup
  │   (agents record GPU passes into the frame graph)
  │
  ├─ end_render_frame               # submit the frame graph, then present
  │
  └─ run_maintenance                # drain outputs, compact the ECS

One acquire, one present, per frame. Lanes never encode into a shared command buffer; they record passes — each declaring the resources it reads and writes — into a frame graph, and the engine drains and submits that graph after the scheduler returns. This stays deliberately simple: it is a topologically-ordered pass list (a shadow-atlas write always precedes a shadow-atlas read, whatever order the lanes ran in), not a full render-graph framework with transient resource pooling or aliasing. The handful of passes per frame today does not warrant that complexity; the design revisits it only when the per-frame pass count grows.

Because the renderer reads Views the data layer projected — never the World directly — it sees a consistent snapshot. The producer of those Views is the render Flow, the only legitimate source of the render and shadow Views the lanes consume from the LaneBus.

Strategies and shadows

The RenderAgent selects one main-pass strategy per frame:

StrategyShape
UnlitNo lighting — the baseline cost floor.
Lit forwardPBR with per-light passes and shadow sampling.
Forward+Tile-based light culling for many lights.
Standard PBRThe physically-based material model the lit paths shade through.

The pipelines for every strategy are compiled at boot, so switching between them is a bind-group flip rather than a stall. This is what makes per-frame negotiation cheap enough to do every frame: GORNA can ask for a cheaper strategy under pressure and the agent honours it without rebuilding anything.

Shadows are a separate agent — the canonical example of why the agent/lane split exists. The ShadowAgent runs in OBSERVE, before the RenderAgent, and publishes a depth shadow atlas plus a per-light shadow frame for the lit consumer lanes to sample. Decoupling shadow encoding from the main pass lets the two negotiate independently: shadow quality is its own GORNA surface with three honest tiers (high / medium / low resolution), each quoting its real VRAM cost, and the lowest tier is always offered as the floor so the agent never returns an empty strategy set. All three tiers produce the same shadow-frame contract, so the consumer lanes are agnostic about which one ran. The subtle part is shimmer prevention: orthographic shadow bounds are snapped to texel boundaries so shadow edges don’t crawl as the camera moves. Leave that logic alone unless you can prove a bug.

Render interpolation

The simulation advances in whole fixed steps while rendering happens once per variable-rate frame, so most frames fall between two sim steps. Drawing the raw simulated pose would judder. To stay smooth, the render Flow blends each simulated body’s pose toward its current one by the interpolation factor the scheduler published for the frame.

The “previous” pose is captured each step into a render-only store — deliberately not an ECS component, because interpolation is representation, not game state (“adapt the HOW, never the WHAT”). Keeping it out of component space means it never shows up in the inspector and is never serialized. The cost is one fixed-step of interpolation latency — the standard trade for jitter-free motion at any frame rate. The full mechanism, including why the simulation clock is fixed-step, lives in The frame.

Device-loss resilience

A GPU hiccup must never panic the frame loop. The backend classifies every surface-acquire outcome and every device-health condition into an explicit action:

  • Transient surface conditions self-heal. A lost or outdated swapchain at a valid size is reconfigured and the acquire retried once in-frame; a minimized or not-yet-ready window simply skips the frame quietly and retries next frame — no error spam, no unbounded spin.
  • Device loss and out-of-memory are reported, not hidden. These can’t recover in place, so they are observed through the backend’s error callbacks as sticky flags. Before any submission the render system checks them and, if set, returns a fatal but non-panicking error so the host can tear down cleanly.

This is honest about its limits: full device recreation is not yet implemented. What is guaranteed is that the loop degrades to a clean fatal error rather than crashing mid-frame, and that transient conditions recover on their own.

For game developers, the model is declarative

You do not call render functions. You describe a scene — spawn a camera, spawn lights, give mesh entities a mesh handle and a material — and the RenderAgent extracts that scene every frame, picks the strategy GORNA approved, and draws it. The engine’s per-frame choices are visible live in the editor’s GORNA panel.

Next steps

  • How-to: materials and lighting — set up a camera, lights, and PBR materials.
  • The frame — the fixed-timestep simulation clock and how render interpolation rides on it.
  • Assets — how meshes, textures, and materials reach the renderer.
  • Glossary — RenderSystem, strategy, frame graph, Flow.

Physics

Rigid-body simulation behind a single trait, stepped on a deterministic fixed clock. This page explains why physics is a swappable provider and how it plugs into the CLAD descent and GORNA budgeting. It is an explanation, not a recipe — for the steps to add a body and a collider, see the rustdoc on the physics components in khora_data::ecs; the model is described below.


The contract is one trait

The entire physics surface is the PhysicsProvider trait in khora-core (physics/mod.rs). It is a small, explicit contract: step the simulation, add and remove bodies and colliders, read and set body transforms, cast rays, drain collision events, resolve character movement. Nothing in the engine above it knows about a concrete solver.

That matters because of the boundary rule it enforces: the physics agent and lane never call the backend directly — they call PhysicsProvider. The default implementation is the Rapier3D backend in khora-infra; a future native solver drops in as a new implementation of the same trait without touching agent or lane code. The trait is the seam, and respecting it is what keeps the engine from coupling to one physics library.

How it plugs into a frame

Physics is one slice of the per-frame descent (Control → Agent → Lane → Data). The data layer projects a physics View from the ECS into the LaneBus; the physics lane consumes that View, syncs the relevant bodies and colliders into the provider, calls step, and reads the updated transforms back. As with every domain, the lane reads a projected View — never the World directly — and the provider is reached only through its trait.

ECS (bodies, colliders, transforms)
  ↓ physics Flow projects a View into the LaneBus
physics lane
  ↓ sync into the provider, then PhysicsProvider::step(dt)
  ↓ read updated poses back out

A debug lane that visualizes collision shapes is opt-in and switched on from the editor; the standard step lane is the only required one.

Components describe, the provider simulates

Physics entities carry two domain components — a rigid body (its type: dynamic, static, or kinematic; its mass, velocity, and a continuous-collision flag) and a collider (a shape — box, sphere, or capsule — plus friction and restitution). Their world-space pose lives in the shared transform components, the same ones the renderer reads.

The split between dynamic, static, and kinematic is the load-bearing distinction: dynamic bodies respond to forces and collisions, static bodies are immovable terrain, and kinematic bodies are moved by code yet still push dynamic ones. Continuous collision detection is opt-in per body — it catches tunneling for fast, small objects at the cost of step time, so it is off by default and enabled only where it earns its keep.

Fixed timestep is the determinism guarantee

Physics steps at a fixed timestep, a whole number of times per frame, decoupled from the variable render rate. The physics agent does not own an accumulator — it advertises its step duration and the scheduler drives the cadence: each frame it accumulates the clamped real delta, computes how many whole fixed steps fit, and invokes the agent that many times. The accumulator arithmetic and the spiral-of-death clamp are part of the engine’s single frame loop, explained in The frame.

Determinism is the entire reason for this: the same total elapsed time produces the same number of steps regardless of frame cadence, which is what makes replays, multiplayer, and reproducible bug reports possible. Variable steps cause subtle simulation drift across machines, so they were considered and rejected.

The step rate is itself a GORNA negotiation surface. Under a healthy budget the agent advertises a finer step; under pressure it advertises a coarser one, so the scheduler runs fewer sub-steps per frame rather than dropping the frame entirely. The transition is graceful — bodies keep their state, only the cadence changes. The agent stays a pure strategist: it chooses a step rate given a budget and dispatches its lane, nothing more.

The default backend — Rapier3D

The default PhysicsProvider is a Rapier3D wrapper in khora-infra/src/physics/rapier/. It translates Khora’s body, collider, and math types into Rapier’s and back, and routes raycasts through Rapier’s query pipeline. It is genuinely swappable: a future native Khora solver — the roadmap targets a unified MLS-MPM / IPC / XPBD approach — implements the same trait and replaces Rapier without the agent or lane noticing. That is the payoff of putting a trait at the boundary instead of calling the library directly.

Next steps

  • The frame — the fixed-timestep sub-loop physics is stepped by.
  • Data and the ECS — how physics components are stored and queried.
  • SAA — why the step rate is a budget GORNA negotiates.
  • Glossary — PhysicsProvider, fixed timestep, agent, lane.

Audio

3D positional audio behind a single device trait, mixed on a spatial lane. This page explains why audio is a swappable device fed through a mix bus, and how the ECS audio domain reaches the speakers. It is an explanation, not a recipe — for the component usage that actually plays a sound, see How-to: play 3D audio.


The contract is one trait, plus a bus

The audio surface is the AudioDevice trait in khora-core (audio/device.rs). It is deliberately narrow: a device is a factory for an output stream. You hand it the audio mix bus and it opens a long-lived stream whose hardware callback pulls mixed samples from that bus on a dedicated real-time thread; dropping the stream stops it. The device has no per-frame API to call — the bus is the sole synchronisation boundary between the engine’s frame loop and the audio thread.

This is the shape worth understanding. The two worlds run at different rates: the engine mixes at frame rate, the hardware callback drains at the device’s sample rate (commonly 48 kHz). Rather than couple them, the mix bus decouples them — the lane writes mixed samples into the bus from the main thread, the callback reads from it on the audio thread, and neither blocks the other. As everywhere else in the engine, the agent and lane call the trait, never the backend directly.

How sound reaches the speakers

Audio is one slice of the per-frame descent (Control → Agent → Lane → Data). The data layer’s audio Flow projects the ECS audio domain — the active listener’s pose and a snapshot of every sound source — into a View on the LaneBus. The spatial mixing lane consumes that View, does the spatial math, and writes the result into the mix bus the device’s callback reads:

ECS (sources, listener, transforms)
  ↓ audio Flow projects a View into the LaneBus
spatial mixing lane
  ↓ distance attenuation + stereo panning
audio mix bus  ──→  device callback fills the output buffer

The lane never queries the World and never touches the device directly. It reads the projected View, mixes, and writes the bus — a clean CLAD descent. A companion maintenance step applies any playback-state changes the lane produced back onto the source components, so the lane itself stays a read-and-write-outputs projector rather than a World mutator.

The spatial model

Two ECS components define the scene. A source carries the sound handle, a volume, and looping/autoplay flags; a listener marks the entity whose pose is the ear. The first entity carrying the listener component (together with a world-space transform) is the active listener.

For each source, the mixing lane computes the vector from the listener to the source: its length drives distance attenuation, and its lateral component drives stereo pan. A source far enough away contributes nothing and is skipped; a source with no listener present is mixed without spatialisation. This is the whole spatial model — distance and direction relative to one listener — and it is intentionally simple. Effects like reverb, HRTF, and filters are real and valuable but not yet implemented; the mix bus is the seam where they will plug in.

Source playback is tied to entity lifetime: there is no global “playing sounds” registry to manage. The concrete component API for spawning a source and a listener lives in How-to: play 3D audio.

The default backend — CPAL

The default AudioDevice is a CPAL wrapper in khora-infra/src/audio/cpal/. CPAL provides cross-platform device enumeration, format negotiation, and the callback loop; Khora wraps it in the AudioDevice contract and delivers the mixed buffer through its callback. A different backend — a platform-native API, or web audio in a future browser target — drops in as a new implementation of the same trait, and the lane never notices. That swappability is, again, the reason the device is a trait rather than a direct call.

Next steps

  • How-to: play 3D audio — spawn a source and a listener with the real component API.
  • Data and the ECS — how audio components are stored and projected.
  • SAA — how the audio agent negotiates a source budget under pressure.
  • Glossary — AudioDevice, mix bus, Flow, lane.

UI

In-engine UI as ECS entities, laid out behind a single trait and rasterized through the same GPU device as everything else. This page explains why UI is retained-mode ECS with a swappable layout engine, and how it plugs into the frame. It is an explanation, not a recipe — for the steps to build a panel, see How-to: build a UI.


The contract is one trait

UI layout is the LayoutSystem trait in khora-core (ui/). It is the only thing the rest of the engine knows about layout: hand it a node tree and available space, get back computed positions and sizes. The renderer is a separate concern — a dedicated UI lane rasterizes the laid-out nodes. Splitting layout from rendering behind a trait is what lets a different layout engine drop in without touching the lanes that draw.

The default implementation is a Taffy-backed layout system in khora-infra (ui/taffy/), giving flex and grid layout. Khora maps its own UI components onto Taffy’s style model, runs the layout, and reads back per-node geometry. Taffy types never leak into components — the mapping happens inside the layout lane — so the backend stays swappable behind the trait, the same boundary discipline the rest of the engine follows.

UI is just entities

There is no separate UI tree and no separate registry. UI is entities with UI components, living in the same ECS as everything else, in the UI semantic domain. A panel is an entity with a layout node and a visual style; an image element adds an image component; hierarchy uses the same parent/child relationship as scene hierarchy — no bespoke UI parent type.

The component vocabulary is small and structural: a layout node carries sizing, min/max, padding, margin, and flex properties; a style carries background and border colour, border width and radius, and an optional texture; an image component references a texture to draw. They are screen-space by design — that is the whole reason UI uses its own layout node rather than the world-space transform the renderer uses. The concrete builder API lives in How-to: build a UI.

How it plugs into a frame

UI follows the same CLAD descent as every other domain (Control → Agent → Lane → Data), as two lanes owned by the UiAgent:

ECS (layout nodes, styles, images, hierarchy)
  ↓ UI Flow projects a UiScene into the LaneBus
layout lane   — runs the LayoutSystem, produces pre-laid-out nodes
  ↓
UI render lane — rasterizes the scene, compositing over what the renderer drew

The data layer’s UI Flow projects the UI domain into a scene View on the LaneBus; the layout lane computes geometry through the LayoutSystem trait; the render lane draws the result in the frame’s OUTPUT phase, loading the existing colour target so it composites over the scene the renderer already produced. Two passes, same shape as the scene render path: a compute-style pass followed by a draw pass.

Notably, there is no separate “UI renderer” — the UI render lane shares the one GPU device with everything else, reached through the same typed-ID abstraction. Text rendering uses a glyph cache and atlas that live in the backend because they depend on that device.

Editor UI versus game UI

Today the UiAgent runs in the editor. Its negotiation surface is minimal — one strategy, no real GORNA pressure yet — because editor UI complexity hasn’t demanded density tiers (full / simplified / hidden chrome) that the design leaves room for. In-game (play-mode) UI is on the roadmap; the path is mostly a matter of which modes the agent is allowed to run in, plus deciding the input model. The retained-mode, ECS-driven shape is the same either way.

Next steps

  • How-to: build a UI — spawn a panel and child elements with the real component API.
  • Data and the ECS — how UI components are stored, domained, and projected.
  • Rendering — the scene pass the UI overlay composites over.
  • Glossary — LayoutSystem, semantic domain, Flow, lane.

Assets and the VFS

How Khora finds, loads, and stores assets — meshes, textures, fonts, sounds. This page explains why assets are addressed by stable identity through a virtual file system, and how the same code path serves loose files in development and a packed archive in release. It is an explanation, not a recipe — for the steps to load an asset and reference it from a component, see How-to: load assets.


Identity, not paths

The seam that holds the whole pipeline together is this: an asset is addressed by a UUID, not a path. Paths change — files get renamed and moved — but a reference must not break when they do. So game code carries a typed handle backed by a UUID, and the virtual file system resolves that UUID to wherever the bytes actually live.

By default the UUID is derived from the asset’s forward-slash relative path (a v5 UUID). But the path is only the default seed for identity, not identity itself: the moment an asset is renamed or moved in the editor, its UUID is frozen in a per-project identity registry (<project>/.khora/asset-registry.ron) so the reference survives the move. Whether frozen or still on its path-derived default, the identifier a texture gets in development is byte-identical to the one it gets when packed for release, because both resolve through the same registry. A handle therefore works in either mode without changes, and the index is deterministic across runs. See File formats — asset identity registry.

The pipeline

The load path is a short, on-demand chain. There is no “asset agent” because there are no per-frame strategies to negotiate — loading is a service, the same line the engine draws everywhere between strategy-bearing agents and fixed-work services:

flowchart LR
    A[AssetUUID] --> B[VirtualFileSystem]
    B --> C{AssetSource}
    C -->|loose file| D[file IO]
    C -->|packed| E[pack IO]
    D --> F[AssetDecoder]
    E --> F
    F --> G[typed storage]
    G --> H[AssetHandle]

The virtual file system is a UUID → metadata table — an O(1) lookup. The metadata names the source (a path in development, an offset-and-size into the pack in release) and any pre-decode hints. The IO layer reads raw bytes from whichever source applies; a per-format decoder turns those bytes into a typed asset; the asset lands in typed storage and the service returns a reference-counted handle. The load is synchronous — the service looks the UUID up, reads, decodes, caches, and hands back a handle, returning a cached one if the asset is already loaded. Multiple entities sharing one mesh or texture share one handle and one copy in memory; when the last handle drops, the asset is queued for unload.

The decoder set is extensible by registration: adding a format is writing a decoder and registering it under a type name, not rewiring the pipeline. The same is true of the IO layer — loading from, say, a network source is a new IO implementation swapped in behind the same VFS and decoders.

Dev and packed are the same path

In development, assets are loose files on disk and the index is built by scanning the project’s asset directory. In release, every asset is concatenated into a single pack file alongside a binary index, and the source descriptors are rewritten from paths to packed offsets. The decoder layer above does not know which is in use — it sees the same VFS and the same handle type. Because UUIDs are content-derived, the two modes are interchangeable; the editor’s build step produces the packed pair from the same asset directory, deterministically.

Dependency tracking

Asset metadata also records each asset’s direct dependencies — the UUIDs of the other assets it references — so a loader can fetch prerequisites without first decoding the asset. This is populated while the index is built, through a single extension point keyed on the asset’s type name.

Today materials are the populated case: a material records the UUIDs of the textures it references (base-colour, metallic-roughness, normal, emissive). Those UUIDs match exactly what the index assigns the texture files, because both derive from the same relative path; the list is deduplicated and sorted so reusing one texture across slots contributes it once and the index stays byte-deterministic. The extension point is generic — scene, prefab, and mesh formats are stubs that return an empty list, and adding real extraction for one is a single match arm. To keep the index build fast, bytes are read only for types that actually have an extractor; leaf assets like textures and audio are never opened.

Next steps

Serialization

Saving and loading scenes through several strategies behind one file format, chosen by intent rather than by hand. This page explains why a scene has more than one on-disk encoding and how the engine picks between them. It is an explanation, not a recipe — for the steps to save and load a scene, see How-to: save and load scenes.


Why more than one strategy

A scene file has more than one consumer, and they want incompatible things. The editor wants something human-readable, hand-editable, and stable across years of Git history. A release build wants something compact. Play mode wants something that snapshots and restores near-instantly. No single encoding is best at all three.

So Khora serializes through a strategy — a concrete encoding selected by intent. The developer states a SerializationGoal; the engine maps that goal to a strategy. Choosing the goal is a developer decision; choosing the strategy is an engine decision. The goals are:

GoalEncoded as
HumanReadableDebugDefinition — a human-readable, hand-editable text encoding
LongTermStabilityDefinition — same, chosen for archival robustness
EditorInterchangeRecipe — compact binary, the editor’s working format
SmallestFileSizeRecipe — same, chosen for size
FastestLoadArchetype — a near-memcpy binary layout
PortableBinaryMessagePack — a portable, cross-tool binary encoding

Four strategies back those six goals. The mapping lives in one place in the serialization service, so a goal always resolves to the same strategy.

One service, one file format

Scene save and load is a service, not an agent — there are no per-frame strategies to negotiate, so it sits on the same side of the line as asset loading. The service exposes save_world (take a goal, produce a scene file) and load_world (take a scene file, repopulate the world).

All strategies share one file format: a fixed-size header — a magic number, a format version, the strategy identifier, and the payload length — followed by the payload. The header is what makes loading symmetric: it records which strategy produced the payload, so load_world dispatches to the matching strategy without the caller having to know or specify it. The format version is a migration seam for future on-disk changes.

How components serialize

The reason adding strategies is tractable is that component serialization is generated, not hand-written. Deriving the component macro on a type generates a serialization mirror with encode/decode, the conversions to and from the live type, and a self-registration so scene loading discovers it with no hand-maintained list.

The mirror exists because GPU handles, runtime caches, and trait objects do not serialize. Fields the developer marks as skipped are excluded from the mirror and reconstructed on load — typically by the asset system. Components needing a fully manual mirror opt out of generation and implement encode/decode by hand. The registration is the seam: scene loading walks the registry, decodes the right mirror, converts to the live type, and attaches it to the entity — no string lookups in the hot path. Maintaining two structs by hand was historically the single biggest source of serialization bugs, which is exactly why the macro generates the mirror.

Play-mode snapshots

Pressing Play snapshots the world; pressing Stop restores it. This rides on the same service: the snapshot is just a save_world into memory and the restore a load_world back, fast because the chosen encoding serializes pages with minimal transformation — a large scene snapshots and restores in milliseconds.

One honest caveat: physics state is not preserved across a snapshot. On restore, the physics engine rebuilds from component data, so velocities and contacts reset to defaults. This is consistent and predictable; whether to add a goal that captures physics state is an open question, not a bug.

Next steps

Telemetry

The engine’s nervous system: where measurements come from, where they go, and who acts on them. This page explains why telemetry is a first-class subsystem rather than a debugging afterthought, and how the DCC turns observations into decisions. It is an explanation, not a recipe — for the steps to read metrics and profile a frame, see How-to: profile performance.


Why telemetry is first-class

A self-optimizing engine is only as smart as its inputs. The DCC negotiates frame budgets through GORNA, but it can only make better decisions than a static configuration if it can see frame time, GPU utilization, VRAM headroom, and heap pressure. Take those readings away and the whole adaptive premise collapses to a fixed config.

So telemetry is not bolted on — it is the nervous system of the SAA. Monitors run alongside the workload, a registry collects the readings, and the DCC consumes them on its cold-path tick and turns them into budget decisions. The same readings power the editor’s control surface, where the engine’s decision-making becomes visible.

Two collection styles

The pipeline has two complementary halves, reflecting two kinds of data:

  • Poll-based monitors. Hardware-facing readings — GPU utilization and timings, heap and virtual memory, video memory — are pulled: the telemetry service asks each registered monitor for its current value. A monitor is a trait; the concrete implementations live in the backend crate because they call platform APIs, while the trait surface stays portable.
  • Push-based metrics. Subsystems push named counters, gauges, and histograms into a metrics registry. Names are dot-separated by convention (subsystem.thing.unit), and the registry is concurrent so agents on different threads write without contention.

The split is principled: hardware state has a current value worth sampling, while software events are produced where they happen and pushed.

The tracking allocator

Memory visibility comes from a tracking allocator installed as the global allocator in every binary. It wraps the system allocator and records allocation counts and sizes into global atomic counters — a few atomic ops per allocation, small but real, so benchmark builds can swap in the bare system allocator.

What makes it first-class is that the readings are consumed, not merely displayed. The memory monitor publishes the counters into the DCC’s store, and the DCC turns them into decisions: a system-RAM budget derives a memory-pressure signal that sits alongside thermal, CPU, and GPU pressure and can cap the global budget multiplier near the ceiling; high volatility in resident bytes surfaces an alert flagging a likely per-frame allocation hotspot; and the deferred layout-repack cost/benefit gate reads memory pressure so it declines a repack — which transiently doubles a column — under tight memory. Allocation tracking feeding adaptation directly is the difference between a readout and a nervous system.

The DCC closes the loop

The DCC’s cold-path loop runs at a low frequency relative to the frame and does the same cycle each tick: poll every monitor, read the named metrics it cares about, feed the readings into its heuristics, arbitrate budgets, and send them out. The hot path never looks metrics up by string — an agent that emits its own per-frame metric holds a typed counter or gauge handle, and only the cold path and the editor pay the string-keyed lookup, which they can afford.

hardware monitors  ┐
                   ├─→ telemetry service ─→ DCC (cold path)
pushed metrics     ┘        ↑                  │ heuristics → GORNA → budgets
                            │                  ↓
                    editor reads out      budget channel → agents

Telemetry → heuristic → budget. The loop closes through the engine observing itself — which is the whole point of making telemetry a subsystem rather than a side-channel.

Honest gaps

The pipeline is real but not complete, and it is worth naming the edges:

  • No per-stage GPU breakdown. GPU timings are coarse; the renderer does not yet attribute cost to individual passes.
  • No per-frame trace records. Integration with an external tracing tool is compatible with the pipeline but not wired up.
  • Histogram export and retention are unsettled. Histograms collect, but an export format and a long-term retention policy are not yet committed — the DCC reads the latest value, not a history.

These are roadmap edges, not design flaws; the seams exist for each.

Next steps

  • How-to: profile performance — read live metrics and investigate a slow frame.
  • SAA — how the DCC turns these readings into per-frame budgets.
  • The frame — the cold-path tick where telemetry is consumed.
  • Glossary — monitor, metrics registry, tracking allocator, DCC.

Reference

Information-oriented lookup. These pages are curated maps and tables — accurate, neutral, structured for finding a fact fast. They do not explain why (see Concepts) or walk through a task (see How-to guides).

The exhaustive API lives in rustdoc, not here. Every public type, trait, function, and method is documented in the generated rustdoc. The book links to it rather than restating it. Start at the published reference: eraflo.github.io/KhoraEngine/api.

Pages

PageWhat you look up here
API referenceThe gateway to the published rustdoc — per-crate links and how to build it locally.
SDK surfaceThe public khora-sdk surface: app traits, run_winit, GameWorld, Vessel, the prelude.
Crate mapAll workspace crates, their one-line roles, the dependency direction, and a “where things live” table.
File formatsThe .kscene scene file, the .pack archive, and the .kmat material format — structure and facts.
Project structureThe Khora project folder layout, project.json schema, asset extensions, and the three code tiers.
GlossaryThe vocabulary index — SAA, CLAD, GORNA, AGDF, CRPECS, Lane, Flow, and the rest.

Need the rationale? See Concepts. Need the steps? See How-to guides. Need every signature? See the rustdoc.

SDK surface

A curated map of the public khora-sdk surface. khora-sdk is the only crate a game depends on; it re-exports the types it needs from internal crates so games never reference those directly.

This page names the surface and links to rustdoc for the exhaustive signatures. For the full method-by-method detail of any type below, follow its link to the published rustdoc. For how to build a game, see Your first game and the How-to guides.

The application traits

A Khora application implements three traits. The composite bound the engine requires is EngineApp + AgentProvider + PhaseProvider.

TraitRolerustdoc
EngineAppApplication lifecycle — window_config, new, setup, update, on_shutdown, plus optional editor-overlay hooks.link
AgentProviderRegister custom agents with the DCC.link
PhaseProviderInsert or remove custom ExecutionPhases.link
WindowProviderAbstracts the platform window backend (default: winit).link

EngineApp — lifecycle

The methods the engine calls on your app type:

MethodWhen
window_config() -> WindowConfigOnce, before window creation.
new() -> SelfOnce, after window creation — no engine context yet.
setup(&mut self, world: &mut GameWorld, runtime: &Runtime)Once, after engine init — spawn entities, cache handles.
update(&mut self, world: &mut GameWorld, inputs: &[InputEvent])Every frame — game logic.
on_shutdown(&mut self)Once, on exit (default no-op).

The optional hooks intercept_window_event, before_frame, before_agents, and after_agents exist so the editor can run an egui overlay around the engine’s frame loop. Most games leave them at their default no-ops.

setup and the optional hooks receive &Runtime — the engine’s injection bundle (see Runtime below).

AgentProvider — register custom agents

#![allow(unused)]
fn main() {
fn register_agents(&self, dcc: &DccService, runtime: &mut Runtime);
}

Called once during boot. Empty for a vanilla game; this is where a custom AI, scripting, or networking agent calls dcc.register_agent(...) or dcc.register_agent_for_mode(...). See Add an agent.

PhaseProvider — custom execution phases

#![allow(unused)]
fn main() {
fn custom_phases(&self) -> Vec<ExecutionPhase> { Vec::new() }
fn removed_phases(&self) -> Vec<ExecutionPhase> { Vec::new() }
}

The built-in phases live in khora_core::agent::ExecutionPhase. Most games return empty vectors. See the ExecutionPhase glossary entry.

run_winit — the entry point

#![allow(unused)]
fn main() {
pub fn run_winit<W: WindowProvider, A: EngineApp>(
    bootstrap: impl FnOnce(&dyn KhoraWindow, &mut Runtime, &dyn Any) + Send + 'static,
) -> anyhow::Result<()>;
}

Opens a window through W, initializes the DCC, registers the default engine services, runs your bootstrap closure, then enters the frame loop. It returns when the window closes. The bootstrap closure receives:

  • &dyn KhoraWindow — the platform window (use it to initialize the renderer).
  • &mut Runtime — register your renderer and any custom backends/services here.
  • &dyn Any — opaque handle to the native event loop (downcast if needed).

WinitWindowProvider is the default WindowProvider. EngineCore is the underlying engine type, exposed for embedding khora-sdk inside another runtime without run_winit (uncommon). run_default is the zero-config entry the prebuilt khora-runtime binary uses — it auto-detects a data.pack and loads the default scene.

Runtime — the injection bundle

Runtime (re-exported from khora-core) is the engine-wide injection point. Engine init builds one, wraps it in Arc<Runtime>, and from then on it is immutable. It bundles three typed containers, each with a clear admission rule:

ContainerHoldsExamples
runtime.servicesConcrete stateful objects with a rich business API.AssetService, SerializationService, TelemetryService, DccService
runtime.backendsConcrete impls of abstract khora-core traits.dyn RenderSystem, dyn PhysicsProvider, dyn AudioDevice, dyn LayoutSystem
runtime.resourcesLong-lived shared state without a service-style API.GpuCache, InputMap, viewport overrides

Look up an entry with runtime.services.get::<T>(), runtime.backends.get::<T>(), or runtime.resources.get::<T>() (each returns Option<&T>; require::<T>() panics if absent). Per-frame state (current viewport, frame deltas, lane outputs) does not live here — it flows through the LaneBus and OutputDeck.

Runtime replaces the legacy single ServiceRegistry. The container API mirrors the old registry (insert / get / require), split three ways by admission criterion.

GameWorld — the ECS facade

GameWorld is the safe public entry point for the ECS; it wraps the internal khora-data World. The surface, grouped by what you do:

GroupMethods
Lifecyclenew, from_world, tick_maintenance
Entitiesspawn, despawn, spawn_camera, spawn_entity, iter_entities
Componentsadd_component, remove_component, get_component, get_component_mut, get_transform, get_transform_mut
Queriesquery::<...>(), query_mut::<...>()
Transformssync_global_transform, update_transform
Assetsadd_mesh, add_material
Internalinner_world, inner_world_mut (low-level; prefer the wrapped surface)

After mutating a Transform, call sync_global_transform(entity) (or use update_transform, which mutates and syncs in one call) so the renderer sees the updated GlobalTransform. Full signatures: GameWorld.

Vessel and the spawn helpers

Vessel is a builder over a freshly spawned entity. Every Vessel starts with a Transform and a GlobalTransform.

  • Construct: Vessel::new(world) (origin) or Vessel::at(world, position).
  • Build chain: with_transform, at_position, with_rotation, with_scale, with_component (chainable), entity (read the EntityId mid-build), build (finalize, sync GlobalTransform, return EntityId).

Primitive helpers are top-level functions returning a Vessel: spawn_plane(world, size, y), spawn_cube_at(world, position, size), spawn_sphere(world, radius, segments, rings). See Spawn and transform. Full signatures: Vessel.

The prelude

#![allow(unused)]
fn main() {
use khora_sdk::prelude::*;            // SDK + input + timing + assets
use khora_sdk::prelude::ecs::*;       // ECS components
use khora_sdk::prelude::math::*;      // Math types
use khora_sdk::prelude::materials::*; // Built-in materials
}
ModuleContents
preludeWindowConfig, WindowIcon, PRIMARY_VIEWPORT, AssetHandle, AssetUUID, SaaTrackingAllocator, InputEvent, KeyCode, MouseButton, Time, SharedTime
prelude::ecsEntityId, Transform, GlobalTransform, Camera, Light, LightType, DirectionalLight, PointLight, SpotLight, MeshRef, MaterialRef, RigidBody, Collider, BodyType, ColliderShape, AudioSource, Parent, Children, Name, Tag, Without, Component, ComponentBundle, ProjectionType, ProceduralMeshKind
prelude::materialsStandardMaterial, UnlitMaterial, EmissiveMaterial, WireframeMaterial
prelude::mathLinearRgba and everything in khora_core::math (Vec2/3/4, Mat3/4, Quaternion, Aabb, …)

The prelude is curated — adding to it is a deliberate decision. Full contents: prelude.

Input

Inputs arrive in update as &[InputEvent]. InputEvent and KeyCode / MouseButton are re-exported at the crate root and in the prelude. key_code values follow the W3C UI Events code names ("KeyW", "Space", "Escape"). See Map input and the InputEvent rustdoc for the full variant list.

Engine modes

EngineMode (re-exported from khora-control) gates which agents run each frame. The base engine knows only EngineMode::Playing; other modes are injected by plugins (the editor registers EngineMode::Custom("editor")). It is distinct from PlayMode (Editing / Playing / Paused), the editor’s own UI-state enum re-exported from khora_core::ui::editor.

SDK re-exports

khora-sdk re-exports types from internal crates so games depend on the SDK alone. The major groups:

GroupRe-exported types (selection)
Engine + ECSEngineCore, GameWorld, Vessel, spawn_*
App traitsEngineApp, AgentProvider, PhaseProvider, WindowProvider
Bootstraprun_winit, run_default, WinitAppRunner, WinitWindowProvider
WindowWindowConfig, WindowIcon, PRIMARY_VIEWPORT
Runtime / controlRuntime, Services, Backends, Resources, DccService, DccConfig, EngineMode, EngineContext, AgentRegistry
Core typesExecutionPhase, ExecutionTiming, AgentId, AgentStatus, StrategyId, AgentImportance
TelemetryTelemetryService, TelemetryEvent, MonitoredResourceType, MetricsRegistry, MonitorRegistry
MonitorsGpuMonitor, MemoryMonitor
Backends + traitsWgpuRenderSystem, RenderSystem, PipelineSystem, RapierPhysicsWorld, PhysicsProvider, CpalAudioDevice, AudioDevice, TaffyLayoutSystem, LayoutSystem
Scene I/OSerializationService, SceneFile, SerializationGoal
AssetsAssetService, AssetIo, FileLoader, PackLoader, PackBuilder, IndexBuilder, AssetWatcher, AssetSource
RenderingMesh, the renderer sub-module
Editor UIthe editor_ui and tool_ui modules (used by the editor and hub)

The exact list is in khora_sdk.

Where things live

You want to…Reach for
Spawn an entity with a primitive shapeVessel::at(...) + spawn_* helpers
Read or mutate a componentworld.get_component::<T> / world.get_component_mut::<T>
Run a queryworld.query::<...>() / world.query_mut::<...>()
Load an assetruntime.services.get::<Arc<AssetService>>()
Save or load a sceneruntime.services.get::<Arc<SerializationService>>()
Read GPU or memory metricsruntime.services.get::<Arc<TelemetryService>>()
Switch backendsEdit your run_winit bootstrap closure (runtime.backends.insert(...))
Add a custom agentImplement Agent, register in AgentProvider::register_agents
Add a custom phaseReturn it from PhaseProvider::custom_phases

Concepts behind the surface: Agents and Lanes, CLAD. Tasks: How-to guides. Every signature: rustdoc.

Crate map

The authoritative map of the workspace: every crate, its one-line role, the dependency direction, and a flat “where things live” lookup.

Why this layering exists is the CLAD concept page. This page is the lookup table CLAD defers to.

The crates

Khora is 16 crates: 13 khora-* plus sandbox, xtask, and hub. Twelve khora-* crates are workspace members; khora-macros is a path crate (a build-dependency of khora-data, not a workspace member).

CrateOne-line role
khora-coreThe trait floor — traits, math, GORNA types, the Runtime containers, scene format. Depends on nothing else in the workspace.
khora-macrosThe #[derive(Component)] proc macro (path crate).
khora-dataCRPECS ECS, component storage, SoA/AGDF layout, Flows, scene serialization strategies.
khora-controlThe DCC, GORNA arbitration, cost model, scheduler, the Substrate Pass.
khora-lanesHot-path Lanes — render, physics, audio, asset, scene, UI; the WGSL shaders.
khora-agentsStrategist Agents — one per LaneKind — plus PhysicsQueryService.
khora-infraConcrete backends: wgpu, Rapier3D, CPAL, Taffy, winit, native telemetry.
khora-ioAsset service, VFS, serialization service, pack/file loaders.
khora-telemetryMetrics, monitors, telemetry events.
khora-pluginsPlugin loading and registration.
khora-sdkThe single public API for game developers (façade).
khora-editorThe editor application built on the SDK (panels, gizmos, dock).
khora-runtimeThe generic player binary, stamped with packed assets.
sandboxExample game using the SDK (examples/sandbox).
xtaskBuild automation (cargo xtask …).
hubProject manager / engine launcher.

Dependency direction

Dependencies flow downward only — never introduce a cycle:

khora-core
  └─► khora-data / khora-control  (and khora-macros, khora-telemetry)
        └─► khora-lanes
              └─► khora-agents
                    └─► khora-infra
                          └─► khora-sdk
                                └─► khora-editor / khora-runtime / sandbox
graph LR
    subgraph User
        SDK[khora-sdk]
        ED[khora-editor]
    end
    subgraph Engine
        CTRL[khora-control]
        AGT[khora-agents]
        LANE[khora-lanes]
        IO[khora-io]
        DATA[khora-data]
        CORE[khora-core]
        INFRA[khora-infra]
        TELE[khora-telemetry]
    end
    subgraph Support
        MACRO[khora-macros]
        PLUG[khora-plugins]
    end
    SDK --> CTRL
    SDK --> AGT
    SDK --> IO
    SDK --> INFRA
    SDK --> TELE
    SDK --> DATA
    CTRL --> CORE
    AGT --> CORE
    AGT --> DATA
    AGT --> LANE
    AGT --> IO
    LANE --> CORE
    LANE --> DATA
    IO --> CORE
    IO --> DATA
    IO --> TELE
    DATA --> CORE
    DATA --> MACRO
    INFRA --> CORE
    INFRA --> DATA
    TELE --> CORE
    ED --> SDK
    ED --> AGT
    ED --> IO

Abstract traits live in khora-core; concrete backends live in per-backend subfolders under khora-infra (graphics/wgpu/, physics/rapier/, audio/cpal/, ui/taffy/, …). Changing a khora-core trait means updating every downstream implementation in the same change.

Where things live

A flat lookup for “I want to find X.”

ConcernCrate / module
Lane traitkhora-core::lane
Agent traitkhora-core::agent
Math typeskhora-core::math
GORNA typeskhora-core::control::gorna
Runtime containers (Services / Backends / Resources)khora-core::runtime
Scene file format + SerializationGoalkhora-core::scene
ECS World and componentskhora-data::ecs
Component storage / pages / archetypeskhora-data::ecs
Flows (read-only projectors)khora-data::flow
Scene serialization strategieskhora-data::scene
VFS and asset loadingkhora-io::asset, khora-io::vfs
Serialization servicekhora-io::serialization
Render pipelineskhora-lanes::render_lane
WGSL shaderskhora-lanes::render_lane::shaders
Physics laneskhora-lanes::physics_lane
Audio laneskhora-lanes::audio_lane
Asset decoder laneskhora-lanes (asset-loader lanes)
Scene / transform laneskhora-lanes::scene_lane
Agent implementationskhora-agents
Scheduler and GORNA arbitrationkhora-control
wgpu backendkhora-infra::graphics::wgpu
Rapier backendkhora-infra::physics::rapier
CPAL backendkhora-infra::audio::cpal
Taffy backendkhora-infra::ui::taffy
Resource monitorskhora-infra::telemetry
User-facing APIkhora-sdk
Editor UIkhora-editor
Sandbox appexamples/sandbox

The descent through these crates — Control → Agent → Lane → Data — is explained in CLAD. The public surface of khora-sdk is mapped in SDK surface.

File formats

The on-disk formats Khora reads and writes: the .kscene scene file, the .pack asset archive, and the .kmat material file. This page is structure and facts — for why the formats are shaped this way see Serialization and Assets; to save and load a scene see Save and load scenes.

.kscene — scene file

A .kscene file is a fixed-size header followed by a single payload. The header records which serialization strategy produced the payload, so loading is symmetric without prior format knowledge.

Header layout

The header is a fixed SceneHeader (defined in crates/khora-core/src/scene/format.rs). Its size is 49 bytes (SceneHeader::SIZE = 8 + 1 + 32 + 8):

FieldTypeBytesNotes
magic_bytes[u8; 8]8Always "KHORASCN" (HEADER_MAGIC_BYTES).
format_versionu81Header/format version. Current writers emit 1 (CURRENT_SCENE_VERSION).
strategy_id[u8; 32]32Null-padded UTF-8 strategy ID, e.g. "KH_RECIPE_V1".
payload_lengthu648Length of the payload that follows, in bytes (little-endian).

The payload immediately follows the header. SceneFile::to_bytes / SceneFile::from_bytes serialize and parse the whole file; the header is written by direct byte manipulation (not serde) because it is fixed-layout and performance-critical. from_bytes returns SceneFileError::InvalidMagicBytes or SceneFileError::TooShort on a malformed file.

Strategies and goals

The payload encoding is chosen by a SerializationGoal, not by file extension. The strategy_id in the header records which strategy produced the payload. There are four strategies, each identified by a versioned string ID:

Strategystrategy_idPayload encodingCharacter
DefinitionKH_DEFINITION_RON_V1RON (text)Human-readable, diffable.
RecipeKH_RECIPE_V1Binary command listCompact, editor interchange.
ArchetypeKH_ARCHETYPE_V1Binary page layoutFastest load; play-mode snapshots.
MessagePackKH_MESSAGEPACK_V1MessagePackPortable, schema-less, cross-language.

SerializationGoal (in khora-core::scene) has six variants. The goal → strategy mapping is performed in SerializationService::save_world:

SerializationGoalStrategy
HumanReadableDebugDefinition (KH_DEFINITION_RON_V1)
LongTermStabilityDefinition (KH_DEFINITION_RON_V1)
SmallestFileSizeRecipe (KH_RECIPE_V1)
EditorInterchangeRecipe (KH_RECIPE_V1)
FastestLoadArchetype (KH_ARCHETYPE_V1)
PortableBinaryMessagePack (KH_MESSAGEPACK_V1)

On load, SerializationService::load_world reads the strategy_id from the header and dispatches to the matching strategy — the goal is irrelevant at load time. A migration seam (migrate_payload) is wired for future format bumps; no migrations are registered today (version 1 is the only scene format).

Choosing a goal is a developer decision; choosing a strategy is an engine decision. The four strategies implement the SerializationStrategy trait in khora-data::scene.

.pack — asset archive

In release builds, a project’s assets are bundled into a two-file layout written by khora_io::asset::PackBuilder, staged under <project>/dist/<target>/:

  • data.pack — the concatenation of every asset’s bytes.
  • index.bin — a bincode-encoded Vec<AssetMetadata> mapping each asset’s UUID to its location inside data.pack.

Both files are needed together. The split keeps the loader’s I/O trivially zero-copy and lets a tool inspect either file independently.

data.pack layout

data.pack
┌──────────────────────────────────────────────────┐  offset 0
│ Header (16 bytes)                                 │
│   ─ Magic:          "KHORAPK\0"  (8 bytes)        │
│   ─ format_version: u32 LE       (4 bytes) = 1    │
│   ─ asset_count:    u32 LE       (4 bytes)        │
├──────────────────────────────────────────────────┤  offset 16
│ asset 0 bytes                                     │
│ asset 1 bytes                                     │
│ …                                                 │
│ asset N-1 bytes                                   │
└──────────────────────────────────────────────────┘

The 16-byte header lets PackLoader::new fail fast on a wrong/renamed file (bad magic), a pack from a future engine (format_version mismatch), or an index.bin that has drifted out of sync (asset_count cross-check). There are no checksums, no per-asset framing, and no padding — every byte after the header is asset payload. Asset offsets recorded in index.bin are relative to the asset region (the first asset is at 0); the loader adds the header size when seeking.

The constants are public: PACK_MAGIC, PACK_FORMAT_VERSION, PACK_HEADER_SIZE (re-exported through khora-sdk).

index.bin layout

bincode(Vec<AssetMetadata>), encoded with bincode::config::standard() so future additions to AssetMetadata keep older files readable. Each AssetMetadata carries:

FieldMeaning
uuidAssetUUID — the asset’s stable identity: AssetUUID::new_v5(forward_slash_rel_path) by default, or the frozen value the identity registry holds for that path.
asset_type_nameCanonical type tag ("texture", "mesh", "material", "script", …).
dependenciesVec<AssetUUID> — assets this one references (deduplicated, sorted).
variantsHashMap<String, AssetSource> — the "default" variant points into data.pack as Packed { offset, size }.
tagsVec<String>.

Determinism

IndexBuilder sorts asset paths lexicographically (forward-slash relative path) before PackBuilder streams them, so two consecutive packs of the same assets/ directory are byte-identical. Each asset’s UUID is resolved the same way in both modes: the frozen entry from the identity registry if one exists, otherwise the default AssetUUID::new_v5(forward_slash_rel_path). Because dev (IndexBuilder::with_registry) and release (PackBuilder, which loads the same registry) resolve through it identically, a UUID is the same in dev mode (loose files + in-memory index) and release mode (packed file + on-disk index.bin) by construction. Game code carrying an AssetHandle<T> works in either mode unchanged.

Asset identity registry

An asset’s AssetUUID must survive a rename or move so that references stored as raw UUID bytes — MeshRef::Asset, MaterialRef, .kmat texture slots — never break. The <project_root>/.khora/asset-registry.ron file decouples identity from path: it freezes a stable UUID for a relative path (AssetIdRegistry in crates/khora-io/src/asset/id_registry.rs).

Lazy freeze. An asset that has never been renamed has no registry entry and keeps its AssetUUID::new_v5(rel_path) default, so pre-registry projects and tests are unaffected. The first time an asset is renamed or moved in the editor, its current UUID is frozen into the registry — so it keeps that UUID forever, regardless of any future path change. A rename therefore rewrites nothing: scenes, prefabs, and .kmat files on disk and the open scene all keep resolving through the unchanged UUID.

Format. RON, one (uuid, path) entry per asset, sorted by UUID. Because the UUID is immutable, adding, renaming, or deleting an asset each touch a single line, so two branches that rename different assets produce non-overlapping diffs that git 3-way-merges cleanly (a real conflict arises only when the same asset is renamed on both branches). The file is written atomically (temp file + rename) so a crash mid-write cannot corrupt it.

Placement. The registry lives at the project root, a sibling of assets/, so it is never scanned, watched, or packed — the scanner, the filesystem watcher, and the packer are all rooted at assets/. The editor (ProjectVfs) is the only writer; the read side (IndexBuilder::with_registry, the runtime, and PackBuilder) resolves through it, which is what makes dev and release agree on identity.

.kmat — material file

A .kmat is a material asset referenced from an entity via MaterialRef::Asset(uuid) and resolved through the VFS. The recognised extensions for the material category are .kmat and .mat (IndexBuilder::asset_type_for_extension maps both to the type tag "material").

The on-disk form is RON of a type-tagged tree:

{
    "type_name": "StandardMaterial",
    "material": { /* the concrete material fields */ },
}

type_name selects a MaterialRegistration from an open, inventory-based registry (khora-data::ecs::components::material_registry); the material sub-value is decoded by that registration. Four material types register by default:

type_nameTypeWorkflow
StandardMaterialPBR metallic-roughnessbase_color, metallic, roughness, optional texture maps (base-color, metallic-roughness, normal, emissive, occlusion).
UnlitMaterialUnlitFlat color, no lighting.
EmissiveMaterialEmissiveSelf-illuminating.
WireframeMaterialWireframeEdge rendering.

A material’s texture references contribute its dependency list in the pack index: the dependencies of a .kmat are the AssetUUIDs of the textures it references (see khora_io::asset::dependencies). Custom material types become serializable by registering their own MaterialRegistration (the #[derive(Material)] macro generates it). See Materials and lighting.


Rationale: Serialization · Assets. Tasks: Save and load scenes · Load assets.

Project structure

A Khora project is the directory the editor opens with khora-editor --project <path>. It bundles the user’s scenes, assets, gameplay scripts, and an optional native Rust extension crate.

The Khora Hub is the only authoritative producer of new projects: it materialises the layout below when a user creates a project. The editor and the SDK consume the same layout — anything not documented here is not part of the contract.

Folder layout

<name>/
├── project.json           # project descriptor (see "project.json schema")
├── .gitignore             # target/ and *.lock
├── src/                   # native Rust extensions (compiled into the game)
└── assets/                # runtime data (loaded by the engine)
    ├── scenes/            # *.kscene
    ├── textures/          # png, jpg, jpeg, tga, bmp, hdr
    ├── meshes/            # gltf, glb, obj, fbx
    ├── audio/             # wav, ogg, mp3, flac
    ├── shaders/           # wgsl, hlsl, glsl
    └── scripts/           # gameplay scripts (data, hot-reloadable)

The hub creates every folder in this tree, even when empty, so the editor’s asset browser can surface the canonical categories from day one.

The first time the editor opens a project, it writes assets/scenes/default.kscene (a Main Camera + a Directional Light) so the user has a viable scene to start from. See scene_io.rs.

Three tiers of code

A Khora project layers three sources of behaviour, each with a different lifecycle:

TierLives inCompilationHot-reloadCross-platform
1. Engine built-inskhora-sdk (linked into every binary)n/a — engine is pre-compilednopre-built per target
2. Native Rustsrc/ + Cargo.toml (opt-in)cargo build --releaseno, requires rebuildhost-only in v1
3. Scriptsassets/scripts/*.kscriptnone — they are datavia the project’s asset watcheruniversal

Tier 1 supplies the primitives (Transform, Camera, Light, Mesh, ECS plumbing). Tier 2 extends them with custom Rust types when you need raw access to internal APIs or compile-time guarantees. Tier 3 sits on top: gameplay logic expressed as data, hot-reloadable at runtime — no recompile to iterate.

A game can ship with any subset. Tier 2 is opt-in: a fresh project from the hub has no Cargo.toml or src/. Most games start data-only (tiers 1 + 3) and stay there.

Adding native code (tier 2)

The hub shows an “Add Native Code” button on any project without a Cargo.toml. Clicking it scaffolds:

<project>/
├── Cargo.toml      # depends on khora-sdk = "<engine_version>"
└── src/main.rs     # `fn main() { khora_sdk::run_default() }`

The generated main.rs is functionally equivalent to the pre-built khora-runtime binary. To register custom components, agents, or lanes, replace its body with a custom EngineApp impl.

project.json schema

{
  "name": "MyGame",
  "engine_version": "0.3.0",
  "created_at": 1714659000
}
FieldTypeSourceMeaning
namestringHub input, sanitised (alphanumerics, _, -, spaces → _)Human-readable name. Distinct from the on-disk folder name when sanitisation changed it.
engine_versionstringHub’s available-engines dropdownThe Khora SDK release the project targets. Shown in the editor status bar and command-palette footer.
created_atu64Unix epoch seconds at creationInformational; not used for runtime logic.

The descriptor type is ProjectDescriptor in hub/src/project.rs — private to the hub today, but the JSON shape is the public contract. The editor reads name and engine_version at startup (crates/khora-editor/src/main.rs, setup); other fields are ignored. Future fields are additive — old editors keep working.

What the editor reads today

  • name — shown in the brand pill and status bar.
  • engine_version — shown in the status bar (Khora v<version>) and the command-palette footer.
  • created_at — read but ignored.

Obvious future additions (not yet part of the contract): description, default_scene, default_camera, engine_features. The JSON is untyped on read, so older project files keep working.

Asset extensions

The editor’s asset browser categorises files by extension. The mapping lives in crates/khora-editor/src/scene_io.rs.

TypeRecognised extensions
Mesh.gltf, .glb, .obj, .fbx
Texture.png, .jpg, .jpeg, .tga, .bmp, .hdr
Audio.wav, .ogg, .mp3, .flac
Shader.wgsl, .hlsl, .glsl
Material.mat, .kmat
Scene.scene, .kscene
Font.ttf, .otf

Files with unknown extensions are still scanned but classified as generic.

Lifecycle

  1. Creation — the user picks a name, engine version, and parent folder in the hub; the hub writes the layout above. It also seeds assets/scripts/main.kscript (a stub for the future scripting language; safe to ignore today).

  2. Openkhora-editor --project <path> reads project.json, builds the project’s VFS by scanning assets/, arms a filesystem watcher for hot reload, and populates EditorState.

  3. First open — the editor writes assets/scenes/default.kscene if absent.

  4. Edit — every save mutates files under assets/. The editor does not touch project.json or src/ after creation. Hot reload picks up disk changes within one frame.

  5. BuildBuild → Build Game… runs the asset packer (khora_io::asset::PackBuilder) against <project>/assets/ and stages a runnable output under <project>/dist/<target>/. The strategy depends on whether the project opted into native Rust:

    Project stateStrategyResult
    No Cargo.toml (data-only)Runtime stampThe pre-built khora-runtime for the target is copied and renamed.
    Cargo.toml presentCargo buildcargo build --release runs; the produced binary replaces the runtime stamp.

    Either way, data.pack + index.bin + runtime.json are emitted alongside the binary, and the runtime auto-detects them at startup. The runtime-stamp path is trivially cross-platform (a file copy from the hub’s engine cache); the cargo path is host-only in v1.


The .kscene, .pack, and .kmat formats these folders hold are documented in File formats. The asset pipeline behind them is the Assets concept page.

Editor

The editor application — panels, gizmos, play mode, scene I/O.

  • Document — Khora Editor v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. What the editor is
  2. Workspace anatomy
  3. Modes
  4. Play mode
  5. Scene I/O
  6. Asset browser
  7. Gizmos and selection
  8. Build Game
  9. The Control Plane
  10. For game developers
  11. For engine contributors
  12. Decisions
  13. Open questions

01 — What the editor is

khora-editor is a separate binary built on the SDK. It opens a project (a folder containing .kscene files and assets), authors scenes through ECS-aware panels, and previews them with play mode — a one-button switch between editing and full simulation.

The editor is not a separate engine. It uses the same agents, lanes, and ECS as a shipping game. What changes is which agents run (Editor mode runs Render, Shadow, UI; Playing mode adds Physics and Audio) and what the panels do on top of the world.

The visual language — colors, typography, panels, voice — is documented in Editor design system. This chapter covers the architecture, not the look.

02 — Workspace anatomy

+--------------------------------------------------------------+
| Title bar — brand, project name, window controls            |
+----+----------------------------------------------+----------+
|    |                                              |          |
| Sp |               Viewport                       | Inspect  |
| in |                                              | -or      |
| e  |                                              |          |
|    |                                              |          |
+----+----------------------------------------------+----------+
| Bottom dock: Assets · Console · GORNA stream                |
+--------------------------------------------------------------+
| Status bar — engine state, FPS, build status                |
+--------------------------------------------------------------+
RegionPurpose
Title barBrand, project name, window controls
SpineMode rail (Scene, Control Plane)
HierarchyTree of entities, left of the viewport
ViewportThe 3D scene with floating gizmos
InspectorComponents of the selected entity, right of the viewport
Bottom dockAssets browser, console, GORNA stream
Status barEngine state, FPS, build status, GORNA pulse

Full anatomy and pixel-level layout in Editor design system.

03 — Modes

Two workspaces ship today, switched from the Spine:

ModePurpose
Scene3D editing (default)
Control PlaneEngine telemetry and GORNA stream

Each mode has its own panel layout. We commit to opinionated defaults — Unity and Unreal let you arrange panels freely, and most users keep the defaults forever. We pick the layouts users won’t want to change. Further authoring workspaces (2D canvas, node graph, animation, shader graph) will be added as they are built.

04 — Play mode

flowchart LR
    A[Editor] -->|Press Play| B[Playing]
    B -->|Press Pause| C[Paused]
    C -->|Press Resume| B
    C -->|Press Stop| A
    B -->|Press Stop| A

When you press Play:

  1. The current world is serialized using SerializationGoal::FastestLoad (Archetype strategy).
  2. The snapshot is stored in memory.
  3. The editor’s PlayMode (UI-state) becomes Playing.
  4. EngineMode switches from Custom("editor") to PlayingPhysicsAgent and AudioAgent start running, UiAgent stops.
  5. The play camera takes over from the editor camera.

When you press Stop:

  1. The editor’s PlayMode becomes Editing.
  2. EngineMode switches back to Custom("editor").
  3. The snapshot is deserialized into the world.
  4. The editor camera resumes.

The snapshot is fast — milliseconds for a 10 000-entity scene — because Archetype strategy serializes ECS pages directly. See Serialization.

AspectEditor (Custom("editor"))Playing (Playing)
Active agentsRender, Shadow, UIRender, Shadow, Physics, Audio
CameraEditor camera (free orbit)Scene cameras (active ones)
InputEditor input (gizmos, selection)Game input (player controls)
ECSMutable — user edits directlySnapshot-based — original world preserved
RenderingViewport texture + gizmos + overlayFull scene, no editor chrome

Two enums, two scopes. PlayMode (Editing / Playing / Paused) is the editor’s UI state — it drives buttons, panel visibility, and what the user sees. EngineMode (Playing / Custom("editor") / other) is the engine’s filter for agent execution. The editor application bridges them: user clicks Play → editor sets PlayMode::Playing → editor requests EngineMode::Playing from the engine.

Physics state is not preserved across play mode. Velocities, contacts, and sleep state are reset on restore. The ECS components are restored exactly; the physics world rebuilds from those components.

05 — Scene I/O

The editor uses SerializationService for all scene operations. As of v0.4 it goes through ProjectVfs (the editor’s wrapper around khora_io::AssetService + AssetWatcher), so saves and loads inside the project use the same UUID identity that a release pack would:

ActionWhat happens
Open projectProjectVfs::open recursively scans <project>/assets/, builds the in-memory UUID index, registers all decoders, arms a filesystem watcher. The asset browser reads off the resulting VFS.
New sceneEditor creates an empty world, ready for editing
Save sceneEncoded with SerializationGoal::EditorInterchange (Recipe — compact, structured) and written via AssetWriter so the new file enters the VFS index immediately
Save scene asSame, with a new path. Out-of-project paths fall back to raw std::fs and log a warning.
Load sceneDouble-click a .kscene in the asset browser, or File → Open. Project-internal paths route through AssetService::load_raw (UUID-keyed).
Play / StopWorld snapshot + restore via SerializationService with FastestLoad (Archetype — no human-readable round-trip needed for in-memory revert).

Scene files are compact-binary in development today. RON dumps for diffing are available through SerializationGoal::HumanReadableDebug — not yet wired to a menu, but the strategy is registered in the service.

06 — Asset browser

The Assets panel in the bottom dock is a real file explorer over <project>/assets/, not just a read-only list. It reads off the same ProjectVfs the rest of the editor uses, so every operation goes through the identity registry — renaming or moving an asset here does not break its references (see Scene I/O and File formats — asset identity registry).

InteractionResult
Left-clickSelects a tile.
Double-clickOpens / activates it (a .kscene loads, other types open).
Right-click a file tileContext menu: Open, Reveal in Explorer, Rename (inline), Duplicate, Delete, plus “Assign to selected” for materials.
Right-click a folderNew Folder, Rename, Delete, Reveal.
Right-click empty spaceNew Folder, Reveal Current Folder, Refresh.
Header buttonsMore (menu), Trash (delete selected), Filter (category menu).

Empty folders are shown, so New Folder produces a usable target immediately. Delete goes to the OS recycle bin (via the trash crate), so a mistaken delete is reversible from the system trash rather than lost.

Drag & drop. Drag a tile onto a folder in the tree to move it (the move is mediated by the editor, so the identity registry freezes the UUID and references survive). Drag a tile into the 3D viewport to instantiate it: a mesh spawns an entity at the drop point, a prefab instantiates its subtree, a scene loads, and a texture or material assigns to the selected entity.

Rename/move only inside the editor. The registry freeze happens because the editor mediates the file operation. Renaming or moving an asset from a shell or git bypasses that path — see Troubleshoot.

07 — Gizmos and selection

The viewport has floating gizmos for the selected entity:

  • Move — three-axis arrows + center sphere for screen-space drag.
  • Rotate — three rings, one per axis.
  • Scale — three handles + uniform-scale center.

Selection is tracked in EditorState.selected_entity. Clicking an entity in the hierarchy or the viewport sets it; Esc clears it. Selected entities get a 2 px gold inner-stroke outline (1 px dark outer stroke), visible against any background.

Numeric fields in the Inspector are draggable scrubbers — drag horizontally to change a value, modifier keys for precision. No spinner buttons.

08 — Build Game

Build → Build Game… packages the open project for a target OS. The editor picks one of two strategies automatically, based on the presence of <project>/Cargo.toml:

Strategy A — Runtime stamp (default, data-only projects)

Used when the project has no Cargo.toml — the typical state for a fresh project from the hub.

  1. Packkhora_io::asset::PackBuilder walks <project>/assets/ (sorted by forward-slash relative path), resolves each file’s UUID through the project’s identity registry (a frozen entry if one exists, else the AssetUUID::new_v5(rel_path) default — the same registry the editor’s dev VFS reads, so dev and release UUIDs match by construction; see File formats — asset identity registry), and writes the two-file release layout: data.pack (16-byte header + concatenated asset bytes) + index.bin (Vec<AssetMetadata> with each variant rewritten to AssetSource::Packed { offset, size }).
  2. Stamp runtime — the editor copies the pre-built khora-runtime for the chosen target into the output directory and renames it after the project. The runtime binary lives next to the editor (release-archive layout) or in the hub’s ~/.khora/engines/<version>/runtime/ cache.
  3. Write runtime.json — read by the runtime at boot to learn which scene to auto-load.

Cross-platform is trivial: the pre-built runtime exists for every target shipped by release.yml, so building Linux from a Windows host is just a file copy.

Strategy B — Cargo build (native-Rust projects)

Used when the user has clicked “Add Native Code” on the project’s hub card, which scaffolds Cargo.toml + src/main.rs (calling khora_sdk::run_default() by default — same as the runtime, but linked into a binary that can register custom components / agents / lanes when the user edits main.rs).

  1. Same PackBuilder step as Strategy A.
  2. cargo build --release --manifest-path <project>/Cargo.toml compiles the user’s binary. stdout / stderr stream into the editor’s console panel.
  3. The compiled binary is copied into the output directory under the project name.
  4. Same runtime.json companion is written.

Host-only in v1 (Rust cross-compilation is fragile without per-target toolchains). The editor refuses non-host targets on this strategy with a clear error pointing the user to “run the editor on the target OS”.

Output layout (identical between strategies)

<project>/dist/<target>/
├── <project_name>{.exe}     # renamed khora-runtime OR compiled user binary
├── data.pack
├── index.bin
└── runtime.json

Run the binary directly — both khora-runtime and any project compiled via khora_sdk::run_default() auto-detect PackLoader (when data.pack + index.bin are siblings) or FileLoader (when a loose assets/ directory is a sibling instead, used by engine contributors who want to skip packing during iteration).

Why two strategies

The choice is deterministic (presence of Cargo.toml is the contract) and opt-in (the user explicitly upgrades a project to native Rust by clicking the hub button). Most projects stay on Strategy A and benefit from trivial cross-platform export. Strategy B is the escape hatch when raw performance, compile-time safety, or new engine primitives are needed. Future scripting (assets/scripts/, hot-reloadable, see Assets) plugs into both strategies transparently — scripts are just assets that get packed.

09 — The Control Plane

The sixth Spine mode is the Control Plane — a workspace dedicated to the engine’s mind.

RegionPurpose
Lane Timeline (top)Horizontal lanes per subsystem, execution windows as colored bands
GORNA Stream (bottom-left)Live feed of negotiation: timestamp, subsystem, suggestion, accept/reject
Meters Wall (bottom-right)Frame time, GPU %, memory, agent budget, assets pending

The Control Plane is not a profiler popup. It is a first-class workspace. The whole pitch of Khora is the self-optimizing architecture; the editor surfaces that intelligence as a place you go to, not a window you launch.

Full design in Editor design system, section 09.


For game developers

You typically run the editor against your project folder:

cargo run -p khora-editor -- --project /path/to/my_project

Inside the editor, you author scenes. Each scene is one .kscene file. Spawn entities by dragging a vessel from the Assets panel into the viewport, or by right-clicking in the hierarchy → Add Entity. Edit components in the Inspector. Save with Ctrl+S.

To preview your scene in play mode, press the Play button. Your EngineApp::update runs every frame, exactly as in your shipping game.

For engine contributors

The editor is implemented as a set of EnginePlugins — each panel registers callbacks at the appropriate ExecutionPhase:

FolderContents
crates/khora-editor/src/panels/Scene tree, properties, asset browser, viewport, console, GORNA stream
crates/khora-editor/src/gizmos/Move / rotate / scale, selection outline
crates/khora-editor/src/ops/High-level scene operations (spawn, despawn, parent, add component)
crates/khora-editor/src/scene_io/Scene save / load via SerializationService
crates/khora-editor/src/state.rsEditorState — selection, mode, pending operations

To add a panel: implement the panel render function, register it as an EnginePlugin in the editor’s main plugin list. Panels read EditorState and the world; they mutate through ops/ operations to keep the action layer clear.

To add a gizmo type: add a render path in gizmos/ that draws the gizmo for the selected entity, plus an interaction handler in ops/ that converts mouse drags into Transform mutations.

The editor depends directly on khora-agents and khora-io for performance — it bypasses the SDK in places. This is a known trade-off (see Decisions).

Decisions

We said yes to

  • Editor as a separate binary. The editor is a different application from a shipping game — different lifecycle, different active agents, different input.
  • Mode-first layouts. Opinionated defaults beat infinite customization for 95% of users.
  • Play mode through scene snapshot. Press Play, run the game; press Stop, you are back where you were. No state pollution.
  • Editor reaches into khora-agents and khora-io. Pragmatic shortcut for performance. The SDK is the public API for games; the editor is privileged.

We said no to

  • Free-form panel docking. Powerful but exhausting. We pick layouts users won’t want to change.
  • Telemetry charts in the main UI. Telemetry belongs in the Control Plane mode. Scene mode shows the scene.
  • Editor chrome during play mode. Play mode is a near-shipping preview. Chrome reappears on stop.

Open questions

  1. Multi-window. How does Spine + Modes work when the user pops the viewport to a second monitor? Likely the popped window keeps its own Spine, with one mode lit.
  2. Plugin UI surface. Third-party plugins need a place to live — probably the Inspector as additional component cards, but the contract is undefined.
  3. Collaboration. Real-time multi-user editing (shared cursors, shared selections) is on no roadmap, but the architecture does not preclude it.

Next: writing your own agent or lane. See Extending Khora.

Glossary

The vocabulary index. Khora uses a small set of proprietary terms — acronyms for its architecture, names for its runtime substrates — that recur across every chapter. This page defines each one in a sentence or two and links to the page that covers it in depth, so a reader landing anywhere can look up a term without reading the book front to back.

Terms are listed alphabetically. Where a term is an acronym, it is expanded on first mention.


AdaptationMode

The per-agent dial that bounds how freely GORNA may change an agent’s strategy. Four variants are enforced today — Learning (default, negotiates freely), Manual(strategy) (pins one strategy), Stable (blocks opportunistic up-switches), and Bounded { min, max } (clamps the strategy range) — set via DccService::set_adaptation_mode. A death-spiral safety stop can still force the cheapest strategy regardless. See GORNA.

AGDF — Adaptive Game Data Flows

The data-layer counterpart of GORNA: the online adaptation of the in-memory layout of ECS component storage (field-split SoA, SIMD tiling, hot/cold splitting) to the access pattern and the hardware — never the meaning of the data. AGDF is self-optimized inside khora-data; the DCC only observes it. See AGDF.

Agent

A tactical manager that owns exactly one LaneKind, exposes that kind’s lane strategies to GORNA, applies the budget GORNA returns, and dispatches the chosen lane each frame. An agent implements only the Agent and Default traits — no extra methods. It is not a controller (the DCC decides global priorities) and not a worker (lanes do the work). Six ship today: Render, Shadow, Physics, UI, Audio, and Overlay. See Agents and Lanes. Also called an ISA (Intelligent Subsystem Agent).

CLAD — Control / Lanes / Agents / Data

The concrete crate structure and dependency layering that implements SAA, and the name of the per-frame descent Control → Agent → Lane → Data. Dependencies flow downward only; the descent names how a budget becomes work each frame: Control arbitrates, the Agent selects a Lane, the Lane reads projected Views and writes results back to Data. CLAD is the how; SAA is the why. See CLAD.

CRPECS — Chunked Relational Page ECS

Khora’s custom archetype-based Entity Component System. Storage is chunked into bounded pages, the entity-to-data relationship is relational (entities are generation-checked identifiers, not pointers), and the page is the unit of iteration, compaction, and serialization. The model makes structural change cheap, which is what AGDF’s adaptive layout requires. See Data and the ECS.

DataSystem

A registered, fixed (non-negotiating) unit of Data-layer work the engine runs in an ordered slot each tick — invariants like transform propagation and GPU mesh sync, plus maintenance write-backs. New invariants are added data-driven, by submitting a DataSystemRegistration rather than wiring a system manually. A DataSystem is not an agent: it has no strategies to negotiate. See The frame and TickPhase.

DCC — Dynamic Context Core

The engine’s central observer and arbitrator, running on a dedicated background (“cold path”) thread at ~20 Hz. It maintains a situational model from telemetry, runs the heuristic engine, arbitrates the GORNA budget auction among agents, and sends budgets to the hot-path Scheduler through the BudgetChannel. It commands nothing: it sets budgets and observes Data; agents and Data decide how. Lives in khora-control. See GORNA and The frame.

Death spiral

The failure mode where the engine misses its frame budget for several consecutive frames, each overrun making the next worse. GORNA treats it as a first-class concept: a death-spiral heuristic forces the cheapest strategy until the engine recovers, then returns to the negotiated strategy. A separate “spiral of death” guard bounds the fixed-timestep accumulator (see Fixed timestep). See GORNA.

ExecutionPhase / TickPhase

Two distinct ordered-slot enums, easy to confuse:

  • ExecutionPhase orders agent execution within a frame: Init, Observe, Transform, Mutate, Output, Finalize, plus optional custom phases. Each agent declares the phases it may run in; the Scheduler runs them in order. See Agents and Lanes and The frame.
  • TickPhase orders the Data layer’s DataSystem invariants around the app’s update — PreSimulation, PostSimulation, PreExtract, and the Maintenance pass — independent of agent phases. See The frame.

Fixed timestep / interpolation alpha

Khora decouples simulation from rendering. The deterministic fixed-timestep agents (physics) advance in whole fixed_delta_seconds steps (default 1/60 s) driven by an accumulator in the Scheduler, so simulation is frame-rate independent; rendering happens once per (variable-rate) frame. To stay smooth between sim steps, the render path blends each simulated body’s previous and current transform by the interpolation alpha — the fraction of a fixed step carried past the last whole step, in [0, 1). The alpha is render-only and never affects simulation semantics. The clock lives in khora_core::time::Time; the previous poses live in khora_core::interpolation::TransformInterpolation (not in the ECS — it carries no game meaning). See The frame.

Flow

A read-only per-domain projector that derives a typed View of the World (RenderWorld, ShadowView, AudioFlow’s output, …) and publishes it into the LaneBus during the Substrate Pass. Flows never mutate the World and never bid for the frame budget; they are the only legitimate producer of the Views lanes consume. New flows are registered data-driven via register_flow!. Some flows opt into per-domain change-epoch caching to skip re-projection when nothing changed. Lives in khora-data/src/flow/. See AGDF and Data and the ECS.

GORNA — Goal-Oriented Resource Negotiation and Allocation

The per-tick protocol by which the DCC and the agents trade frame budgets, replacing static compile-time allocations. Each tick the DCC runs five phases — Awareness, Analysis, Negotiation, Arbitration, Application — collecting each agent’s strategy options and costs, then handing back a ResourceBudget per agent that reflects this hardware, this scene, this frame. Only agents negotiate; the Data layer does not. See GORNA.

Headroom

The slack between current resource use and the budget or safety ceiling. When the frame-time PID loop sees headroom (measured frame time under target), it climbs the global budget multiplier back toward 1.0 so agents can upgrade to richer strategies; under pressure it lowers the multiplier. Memory headroom similarly gates whether a layout repack is allowed. See GORNA and AGDF.

ISA — Intelligent Subsystem Agent

The SAA name for an Agent: a semi-autonomous subsystem manager with self-assessment, multiple strategies, and cost estimation, negotiating through GORNA. “Agent” is the trait and the day-to-day term; “ISA” is the conceptual name used in the architecture and roadmap. See SAA and Agents and Lanes.

Lane

A hot-path execution unit: one deterministic strategy an agent can choose from (render a forward pass, simulate one physics step, mix one audio frame). Lanes do not decide whether to run and do not negotiate — they run when an agent dispatches them. Lanes consume the typed Views the Flows publish into the LaneBus and never query the World directly. The Lane trait has a prepare/execute/cleanup triple. See Agents and Lanes.

LaneBus / OutputDeck

The typed input/output substrate of the lane layer. The LaneBus is where Flows publish their per-domain Views for lanes to read (the lane input side). The OutputDeck is the typed sink lanes write cross-domain results into (audio and physics write-backs), drained by the Maintenance DataSystems at end of frame. GPU render passes use a separate sink, the FrameGraph. Both live in khora-core/src/lane/. See The frame and Agents and Lanes.

ResourceBudget

The result GORNA hands each agent: the chosen StrategyId, a time limit, optional memory and VRAM limits, and an extra-params map. Narrow by design — adding a resource dimension is a deliberate change, not a free-form bag. See GORNA.

SAA — Symbiotic Adaptive Architecture

Khora’s organizing philosophy: subsystems live in symbiosis (neither commanding nor commanded) and the engine adapts to its environment continuously rather than through a configuration step. Every major subsystem is a negotiating agent; a central observer (the DCC) watches and arbitrates each tick. SAA is the why; CLAD is the how. See SAA and CLAD.

SemanticDomain

The partition a component belongs to, declared with #[component(domain = …)] and carried in the component registry: Spatial, Render, Physics, Audio, Ui. Domains let query planners and Flows pre-filter pages (a render extraction never touches UI pages) and anchor the per-domain change epochs that gate Flow view caching. See Data and the ECS.

Strategy / StrategyId

A strategy is one algorithm an agent can run for its LaneKind, embodied by a lane (e.g. RenderAgent’s Unlit / LitForward / Forward+). The agent exposes each as a StrategyOption with a cost estimate during negotiation; the chosen one returns in the ResourceBudget as a StrategyId. See GORNA and Agents and Lanes.

Substrate Pass

The pre-agent phase of the Scheduler’s frame in which every registered Flow runs and publishes its typed View into the LaneBus. It ensures lanes (which run afterward) read pre-projected, AGDF-adapted Views rather than touching the World. The Scheduler invokes it because it owns tick ordering — this is orchestration of when Data takes its turn, not control over how Data lays itself out. See The frame.

Vessel

The SDK’s entity-spawn builder. It guarantees every spawned entity has a Transform and a GlobalTransform and lets you attach components fluently (Vessel::at(world, pos).with_component(c).build()). The primitive helpers (spawn_plane, spawn_cube_at, spawn_sphere) return a pre-loaded Vessel. See SDK surface and Your first game.

View

The read-only, typed projection of one domain’s World state that a Flow produces and publishes into the LaneBus for lanes to consume (RenderWorld, ShadowView, …). A cached View is bit-identical to a freshly projected one — only whether the projection runs changes, never what it contains. See AGDF.


See also: Concepts for where each idea fits, and the Crate map for where each concept lives in the codebase.

API reference

The generated rustdoc is the exhaustive reference for every public crate in the workspace. The book covers concepts and curated maps; the rustdoc covers every type, trait, function, and method.

The published reference is built from main on every release and lives at eraflo.github.io/KhoraEngine/api.

Public SDK

khora-sdk is the only crate game developers should depend on. Everything else is implementation detail. See the SDK surface page for a curated map.

Entryrustdoc
Crate rootkhora_sdk
Preludekhora_sdk::prelude
Engine typeEngineCore
ECS facadeGameWorld
App lifecycle traitEngineApp
Agent registration traitAgentProvider
Custom phase traitPhaseProvider
Spawn builderVessel
Bootstrap entryrun_winit
Window settingsWindowConfig

Internal crates

Visible for engine contributors. Game code should not depend on these directly — their surface is re-exported through khora-sdk where game code needs it. See the crate map for the dependency layering.

Generating locally

To build the same rustdoc on your machine:

cargo doc --workspace --no-deps --open

The output lands under target/doc/. The combined documentation site (this book plus rustdoc mounted under /api) is assembled by .github/workflows/docs.yml on every push to main; there is no single local command that mirrors the full combined site.


The API reference is the contract. The book is the rationale — start with Concepts.

FAQ

Frequently asked questions — for newcomers deciding whether to use Khora, and for evaluators trying to place it. Answers are short and link to the chapter that covers the topic properly; terms are defined in the Glossary.


Why SAA — why not just a normal ECS engine?

Because most engines decide their resource allocation at compile time: physics gets N ms, rendering gets M ms, budgets baked in. Those numbers are wrong on every machine that is not the developer’s. Khora’s Symbiotic Adaptive Architecture replaces the static budget with a per-tick negotiation: subsystems are agents that declare what they can do at various cost points, and a central observer (the DCC) hands out budgets that reflect this hardware, this scene, this frame. It still is an ECS engine — CRPECS is the data layer — but the ECS exists in part to make that adaptation cheap. See Principles and Decisions.

Is Khora production-ready?

No. Khora is experimental. The architecture is stable enough to support a runnable sandbox, an editor with a play mode, and a large workspace test suite, but the SDK surface is intentionally narrow and the engine is mid-roadmap. Treat it as a research-grade engine to study or build prototypes on, not to ship a commercial title on today. The phased plan — scene/assets, the adaptive core, tooling/scripting, advanced intelligence, then a native physics solver — is laid out honestly in the Roadmap, and uncertainties are tracked in Open questions.

How do I get started?

Clone the repo, run cargo test --workspace to confirm your environment, then cargo run -p sandbox for the shipping demo. The smallest working game is under a hundred lines and is walked through end to end — app struct, bootstrap closure, spawning a scene with Vessel — in the SDK quickstart. The full example lives at examples/sandbox/src/main.rs.

Can I swap the physics, audio, or render backend?

Yes, by design — that is the load-bearing reason backend code is segregated. Every backend in khora-infra implements a trait that lives in khora-core: PhysicsProvider (physics), AudioDevice (audio), and the rendering traits (RenderSystem / GraphicsDevice), plus LayoutSystem for UI layout. Swapping a backend means writing a new implementation of the trait, typically as a new sibling folder under khora-infra/src/<area>/<backend>/; the rest of the engine never sees the change. Today there is one shipping implementation per trait — wgpu, Rapier3D, CPAL, Taffy — so swapping is a supported extension point, not a menu of ready alternatives. See Architecture and Extending Khora.

How do I debug why GORNA chose a particular strategy?

GORNA is observable by design — “the engine has a mind; show it.” At runtime you watch decisions in the editor’s Control Plane mode, whose GORNA Stream panel is a live feed of negotiations (timestamp, subsystem, suggestion, accept/reject — e.g. “RenderAgent: LitForward → Forward+, reason: GPU pressure”). The underlying signals come from the TelemetryService. See GORNA, Telemetry, and Editor. Note: a richer offline decision recorder (the Replay adaptation mode and a DCC/GORNA decision tracer) is roadmap work, not shipped.

What is the difference between an Agent and a Lane?

An agent is a strategist: it owns one LaneKind, negotiates a budget through GORNA, and selects which strategy to run. A lane is the worker: one deterministic algorithm (render a forward pass, step physics once) that runs when its agent dispatches it. The agent owns selection; the lane owns execution. RenderAgent chooses between the SimpleUnlit, LitForward, and Forward+ lanes. See Agents and lanes.

What is the difference between GORNA and AGDF?

They are twin adaptation loops on different layers. GORNA adapts strategy: which lane an agent runs, negotiated per tick against the frame budget. AGDF adapts data layout: how an ECS component’s storage is arranged in memory (field-split SoA, SIMD tiling), self-optimized inside the Data layer and only observed by the DCC. GORNA is competitive (agents bid for the budget); AGDF is not (Data never bids). Both follow the same observe → decide → apply loop, and both change only the how, never the what. See GORNA and AGDF.

Does adaptation change my game’s behavior?

No — that is the engine’s central guarantee: adapt the HOW, never the WHAT. Automatic adaptation may change representation — render strategy, simulation quality, memory layout — but it must never change game semantics: which components an entity has, or any simulation-observable behavior. Dropping an entity’s physics because it is far away changes the game; that is a developer decision, opt-in and authored by you, never something the engine does on its own. See Principles (pillar 7) and AGDF.

What platforms are supported?

Desktop: Windows, Linux, and macOS — all three are built and tested in CI every change, rendering through wgpu (Vulkan / Metal / DX12). WebAssembly and mobile are not current targets — they are not in CI and the engine does not claim them; mobile and VR appear in the docs only as the kind of hardware diversity SAA is designed to handle eventually, and XR is an explicit later-phase Roadmap item. Treat Khora as a desktop engine today.

What is the minimum supported Rust version (MSRV)?

Rust 1.91. It is the lowest stable toolchain that compiles the whole workspace, enforced in CI. The binding constraint is the #[derive(Component)] macro output in khora-data, which uses const fn TypeId::of in a const context — that became usable in const in Rust 1.91. The project is stable Rust (no nightly features); the workspace crates are edition 2021. The manifest’s rust-version = "1.91" is the source of truth.

How do I add a component, a lane, or an agent?

All three are pure-Rust extension points, and the engine wiring is data-driven (no manual registration in engine code). Add a component with #[derive(Component)] plus #[component(domain = …)], which self-registers it. Add a lane (a new strategy) or an agent (a new negotiating subsystem) by implementing the Lane / Agent traits. The end-to-end worked example — defining the lanes, writing the agent, registering it through AgentProvider — is in Extending Khora; the contracts themselves are in Agents and lanes.

How is the engine licensed?

Apache-2.0. The whole workspace ships under it; the license is declared once in the workspace manifest and inherited by every crate, and the full text is in LICENSE at the repo root.

Where does physics run — every frame?

No. Physics runs on a fixed timestep (default 1/60 s), decoupled from the render rate. The Scheduler keeps an accumulator: each frame it advances the simulation in whole fixed steps (zero or more, bounded to avoid a spiral of death under overload), while rendering happens once per frame. To stay smooth between sim steps, the render path blends the previous and current transforms by the interpolation alpha. So on a fast machine a single rendered frame may run no physics step (just interpolate); on a slow one it may run several. The clock is khora_core::time::Time. See The frame and the Glossary.

Why is the DCC on a separate thread, and what if its budget is late?

The DCC runs the cold path (~20 Hz) on a background thread precisely so analysis and negotiation never block the frame loop. The two paths touch only through the BudgetChannel, with last-wins semantics: if a budget is late, the previous one simply stays in effect, and if several arrive between frames only the latest is used. The hot path never waits on the cold path. See The frame and GORNA.

Can I override the engine and pin a strategy myself?

Yes, within bounds, via an agent’s AdaptationMode (set through DccService::set_adaptation_mode): Manual(strategy) pins one strategy, Stable blocks opportunistic up-switches, Bounded { min, max } clamps the range, and Learning (the default) lets GORNA negotiate freely. A death-spiral safety stop can still force the cheapest strategy in an emergency, regardless of mode. Finer controls — calibration, deterministic replay, game→engine hints, and spatial priority volumes — are on the Roadmap. See GORNA.


See also the Glossary for term definitions, and Open questions for what the engine has not yet decided.

Contributing

This section is for people working on the engine itself — fixing bugs in a subsystem, adding a lane or an agent, extending the ECS, improving the renderer. If instead you want to build a game on top of Khora, you want the tutorials and the game-side how-to guides — not this section.

Contributing to an engine with a strong architecture (SAA, the CLAD descent, GORNA) means there are conventions you have to internalise before your change will pass review and CI. These pages give you the fastest path to productive, mergeable work.

The path

Follow these in order on your first contribution; come back to any of them as a reference later.

  1. Set up your environment — clone, build, run the sandbox and the editor, serve the docs. The ordered getting-started that gets a working tree in front of you.
  2. Take the architecture tour — a guided reading order through the Concepts so you understand the internals fast, in the right sequence, before you touch code.
  3. Learn the conventions — the hard rules and coding conventions every change must follow (math types, logging, error handling, components, agents, lanes, shaders, the dependency direction).
  4. Know the workflow — the branch/PR flow and the CI gates your change must pass (format, clippy, the cross-platform test matrix, doctests, supply-chain, MSRV).

When you’re ready to write code

Once you have the lay of the land, the hands-on material lives in two places:

  • The Extending the engine tutorial — a guided, end-to-end walk through adding a new piece to the engine.
  • The engine-side how-to recipes — task-sized guides for adding a component, a lane, an agent, a shader, an asset decoder, or a flow.

Read those for the how; this section gives you the setup, the map, and the rules that make them stick.

Setting up a dev environment

This is the ordered getting-started for working on the engine. By the end you will have a clean build, a green test run, and the sandbox, editor, and docs all running locally.

Prerequisites

  • Rust toolchain. The minimum supported Rust version (MSRV) is 1.91. CI enforces it with a dedicated cargo check on 1.91, so anything newer than the stdlib/language surface of 1.91 will fail the build. Install with rustup; stable is fine for day-to-day work as long as your change still compiles on 1.91.

  • System libraries (Linux). The audio, windowing, and UI backends need native dev packages. On Debian/Ubuntu:

    sudo apt-get update
    sudo apt-get install -y libasound2-dev pkg-config libgtk-3-dev \
        libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev
    

    Windows and macOS need no extra system packages for a default build.

1. Clone and build

git clone https://github.com/eraflo/KhoraEngine.git
cd KhoraEngine
cargo build --workspace

The first build compiles the full graphics/physics/audio stack and takes a while; later incremental builds are fast.

2. Run the tests

The primary check is the whole-workspace test run:

cargo test --workspace

This must be green before you start changing things — it tells you the tree is healthy on your machine. (CI runs the same suite cross-platform via cargo nextest; see Workflow.)

3. Run the sandbox

The sandbox is a small example game that exercises the engine directly through the SDK — the quickest way to see a frame loop running:

cargo run -p sandbox

Watch the console: the frame loop should be clean, with no GPU validation errors.

4. Run the editor

cargo run -p khora-editor

For the full author-time loop, the editor needs the runtime binary on disk so its “Build Game” feature can stamp it onto a project. The convenience alias builds editor and runtime in debug and then launches the hub (the project manager / engine launcher) in release:

cargo hub-dev

That alias is equivalent to:

cargo build -p khora-editor -p khora-runtime
cargo run   -p khora-hub --release

Use cargo hub-dev rather than typing the chain by hand — without khora-runtime present, the editor’s “Build Game” step fails with “khora-runtime binary not found”.

5. Serve the documentation

This book is built with mdBook. Install it once, then serve with live reload from the repo root:

cargo install mdbook
mdbook serve docs --open

Tooling you’ll use

  • cargo xtask — the workspace build-automation entry point. cargo xtask all runs the full local gate (build, test, check, format, clippy) — run it before opening a pull request. Other subcommands cover assets (cargo xtask assets pack) and regenerating the AI doc wrappers.
  • cargo hub-dev — the contributor dev loop described above (build editor + runtime, launch the hub).
  • The hub — the project manager / engine launcher; cargo hub-dev builds and runs it for you.

Keeping target/ in check

[profile.dev] already uses debug = "line-tables-only" workspace-wide, which strips most debuginfo while keeping panics and RUST_BACKTRACE=1 useful. When the build directory still grows too large, cargo clean resets it; for a lighter touch, cargo install cargo-cache then cargo cache --autoclean drops stale incremental and registry artifacts without forcing a full rebuild. If you need a real debugger and line tables aren’t enough, override debug = 2 in a local Cargo.toml patch kept out of git.


Tree builds, tests pass, sandbox and editor run? Good. Next, take the architecture tour before you start changing code.

Architecture tour

The Concepts section explains every idea in Khora, but when you’re contributing you want them in a specific order — the one that builds the mental model fastest, from the philosophy down to the subsystem you’re about to touch. This page is that thread. It adds no new architecture content; it points you into the existing concept pages in the order that pays off.

Read these in sequence. Each line tells you what you’ll get from the page.

The core mental model

  1. The big idea — SAA What you’ll get: why an engine would negotiate with itself — the Symbiotic Adaptive Architecture, where subsystems are intelligent agents that bargain for resources rather than running fixed code paths.

  2. Architecture — CLAD What you’ll get: the Control → Agent → Lane → Data descent that names the command path of a frame, and the strict downward dependency direction every crate obeys.

  3. The frame What you’ll get: the per-frame loop end to end — fixed timestep, render interpolation, and where the CLAD descent actually happens each tick. This is the spine everything else hangs off.

The adaptive machinery

  1. Data and the ECS — CRPECS What you’ll get: how entities and components are stored, queried, and laid out — the data layer at the bottom of the descent.

  2. Agents and Lanes What you’ll get: the split between strategists that choose (agents) and executors that run (lanes), and why agents own no per-frame state.

  3. GORNA What you’ll get: the negotiation protocol — how agents bid for the frame budget and how the controller arbitrates. This is the heart of SAA in practice.

Then: the subsystem you care about

With the spine in place, dive into the one (or two) subsystems your change touches. Each is a khora-core trait with a default backend in khora-infra:

If your work is about data layout adaptation, also read AGDF — how the data layer changes its representation at runtime without ever changing meaning (“adapt the HOW, never the WHAT”).

Two facts make the codebase legible once you start reading it:

  • Dependencies flow strictly downward: khora-corekhora-data / khora-controlkhora-laneskhora-agentskhora-infrakhora-sdk. There are no cycles. Abstract traits live in khora-core; concrete backends live under khora-infra. Knowing this tells you, for any symbol, roughly which crate it must be in.
  • Query the codegraph before grepping. The repo ships a codegraph index of every symbol and edge. Use it to jump straight to a definition, find callers, or see what a change would impact, instead of text-searching the tree.

Got the map? Now learn the rules every change must follow: Conventions.

Conventions

These are the rules a change has to follow to pass review and CI. This page is the human-readable summary. The authoritative, machine-readable source — the single source of truth that the project’s AI agents also follow — lives in the repository under .agent/engine/ (conventions.md and RULES.md). When the two ever disagree, those files win; this page is a digest, not a duplicate.

Math

  • All math goes through khora_core::mathVec2/3/4, Mat3/4, Quaternion, Aabb, LinearRgba, and so on. Never use raw glam. If you need an operation the module doesn’t expose, extend the module rather than reaching for the dependency directly.
  • The convention is right-handed, column-major, Y-up. Document any non-trivial derivation with the formula or paper it comes from.

Logging

  • Log through log::{info, warn, error, debug, trace}never println! or eprintln!. Those bypass the log pipeline and the editor console.
    • info for lifecycle events, warn for recoverable anomalies, error for unrecoverable errors before bubbling a Result, debug/trace for gated hot-path diagnostics.
  • One exception: the engine’s own log sink, EditorLogCapture::log (in khora-core/src/ui/editor/log_capture.rs), writes to stderr with eprintln! — a log::Log implementation that called log::* would recurse forever. That is the only sanctioned eprintln! in the codebase.

Errors

  • Never unwrap() on a fallible GPU or I/O operation. One panic in render or asset code takes down the frame loop. Use Result, the ? operator, or map_err to add context.
  • Each subsystem owns its error enum (LaneError, AssetError, PhysicsError, …), derived with thiserror, with variants that carry context (paths, IDs, expected state). Validate at boundaries (user input, file I/O, GPU); trust internal contracts.

unsafe

  • Every unsafe block carries a // SAFETY: comment explaining why the invariant holds. No exceptions.

Components and the ECS

  • Use #[derive(Component)] on every ECS component. The macro generates the serializable mirror plus the From conversions, and self-registers the component via inventory — you don’t wire it up by hand.
  • Declare a component’s domain with #[component(domain = Physics)] (or the relevant domain).
  • Use #[component(skip)] for fields that must not serialize (GPU handles, runtime caches) and #[component(no_serializable)] for components with a manual mirror.

Agents

  • An agent implements only Agent and Default — nothing else. No start/stop, no builders, no accessors; construction is via Default::default(). (Private free functions in the module file are fine.)
  • An agent owns exactly one LaneKind, and its only jobs are: select a lane for the budget, negotiate that budget via GORNA, and dispatch Lane::execute(). No per-frame state, no buffering outputs, no owning a flow.
  • Only use an agent for a subsystem that actually needs GORNA negotiation. For everything else use a direct service (AssetService, SerializationService, …).

Lanes and flows

  • Lanes consume Views from the LaneBus — they never query the World directly. The domain Flow (in khora-data/src/flow/) is the only legitimate producer of those Views, and a flow is a read-only projector: select → project, never a mutation.
  • Never bypass the Lane abstraction for hot-path work.
  • Adapt the HOW, never the WHAT. Automatic adaptation (DCC / GORNA / Flow / AGDF) may change representation — strategy, quality, memory layout — but must never change game semantics. Structural mutations are forbidden inside lanes.

Shaders

  • Shaders are .wgsl files, never inline Rust const/static strings. They live under crates/khora-lanes/src/render_lane/shaders/pipelines/ for entry points, lib/ for reusable modules — and are composed via ShaderRegistry with naga_oil #import. Write WGSL only (no GLSL or SPIR-V).
  • The four-bind-group budget. Every render lane uses exactly four bind groups: 0 Frame (camera), 1 Object (per-draw model/normal matrix), 2 Material, 3 Lighting (the entire lighting domain). A new lighting feature adds bindings to group 3; it never adds a fifth group. Four groups is the universal wgpu baseline; bindings within a group are effectively unbounded.

Architecture boundaries

  • Dependencies flow strictly downward: khora-corekhora-data / khora-controlkhora-laneskhora-agentskhora-infrakhora-sdk. Never introduce a cycle. Abstract traits live in khora-core; concrete backends (wgpu, Rapier, CPAL, Taffy, winit) live under khora-infra.
  • Keep GPU resources behind abstract IDs (TextureId, BufferId, PipelineId) — never expose raw wgpu handles in public APIs.
  • Concurrency goes through the DCC agent system — never std::thread::spawn directly (test code may, for isolation).

Before you push

  • The workspace must build and test clean: cargo test --workspace is the primary check.
  • cargo clippy --workspace must be clean — CI runs it with -D warnings, so any warning fails the build.
  • cargo fmt must report no changes.

cargo xtask all runs format, clippy, build, test, and doc in one go — run it before opening a pull request. See Workflow for the exact CI gates.


Again: .agent/engine/conventions.md and .agent/engine/RULES.md in the repo are the authoritative source. This page summarises them for humans — when in doubt, read the source.

Contribution workflow

This page covers how a change goes from your working tree to main: the branch/PR flow, the gates CI enforces, and how releases are cut.

Talk first

Given the architectural nature of the current work, open an issue or a discussion before writing a non-trivial change so it lines up with the roadmap and the architecture. Bug reports and feature requests go through the issue templates; broader ideas go to GitHub Discussions.

Branch and PR flow

  • Development happens on dev; stable releases land on main.
  • Work on a branch, then open a pull request targeting main and link it to the relevant issue.
  • Before opening the PR:
    1. Run cargo xtask all locally (format + clippy + build + test + doc) so your change is already green against the gates below.
    2. Update the docs alongside the code — this book for concepts/narrative, rustdoc comments for API surface. A public-API change updates its mdBook chapter in the same PR.

A secret-scan pre-commit hook (installed by the AI tooling) blocks commits that contain secrets. Commit messages follow imperative mood with an optional conventional prefix (feat:, fix:, refacto:, docs:) — the prefix matters, because releases are derived from it (see Releases).

CI gates

Every push to main and every pull request targeting main runs the Rust CI workflow. All of the following must pass before a PR can merge (an All Checks Pass job aggregates them):

GateCommandNotes
Formatcargo fmt --all -- --checkMust report no changes.
Lintcargo clippy --workspace --all-targets --all-features -- -D warningsWarnings are errors.
Test — Linuxcargo nextest run --workspace --all-targets --all-featuresRuns on ubuntu-latest.
Test — WindowssameRuns on windows-latest.
Test — macOSsameRuns on macos-latest.
Doctestscargo test --workspace --doc --all-features --lockedSeparate job — nextest does not run doctests, so rustdoc # Examples are verified only here.
Supply chaincargo deny checkAdvisories, licenses, and duplicate-version policy from deny.toml.
MSRVcargo check --workspace --all-features --all-targets --locked on 1.91Fails if a change uses a newer stdlib/language feature than the pinned MSRV.

A coverage job also runs but is report-only — it produces an lcov.info artifact, has no threshold, and never blocks the merge.

Notes on specific gates

  • The test matrix runs on all three platforms. A change that compiles and passes on your OS can still fail on another (path handling, backend availability) — write platform-portable code.
  • Doctests are their own gate. Because nextest skips doctests, the only place your rustdoc examples are compiled and run is the doctests job. Keep # Examples blocks correct, or run them locally with cargo test --workspace --doc.
  • cargo deny and the bincode advisory. The supply-chain gate fails on vulnerabilities. One advisory is deliberately ignored in deny.toml: RUSTSEC-2025-0141, which flags bincode as unmaintained — a notice, not a vulnerability, kept in the ignore list with its rationale documented inline. If you add a dependency that trips a new advisory, the gate fails until it is resolved or explicitly justified.
  • MSRV is 1.91. This gate is a plain cargo check on the pinned toolchain. If your change needs a newer language or stdlib feature, the MSRV has to be bumped deliberately — it is not raised implicitly by a PR.

Releases

Releases are automated. On a push to main, a semantic-release workflow analyses commit history, derives the next version from the conventional-commit prefixes, updates the changelog, builds the release binaries (engine, hub, runtime) for Windows, Linux, and macOS, and publishes them. This is why commit message prefixes matter: a feat: and a fix: produce different version bumps. Contributors don’t cut releases by hand — writing correct commit messages is the whole input.


That’s the full loop: set up your tree, learn the map, follow the conventions, pass the gates. Ready to build something? Start with the Extending the engine tutorial or pick an engine how-to recipe.

Roadmap

The phased development plan for Khora. Six phases, multi-year horizon.

  • Document — Khora Roadmap v1.0
  • Status — Living
  • Date — May 2026

Contents

  1. Phase 1 — Foundational architecture
  2. Phase 2 — Scene, assets, basic capabilities
  3. Phase 3 — The adaptive core
  4. Phase 4 — Tooling, usability, scripting
  5. Phase 5 — Advanced intelligence
  6. Phase 6 — Native physics
  7. Closed milestones (historical)

01 — Phase 1 — Foundational architecture

Goal: Establish the complete, decoupled CLAD crate structure and render a basic scene through the SDK.

Status: Complete.

With the successful abstraction of command recording and submission, the core architectural goals for the foundational phase are met. The engine is fully decoupled from the rendering backend — wgpu is one implementation, not the implementation.

02 — Phase 2 — Scene, assets, basic capabilities

Goal: Build out the necessary features to represent and interact with a game world, starting with the implementation of CRPECS.

Architecture refactoring

  • Lift asset_lane and ecs_lane out of the Lane abstraction. Per the Agent vs Service rule, a Lane is a strategy variant an agent picks under GORNA negotiation. Asset decoders (glTF, OBJ, WAV, Symphonia, texture, font, pack) and ECS compaction have no per-frame strategies to negotiate — they are on-demand or fixed maintenance work. They should expose their behavior through the existing service surfaces (AssetService, EcsMaintenance) rather than implement Lane. Targets:
    • Replace AssetDecoder<A> lane implementations with plain AssetDecoder<A> services registered in DecoderRegistry. The AssetDecoder<A> trait already exists in khora-lanes without a Lane bound — finish moving the decoders to use it cleanly and drop the lane scaffolding.
    • Move CompactionLane work directly into EcsMaintenance::tick, deleting the lane wrapper. Maintenance is already not an agent (see ECS §08); the lane wrapper is residual.
    • Update Lanes and Architecture tables once the migration lands — today they still list asset_lane/ and ecs_lane/ for accuracy with the current code, but those entries should disappear after this refacto.

Rendering capabilities, physics, animation, AI

  • #101 Implement Skeletal Animation System
  • #162 Implement SkinnedMesh ComputeLane
  • #104 Implement Basic AI System (placeholder behaviors, simple state machine)

03 — Phase 3 — The adaptive core

Goal: Implement the magic of Khora — the DCC, ISAs, and GORNA — proving the SAA concept.

Intelligent Subsystem Agents v1

  • #176 Evolve AssetAgent into a full ISA (depends on #174)
  • #83 Refactor a second subsystem as ISA v0.1

04 — Phase 4 — Tooling, usability, scripting

Goal: Make the engine usable and debuggable by humans. Build the editor, provide observability tools, integrate scripting.

Editor GUI, observability, UI

  • #52 Choose and integrate a GUI library
  • #53 Create the editor layout
  • #54 Implement the render viewport
  • #55 Implement the scene hierarchy panel
  • #56 Implement the inspector panel (basic components)
  • #57 Implement the performance / context visualization panel
  • #58 Implement basic Play / Stop mode
  • #77 Visualize the full context model in the editor debug panel
  • #102 Implement the in-engine UI system
  • #164 Implement UiRenderLane
  • #165 Implement a Decision Tracer for DCC / GORNA in the editor
  • #166 Implement a timeline scrubber for the context visualization panel

Editor polish, networking, manual control

  • #175 Real-time asset database for the editor (depends on #41)
  • #177 DeltaSerializationLane for game saves and undo / redo (depends on #45)
  • #66 Implement an asset browser (depends on #175)
  • #67 Implement a material editor
  • #68 Implement gizmos
  • #167 Implement an EditorGizmo RenderLane
  • #69 Implement undo / redo
  • #70 Implement editor panels for fine-grained system control
  • #103 Implement a basic networking system

Scripting v1

  • #168 Evaluate and choose a scripting language
  • #169 Implement scripting backend and bindings
  • #170 Make the scripting VM an ISA (ScriptingAgent)

Maturation, optimization, packaging

  • #94 Extensive performance profiling and optimization
  • #95 Documentation overhaul (including SAA concepts)
  • #96 Build and packaging for target platforms

API ergonomics and developer experience

  • #173 Implement a fluent API for entity creation

05 — Phase 5 — Advanced intelligence

Goal: Build upon the stable SAA foundation to explore next-generation features.

Advanced adaptivity (AGDF, contracts)

  • #89 Design semantic interfaces and contracts v1
  • #90 Investigate Adaptive Game Data Flow (AGDF) feasibility and design
  • #91 Implement basic AGDF for a specific component type
  • #92 Explore using specialized hardware (ML cores)
  • #129 Metrics system advanced features (labels, histograms, export)

DCC v2 — developer guidance and control

  • #93 Implement more sophisticated DCC heuristics, potentially ML-based decision model
  • #171 Implement engine adaptation modes (Learning, Stable, Manual)
  • #172 Implement developer hints and constraints system (PriorityVolume)

Core XR integration

  • #59 Integrate OpenXR SDK and bindings
  • #60 Implement XR instance / session / space management
  • #61 Integrate the graphics API with XR
  • #62 Implement stereo rendering path
  • #63 Implement head and controller tracking
  • #64 Integrate XR performance metrics
  • #65 Display a basic scene in VR with performance overlay

06 — Phase 6 — Native physics

Goal: Replace the third-party solver with a native Khora solver implementing cutting-edge physical simulation research.

Pillar 1 — unified simulation, MPM

  • #300 Unified simulation (MLS-MPM). Implement MLS-MPM: Moving Least Squares Material Point Method for unified simulation of snow, sand, and fluids. Target: pure algorithmic interaction between disparate materials.
  • #301 Sparse volume physics (NanoVDB). Integrate NanoVDB (OpenVDB) for GPU-accelerated sparse volume simulation (fire, smoke, large-scale explosions).

Pillar 2 — robust constraints and collision

  • #302 Incremental Potential Contact (IPC). Integrate Incremental Potential Contact (Li et al. 2020) to guarantee intersection-free and inversion-free simulation. Focus: eliminating clipping in soft-bodies and high-speed collisions.
  • #303 Stable constraints (XPBD and ADMM). Combine XPBD for stability with ADMM optimization for complex hard constraints and heterogeneous materials.

Pillar 3 — soft-body and Gaussian dynamics

  • #304 High-speed soft bodies (Projective Dynamics). Study Projective Dynamics for real-time muscle and flesh simulation with implicit stability.
  • #305 Differentiable and Gaussian physics. Explore PhysGaussian and DiffTaichi for physics-integrated Gaussian splatting and differentiable simulation.

Pillar 4 — intelligent characters and neural simulation

  • #306 Learning-based character motion (DeepMimic). Research DeepMimic for physics-based character animation using reinforcement learning.
  • #307 Graph network simulation. Analysis of Learning to Simulate Complex Physics with Graph Networks (DeepMind) for complex particle-based interactions.

Implementation and transition

  • #308 Implement Custom Khora-Solver v1 (rigid body + XPBD core)
  • #309 Transition PhysicsAgent and lanes to the native solver
  • #310 Performance match and exceed against the previous third-party backend

07 — Closed milestones (historical)

Core foundation and basic window

  • #1 Setup Project Structure and Cargo Workspace
  • #2 Implement Core Math Library (Vec3, Mat4, Quat) — design for DOD / potential AGDF
  • #3 Choose and Integrate a Windowing Library
  • #4 Implement Basic Input System
  • #5 Create Main Application Loop Structure
  • #6 Display Empty Window with Basic Stats (FPS, memory)
  • #7 Setup Basic Logging and Event System
  • #8 Define Project Coding Standards and Formatting
  • #18 Design Core Engine Interfaces and Message Passing (thinking about ISAs and DCC)
  • #19 Implement Foundational Performance Monitoring Hooks (CPU timers)
  • #20 Implement Basic Memory Allocation Tracking

Rendering primitives and ISA scaffolding

  • #31 Choose and Integrate a Graphics API Wrapper
  • #32 Design Rendering Interface as a potential ISA
  • #33 Implement Graphics Device Abstraction
  • #34 Implement Swapchain Management
  • #35 Implement Basic Shader System
  • #36 Implement Basic Buffer / Texture Management (track VRAM usage)
  • #37 Implement GPU Performance Monitoring Hooks (timestamps)
  • #110 Implement Robust Graphics Backend Selection (Vulkan / DX12 / GL fallback)
  • #118 Implement Basic Rendering Pipeline System
  • #121 Develop Custom Bitflags Macro for Internal Engine Use
  • #123 Implement Core Metrics System Backend v1 (in-memory)
  • #124 Integrate VRAM Tracking into Core Metrics System
  • #125 Integrate System RAM Tracking into Core Metrics System
  • #38 Render a Single Triangle / Quad with Performance Timings
  • #135 Advanced GPU Performance and Resize Heuristics
  • #140 Implement Basic Command Recording and Submission

Scene representation, assets, data focus

  • #39 Define Khora’s ECS Architecture
  • #154 Implement Core ECS Data Structures (CRPECS v1)
  • #155 Implement Basic Entity Lifecycle (CRPECS v1)
  • #156 Implement Native Queries (CRPECS v1)
  • #40 Implement Scene Hierarchy and Transform System (depends on #156)
  • #41 Design Asset System with VFS and Define Core Structs
  • #174 Implement VFS Packfile Builder and Runtime (depends on #41)
  • #42 Implement Texture Loading and Management (depends on #174)
  • #43 Implement Mesh Loading and Management (depends on #174)
  • #44 Render Loaded Static Model with Basic Materials (depends on #40, #42, #43)
  • #157 Implement Component Removal and Basic Garbage Collection (CRPECS v1)
  • #45 Implement Basic Scene Serialization
  • #99 Implement Basic Audio System (playback and management)

Rendering capabilities, physics, animation, strategies

  • #159 Implement SimpleUnlit RenderLane
  • #46 Implement Camera System and Uniforms
  • #47 Implement Material System
  • #48 Implement Basic Lighting Models (track shader complexity / perf)
  • #160 Implement Forward+ Lighting RenderLane
  • #49 Implement Depth Buffering
  • #50 Explore Alternative Rendering Paths and Strategies (Forward vs Deferred concept)
  • #158 Implement Transversal Queries (CRPECS v1)
  • #100 Implement Basic Physics System (integration and collision detection) (depends on #40)
  • #161 Define and Implement Core PhysicsLanes (broadphase, solver)

ISA v1 and basic adaptation

  • #75 Design Initial ISA Interface Contract v0.1
  • #76 Refactor one subsystem to partially implement ISA v0.1 (RenderAgent Base)
  • #78 Implement Multiple Strategies for one key ISA (RenderAgent: Unlit, LitForward, ForwardPlus, Auto)
  • #79 Refine ISA Interface Contract (Agent trait: negotiate, apply_budget, report_status)
  • #80 Implement DCC Heuristics Engine v1 (9 heuristics in khora-control)
  • #81 Implement DCC Command System to trigger ISA Strategy Switches (GornaArbitratorapply_budget flow)
  • #82 Demonstrate Automatic Renderer Strategy Switching (Auto mode + GORNA negotiation, 16 tests)
  • #224 Implement RenderLane Resource Ownership (pipelines, buffers, bind groups; proper on_shutdown)
  • #225 Implement Light Uniform Buffer System (UniformRingBuffer in khora-core, persistent GPU ring buffers for camera / lighting uniforms)

GORNA v1

  • #84 Design GORNA Protocol
  • #85 Implement Resource Budgeting in DCC
  • #86 Enhance ISAs to Estimate Resource Needs per Strategy (estimate_cost + VRAM-aware negotiate)
  • #88 Demonstrate Dynamic Resource Re-allocation under Load

DCC v1 — awareness

  • #71 Design DCC Architecture
  • #72 Implement DCC Core Service
  • #73 Integrate Performance / Resource Metrics Collection into DCC
  • #74 Implement Game State Monitoring Hook into DCC
  • #128 DCC v1 Integration with Core Metrics System (MetricStore, RingBuffer, GpuReport ingestion)
  • #163 Make CRPECS Garbage Collector an ISA
  • #116 Evaluate Abstraction for Windowing / Platform System

This roadmap reflects the current plan. Items move through Open → In Progress → Closed. The set of phases is stable; the contents within each phase grow as work continues.

Decisions

Choices we made, and what we said no to. The global ledger.

  • Document — Khora Decisions v1.0
  • Status — Living
  • Date — May 2026

Contents

  1. Architecture
  2. Subsystems
  3. SDK and editor
  4. Process

01 — Architecture

We said yes to

  • A self-optimizing core. GORNA, DCC, and per-tick negotiation are non-negotiable. Without them, Khora is just another engine.
  • Cold path / hot path separation. The frame loop is never blocked by analysis. Budgets flow one way through a channel.
  • Agent per LaneKind. Render, Shadow, Physics, UI, Audio. One subsystem, one negotiation surface.
  • Trait-defined contracts. Every seam in the engine is a Rust trait. No string-keyed APIs in the hot path.
  • Splitting khora-io from khora-data. Asset loading and serialization are I/O concerns; ECS storage is not.
  • Backends are swappable. Every khora-infra backend implements a khora-core trait. wgpu, Rapier3D, CPAL, Taffy are current defaults, not architectural commitments.
  • Trait coherence in khora-core. Every public surface seam is a trait. No backend types leak into agents or the SDK.
  • Two threads, one channel. The DCC owns its thread; the Scheduler owns the main thread; they touch only through BudgetChannel.
  • Last-wins budget delivery. The Scheduler doesn’t replay a queue; it reads the latest snapshot.
  • Phase-based ordering. Agents declare phases, not absolute frame slots. The Scheduler resolves the dependency graph each frame.
  • Adaptation targets the HOW, never the WHAT. Automatic adaptation changes representation (strategy, quality, memory layout); game semantics (which components an entity has, simulation behaviour) stay developer-authored. Consequently AGDF (Adaptive Game Data Flows) = adaptive data layout, not gameplay restructuring.

We said no to

  • Static budgets baked at compile time. A MAX_LIGHTS constant has no place in an engine that adapts.
  • Synchronous DCC calls from agents. Agents must never wait on the DCC.
  • Adding more agents than LaneKind variants. If a subsystem has no strategies to negotiate, it is a service.
  • Mega-crates. Every crate has a single, scannable responsibility.
  • Sibling dependencies between agents and control. Agents talk down to lanes and across to a unidirectional channel — never up to control.
  • Dynamic plugin discovery via reflection. Plugins register through inventory::submit! and explicit Rust APIs.
  • A separate “physics tick” loop. PhysicsAgent owns its accumulator and runs in Transform like everything else.

02 — Subsystems

ECS (CRPECS)

  • Yes: archetype-based storage; bitset-guided iteration; generations on EntityId; #[derive(Component)] generates the serializable mirror.
  • No: sparse-set ECS; globally synchronous component change; reflection-driven serialization.

AGDF — adaptive layout

  • Yes: surgical, opt-in, registration-time field-SoA / AoSoA + explicit SIMD (wide::f32x8) on designated hot compute components — the measured ~4× lever on transform/quaternion/particle/skinning loops. Layout abstracted LLAMA-style (access decoupled from physical mapping; AoSoA tile width L a compile-time power-of-two). The access counters + CostModel + the deterministic Ucb1 bandit run as a read-only layout advisor (recommends opt-ins; the DCC observes, never repacks). Citation anchors: OREO (Metrical Task System α-gate), Chilimbi / Pettis–Hansen (hot/cold split), MAPE-K (the DCC loop).
  • No: std::simd (nightly — Khora is stable Rust); raw glam in batched kernels (glam is 128-bit per-vector — kept for scalar math only); making all of CRPECS runtime layout-polymorphic now (only ~15 % on lean components for a hot-path rewrite — the field-SoA/SIMD win is on compute-heavy loops and stays opt-in); a runtime physical repack driven by the bandit (deferred until the advisor proves a component needs it, then gated by OREO’s α-counter + a V8-style speculate-guard-deopt).

Agents

  • Yes: agents implement only Agent + Default (no extra methods); one agent per LaneKind; Hard / Soft / Parallel dependency model; last-wins on BudgetChannel.
  • No: agent-managed concurrency (DCC handles cold-path concurrency); agents reading from each other directly (cross-agent data flows through FrameContext slots).

Lanes

  • Yes: three-phase lifecycle (prepare / execute / cleanup); type-erased LaneContext; estimate_cost returning f32.
  • No: lanes referencing each other directly; lane-owned threads; inlined shader source as Rust strings.

GORNA

  • Yes: simple, narrow request shape; heuristics as independent functions; per-tick re-negotiation (~50 ms); death spiral as a first-class concept.
  • No: synchronous negotiation in the hot path; multi-resource vector budgets; GORNA forcing phases.

Rendering

  • Yes: strategy-based rendering (Unlit / LitForward / Forward+); shadow as a separate agent; WGSL files on disk; GPU IDs over raw handles; one acquire, one present per frame.
  • No: a render graph (deferred — current lane order is small enough); inline shader source; backend choice exposed in lane code.

Physics

  • Yes: PhysicsProvider trait as the single contract; fixed timestep with accumulator; CCD as opt-in per body; strategy includes Disabled.
  • No: calling Rapier from agents or game code; a separate physics tick loop; variable timestep.

Audio

  • Yes: single trait surface (AudioDevice); source budget as the primary GORNA dimension; listener tied to ECS; 2D and 3D sources distinguished by flag.
  • No: calling CPAL directly from anywhere except the backend folder; a “global music” channel; DSP effects in v1.

Assets and VFS

  • Yes: UUID-based identity; loose files in dev, pack in release; asset loaders as lanes; reference-counted handles.
  • No: asset path strings as identity; an “asset agent”; asset hot-reload as a v1 feature.

UI

  • Yes: UI components in the same ECS; LayoutSystem trait; two-lane split (compute + render); hierarchy via Parent / Children.
  • No: an immediate-mode UI inside the engine; a separate UI rendering backend; Taffy types in components.

Serialization

  • Yes: three strategies, one file format; #[derive(Component)] generates the mirror; play mode uses Archetype; editor uses Definition.
  • No: reflection-based serialization; a “serialization agent”; preserving physics state across play mode (in v1).

Telemetry

  • Yes: telemetry as a first-class service; two collection styles (poll + push); SaaTrackingAllocator as the default; string-keyed metric registry.
  • No: a separate “telemetry agent”; hot-path string lookups for metrics; an external profiler-only dependency.

03 — SDK and editor

SDK

  • Yes: a small public surface (EngineCore, GameWorld, EngineApp / AgentProvider / PhaseProvider traits, run_winit, Vessel + spawn helpers, WindowConfig); curated prelude; safe ECS facade; explicit bootstrap closure for renderer registration.
  • No: hidden global setup; exposing internals (Scheduler internals, GORNA arbitration) through the SDK; a single prelude::* that imports everything.

Editor

  • Yes: editor as a separate binary; mode-first layouts; play mode through scene snapshot; editor reaches into khora-agents and khora-io directly (pragmatic shortcut for performance).
  • No: free-form panel docking; telemetry charts in the main UI (they belong in the Control Plane mode); editor chrome during play mode.

Extension model

  • Yes: extension through traits, not callbacks; the agent rule applies to custom agents too; custom strategies as new lanes; backends as trait implementations in khora-infra.
  • No: plugin DLLs at v1; a “lite” Agent trait for simple cases; custom phases as a stable v1 feature.

04 — Process

We said yes to

  • Tests are the contract. ~470 workspace tests. Adding a feature without a test is a code smell.
  • CHANGELOG is auto-generated. No human edits.
  • CI runs cargo xtask all. fmt + clippy + test + doc. If it passes there, it passes locally.
  • Documentation ships with the engine. When the engine changes, the book changes in the same commit.
  • Decisions logged in writing. This document is the long-form artifact.

We said no to

  • Pushing without explicit permission. AI agents and developers alike require explicit user permission to push to dev, main, or any remote.
  • Skipping git hooks. --no-verify is forbidden unless explicitly requested by the user.
  • Untyped configuration. No magic strings, no untyped JSON. Configuration is Rust types.
  • A “stable” version of dev. dev is the development branch. Stable releases live on main.

See Open questions for the things we have not yet decided.

Open questions

What this engine does not yet answer, and where the next iteration should go.

  • Document — Khora Open Questions v1.0
  • Status — Living
  • Date — May 2026

Contents

  1. Adaptive core
  2. ECS and data
  3. Agents and lanes
  4. Rendering
  5. Physics
  6. Audio
  7. Assets
  8. UI
  9. Serialization
  10. Telemetry
  11. SDK and editor
  12. Extension model

01 — Adaptive core

  1. Adaptation modes. Learning, Manual (pinned), Stable (no opportunistic upgrade), and Bounded { min, max } are implemented (AdaptationMode, settable per agent via DccService::set_adaptation_mode). Replay is now implemented: DecisionTrace records GORNA’s per-tick decisions (DccService::start/stop_decision_recording) and DccService::replay_decisions re-issues them deterministically (bit-for-bit — QA / lockstep / bug repro), bypassing live fit and per-agent mode. Still open: Calibration (deliberately explore strategy arms to seed the cost model, then freeze) and Hinted (a game→engine semantic-hint channel, e.g. “cutscene”/“combat”).
  2. Constraints API. “In this volume, physics > graphics” is a stated capability without a concrete API. PriorityVolume is in the roadmap.
  3. Cross-agent coordination. Today agents declare hard dependencies on each other (RenderAgent → ShadowAgent). When the dependency graph grows, do we need a richer scheduling model than per-frame topological sort?
  4. Variable cold-path frequency. ~20 Hz is a default. On low-power targets we may want 5–10 Hz. The trigger model for changing this at runtime is open.
  5. ML-augmented heuristics. A future heuristic could be a small ML model trained on telemetry. The deployment story (model storage, update cadence) is undecided.
  6. Predictive cost model. A CostModel fits measured (n, time) samples to a complexity class (c·f(n)) so the DCC can forecast a budget breach (“at this growth rate, the frame budget breaks at ~N entities”) instead of only reacting. The loop is now closed: the scheduler publishes live per-agent samples via TelemetryEvent::AgentCost, the forecast tightens the target before a breach, and the measured costs calibrate agents’ quoted estimates inside GornaArbitrator::arbitrate (rescaled so the current-strategy option equals the measurement, factor clamped to [0.25, 4.0]). Still open: the workload size n is the coarse global entity count — per-domain workload refinement is pending.
  7. Frame-time PID extensions. The global budget multiplier is now closed-loop: a PID (khora_core::control::pid) drives the measured frame time onto the heuristic-suggested target (thermal/battery/phase shape the target; a hard ceiling caps Critical states). The controller already does derivative-on-measurement + low-pass filter, back-calculation anti-windup, setpoint weighting, and output clamping. Deferred, identified by the design survey: gain scheduling (distinct gains per regime, e.g. Critical), a deadband / hysteresis to suppress limit cycles on the discrete strategy ladder, CPU/GPU-load feedforward, a variance-aware term (widen the deadband as stutter rises), and auto-tuning (Åström–Hägglund relay) or ML-adaptive gains. Default gains are conservative and hand-tuned; per-target tuning lives in DccConfig::frame_pid.

02 — ECS and data

  1. Parallel query execution. Today queries run on the calling thread. The borrow-checker’s compile-time exclusivity makes parallelization safe; the policy and API are not yet decided.

  2. Adaptive layout (AGDF — Adaptive Game Data Flows). AGDF is the adaptation of data layout — laying hot component columns out for the access pattern and the hardware (field-split SoA, AoSoA tiling, hot/cold split) instead of one fixed representation. After a deeper survey of the prior art (academic + shipping engines + cross-domain systems), it resolves into three layers:

    • Runtime lever (the build target now): explicit-SIMD batch kernels over field-SoA data. The shipped kernel khora_core::math::simd::normalize_quat_batch measures ~4.25× over its scalar twin on this machine; auto-vectorisation alone caps near ~2.9× because the FP reduction/normalize/sqrt path is non-associative and the compiler may not reorder it. Measured caveat that shapes the design: the win is a whole-pipeline property — it holds only while the data stays field-SoA resident. The same wide::f32x8 math in compose_trs_to_mat4 (SoA in, AoS Mat4 out) is scatter-bound and ~0.9× — a net loss — because the per-lane transpose-back dominates the cheap quaternion expansion. So the kernels are adopted only by loops that stay SoA end-to-end; a one-off transpose for a single op never pays. This mirrors the whole industry — Unity DOTS, Unreal Mass, Bevy, flecs are all SoA-across-entities, AoS-within-component, and Unity explicitly pushes field-SoA (float4) batching into the hot loop by hand; the unexploited layer everyone leaves on the table is the field level, which is exactly where this operates. (Engine evidence: crates/khora-data/examples/layout_bench.rs.)

    • Advisory layer: a read-only layout advisor. The access instrumentation, the CostModel (c·f(n)), and the decision learner (a deterministic Ucb1 bandit, arms = candidate layouts) run as glass-box introspection that recommends which components to opt in and which tiling — never as a runtime repacker. This keeps the DCC’s relationship to Data observation-only. Open refinements, all still deterministic: a sliding-window UCB (the reward is non-stationary across a session), a cost-into-reward term (reward = benefit − c·migration_cost, the anti-thrash form from contextual DBA bandits), and discounted/evaporating access counters.

    • Deferred: persistent SoA storage and true online repack. The resident-vs-scatter finding above makes the case for persistent field-SoA storage of opted-in components concrete: a kernel can only stay resident (and keep the 4×) if the column it reads and the column it writes are both field-SoA, rather than transposing in and out each frame. Making CRPECS storage and WorldQuery::fetch layout-polymorphic is the hard, hot-path blocker — today a column is a type-erased Vec<T> the fetch downcasts to, and components like Transform are queried by &T pervasively, so a persistent field-SoA column forces a by-value query-Item ripple. It is deferred as its own controlled change; a transient SoA batch (TrsBatchSoa) covers the kernels in the meantime. No shipping engine does online layout switching, so this is genuinely novel and has no playbook — it is gated behind a conservative trigger and only justified once the advisor proves a component needs it. The design anchors are recorded: OREO’s α-counter (online reorg as a Metrical Task System, provable 2(1+log|layouts|) competitive ratio + built-in hysteresis) for the cost/benefit gate, and the V8/HotSpot speculate→cheap-guard→deopt pattern for safety (a wrong layout guess costs speed, never correctness). Cross-domain bets to test here: a reuse-distance cost model that estimates a layout’s miss-rate without repacking, SimPoint-style phase detection as the re-evaluation trigger, and an AutoFDO/BOLT-style shipped layout profile to warm-start the advisor.

    Prior art drawn on: profile-guided hot/cold splitting (Chilimbi PLDI’99; Pettis–Hansen PLDI’90), AoSoA layout abstraction (LLAMA, Cabana), online reorganization with worst-case bounds (OREO, ICDE’24), just-in-time data structures (De Wael & Marr, 2015), and the MAPE-K autonomic loop (Kephart & Chess, 2003) — which the DCC is an instance of. Distance-based gameplay gating (detaching physics) is not AGDF — it is opt-in, developer-authored policy.

  3. Page-size tuning. Pages start at 8 entries and grow geometrically. Whether 64 or 256 would be better at scale is unmeasured.

  4. khora-plugins API. The plugin model is real but its public API is still settling alongside editor needs.

  5. Flow view-cache signals. RenderFlow/ShadowFlow/AudioFlow now republish their previous View when their Flow::cache_key — per-domain change epochs (World::domain_epoch) plus, for the render-side flows, a bit-level fingerprint of the editor viewport override — is unchanged (see AGDF §07). UiFlow and PhysicsFlow stay uncached: surface size and hot-reloadable fonts have no change signal a key could fold in, and physics mutates its domains every simulated frame. Folding asset-version signals (hot reload) and a surface-size signal into cache keys is open.

03 — Agents and lanes

  1. asset_lane and ecs_lane should not be lanes. A Lane is a strategy variant a GORNA-negotiating agent picks per frame. Asset decoders and ECS compaction have no strategies — they are on-demand or fixed maintenance work. The current implementations as lanes are residual and should be lifted into services (AssetService / DecoderRegistry, EcsMaintenance). See Roadmap Phase 2 — Architecture refactoring.
  2. Plugin agents. Agents are added at compile time via registration. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
  3. Multi-LaneKind agents. Forbidden by current rule. If a future subsystem genuinely needs to coordinate two lane kinds (compute + render in the same pipeline), the rule may need a carve-out.
  4. Async agent work. Some lanes (asset streaming) want async I/O. The contract for an agent that yields control mid-frame is open.
  5. Lane-level parallelism. Today lanes run sequentially within an agent’s execute. For some agents (asset decoders) parallel lane execution is obvious; the contract is undefined.
  6. Shader hot-reload. Files-on-disk make this trivial in principle. The wgpu pipeline cache invalidation policy is not yet decided.
  7. Asynchronous lanes. Asset streaming wants async fn execute. The current sync-only contract is a known constraint.

04 — Rendering

  1. Forward+ tile size and light limits. Tunable in forward_plus.wgsl. Defaults work; the optimal is hardware-dependent and deserves a heuristic.
  2. HDR pipeline. Currently SDR. HDR target format support exists in wgpu 28.0; the tone-mapping pass and editor color-correctness pass are not yet implemented.
  3. Compute-driven culling. A compute pass for view-frustum culling would let us skip the per-frame extraction cost in LitForwardLane::prepare. Designed, not built.
  4. Render graph. Considered, deferred. Today the lane order is small enough that explicit dependency declaration is clearer than a graph. We will revisit when the lane count crosses ~10 per frame.

05 — Physics

  1. Per-region simulation rate. “Use Standard near the player, Simplified everywhere else” is a gameplay-relevance policy — not AGDF (which is layout only). It must be opt-in and developer-authored; the engine provides the detach/reattach mechanism but never applies it by default. The opt-in API is not built.
  2. Physics state in serialization. SerializationGoal::FastestLoad does not preserve velocities or contacts. Whether to add a “snapshot with physics” goal is open.
  3. Native solver migration. Roadmap Phase 6. The trait surface is stable enough; the implementation is a multi-quarter effort.

06 — Audio

  1. HRTF (head-related transfer function) for headphones. Better spatialization for headphone users. Library candidates exist; integration is not designed.
  2. Listener selection. Today, first-registered wins. Multiple listeners (split-screen, recording) need an explicit selection model.
  3. Convolution reverb. Real-time convolution is feasible on modern hardware; the API for impulse responses is undecided.

07 — Assets

  1. Streaming. Today assets load entirely into memory. Streaming meshes (Nanite-style) and textures (sparse residency) are roadmap items.
  2. Async decoder execution. The decoder runs on the calling thread. Large assets should use a thread pool — the contract is undecided.
  3. Pack builder. A working .pack builder tool is needed to move releases off FileLoader. Designed; in development.
  4. Asset hot-reload. The VFS layer can detect changes; the policy for invalidating in-flight handles is undecided.

08 — UI

  1. In-game UI. UiAgent is currently editor-only. The path to a play-mode HUD is mostly a matter of changing allowed_modes, plus deciding the input model.
  2. Animations on UI. No tween / spring system today. Probably belongs as a separate lane that mutates UI components over time.
  3. Accessibility. Screen reader hooks, contrast modes. Not designed yet.

09 — Serialization

  1. DeltaSerialization. Roadmap item. Save games and undo/redo both want incremental snapshots. The trait surface is sketched, not implemented.
  2. Physics snapshot goal. Should there be a SerializationGoal::IncludePhysicsState that captures velocities, sleep state, contacts?
  3. Versioned components. Today, scene format version is tracked in the header. Component schema versions are not. A scene saved against an older component definition may fail to load.

10 — Telemetry

  1. Histogram exporter. Histograms collect, but the export format (Prometheus, OpenMetrics) is not yet committed.
  2. Per-frame trace records. Tracy integration would be valuable. The telemetry pipeline is compatible; the hookup is undecided.
  3. Telemetry retention. The DCC reads the latest value. Long-term retention (for replay-after-incident analysis) needs a storage policy.

11 — SDK and editor

  1. khora-editor dependencies. The editor depends directly on khora-agents and khora-io for performance. Justified but a violation of “SDK is the public API.” Worth revisiting.
  2. Workspace size. Eleven crates is comfortable today. At twenty it might not be. The split rule is “per scannable responsibility,” but we don’t yet have a deterministic threshold.
  3. Service registration API. Custom services are registered inside the bootstrap closure passed to run_winit. The pattern works but isn’t formalized — a stable, discoverable surface (e.g., a builder over the registry) is overdue.
  4. Multi-window editor. Popping the viewport to a second monitor — does the popped window keep its own Spine?
  5. Plugin UI surface. Third-party plugins need a place to live in the Inspector. The contract is undefined.
  6. Collaboration. Real-time multi-user editing. No roadmap, but the architecture does not preclude it.

12 — Extension model

  1. Agent registration API. EngineConfig::register_agent is illustrative, not stable. Settling alongside khora-plugins.
  2. Plugin DLL ABI. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
  3. Custom phases. ExecutionPhase::custom(id) exists but the surrounding tooling (editor visibility, telemetry naming) is incomplete.

This list is honest. If a question is here, it has not been answered. If it is answered, it moves to Decisions.

Khora Editor — Design Document

An editor that thinks.

A hi-fidelity design system for the Khora Engine editor — a 2D/3D Rust engine built on a self-optimizing Symbiotic Adaptive Architecture. This document captures the visual language, architectural choices, and panel-by-panel rationale behind the design.

  • Document — Khora Editor Design v1.0
  • Status — Hi-fi mockup
  • Date — May 2026

Contents

  1. Design principles
  2. Brand & identity
  3. Color system
  4. Typography
  5. Layout & spine
  6. Panels
  7. Viewport
  8. Command palette
  9. Control Plane
  10. Interactions
  11. Decisions log
  12. Open questions

01 — Design principles

The five rules everything else descends from.

1. The work is the hero

Chrome retreats. Toolbars are thin, panels are quiet, the viewport breathes. The user’s scene, code, or canvas is always the loudest thing on screen.

2. The engine has a mind — show it

Khora is built on a self-optimizing architecture (GORNA). The editor must surface that intelligence — telemetry as ambient signal, not a separate dashboard. The user should feel the engine thinking.

3. Density without density anxiety

Engine editors fail by hiding everything in nested menus or by drowning the user in chips. We use a Spine (mode rail) and Pills (compact groups) so dense information stays scannable.

4. Mode-first, not panel-first

Unity and Unreal let you arrange panels freely — and most users keep the default forever. We commit to opinionated layouts per Mode. Two workspaces ship today — Scene (3D dock) and Control Plane (DCC / agents) — with further authoring workspaces (2D canvas, node graph, animation, shader graph) added as they are built. Power users can still customize, but the default is excellent.

5. Calm color, loud signal

The base palette is near-monochrome (deep blue-black + warm silver). Color is reserved for state — gold for the active selection, green for healthy telemetry, amber for warnings. When something is colorful, it matters.


02 — Brand & identity

The mark

A faceted diamond of four kite shapes — the four cardinal architectural pillars of Khora (Renderer · Agents · Assets · Editor) folded into a single shape. Read as a gem (precision, value), a compass rose (orientation), or a pulse diamond (the engine’s heartbeat).

Where the mark lives

  • Title bar — small, in the brand pill, before KhoraEngine
  • Spine top — slightly larger with a subtle glow, anchoring the mode rail
  • Empty viewport — giant watermark at 5% opacity behind the grid
  • Command palette — replaces the search-icon glyph
  • Status bar — small leading glyph, slow pulse on async work

Mood

Not industrial. Not playful. Instrumental — like a workshop where every tool has earned its hook on the wall.

deeppreciseawakehonest
SurfaceTypeMotionVoice

03 — Color system

The palette is designed in OKLCH for perceptual uniformity. Hues hold their identity across light/dark, and our gold stays gold across alpha tints.

Foundation — the deep field

The shell sits in a narrow band of blue-black, never pure black. Pure black creates harsh contrast that fights with code syntax colors and 3D viewport content.

RoleOKLCHUse
bg-0 Voidoklch(0.14 0.020 265)App background — rarely seen directly
bg-1 Shelloklch(0.16 0.022 265)Panel surfaces, title bar
bg-2 Raisedoklch(0.18 0.022 265)Cards, hover states
bg-3 Elevatedoklch(0.21 0.024 265)Modals, command palette

Foreground — warm silver

Text and icons sit in a warm, slightly desaturated silver. Cooler grays read as clinical; warmer grays as cheap. We split the difference and bias warm.

Accents — three signals only

  • Gold oklch(0.78 0.13 75) — selection, active mode, focus, brand
  • Green oklch(0.65 0.18 145) — healthy telemetry, success
  • Amber oklch(0.75 0.16 65) — warning, GORNA suggestion ready

Color is a load-bearing element. Gold means now. Silver means noticed. Everything else is structure.


04 — Typography

Three families, each with one job. No more.

The trio

  • Geist — Sans for UI, labels, body. Designed for software interfaces; neutral but not generic.
  • Geist Mono — Code, telemetry, paths, numbers. Pairs perfectly with Geist Sans.
  • Fraunces — Display only. Editorial serif with optical sizing — used for hero titles and section emphasis. Adds gravity without ceremony.

The scale

RoleSampleSpec
HeroAn editor that thinks.Fraunces 56, opsz 144, italic
H2Color systemFraunces 32, regular
H3Section titleGeist 18, weight 600
BodyDefault reading textGeist 14, line-height 1.6
LabelEXECUTION TIMINGGeist Mono 11, uppercase, letter-spacing 0.08em
Enginekhora-agents · 7.04 / 16.67msGeist Mono 12

Voice & microcopy

The editor talks back. How it talks matters. We aim for plainspoken with technical precision — never marketing, never apologetic, never cute.

Yes

Build failed — 3 errors in renderer.rs

17 entities skipped. View log →

GORNA recommends MeshletPipeline. Apply?

No

Oops! Something went wrong 😔

Some entities couldn’t be processed.

✨ Smart suggestion: try MeshletPipeline!

Rules of voice

  • Numbers before adjectives. “7.04 ms”, not “fast”.
  • The engine has a name. When GORNA suggests something, attribute it. The user is collaborating with a system, not receiving advice from a mascot.
  • No exclamation marks. The work is serious. Praise feels condescending; warnings shout louder when written calmly.
  • Sentence case for everything. Title Case is for documents and brand names; UI strings are sentences.
  • Verbs in the user’s voice. Buttons say Apply, not Applying. The user is the agent.
  • No emoji in product UI. The diamond is the only mark.

05 — Layout & spine

Most engine editors waste 220 px on a left sidebar that simply switches workspaces. Khora replaces that with a 48 px Spine — a vertical mode rail welded to the left edge — and gives the saved real estate back to the work.

Anatomy

  • Title bar — 44 px. Brand pill (logo + project), centered window controls, account.
  • Spine — 48 px wide, full height. Mode icon buttons (Scene, Control Plane today); active mode lit gold.
  • Workbench — everything to the right of the Spine. Layout determined by current Mode.
  • Status bar — 28 px. Engine state, FPS, build status, GORNA pulse.

Modes

  1. Scene — 3D editing (default)
  2. Canvas — 2D layout & UI
  3. Graph — Node-based logic / shader
  4. Animation — Timeline & curves
  5. Shader — Code editor with live preview
  6. Control Plane — Engine telemetry & GORNA stream

A panel that demands attention every second isn’t a panel — it’s an alarm. We design for ambient awareness, not constant interruption.


06 — Panels

Each Mode has an opinionated panel layout. Below: Scene mode.

Hierarchy (left, 280 px)

Tree of entities. Indented with thin guide lines — never with chevron-only disclosure. Icons match entity type (mesh, light, camera, group). Selection = gold left-edge bar + raised background.

Inspector (right, 320 px)

Stacked component cards. Each card is collapsible, with a 6 px gold accent on hover. Numeric fields are draggable scrubbers (no spinners) — drag horizontally to change value, modifier keys for precision.

Viewport (center, fluid)

The 3D scene. Floating gizmo overlay top-left (move/rotate/scale). Coordinate readout bottom-right in mono. View modes (wireframe, lit, normals) as a floating segmented pill, top-right.

Bottom dock (180 px, retractable)

Tabbed: Assets (grid of project files) · Console (filtered log) · GORNA (engine suggestions). Drag the divider up to expand; collapses to 28 px tab strip.


07 — Viewport

The viewport is the most important rectangle in the editor. Everything else exists to serve it.

Empty state

A subtle 5% diamond watermark, centered. Above it, a single line of microcopy: “Drag assets here, or press ⌘N for a new entity.” Nothing else.

Active state

  • Grid — 1m primary lines at 10% opacity; 0.1m secondary at 4%. Origin marked with thin gold cross.
  • Gizmo — Three-axis (X red, Y green, Z blue) with a unified center sphere for screen-space drag. Always above the grid, below selection outlines.
  • Selection outline — 2 px gold inner stroke, 1 px void outer stroke (so it reads on any background).
  • Coordinate HUD — Bottom-right, mono, 4 decimals: x: 12.3450 y: 0.0000 z: -4.2010

Floating controls

View pill (top-right) — segmented: Lit · Wire · Normals · UV. Camera pill (top-left) — Persp · Top · Front · Side. Both use the standard Pill component (12 px height, 999 radius).

Selection scrubber

Drag any numeric field horizontally to scrub. No arrows. No spinners. The cursor becomes a gold double-arrow during drag; ticks snap to integer units (toggle off with Alt).


08 — Command palette

Press ⌘K. The editor’s most important keyboard shortcut.

Layout

A centered modal, ~640 px wide, 60% viewport height max. Top: search field with diamond glyph leading. Below: results in a flat ranked list, OR a quadrant view if the query is empty.

Quadrant view (empty query)

Four boxes, equal weight: Recent · Suggested (from GORNA) · Modes · Tools. Each shows three to five items. Hover lights the box gold; arrow keys move between quadrants then between items.

Result item

  • Diamond glyph (action type) — 12 px, fg-3
  • Label — Geist 14
  • Path or category — Geist Mono 11, fg-3
  • Shortcut — right-aligned, mono 11, kbd-styled

The currently-selected result has a gold left bar (3 px) and bg-3 background. Enter activates; Esc closes.


09 — Control Plane

The DCC workspace. Where the engine’s mind becomes visible.

Why it exists

Most engines hide their internals behind a profiler you launch separately. Khora’s whole pitch is the self-optimizing architecture — so the engine’s internal state should be a first-class workspace, not a popup.

Anatomy

  • Lane Timeline (top, 60% height) — Horizontal lanes per subsystem (renderer, agents, physics, audio, IO). Each lane shows execution windows as colored bands. Hover any band for a tooltip with timing breakdown.
  • GORNA Stream (bottom-left) — A live feed of the optimizer’s reasoning. Each entry: timestamp · subsystem · suggestion · accept/reject affordance.
  • Meters Wall (bottom-right) — Grid of small gauges: frame time, GPU %, memory, agent budget, assets pending. All meters share the same visual grammar (silver track, gold fill, mono readout below).

The Lane Timeline is the answer to “what is the engine doing right now?” — and the GORNA Stream is the answer to “why?”

Meter anatomy

  • Background — bg-2 with 1 px line-soft border
  • Track — silver at 12% opacity
  • Fill — gold (or green if “good”, amber if “warn”)
  • Readout — mono 14 below, with unit in fg-3 to its right
  • Hover — opacity 1, gold ring
  • Drag — fill gold, snapping ticks every 1 unit (toggleable)

10 — Interactions

How the editor moves.

Motion principles

  • Fast first frame. Anything that responds to user input must move within 16 ms. Tweens that delay feedback are forbidden.
  • 150 ms is the default. Most state changes (panel collapse, tab switch, hover) finish in 150 ms with cubic-bezier(0.2, 0, 0, 1).
  • 300 ms for spatial. Modes that change layout (entering Control Plane, opening the palette) take 300 ms — long enough to read the spatial change, short enough to not feel slow.
  • No bounces. No springs. Ever. This isn’t a consumer app.

Drag

  • Numeric fields scrub on horizontal drag — gold cursor, mono readout follows the cursor.
  • Panel dividers resize on drag — 4 px hit area, gold highlight on hover.
  • Hierarchy entries reorder on vertical drag — ghosted item follows cursor, drop zones show as 2 px gold lines between siblings.

Hover

Hover is information, not decoration. Hover any meter for breakdown; hover any timeline band for a tooltip; hover any pill for its full label. Hover delay: 200 ms (long enough to ignore mouse-overs, short enough to feel instant when intended).

Keyboard

Every action has a keyboard path. The command palette is the master key. Mode switching is ⌘1 through ⌘6. Selection moves with arrow keys in any tree or list. Esc always retreats one level (close modal → deselect → exit mode).


11 — Decisions log

Choices we made, and what we said no to.

We said yes to

  • A 48 px Spine instead of a 220 px sidebar. Saves real estate; mode-switching is a one-tap operation, not a navigation tree.
  • Mode-first layouts. Opinionated defaults beat infinite customization for 95% of users.
  • OKLCH color throughout. Better perceptual uniformity, future-proof, browsers ship it.
  • Fraunces for display only. Adds editorial gravity to a tool category dominated by all-sans interfaces.
  • The Control Plane as a Mode. Engine internals are not a popup or a tab — they’re a workspace.

We said no to

  • Free-form panel docking. Powerful but exhausting. We pick layouts users won’t want to change.
  • A ribbon toolbar. Wastes vertical space; teaches users nothing about keyboard paths.
  • Skeuomorphic 3D widgets. No beveled gizmos, no glossy buttons. Flat, calibrated, instrumental.
  • A welcome screen with templates. Templates live in the command palette. The first thing you see is a viewport.
  • Telemetry charts in the main UI. Telemetry belongs in the Control Plane Mode. The Scene mode shows you the scene.

12 — Open questions

What this design does not yet answer, and where the next iteration should go.

  1. Multi-window. How does Spine + Modes work when the user pops the viewport to a second monitor? Likely the popped window keeps its own Spine, but with only one mode lit.
  2. Collaboration cursors. If two designers edit the same scene, where do their selections live in the gold-only color system? Likely tinted gold variants, but needs exploration.
  3. Plugin UI. Third-party plugins need a place to live. Probably the Inspector as additional component cards, but the contract isn’t defined.
  4. Mobile viewer. Out of scope for v1, but the Spine pattern probably translates well to a tablet-sized viewer.
  5. Light theme. Not planned. The deep field is load-bearing for the brand — a light variant would be a different product.

End of document.