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.

A documentation set for the Khora Engine — an experimental Rust game engine built on a self-optimizing Symbiotic Adaptive Architecture. This book captures the philosophy, the architecture, the subsystems, and the SDK, with the same instrumental voice as the editor it ships with.

  • Document — Khora Engine Documentation v1.0
  • Status — Living document
  • Date — May 2026

Contents

  1. What Khora is
  2. The problem with rigid engines
  3. The Khora answer
  4. Who this book is for
  5. How to read this book
  6. Status

01 — What Khora is

Khora is an experimental real-time engine, written in Rust on edition 2024, organized as a Cargo workspace of eleven crates. It renders through wgpu 28.0 (Vulkan / Metal / DX12), simulates physics through Rapier3D, mixes audio through CPAL, lays out UI through Taffy, and stores entities through CRPECS — a custom archetype-based ECS.

What sets it apart is not the parts list. It is what those parts do together. Every major subsystem in Khora is an agent with a sense of cost, a sense of options, and a willingness to negotiate. A central observer — the Dynamic Context Core — watches the engine’s behavior in real time, tracks thermal headroom, frame-time stutter, battery, GPU pressure, and trades resource budgets with each agent through a protocol called GORNA. The agents adapt; the work continues.

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

02 — The problem with rigid engines

Modern game engines are rigid. They impose static pipelines, force developers into manual per-platform tuning, and adapt poorly to hardware diversity — from high-end PCs to mobile and VR.

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

The result is a class of engines that perform well in their default configuration on one target platform, and progressively worse everywhere else.

03 — The Khora answer

Khora replaces the rigid orchestrator with a council of intelligent, collaborating agents.

  • Automated self-optimization. The engine detects bottlenecks and reallocates resources autonomously.
  • Strategic flexibility. Rendering switches techniques based on system load, with no developer intervention. Physics shrinks its tick rate when the GPU is starving. Audio sheds voices when memory tightens.
  • Goal-oriented decisions. Every adaptation is driven by a high-level goal — maintain 90 fps in VR, conserve battery on mobile, prioritize physics in this volume.

The architecture has a name: Symbiotic Adaptive Architecture (SAA). 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.

The full philosophy lives in Principles. The crate-by-crate map of where SAA becomes CLAD lives in Architecture.

04 — Who this book is for

Two audiences, equally served:

AudienceWhat you get
Game developersA clean SDK, a GameWorld facade over the ECS, a cargo run -p sandbox you can copy from. The engine handles the performance problem; you focus on the creative one. Start at SDK quickstart.
Engine contributorsA complete map of CLAD, the trait surface that holds it together, the rationale behind every layer. Start at Architecture, then read the per-subsystem chapters.

Most chapters in the Subsystems section are split into a For game developers part and a For engine contributors part. The split is explicit. You can skip the half that is not yours.

05 — How to read this book

If you have never seen Khora before, read in order — at least up to chapter 04. The first five chapters establish vocabulary you will need everywhere else.

If you are evaluating Khora for a game, jump to:

If you are extending Khora — writing a custom agent, lane, or backend — read:

If you are interested in the editor, read:

06 — Status

Khora is experimental. The architecture is stable enough to support a sandbox application, an editor, a play-mode loop, and ~470 workspace tests. The SDK surface is intentionally narrow and will grow as the engine matures.

The Roadmap lays out the multi-year path: scene and assets, then the adaptive core (DCC and GORNA in earnest), then tooling and scripting, then advanced intelligence — and, in a later phase, a native physics solver replacing the third-party backend.

This book ships with the engine. When the engine changes, the book changes in the same commit. When something is uncertain, the Open questions chapter is honest about it.


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

Principles

The why behind Khora — the philosophy that the architecture serves.

  • Document — Khora Principles v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The Symbiotic Adaptive Architecture
  2. The seven pillars
  3. Cold path and hot path
  4. Five engineering principles
  5. Decisions
  6. Open questions

01 — The Symbiotic Adaptive Architecture

The Symbiotic Adaptive Architecture, or SAA, is the conceptual framework Khora is built on. 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 is sharp. A traditional engine is a tree. A central runtime calls into renderers, physics, audio in a fixed order, with budgets baked in at compile time. SAA is a council. A central observer (the DCC) watches; specialists (the agents) negotiate; an arbitrator hands out budgets each tick. Decisions are revisited every frame.

This is not a research toy. The engine ships ~470 tests, a working renderer, a working physics step, an editor with a play mode, and a sandbox you can run today. SAA is the framework — a deliberate, opinionated answer to the rigidity problem stated in the Introduction.

02 — The seven pillars

SAA rests on seven pillars. Each one has a corresponding home in the codebase. The mapping is in the Architecture chapter; here, the idea.

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 entire application state.

What it monitorsExamples
Hardware loadCPU cores, GPU utilization, VRAM, memory bandwidth
Game stateScene complexity, entity counts, light count, physics interactions
Performance goalsTarget framerate, maximum input latency, power budget

The DCC runs on a dedicated background thread at ~20 Hz, completely independent of the main frame loop. It aggregates telemetry, runs heuristics, and sends resource budgets to the Scheduler through a unidirectional channel.

2. Intelligent Subsystem Agents — the specialists

Every major subsystem is an agent. An agent is not a passive library — it is a semi-autonomous component with deep understanding of its own domain.

CapabilityDescription
Self-assessmentConstantly measures its own performance and resource consumption
Multi-strategyPossesses multiple algorithms with different performance characteristics
Cost estimationPredicts the resource cost (CPU, memory, VRAM) of each strategy

Five agents exist today — one per LaneKind: Render, Shadow, Physics, UI, Audio. The architecture is open: users can add their own. See Agents.

3. GORNA — Goal-Oriented Resource Negotiation and Allocation

GORNA is the formal communication protocol used by the DCC and the agents to allocate resources. This negotiation replaces static, pre-defined budgets.

flowchart LR
    A[Agent requests] -->|needs + costs| B[DCC analysis]
    B -->|heuristics| C[Arbitration]
    C -->|budgets| D[Agent adaptation]
    D -->|telemetry| A
StepAction
1. RequestAgents submit desired resource needs with strategy costs
2. ArbitrationDCC analyzes all requests against the global model and goals
3. AllocationDCC grants a final budget to each agent (may be less than requested)
4. AdaptationAgent selects a less resource-intensive strategy to stay within budget

GORNA v0.3 is fully operational. The DCC runs nine heuristics each tick (Phase, Thermal, Battery, Frame Time, Stutter, Trend, CPU Pressure, GPU Pressure, Death Spiral). The full protocol lives in GORNA.

4. Adaptive Game Data Flows — the living data

AGDF is the principle that not only algorithms but also the structure of data should be dynamic. Realized through CRPECS, Khora’s archetype-based ECS:

ScenarioAGDF action
Entity far from playerRemove physics components, reduce update frequency
Entity enters player vicinityAdd physics components, increase update frequency
Scene complexity exceeds budgetMerge similar entities, simplify component data

Archetype storage makes structural change cheap. Adding or removing a component shifts an entity to a different page; queries see the change immediately. See ECS — CRPECS.

5. Semantic interfaces and contracts — the common language

For intelligent negotiation to be possible, all agents must speak a common, unambiguous language. Khora’s contracts are formal Rust traits.

Contract typeExample
Capabilities“I can render scenes using Forward+ or Simple Unlit”
Requirements“I require access to all entity positions and meshes”
Guarantees“With 4 ms CPU budget, I guarantee stable physics for 1000 rigid bodies”

These contracts live in khora-core and 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 a first-class principle.

  • Every DCC decision is logged with complete context — telemetry, requests, final budget.
  • Developers can ask not just “what happened?” but “why did the engine make that choice?”
  • The TelemetryService provides real-time metrics for every subsystem. See Telemetry.

7. Developer guidance and control — partnership, not autocracy

The engine’s autonomy serves the developer. It does not replace them.

MechanismPurpose
ConstraintsDefine rules or volumes to influence decisions (“In this zone, physics > graphics”)
Adaptation modes (planned)Learning (fully dynamic), Stable (predictable), Manual (locked strategies)

03 — Cold path and hot path

The clearest way to understand SAA is to see the two paths it runs on.

graph TD
    subgraph Cold["Cold path — background thread, ~20 Hz"]
        DCC[DCC service]
        Telemetry[Telemetry aggregation]
        Heuristics[9 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)
ThreadBackground (std::thread)Main
Frequency~20 Hz60+ Hz (every frame)
ResponsibilityObserve, analyze, negotiateExecute agents, dispatch lanes, produce output
CommunicationUnidirectional BudgetChannelAgents read budgets at frame start

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.

04 — Five engineering principles

These descend from SAA but apply at every level of the codebase.

1. The work is the hero

Chrome retreats. The user’s scene, code, simulation, render is always the loudest thing in the system. The engine’s machinery — schedulers, allocators, heuristics — should be auditable but never in the way.

2. The engine has a mind — show it

Khora’s defining trait is its self-optimizing core. The editor must surface that intelligence — telemetry as ambient signal, not a separate dashboard. The user should feel the engine thinking. The full editor design lives in Editor design system.

3. Density without density anxiety

Engine codebases fail by hiding everything in nested abstractions or by drowning the contributor in indirection. Khora aims for dense, scannable structure: one primary type per file, traits in khora-core, backends in khora-infra/<backend>/. You can find anything in two clicks.

4. Decisions belong in code, but they belong in writing too

Every major architectural choice has a yes and a no. The Decisions section at the end of major chapters records both. The full ledger is in Decisions.

5. Calm voice, loud signal

Logs say what they mean. Errors give context. Metrics carry units. Color, when used, means state — green for healthy telemetry, amber for warning, gold for active selection in the editor. When something is loud, it matters.

05 — Decisions

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. Splitting Render and Shadow lets the shadow atlas run in OBSERVE and RenderAgent declare a hard dependency.
  • Trait-defined contracts. Every seam in the engine is a Rust trait. No string-keyed APIs, no Box<dyn Any> downcasting in production paths.

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. The relationship is fire-and-forget through a channel.
  • Adding more agents than LaneKind variants. If a subsystem has no strategies to negotiate, it is a service, not an agent.

06 — Open questions

What this chapter does not answer, and where the next iteration should go.

  1. Adaptation modes. Learning, Stable, Manual are designed but not yet implemented. The contract for switching between them at runtime is open.
  2. Constraints API. “In this zone, physics > graphics” is a stated capability with no concrete API yet. 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?

Next: the architecture that makes the principles real. See Architecture.

Architecture

The how — where the SAA pillars live in the codebase. Pair with Principles.

  • Document — Khora Architecture v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. SAA, meet CLAD
  2. The mapping
  3. Crate dependency graph
  4. Dependency rules
  5. The eleven crates
  6. Trait map
  7. Standard components
  8. Decisions
  9. Open questions

01 — SAA, meet CLAD

Khora is built on two architectural concepts. They are not separate — they are two sides of the same coin.

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 follows the split between strategy and execution. The split between data and core follows 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 -.->|Orchestrates| Agents
    Agents -.->|Switches| Lanes
    Lanes -.->|Uses traits| Core
    Data -.->|Uses traits| Core
    IO -.->|I/O services| Agents
    Infra -.->|Implements contracts| Core

02 — The mapping

SAA concept (the why)CLAD crate (the how)Role
Dynamic Context Core & GORNAkhora-controlStrategic brain — observes telemetry, allocates budgets, runs the Scheduler
Intelligent Subsystem Agentskhora-agentsTactical managers — each responsible for a LaneKind (rendering, shadow, physics, audio, UI)
Multiple agent strategieskhora-lanesFast, deterministic workers — algorithms an agent can choose from
Adaptive Game Data Flowskhora-dataFoundation — CRPECS enables flexible data layouts, dynamic component change
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

03 — Crate dependency graph

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

04 — Dependency rules

Never create circular dependencies. Dependencies flow downward only.

RuleDescription
No upward depskhora-core cannot depend on any other crate
No lateral depskhora-agents cannot depend on khora-control
I/O is sharedkhora-io is used by both agents and the SDK
Traits in coreAbstract traits live in khora-core, implementations in specific crates
Backends in infraPer-backend code lives in khora-infra/src/<area>/<backend>/ (e.g., graphics/wgpu/, physics/rapier/)

Violating these is a hard build error. The dependency graph is the architecture; if you change one, you change the other.

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 as 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.

05 — The eleven crates

CrateLayerResponsibility
khora-coreFoundationTrait definitions, math types, GORNA types, ServiceRegistry, EngineContext, error hierarchy, memory tracking
khora-dataDataCRPECS ECS (archetype SoA), components, scene definitions, EcsMaintenance, allocators
khora-ioDataVFS, asset loading (FileLoader / PackLoader), serialization strategies, AssetService, SerializationService
khora-lanesLanesHot-path pipelines: render strategies, physics steps, audio mixing, asset decoders, scene transforms, ECS compaction, UI
khora-agentsAgentsIntelligent subsystem managers: RenderAgent, ShadowAgent, PhysicsAgent, UiAgent, AudioAgent, plus PhysicsQueryService
khora-controlControlDCC orchestration, GORNA protocol, ExecutionScheduler, BudgetChannel, EnginePlugin, HeuristicEngine
khora-infraInfrastructurewgpu backend, winit window, Rapier3D physics, CPAL audio, Taffy layout, GPU/Memory/VRAM monitors
khora-telemetryTelemetryTelemetryService, MetricsRegistry, MonitorRegistry, resource monitors
khora-sdkPublic APIEngineCore, GameWorld, EngineApp / AgentProvider / PhaseProvider traits, Vessel + spawn helpers, run_winit, WindowConfig
khora-editorEditorEditor application — panels, gizmos, scene I/O, play mode
khora-macrosSupport#[derive(Component)] proc macro

The eleventh slot once held khora-plugins, used for plugin loading and registration. It remains in the workspace but its API is stabilizing alongside the editor’s plugin needs.

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

06 — Trait map

The contracts that hold the engine together.

TraitDefined inImplemented by
Lanekhora-coreAll lane types in khora-lanes
Agentkhora-coreAll agent types in khora-agents
RenderSystemkhora-coreWgpuRenderSystem in khora-infra
PhysicsProviderkhora-coreRapier3D backend in khora-infra
AudioDevicekhora-coreCPAL backend in khora-infra
LayoutSystemkhora-coreTaffyLayoutSystem in khora-infra
Assetkhora-coreAll loadable asset types
Componentkhora-dataAll ECS components (via derive macro)
AssetDecoder<A>khora-lanesPer-format decoder lanes

Reading these traits is reading the engine’s API. They are kept short, stable, and free of backend-specific types.

07 — Standard components

The components shipped in khora-data. Custom components are added the same way — #[derive(Component)] plus an inventory::submit! registration.

ComponentDomainPurpose
TransformAllLocal position / rotation / scale
GlobalTransformAllWorld-space computed transform
CameraRenderProjection + view configuration
LightRenderLight type, color, intensity, shadow config
MaterialComponentRenderMaterial reference (handle)
RigidBodyPhysicsBody type, mass, velocity, CCD
ColliderPhysicsShape descriptor for collision
AudioSourceAudioAudio clip, volume, spatial flags
AudioListenerAudioListener position for 3D audio
Parent / ChildrenSceneEntity hierarchy
HandleComponentAssetGeneric asset handle wrapper
UiTransformUIPosition, size, anchoring
UiColorUIBackground color
UiTextUIText content, font, color
UiImageUITexture handle, scale mode
UiBorderUIBorder width, color

08 — Decisions

We said yes to

  • Splitting khora-io from khora-data. Asset loading and serialization are I/O concerns; ECS storage is not. Separating them avoids cyclic constraints.
  • Backends are swappable. Every khora-infra backend implements a khora-core trait. wgpu, Rapier3D, CPAL, Taffy are current defaults, not architectural commitments. Adding a new graphics, physics, audio, or layout backend means a new sibling folder under khora-infra/src/<area>/.
  • Trait coherence in khora-core. Every public surface seam is a trait. No backend types leak into agents or the SDK.
  • One agent per LaneKind. This forces the right number of agents — no more, no less.

We said no to

  • Mega-crates. Every crate has a single, scannable responsibility. We would rather pay the workspace overhead than the cognitive overhead of a 10 000-line crate.
  • 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. No Box<dyn Any> lookup at runtime in the hot path.

09 — Open questions

  1. khora-plugins API. The plugin model is real but its public API is still settling alongside editor needs.
  2. Editor as a contributor crate. khora-editor depends directly on khora-agents and khora-io for performance. Is that a violation of “SDK is the public API,” or a justified pragmatic shortcut?
  3. 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.

Next: the per-frame mechanics that bring this architecture to life. See Lifecycle.

Lifecycle

How Khora wakes up, runs, and shuts down.

  • Document — Khora Lifecycle v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The big picture
  2. Startup
  3. The frame loop
  4. Cold path — DCC thread
  5. Execution phases
  6. Engine modes
  7. Decisions
  8. Open questions

01 — The big picture

Khora has two clocks. The hot path runs every frame on the main thread, at 60 Hz or higher, and it must never block. The cold path runs ~20 Hz on a background thread, watches what just happened, and decides what should happen next. The two communicate through one channel: budgets flow from the cold path to the hot path; telemetry flows back through the registry.

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->>App: app.update(world, inputs)
    SDK->>GW: tick_maintenance()
    SDK->>SDK: extract scene + UI into stores
    SDK->>RS: begin_frame() → ColorTarget, DepthTarget
    SDK->>Sch: run_frame()
    Sch->>Sch: budget_channel.sync()
    Sch->>Sch: per phase: plugins, topo sort, execute agents
    Note over Sch: Agents record passes into FrameGraph
    SDK->>FG: drain + submit (topological pass order)
    SDK->>RS: end_frame(presents) → swapchain present
    DCC-->>Sch: budgets via BudgetChannel

02 — Startup

run_winit::<WinitWindowProvider, MyApp>(bootstrap)  ← Entry point
  └─ MyApp::window_config()         ← Read window settings
  └─ window opened
  └─ bootstrap(window, services, _) ← Your closure registers the renderer
  └─ MyApp::new()                   ← Construct the app, no context yet
  └─ engine init                    ← Default services + DCC + agents registered
  └─ MyApp::setup(world, services)  ← Cache services, spawn entities
  └─ dcc.initialize_agents(ctx)     ← Agents cache services once
StepWhat happens
run_winit::<W, A>(bootstrap)The SDK boots: opens a window via the chosen WindowProvider, runs your bootstrap closure (typically registers WgpuRenderSystem), then enters the frame loop.
MyApp::window_config()Returns a WindowConfig — title, size, optional icon.
Bootstrap closureReceives the window, the ServiceRegistry, and the native event loop. Register your renderer and any custom services here.
MyApp::new()Your app constructor — no arguments, no context. Used to set up internal state.
MyApp::setup(world, services)Called once after engine init. Spawn initial entities and cache service handles.
dcc.initialize_agents(ctx)The DCC walks every registered agent and calls on_initialize. Agents cache their services here, exactly once.

After this, the engine enters the frame loop. Nothing in setup is ever re-run.

03 — The frame loop

EngineCore::tick_with_services runs five stages in order. Each is a public method on EngineCore so drivers (the editor’s overlay/shell) can interleave hooks between them.

tick_with_services(frame_services):
  1. drain_inputs()              ← Pop queued InputEvents, tick telemetry
  2. run_app_update(&inputs)     ← App logic + maintenance + extraction
  3. presents = begin_render_frame(&frame_services)
                                 ← RenderSystem::begin_frame, swapchain acquire
  4. run_scheduler(&frame_services)
                                 ← Phase-by-phase agent execution
  5. end_render_frame(presents)  ← submit_frame_graph + RenderSystem::end_frame

Stage 1 — drain_inputs

Pops queued InputEvents into a vector for the app to consume. Ticks telemetry counters. Marks the simulation as started on the first input frame.

Stage 2 — run_app_update

Runs in this order:

  1. app.update(world, &inputs) — user game logic.
  2. world.tick_maintenance() — drain ECS cleanup / vacuum queues, compact pages.
  3. GPU mesh sync — handles freshly added meshes are uploaded through GpuCache.
  4. Scene extractionkhora_data::render::extract_scene populates RenderWorldStore; khora_data::ui::extract_ui_scene populates UiSceneStore.

Stage 3 — begin_render_frame

Calls RenderSystem::begin_frame(), which acquires the swapchain texture, registers a view, and inserts ColorTarget, DepthTarget, and ClearColor into the per-frame FrameContext. Returns the presents token used at end-of-frame.

Stage 4 — run_scheduler

The ExecutionScheduler runs every active phase in order (INIT, OBSERVE, TRANSFORM, MUTATE, OUTPUT, FINALIZE, plus any custom phases inserted after OUTPUT). For each phase it:

  1. Syncs budgets from the DCC via BudgetChannel::sync().
  2. Runs registered EnginePlugin hooks for this phase.
  3. Topologically sorts the agents declared for this phase + current EngineMode, using their hard dependencies as edges; tiebreaks by AgentImportance then priority.
  4. Executes agents sequentially, skipping Optional agents under budget pressure (frame elapsed > 16 ms).
  5. Marks completion in an AgentCompletionMap so dependent agents can tell their preconditions ran.

Stage 5 — end_render_frame

  1. Drain FrameGraph — agents that recorded passes during OUTPUT now have their command buffers topologically ordered by resource reads/writes and submitted to the device.
  2. RenderSystem::end_frame(presents) — present the swapchain texture.

The five stages are the single most important sequence in Khora. Everything performance-critical happens here, in this order.

04 — Cold path — DCC thread

The DCC runs independently at ~20 Hz on a background thread:

  1. Collect telemetry from agents and hardware monitors.
  2. Analyze with the heuristic engine (thermal, battery, load).
  3. Negotiate via GORNA — request strategies from agents.
  4. Arbitrate — select an optimal strategy per agent based on the budget.
  5. Apply — send budgets through the BudgetChannel to the Scheduler.

The cold path never blocks the hot path. Budgets are sent through a unidirectional channel with last-wins semantics — if multiple budgets arrive between frames, only the latest is used.

flowchart LR
    A[Telemetry] --> B[Heuristics]
    B --> C[GORNA negotiation]
    C --> D[Arbitration]
    D -->|BudgetChannel| E[Scheduler reads at frame start]

Heuristics, request shapes, and arbitration are detailed in GORNA.

05 — Execution phases

The Scheduler organizes per-frame work into phases. Each agent declares which phases it can run in.

PhasePurposeExample agents
InitFrame setup, reset
ObserveRead-only extractionRenderAgent, ShadowAgent, UiAgent
TransformSimulation, computationPhysicsAgent, AudioAgent
MutateWrite results, sync
OutputExternal output (present)RenderAgent, UiAgent
FinalizeCleanup, telemetry

Phases are not subsystem-specific. They describe what kind of work runs, not who runs it. A custom phase can be inserted with ExecutionPhase::custom(id).

06 — Engine modes

EngineMode is an open enum:

#![allow(unused)]
fn main() {
pub enum EngineMode {
    Playing,
    Custom(String),
}
}

The base engine ships only Playing. Other modes are injected by plugins — the editor registers EngineMode::Custom("editor") for example. Agents declare allowed_modes in their ExecutionTiming; the Scheduler filters automatically.

ModeTypical active agentsPurpose
Custom("editor")Render, Shadow, UIScene editing, UI panels, gizmos (editor application)
PlayingRender, Shadow, Physics, AudioFull game simulation

The mode boundary is also where play mode snapshots happen — the world is serialized when you press Play and restored when you press Stop. See Serialization.

Note: the editor’s own PlayMode enum (Editing/Playing/Paused) is a UI-state concept, separate from EngineMode. The editor mediates between the two — see Editor.

07 — Decisions

We said yes to

  • Two threads, one channel. The DCC owns its thread; the Scheduler owns the main thread; they touch only through BudgetChannel. Anything more would let the cold path stall the frame loop.
  • Last-wins budget delivery. The Scheduler doesn’t replay a queue; it reads the latest snapshot. Reasoning: if two budgets arrived in the same frame interval, the older one is already irrelevant.
  • Phase-based ordering. Agents declare phases, not absolute frame slots. The Scheduler resolves the dependency graph each frame.
  • tick_maintenance outside the agent system. ECS GC has no strategies — it does the same thing every frame. Making it an agent would dilute what “agent” means.

We said no to

  • Synchronous DCC calls from agents. An agent that waits on the DCC is a frame stall waiting to happen.
  • Fixed agent execution order at compile time. The DCC may reorder importance; the Scheduler resolves dependencies dynamically.
  • A separate “physics tick” loop. PhysicsAgent owns its accumulator and runs in Transform like everything else. One frame loop is enough.

08 — Open questions

  1. Multi-threaded execution within a phase. Today, agents in the same phase run sequentially after dependency resolution. A future Scheduler may parallelize independent agents across worker threads.
  2. Variable cold-path frequency. ~20 Hz is a default. On low-power targets (mobile, handheld) we may want 5–10 Hz. The trigger model for changing this at runtime is open.
  3. Frame pacing. Khora does not yet implement explicit frame pacing for VRR / fixed-rate displays. The hooks exist; the policy doesn’t.

Next: the eleven crates, in detail. See Crate map.

Crate map

Where things live, in order of dependency depth.

  • Document — Khora Crate Map v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The graph at a glance
  2. Foundation crates
  3. Data crates
  4. Lane and agent crates
  5. Control and infrastructure
  6. Public API
  7. Editor
  8. Where things live
  9. Open questions

01 — The graph at a glance

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

Dependencies flow downward only.

02 — Foundation crates

khora-core

The trait floor. Everything builds on it; it depends on nothing else in the workspace.

ModuleContents
lane/Lane trait, LaneContext, slots and refs
agent/Agent trait, AgentId, ExecutionTiming
math/Vec2/3/4, Mat3/4, Quat, Aabb, LinearRgba
physics/PhysicsProvider trait, body and collider types
audio/AudioDevice trait
asset/, vfs/Asset trait, VirtualFileSystem
ui/LayoutSystem trait
scene/Scene file format, serialization types
control/gorna/GORNA types — NegotiationRequest/Response, ResourceBudget
service_registry.rs, context.rsServiceRegistry, EngineContext
renderer/error.rsError hierarchy

khora-macros

Procedural macros. Today: #[derive(Component)], which generates the SerializableX mirror struct, From conversions, and inventory registration glue.

03 — Data crates

khora-data

The ECS and component storage layer.

ModuleContents
ecs/World, Archetype, Query, Page, SemanticDomain, EcsMaintenance
ecs/components/Standard components and the register_components! macro
ui/UI components (UiTransform, UiColor, UiText, UiImage, UiBorder)
assets/Assets<T> registry, AssetHandle<T>
allocators/SaaTrackingAllocator — heap allocation tracking

khora-io

Asset and serialization services. Sits between data storage and the agents that need them.

ModuleContents
vfs/VirtualFileSystem (UUID → metadata, O(1))
asset/AssetService, AssetIo trait, FileLoader, PackLoader, DecoderRegistry
serialization/SerializationService, three strategies (Definition, Recipe, Archetype)

04 — Lane and agent crates

khora-lanes

The hot-path workers. Every algorithm lives here.

FolderLanes
render_lane/SimpleUnlitLane, LitForwardLane, ForwardPlusLane, ShadowPassLane, UiRenderLane, ExtractLane
render_lane/shaders/WGSL files: lit_forward.wgsl, shadow_depth.wgsl, simple_unlit.wgsl, standard_pbr.wgsl, forward_plus.wgsl, ui.wgsl
physics_lane/StandardPhysicsLane, PhysicsDebugLane
audio_lane/SpatialMixingLane
asset_lane/loading/Per-format decoders: glTF, OBJ, WAV, Symphonia (Ogg/MP3/FLAC), texture, font, pack
scene_lane/TransformPropagationLane, scene serialization lanes
ui_lane/StandardUiLane (Taffy layout)
ecs_lane/CompactionLane

khora-agents

Five agents — one per LaneKind.

AgentFolderLaneKind
RenderAgentrender_agent/Render
ShadowAgentshadow_agent/Shadow
UiAgentui_agent/Ui
PhysicsAgentphysics_agent/Physics
AudioAgentaudio_agent/Audio

Plus PhysicsQueryService — an on-demand wrapper over PhysicsProvider for raycasts and debug geometry.

05 — Control and infrastructure

khora-control

The cold-path brain.

ModuleContents
service.rsDccService — agent lifecycle, tick loop
gorna/GornaArbitrator — budget fitting algorithm
analysis.rsHeuristicEngine — nine heuristics, death-spiral detection
scheduler.rsExecutionScheduler — hot-path orchestration
budget_channel.rsBudgetChannel — cold→hot pipe
plugin.rsEnginePlugin — extensible per-phase hooks

khora-infra

Backends. One subfolder per backend, each implementing a khora-core trait. The wgpu, Rapier, CPAL, Taffy choices below are current defaults — alternative backends drop in as new sibling folders without touching the rest of the engine.

FolderBackendImplements
graphics/wgpu/wgpu 28.0RenderSystem (WgpuRenderSystem, WgpuDevice)
physics/rapier/Rapier3DPhysicsProvider
audio/cpal/CPALAudioDevice
ui/taffy/TaffyLayoutSystem
platform/window/winitWindow creation, event loop
platform/input.rswinitInputEvent translation
telemetry/Native APIsGpuMonitor, MemoryMonitor, VramMonitor

khora-telemetry

Observability infrastructure.

ModuleContents
service.rsTelemetryService
metrics/MetricsRegistry, MonitorRegistry

06 — Public API

khora-sdk

The user-facing surface. Everything else is implementation detail.

ModuleContents
lib.rsRe-exports + prelude. Defines WindowConfig, WindowIcon, PRIMARY_VIEWPORT
engine.rsEngineCore — the engine type
game_world.rsGameWorld — safe ECS facade
traits.rsEngineApp, AgentProvider, PhaseProvider, WindowProvider
vessel.rsVessel builder + spawn_plane / spawn_cube_at / spawn_sphere helpers
winit_adapters.rsrun_winit entry point + WinitWindowProvider (default winit-based window)
prelude/Curated re-exports — prelude::*, prelude::ecs::*, prelude::math::*, prelude::materials::*

The walkthrough is in SDK quickstart. The full API surface is in SDK reference.

07 — Editor

khora-editor

The editor application. Built on the SDK, but reaches deeper for performance.

FolderContents
panels/Scene tree, properties, asset browser, viewport, console, GORNA stream
gizmos/Move / rotate / scale, selection outline
ops/High-level scene operations (spawn, despawn, parent, add component)
scene_io/Scene save / load via SerializationService

Visual language and panel anatomy in Editor design system.

08 — Where things live

A flat lookup table 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
ECS World and componentskhora-data::ecs
Tracking allocatorkhora-data::allocators
VFS and asset loadingkhora-io
Serialization strategieskhora-io::serialization
Render pipelineskhora-lanes::render_lane
WGSL shaderskhora-lanes::render_lane::shaders
Physics laneskhora-lanes::physics_lane
Audio laneskhora-lanes::audio_lane
Scene transformskhora-lanes::scene_lane
Agent implementationskhora-agents
Scheduler and GORNAkhora-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

09 — Open questions

  1. Should khora-editor depend on khora-sdk only? Today it reaches into khora-agents and khora-io for performance. Justified, but a violation of the “SDK is the public API” principle.
  2. Plugin crate scope. khora-plugins exists but its API is in flux. Editor extensibility, scripting, and runtime plugin loading all share this crate’s eventual surface.

Next: the data layer that everything sits on. See ECS — CRPECS.

ECS — CRPECS

The data layer Khora is built on. CRPECS is a custom archetype-based ECS with SoA storage, parallel queries, and semantic domains.

  • Document — Khora ECS v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. Why CRPECS
  2. Architecture overview
  3. Entities
  4. Components
  5. Archetype pages
  6. Semantic domains
  7. Queries
  8. ECS maintenance
  9. Memory layout
  10. For game developers
  11. For engine contributors
  12. Decisions
  13. Open questions

01 — Why CRPECS

CRPECS — Chunked Relational Page ECS — exists because SAA’s promise of Adaptive Game Data Flows 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: adding or removing a component to an entity is not an O(N) operation; whole pages are queryable in cache-friendly bursts; queries are guided by bitsets so sparse iteration stays fast. Off-the-shelf ECS libraries optimize for one of these. CRPECS is built to do all three, because the SAA needs all three.

02 — Architecture overview

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
    subgraph Query
        Q[Query planner]
        BS[Bitset-guided iteration]
    end
    ES --> R
    R --> P
    P --> P1
    P --> P2
    P --> P3
    Q --> BS
    BS --> P

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

03 — Entities

Entities are lightweight identifiers with index and generation for safety:

#![allow(unused)]
fn main() {
pub struct EntityId {
    pub index: u32,
    pub generation: u32,
}
}

The generation prevents stale-handle bugs. When an entity is despawned and the slot is reused, the generation increments — old EntityId handles silently fail their lookups instead of pointing at the wrong entity.

04 — Components

Components are plain data types annotated with #[derive(Component)]:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Transform {
    pub translation: Vec3,
    pub rotation: Quaternion,
    pub scale: Vec3,
}
}

The derive does four things at compile time:

  1. impl Component for Transform
  2. Generates SerializableTransform with Encode / Decode (via bincode / serde).
  3. Generates From<Transform> for SerializableTransform and the reverse.
  4. Registers the component for scene serialization through inventory::submit!.

Two attributes refine the behavior:

AttributeUse
#[component(skip)]Field excluded from serialization. Use for GPU handles, runtime state.
#[component(no_serializable)]The whole component skips the auto-generated mirror. Use for unit structs and trait objects you handle manually.

05 — Archetype pages

Components are stored in archetype pages — contiguous SoA arrays grouped by component combination:

PageComponentsEntities
0Transform, GlobalTransform1, 2, 3
1Transform, GlobalTransform, RigidBody, Collider4, 5
2Transform, GlobalTransform, Camera6

Adding a RigidBody to entity 1 moves it from page 0 to page 1. The cost is one component-by-component memcpy — bounded, predictable, cache-friendly. Bitsets on each page guide iteration so empty slots are skipped without branching.

06 — Semantic domains

Components are tagged with a semantic domain for optimized queries. Domains are encoded in a DomainBitset carried by every component registration:

DomainComponents
SpatialTransform, GlobalTransform, RigidBody, Collider
RenderCamera, Light, HandleComponent<Mesh>, MaterialComponent
UIUiTransform, UiColor, UiText
AudioAudioSource, AudioListener

Domains let query planners pre-filter pages: a render extraction query with a Render domain hint never touches UI pages. This is part of how Khora keeps per-frame extraction fast even as the entity count grows.

Components are registered with the Registry at startup (one entry per type, via inventory::submit!). The registry is the source of truth for domain assignment, serialization metadata, and component identity.

07 — Queries

Queries are type-safe and use a planner for optimal execution:

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

The planner picks pages whose archetype contains every requested component, and iterates them in SoA order. References are borrow-checked at compile time — a &mut Component in one query closes the door on any other query touching that component for the duration.

08 — ECS maintenance

ECS maintenance is not an agent — it is a direct data-layer operation owned by GameWorld:

#![allow(unused)]
fn main() {
impl GameWorld {
    pub fn tick_maintenance(&mut self) {
        self.maintenance.tick(&mut self.world);
    }
}
}

EcsMaintenance lives at crates/khora-data/src/ecs/maintenance.rs. Each frame, between user logic and agent execution, the engine calls tick_maintenance() to drain pending cleanup and compaction work.

OperationTriggerEffect
queue_cleanup()Component removalMarks orphaned data for cleanup
queue_vacuum()Entity despawnMarks page holes for compaction
tick()Every frame, before agentsDrains queues, compacts pages

Why not an agent? Maintenance has no strategies to negotiate. It does the same thing every frame. Agents are for subsystems with multiple execution strategies. Maintenance is a fixed data operation. See the Agent vs Service rule in Architecture.

09 — Memory layout

Page 0 (Transform + GlobalTransform)
┌─────────────┬─────────────┬─────────────┐
│ SoA arrays  │   Bitset    │  Metadata   │
│ tx ty tz rx │ 1 1 1 0 0 0 │ count: 3    │
│ ry rz sc... │             │ capacity: 8 │
└─────────────┴─────────────┴─────────────┘

Each page holds component arrays in struct-of-arrays form, a bitset describing which slots are live, and metadata for capacity and count. Iteration walks set bits and indexes into the SoA arrays — no per-entity allocation, no per-entity branching.

Compaction runs in tick_maintenance(). When too many holes accumulate, the page is rewritten with live entries packed to the front. Bitsets are rebuilt in the same pass.


For game developers

You typically interact with the ECS through GameWorld, the SDK facade. It hides the raw World type and exposes a focused API.

#![allow(unused)]
fn main() {
// Spawn (raw tuple bundle)
let entity = world.spawn((Transform::identity(), GlobalTransform::identity()));

// Spawn through Vessel (the recommended path — see SDK quickstart)
let player = khora_sdk::Vessel::at(world, Vec3::new(0.0, 2.0, 10.0))
    .with_component(my_camera)
    .build();

// Read a transform
if let Some(t) = world.get_transform(entity) {
    log::info!("at {:?}", t.translation);
}

// Mutate a transform and sync to the renderer
if let Some(t) = world.get_transform_mut(entity) {
    t.translation += Vec3::Y;
}
world.sync_global_transform(entity);

// Generic component access
if let Some(c) = world.get_component::<MyComponent>(entity) {
    /* read */
}
if let Some(c) = world.get_component_mut::<MyComponent>(entity) {
    /* write */
}

// Add or remove components after spawn
world.add_component(entity, my_light);
world.remove_component::<MyComponent>(entity);

// Query
for (t, g) in world.query::<(&Transform, &GlobalTransform)>() {
    /* iterate */
}
for (t,) in world.query_mut::<(&mut Transform,)>() {
    /* mutate */
}

// Despawn
world.despawn(entity);
}

For your own components: derive Component, register it once via inventory::submit! in your crate, and use it everywhere. The serialization mirror is generated for you. See SDK quickstart for a worked example.

For engine contributors

The contract surface lives in khora-core::ecs (the trait pieces) and khora-data::ecs (the implementation):

FilePurpose
crates/khora-data/src/ecs/world.rsWorld — entity store, page registry, query entry point
crates/khora-data/src/ecs/archetype.rsArchetype — component combination identity
crates/khora-data/src/ecs/page.rsPage — SoA storage, bitset, compaction
crates/khora-data/src/ecs/query.rsQuery — type-safe iteration, planner
crates/khora-data/src/ecs/components/registrations.rsStandard component registrations
crates/khora-data/src/ecs/maintenance.rsEcsMaintenance — GC, compaction queues
crates/khora-macros/src/lib.rs#[derive(Component)] proc macro

When extending CRPECS, the rule of thumb is: changes to storage layout require a benchmarking pass. The ECS is on the hot path; a 5% regression in iteration cost shows up everywhere.

Decisions

We said yes to

  • Archetype-based storage. Per-archetype SoA pages give us cache-friendly iteration and cheap structural change. Hybrid models (component sparse sets) trade one for the other.
  • Bitset-guided iteration. Sparse pages are fast. We don’t pay for empty slots.
  • Generations on EntityId. Stale handles return None instead of pointing at a different entity.
  • #[derive(Component)] generating the serializable mirror. Two structs to maintain by hand was a recurring source of drift.

We said no to

  • A non-archetype ECS. Sparse-set ECS is simpler but pays a query-time cost we can’t afford.
  • Globally synchronous component change. Each add_component / remove_component is local. The structural cost is an O(component_count_on_entity) memcpy, not a world-wide event.
  • Reflection-driven serialization. We considered runtime reflection. The proc macro is faster, statically checked, and has no allocation.

Open questions

  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. Live AGDF triggers. The architecture supports adding/removing components based on context, but the policy — who decides, when, with what hysteresis — is open. See Open questions.
  3. Page-size tuning. Today pages start at 8 entries and grow geometrically. Whether 64 or 256 would be better at scale is unmeasured.

Next: who decides which lane runs each frame. See Agents.

Agents

Intelligent subsystem managers. One per LaneKind. Each negotiates with GORNA and dispatches lanes.

  • Document — Khora Agents v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. What an agent is
  2. The Agent trait
  3. The five agents
  4. ExecutionTiming
  5. Agent dependencies
  6. The Scheduler
  7. BudgetChannel
  8. EnginePlugin
  9. For game developers
  10. For engine contributors
  11. Decisions
  12. Open questions

01 — What an agent is

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.

What an agent is not:

  • An agent is not a controller. It does not decide global priorities. The DCC does that.
  • An agent is not a worker. It does not contain pipeline code. Lanes do that.
  • An agent is not a service. If a subsystem has no strategies to negotiate, it is a service (AssetService, SerializationService, EcsMaintenance), not an agent.

The five agents shipped today exhaust the five LaneKind variants. New LaneKind would mean a new agent. The architecture is designed for it.

02 — The Agent trait

#![allow(unused)]
fn main() {
pub trait Agent: Send + Sync {
    fn id(&self) -> AgentId;
    fn negotiate(&mut self, request: NegotiationRequest) -> NegotiationResponse;
    fn apply_budget(&mut self, budget: ResourceBudget);
    fn report_status(&self) -> AgentStatus;
    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {}  // Once after registration
    fn execute(&mut self, context: &mut EngineContext<'_>);          // Every frame
    fn execution_timing(&self) -> ExecutionTiming;
    fn as_any(&self) -> &dyn Any;
    fn as_any_mut(&mut self) -> &mut dyn Any;
}
}

Agents implement only Agent plus Default — no extra methods. Construction goes through Default::default(). Private free functions in the same module file are acceptable for internal helpers; methods on the agent struct are not. This rule keeps agents legible and prevents the slow drift toward god-object subsystems.

03 — The five agents

EngineMode is open: the base engine ships Playing; the editor application injects Custom("editor"). Agents declare which modes they accept.

AgentLaneKindAllowed modesAllowed phasesImportanceFixed timestep
RenderAgentRenderPlaying, Custom("editor")Observe, OutputCriticalNo
ShadowAgentShadowPlaying, Custom("editor")ObserveCriticalNo
PhysicsAgentPhysicsPlayingTransformCriticalYes (1/60 s)
UiAgentUiCustom("editor")Observe, OutputImportantNo
AudioAgentAudioPlayingTransformImportantNo

ShadowAgent is the canonical example of agent split: it runs in OBSERVE, encodes the shadow atlas off-swapchain, and publishes ShadowAtlasView + ShadowComparisonSampler into the per-frame FrameContext. RenderAgent declares AgentDependency::Hard(AgentId::ShadowRenderer) in execution_timing(); the Scheduler enforces the ordering. RenderAgent then reads the atlas values from FrameContext and re-injects them into its own LaneContext for the main pass.

04 — ExecutionTiming

Each agent declares when and how it wants to execute:

#![allow(unused)]
fn main() {
fn execution_timing(&self) -> ExecutionTiming {
    ExecutionTiming {
        allowed_modes: vec![EngineMode::Playing, EngineMode::Custom("editor".into())],
        allowed_phases: vec![ExecutionPhase::OBSERVE, ExecutionPhase::OUTPUT],
        default_phase: ExecutionPhase::OUTPUT,
        priority: 1.0,
        importance: AgentImportance::Critical,
        fixed_timestep: None,
        dependencies: vec![],
    }
}
}
FieldPurpose
allowed_modesEngine modes where this agent can run (Editor, Playing)
allowed_phasesFrame phases where this agent can run
default_phasePhase to use if GORNA does not specify one
priorityOrder within the same phase (higher = earlier)
importanceCritical / Important / Optional — determines skip behavior under budget pressure
fixed_timestepIf set, agent only runs when accumulator exceeds this duration
dependenciesOther agents this one depends on

05 — Agent dependencies

Agents declare ordering relationships explicitly:

#![allow(unused)]
fn main() {
dependencies: vec![
    AgentDependency {
        target: AgentId::Physics,
        kind: DependencyKind::Hard,
        condition: Some(DependencyCondition::IfTargetActive),
    },
]
}
KindBehavior
HardTarget must run first. If target is skipped, this agent is also skipped.
SoftPrefers target first, but can run without it.
ParallelNo ordering constraint — can run alongside target.

The Scheduler resolves the dependency graph each frame after filtering by mode and phase. Cycles are detected at registration; mismatched dependencies (declaring a dep on an agent that is not registered) are warnings.

06 — The Scheduler

flowchart TD
    A[Frame start] --> B[budget_channel.sync]
    B --> C[Build frame service overlay]
    C --> D[Read current EngineMode]
    D --> E[For each ExecutionPhase in order]
    E --> F[Run plugin hooks for this phase]
    F --> G[Collect agents matching phase and mode]
    G --> H[Topological sort by hard deps; tiebreak by importance, priority]
    H --> I{For each agent}
    I --> J{Optional and frame elapsed > 16ms?}
    J -->|Yes| K[Skip agent]
    J -->|No| L{Hard deps completed?}
    L -->|No| K
    L -->|Yes| M[Execute agent]
    M --> N[Mark agent in AgentCompletionMap]
    N --> I
    K --> I
    I -->|Done| O[Next phase]
    O --> E

The Scheduler lives in khora-control::scheduler. Real algorithm:

  1. Budget sync. budget_channel.sync() drains every per-agent crossbeam channel, keeping the latest budget for each agent.
  2. Per-frame service overlay. A ServiceRegistry::with_parent wraps the global registry — frame-scoped services (the FrameContext itself, the SharedFrameGraph) live in the overlay.
  3. AgentCompletionMap. Built fresh each frame. Every agent execution writes into it; agents with hard dependencies read it before running.
  4. Per-phase work. For each phase in order:
    • Plugin hooks fire first (one closure per phase, signature Fn(&mut World)).
    • The AgentRegistry returns every agent declared for this phase and the active EngineMode.
    • The set is topologically sorted by hard dependencies; cycles are detected and reported.
    • Within an order-equivalent group, sort by AgentImportance (Critical / Important / Optional) then priority.
  5. Execute. Each agent’s execute(&mut EngineContext) is called sequentially, after a budget-pressure check (Optional agents are skipped if frame_start.elapsed() > 16 ms) and a hard-dependency check (skip if any prerequisite was skipped).

The Scheduler is private to the SDK — game developers never touch it. Its contract: respect the agents’ declared timings, respect the dependency graph, never block on the cold path.

07 — BudgetChannel

The DCC sends budgets to the Scheduler through one crossbeam_channel per agent, plus a shared current-state cache.

#![allow(unused)]
fn main() {
// Conceptual shape
struct BudgetChannel {
    senders:   HashMap<AgentId, crossbeam_channel::Sender<ResourceBudget>>,
    receivers: HashMap<AgentId, crossbeam_channel::Receiver<ResourceBudget>>,
    current:   RwLock<HashMap<AgentId, ResourceBudget>>,
}
}
PropertyDetail
TransportOne crossbeam_channel per agent
SemanticsLast winssync() drains every channel, keeps only the latest budget per agent
Cold-side writesend(agent_id, budget) — non-blocking via try_send
Hot-side read at syncsync() drains all channels, updates the current map under RwLock
Hot-side read in-frameget(agent_id) -> ResourceBudget from the current map

Per-agent channels keep the bookkeeping local — adding an agent does not change the channel surface for the others, and the cold path can update one agent’s budget without touching the rest.

08 — EnginePlugin

Plugins inject callbacks into the frame pipeline at specific phases. The closure receives just the ECS World — anything else (services, frame context) is reached through the world’s resources.

#![allow(unused)]
fn main() {
let mut plugin = EnginePlugin::new("my-plugin");
plugin.on_phase(ExecutionPhase::OUTPUT, |world: &mut World| {
    // Inspect or mutate the world before agents run for this phase.
});
scheduler.register_plugin(plugin);
}

The Scheduler runs every plugin hook for the current phase before the agents for that phase. Plugins are the canonical way to add work that does not need GORNA negotiation but should land in a specific phase — for example, the editor’s per-phase bookkeeping. See Editor.


For game developers

Most game developers will never see an agent. The SDK shields you. You write components, you write update, the engine handles the rest.

The one exception: when you need a custom subsystem with multiple performance strategies, you can implement Agent and register it. Read Extending Khora for a worked example.

For engine contributors

The agents shipped today live in crates/khora-agents/src/<name>_agent/. Each follows the same skeleton:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct MyAgent {
    cached_service: Option<Arc<MyService>>,
    current_strategy: Option<Box<dyn Lane>>,
}

impl Agent for MyAgent {
    fn id(&self) -> AgentId { AgentId::My }

    fn execution_timing(&self) -> ExecutionTiming { /* see above */ }

    fn negotiate(&mut self, request: NegotiationRequest) -> NegotiationResponse {
        // Return strategies + cost estimates
    }

    fn apply_budget(&mut self, budget: ResourceBudget) {
        // Switch self.current_strategy based on budget.strategy_id
    }

    fn on_initialize(&mut self, ctx: &mut EngineContext<'_>) {
        self.cached_service = ctx.services.get::<Arc<MyService>>();
    }

    fn execute(&mut self, ctx: &mut EngineContext<'_>) {
        if let Some(lane) = self.current_strategy.as_mut() {
            let mut lane_ctx = LaneContext::from(ctx);
            lane.execute(&mut lane_ctx);
        }
    }

    fn report_status(&self) -> AgentStatus { /* metrics */ }

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

No start, stop, builder methods, or accessors. If you find yourself adding one, you are leaking lane work into the agent.

Decisions

We said yes to

  • Agents implement only Agent + Default. No methods, no builders. The shape is uniform across all agents in the workspace.
  • One agent per LaneKind. Splitting Render and Shadow into two agents is the canonical example: each has its own negotiation surface, its own dependency declaration, its own per-frame role.
  • Hard / Soft / Parallel dependency model. Three kinds covered every concrete need in two years of development. Adding a fourth would require strong evidence.
  • Last-wins on BudgetChannel. Replaying old budgets would accumulate latency and contradict the premise of adaptive.

We said no to

  • Agent-managed concurrency. Agents do not spawn threads. The DCC handles cold-path concurrency; the Scheduler handles per-frame ordering.
  • Agents reading from each other directly. All cross-agent data flow is through FrameContext slots. The ShadowAgent → RenderAgent path goes through the context, not through a shared field.
  • Optional methods that take input. on_initialize and execute are the only inputs. Everything else is configuration via execution_timing().

Open questions

  1. Plugin agents. Today, agents are added at compile time via register_components!-style registration. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
  2. Multi-LaneKind agents. Forbidden by current rule, but if a future subsystem genuinely needs to coordinate two lane kinds (e.g., compute + render in the same pipeline), the rule may need a carve-out.
  3. Async agent work. Some lanes (asset streaming) want async I/O. The contract for an agent that yields control mid-frame is open.

Next: the workers agents dispatch. See Lanes.

Lanes

The execution units. Every algorithm in Khora is a lane.

  • Document — Khora Lanes v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. What a lane is
  2. The Lane trait
  3. Lane lifecycle
  4. The three contexts
  5. LaneContext in detail
  6. Lane types by subsystem
  7. Cost estimation
  8. For game developers
  9. For engine contributors
  10. Decisions
  11. Open questions

01 — What a lane is

A lane is a hot-path worker. It does one thing — render a forward pass, simulate one physics step, mix one audio frame, decode one glTF — and it does it deterministically.

Lanes do not decide whether to run. They do not negotiate. They run when an agent dispatches them. Each lane represents a strategy the owning agent can choose from.

The naming is symmetric. RenderAgent chooses between SimpleUnlitLane, LitForwardLane, and ForwardPlusLane. PhysicsAgent runs StandardPhysicsLane (with PhysicsDebugLane as an opt-in overlay). AudioAgent runs SpatialMixingLane. The agent owns selection. The lane owns execution.

02 — The Lane trait

#![allow(unused)]
fn main() {
pub trait Lane {
    /// Phase 1: read-only extraction, setup
    fn prepare(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> { Ok(()) }

    /// Phase 2: the actual work
    fn execute(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError>;

    /// Phase 3: teardown, reset
    fn cleanup(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> { Ok(()) }

    /// Human-readable strategy name
    fn strategy_name(&self) -> &'static str;

    /// Cost estimate for GORNA (0.0 = cheap, 1.0 = expensive)
    fn estimate_cost(&self, ctx: &LaneContext<'_>) -> f32;

    /// One-time initialization
    fn on_initialize(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> { Ok(()) }
}
}

prepare, execute, cleanup are the per-frame triple. on_initialize is one-shot. estimate_cost lets the lane participate in GORNA negotiation through its agent. strategy_name is the identifier shown in telemetry, the editor’s GORNA stream, and the decisions log.

03 — Lane lifecycle

flowchart LR
    A[Created] -->|on_initialize| B[Initialized]
    B -->|prepare| C[Preparing]
    C -->|execute| D[Executing]
    D -->|cleanup| E[Cleaning]
    E -->|next frame| C
    E -->|shutdown| F([End])
PhaseWhenPurposeExample
on_initializeOnce, at bootCache services, create GPU resourcesCreate render pipeline
prepareEvery frame, before executeRead-only extraction from ECSExtract meshes, cameras
executeEvery frame, after prepareThe actual workEncode GPU commands
cleanupEvery frame, after executeReset state for next frameClear render world

The split between prepare and execute lets us separate read-only ECS access (which can be parallelized over many lanes) from mutating output (which is single-threaded). It also gives the lane a clear seam to flush per-frame state in cleanup without leaking between frames.

04 — The three contexts

Khora has three context types — distinct shapes, distinct scopes. Confusing them is the most common reading mistake.

ContextCrate / moduleScopeCarries
EngineContextkhora-core::contextPer agent invocationworld: Option<&'a mut dyn Any>, services: Arc<ServiceRegistry>
LaneContextkhora-core::lanePer lane invocationType-map of Slot<T> / Ref<T> plus arbitrary inserted values
FrameContextkhora-core::renderer::api::core::frame_contextOne frameType-erased blackboard, StageHandle<T> sync, tokio task tracking

EngineContext — agent input

The Scheduler builds one and passes it to every agent’s execute(&mut EngineContext). It carries a type-erased pointer to the ECS world and a clone of the per-frame service registry. Agents read services through this; lanes get one too when an agent constructs a LaneContext for them.

LaneContext — lane data exchange

Lanes communicate through this. The agent populates it with the data its lanes need, calls lane.execute(&mut ctx), and the lane retrieves typed values out of it. See section 05.

FrameContext — cross-agent blackboard

Created once per frame. Lives in the per-frame service overlay. Agents reach it through services.get::<FrameContext>(). Three jobs:

  • Type-keyed blackboard. insert::<T>(value) and get::<T>() -> Option<Arc<T>>. The renderer inserts ColorTarget / DepthTarget here at begin_frame; agents read them.
  • Stage synchronization. insert_stage::<MyStage>() returns a StageHandle<T> backed by tokio::sync::watch. Producers mark_done(); consumers await stage.wait(). Cross-agent ordering is normally handled by the Scheduler’s AgentCompletionMapStageHandle is the ad-hoc primitive for plugins or sub-tasks that need their own sync without going through agent registration.
  • Hot-path async tasks. spawn(future) runs an async task on the shared tokio runtime, tracked by an atomic counter. The runner calls wait_for_all().await after engine.tick() to flush pending work.

The split is load-bearing. EngineContext is a call argument, LaneContext is a parameter bag, FrameContext is a shared blackboard. They never replace each other.

05 — LaneContext in detail

Lanes communicate through a type-erased context:

#![allow(unused)]
fn main() {
let mut ctx = LaneContext::new();
ctx.insert(Slot::new(&mut render_world));
ctx.insert(device.clone());
ctx.insert(ColorTarget(view));
}
TypePurpose
Slot<T>Mutable access to owned data
Ref<T>Immutable reference
ColorTargetRender target for encoding
DepthTargetDepth/stencil target

The context is the only way data crosses the lane boundary. Lanes do not hold long-lived references to each other; they read from and write to the context, frame after frame. This keeps lanes orthogonal — a lane can be removed, replaced, or substituted without touching the others.

06 — Lane types by subsystem

SubsystemLanesStrategy variants
RenderLitForward, ForwardPlus, SimpleUnlit, ShadowPassQuality versus performance
PhysicsStandardPhysicsLane, PhysicsDebugLaneFixed timestep + optional debug overlay
AudioSpatialMixingLane3D positional mixing
SceneTransformPropagationLaneHierarchy updates
AssetTextureLoader, MeshLoader, FontLoader, AudioDecoderFormat-specific decoding
UIStandardUiLane, UiRenderLaneLayout + render
ECSCompactionLanePage defragmentation

One lane type per agent. The RenderAgent manages render lanes, the PhysicsAgent manages physics lanes. An agent selects between lane strategies based on its GORNA budget.

07 — Cost estimation

estimate_cost is how a lane participates in GORNA. The convention:

  • 0.0 — cheap, runs in a known small budget
  • 1.0 — expensive, the most resource-intensive strategy this agent has

The agent collects estimate_cost for each of its lanes, packages them as StrategyOptions, and returns them in negotiate(). GORNA arbitrates across all agents. The chosen strategy comes back as a ResourceBudget, and the agent switches current_strategy accordingly.

The estimate is not a hard contract. It is a hint. The DCC’s heuristics smooth measurements over many frames; one stretched frame does not trigger a strategy switch.


For game developers

You almost never write a lane. Lanes are engine internals. The exception: if you write a custom agent (Extending Khora), you will write at least one lane to give it something to do.

For most game work, the lane abstraction shows up indirectly — when you read render strategy names in the editor’s GORNA stream, when you see LitForward swap to Forward+ because GORNA detected too many lights, when you log strategy_name() from telemetry.

For engine contributors

Lanes live in crates/khora-lanes/src/<subsystem>_lane/. Each lane file contains:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct MyLane {
    // Owned state — pipeline, ring buffer, accumulator, etc.
}

impl Lane for MyLane {
    fn on_initialize(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> {
        let device = ctx.get::<Arc<dyn GraphicsDevice>>().ok_or(LaneError::MissingService)?;
        // Create pipelines, buffers, bind groups
        Ok(())
    }

    fn prepare(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> {
        let world = ctx.get::<World>().ok_or(LaneError::MissingData)?;
        self.extract_renderables(world);
        ctx.insert(self.render_world.clone());
        Ok(())
    }

    fn execute(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> {
        let render_world = ctx.get::<RenderWorld>().ok_or(LaneError::MissingData)?;
        self.encode_gpu_commands(render_world);
        Ok(())
    }

    fn cleanup(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> {
        self.render_world.clear();
        ctx.remove::<RenderWorld>();
        Ok(())
    }

    fn strategy_name(&self) -> &'static str { "MyStrategy" }

    fn estimate_cost(&self, _ctx: &LaneContext<'_>) -> f32 { 0.5 }
}
}

Three rules:

  1. No long-lived references. Anything that survives between frames belongs in self. Anything frame-scoped belongs in LaneContext.
  2. cleanup actually cleans. A lane that leaves stale data in LaneContext is a frame-leak waiting to happen.
  3. Errors are LaneError. They bubble to the agent, which logs and can fall back to a less expensive strategy.

For shaders specifically: WGSL files live under crates/khora-lanes/src/render_lane/shaders/. Never inline shader source as a Rust string; the file is the source of truth, hot-reloadable in development.

Decisions

We said yes to

  • Three-phase lifecycle (prepare / execute / cleanup). The split is load-bearing: it is the seam between read-only extraction and mutating output, and the place where per-frame state is reset.
  • Type-erased LaneContext. Lanes communicate through inserted slots, not direct references. The cost is some boilerplate; the win is total decoupling.
  • estimate_cost returning f32. A simple, comparable scalar. Richer cost models (multi-resource vectors) were considered and rejected as premature.

We said no to

  • Lanes referencing each other. A LitForwardLane does not depend on a ShadowPassLane instance — it depends on ShadowAtlasView and ShadowComparisonSampler slots in the context. The shadow lane can be replaced without touching the forward lane.
  • Lane-owned threads. A lane runs on the calling thread. Parallelism happens at the Scheduler level.
  • Inlined shader source. lit_forward.wgsl is a file. It is editable, reviewable, and (in a future iteration) hot-reloadable.

Open questions

  1. 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.
  2. Shader hot-reload. Files-on-disk make this trivial in principle. The wgpu pipeline cache invalidation policy is not yet decided.
  3. Asynchronous lanes. Asset streaming wants async fn execute. The current sync-only contract is a known constraint.

Next: the protocol that decides which lane runs. See GORNA.

GORNA

Goal-Oriented Resource Negotiation and Allocation. The protocol that lets agents and the DCC trade budgets in real time.

  • Document — Khora GORNA v0.3
  • Status — Operational
  • Date — May 2026

Contents

  1. Why GORNA
  2. The five phases
  3. Data structures
  4. The nine heuristics
  5. Compliance today
  6. Cold path and hot path, again
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — Why GORNA

A traditional engine assigns budgets at compile time: physics gets 4 ms, rendering gets 12 ms, audio gets the rest. Those numbers are wrong on every machine that is not the developer’s.

GORNA replaces that with a per-tick negotiation. Agents declare what they can do at various cost points; the DCC observes the system, applies heuristics (thermal, battery, frame-time stutter, GPU pressure), and hands out a budget that reflects this hardware, this scene, this frame.

The result: an engine that runs the same code on a workstation, a laptop on battery, and a Steam Deck — and adapts strategy each tick to keep the frame rate.

02 — The five phases

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 ~50 ms| A
PhaseDurationAction
AwarenessInstantCollect telemetry from agents and hardware monitors
Analysis~1 msRun the heuristic engine — thermal, battery, load
Negotiation~2 msRequest strategy options from each agent
Arbitration~1 msSelect an optimal strategy per agent within budget
ApplicationInstantCall apply_budget() on each agent, send to BudgetChannel

The whole loop runs at ~20 Hz on the cold path. The hot path never waits for it.

03 — Data structures

NegotiationRequest

#![allow(unused)]
fn main() {
pub struct NegotiationRequest {
    pub target_latency: Duration,       // e.g., 16.6 ms for 60 FPS
    pub priority_weight: f32,           // 0.0 to 1.0
    pub constraints: ResourceConstraints,
    pub current_phase: EnginePhase,     // Boot, Menu, Simulation, Background
    pub agent_timing: ExecutionTiming,  // Agent's declared timing
}
}

NegotiationResponse

#![allow(unused)]
fn main() {
pub struct NegotiationResponse {
    pub strategies: Vec<StrategyOption>,
    pub timing_adjustment: Option<TimingAdjustment>,
}

pub struct StrategyOption {
    pub id: StrategyId,         // LowPower, Balanced, HighPerformance
    pub estimated_time: Duration,
    pub estimated_vram: u64,
}
}

ResourceBudget

#![allow(unused)]
fn main() {
pub struct ResourceBudget {
    pub strategy_id: StrategyId,
    pub time_limit: Duration,
    pub memory_limit: Option<u64>,
    pub vram_limit: Option<u64>,
    pub extra_params: HashMap<String, f64>,
}
}

The shape is intentionally narrow. Adding a new resource dimension is a small, considered change — not a free-form bag.

04 — The nine heuristics

The DCC’s heuristic engine runs nine heuristics each tick:

HeuristicInputOutput
PhaseCurrent EnginePhase (Boot, Menu, Simulation, Background)Multiplier favoring relevant subsystems
ThermalGPU/CPU temperatureReduce budget multiplier when hot
BatteryBattery level + AC stateReduce budget on low battery, prefer LowPower strategies
Frame TimeRecent frame durationsTighten budgets if frames are over target
StutterFrame time variancePenalize strategies that produce inconsistent timings
TrendFrame time slopeAnticipate degradation before it triggers a stutter
CPU PressureCPU utilizationRebalance time budgets toward CPU-light strategies
GPU PressureGPU utilizationRebalance toward GPU-light strategies
Death SpiralConsecutive over-budget framesForce LowPower strategy until recovery

Heuristics are independent. Each emits a multiplier or a recommendation; the arbitrator combines them. New heuristics can be added without touching existing ones — the engine’s adaptive intelligence grows by accretion.

GORNA cannot force phases. It can only suggest importance changes (TimingAdjustment). Agents always control which phases they run in via allowed_phases.

05 — Compliance today

AgentNegotiatesApplies budgetReports status
RenderAgent3 strategies (Unlit / LitForward / Forward+)Switches lane strategyFrame time, draw calls, lights
ShadowAgent1 strategy (atlas)(no-op, single strategy)Atlas usage, cascade count
PhysicsAgent3 strategies (Standard / Simplified / Disabled)Adjusts fixed timestepStep time, body count, collider count
UiAgent1 strategy (layout + render)(no-op, single strategy)Node count, text count
AudioAgent3 strategies (Full / Reduced / Minimal)Adjusts max sourcesSource count, frame

GORNA v0.3 is the current version. Agents that today expose a single strategy are placeholders for future split — for instance, UiAgent will gain density-based strategies as the editor’s UI complexity grows.

06 — Cold path and hot path, again

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

The two paths only touch through the BudgetChannel — one crossbeam_channel per agent, shared current-state cache, last-wins semantics. The hot path never blocks on the cold path. If a budget is late, the previous one stays in effect.


For game developers

GORNA is internal. As a game developer, you observe its decisions through the editor’s GORNA Stream panel: a live feed of “RenderAgent: switching from LitForward to Forward+ — reason: GPU pressure.” If you want a lane never to switch, today the answer is to override the agent’s negotiation surface (advanced — see Extending Khora). A first-class user-facing constraint API is on the Roadmap under DCC v2.

For engine contributors

The protocol code lives in two crates:

FilePurpose
crates/khora-core/src/control/gorna/Type definitions: NegotiationRequest, NegotiationResponse, ResourceBudget, StrategyOption
crates/khora-control/src/gorna/GornaArbitrator — budget fitting, multi-agent solve
crates/khora-control/src/analysis.rsHeuristicEngine — nine heuristics, death-spiral detection
crates/khora-control/src/service.rsDccService — owns the cold thread, runs the loop

Adding a heuristic: implement the Heuristic trait, register it in HeuristicEngine::new, write a test that feeds synthetic telemetry. Heuristics are pure functions of telemetry → multiplier; do not let them store state without a strong reason.

Adding a new agent strategy: add a new lane, give it a strategy_name(), expose it from the agent’s negotiate(). The arbitrator picks it up automatically as soon as estimate_cost returns a meaningful number.

Decisions

We said yes to

  • A simple, narrow request shape. target_latency, priority, constraints, phase, timing. Anything more would invite each subsystem to invent its own dialect.
  • Heuristics as independent functions. Composable, individually testable, additive.
  • Per-tick re-negotiation. Not per-frame, not per-event. ~50 ms is fast enough to react to thermal events, slow enough to avoid thrashing.
  • Death spiral as a first-class concept. When the engine cannot keep frame budget for several frames in a row, GORNA forces the cheapest strategy. Recovery is monitored and the agent returns to negotiated strategy when the spiral breaks.

We said no to

  • Synchronous negotiation in the hot path. The frame loop never waits for GORNA.
  • Multi-resource vector budgets. Considered. Rejected as premature — the four current resources (time, memory, VRAM, extras) cover every case to date.
  • GORNA forcing phases. Phases are agent-declared. GORNA suggests importance, not when. Otherwise agents lose autonomy over their execution model.

Open questions

  1. User constraints. “In this volume, physics > graphics” is a stated capability without a concrete API. PriorityVolume is in the roadmap.
  2. Adaptation modes. Learning (fully dynamic), Stable (predictable), Manual (locked). The contract for switching at runtime is open.
  3. ML-augmented heuristics. A future heuristic could be a small ML model trained on telemetry. The deployment story (model storage, update cadence) is undecided.

Next: how GORNA decisions become pixels. See Rendering.

Rendering

The pipeline that turns ECS components into pixels. Adaptive, strategy-based, shadow-aware.

  • Document — Khora Rendering v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. Why this design
  2. Frame lifecycle
  3. The frame data containers
  4. The frame graph
  5. Render strategies
  6. Shadow system
  7. Shader files
  8. GPU resource management
  9. The default backend — wgpu
  10. For game developers
  11. For engine contributors
  12. Decisions
  13. Open questions

01 — Why this design

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

Khora’s renderer is a family of strategies. The RenderAgent chooses between Unlit, LitForward, and Forward+ each tick based on GORNA’s budget. The ShadowAgent runs in OBSERVE and publishes the shadow atlas before the main pass. Shaders are WGSL files on disk — editable, reviewable, never inlined.

The default backend is wgpu 28.0. It can be replaced — a Vulkan-direct or Metal-direct backend would drop in as a new khora-infra/src/graphics/<backend>/ and implement the RenderSystem trait from khora-core.

02 — Frame lifecycle

EngineCore::tick_with_services
  ├─ drain_inputs                                     # Pop queued events
  │
  ├─ run_app_update(&inputs)
  │   ├─ app.update(world, inputs)                    # User logic
  │   ├─ world.tick_maintenance()                     # ECS GC
  │   ├─ GpuCache: upload freshly added meshes        # GPU mesh sync
  │   ├─ extract_scene → RenderWorldStore             # Read ECS, fill render scene
  │   └─ extract_ui_scene → UiSceneStore              # Read ECS, fill UI scene
  │
  ├─ presents = begin_render_frame(&frame_services)
  │   └─ RenderSystem::begin_frame()
  │       ├─ device.poll_device_non_blocking()
  │       ├─ device.wait_for_last_submission()
  │       ├─ get_current_texture()
  │       └─ insert ColorTarget, DepthTarget, ClearColor → FrameContext
  │
  ├─ run_scheduler(&frame_services)
  │   ├─ OBSERVE phase
  │   │   ├─ ShadowAgent.execute()                    # Encode shadow atlas pass
  │   │   │   └─ records into SharedFrameGraph; publishes ShadowAtlasView,
  │   │   │      ShadowComparisonSampler into FrameContext
  │   │   └─ RenderAgent.execute() (Observe)          # Records main pass
  │   │       └─ LitForwardLane reads atlas from FrameContext, encodes draw
  │   ├─ TRANSFORM phase                              # Physics, audio, AI agents
  │   ├─ MUTATE phase
  │   ├─ OUTPUT phase
  │   │   └─ UiAgent.execute()                        # Records UI overlay pass
  │   │       └─ UiRenderLane (LoadOp::Load) into SharedFrameGraph
  │   └─ FINALIZE phase                               # Telemetry, cleanup
  │
  └─ end_render_frame(presents)
      ├─ submit_frame_graph(graph, device)            # Drain passes, topo-order, submit
      └─ RenderSystem::end_frame(presents)            # surface_texture.present()

One swapchain acquire, one present, per frame. Lanes do not encode directly to a shared encoder — they record PassDescriptors into the SharedFrameGraph, and the engine drains the graph after the scheduler completes.

03 — The frame data containers

Several engine-registered services carry data through the frame:

ServiceCratePurpose
GpuCachekhora-data::gpu::cacheShared GPU mesh store — handles to uploaded mesh buffers, keyed by ECS handle
ProjectionRegistrykhora-dataRuns sync_all() once per frame before agents — uploads new meshes through GpuCache, syncs projection state
RenderWorldStorekhora-data::renderArc<RwLock<RenderWorld>> populated each frame by extract_scene from the ECS
UiSceneStorekhora-data::uiArc<RwLock<UiScene>> populated each frame by extract_ui_scene
SharedFrameGraphkhora-data::render::frame_graphArc<Mutex<FrameGraph>> — pass collector; agents append, the engine drains
FrameContextkhora-core::renderer::api::core::frame_contextPer-frame blackboard for cross-agent sync (shadow atlas, color/depth targets, stages)

Game code never touches these directly; agents and lanes read and write them through the per-frame service registry.

04 — The frame graph

FrameGraph is Khora’s pass collector. It is intentionally simple — a list of PassDescriptors with declared resource reads and writes, plus the matching command buffers, ordered topologically before submission.

#![allow(unused)]
fn main() {
pub struct PassDescriptor {
    pub name: String,
    pub reads:  Vec<ResourceId>,
    pub writes: Vec<ResourceId>,
}

pub enum ResourceId {
    Color,
    Depth,
    ShadowAtlas,
    Custom(u64),
}
}

Lanes call frame_graph.lock().submit_pass(descriptor, command_buffer) during OUTPUT (or any phase that produces GPU work). After the Scheduler returns, the engine calls submit_frame_graph(graph, device) which:

  1. Builds the dependency graph from reads / writes overlap.
  2. Topologically orders passes — a ShadowAtlas-write pass must precede a ShadowAtlas-read pass, even if the lanes were registered in a different order.
  3. Submits command buffers in the resolved order.

This is not a full render-graph framework. There is no implicit resource allocation, no transient resource pooling, no aliasing analysis. Khora has nine to ten passes per frame today; that does not warrant the complexity. The decision is logged in Decisions; the size at which we revisit is in Open questions.

05 — Render strategies

Per-frame switching via GORNA. The RenderAgent selects based on its current ResourceBudget.

StrategyLaneDescription
UnlitSimpleUnlitLaneNo lighting, baseline cost
ForwardLitForwardLanePBR with per-light passes, shadow sampling (PCF 3×3)
Forward+ForwardPlusLaneTile-based light culling, many lights
ShadowShadowPassLaneDepth-only shadow map rendering (owned by ShadowAgent)
UIUiRenderLane2D UI primitives (owned by UiAgent)
ExtractExtractLaneECS → GPU-ready data transfer

The transition between strategies is seamless: pipelines for all strategies are pre-compiled at boot; switching is one bind group flip.

06 — Shadow system

ShadowAgent is the canonical example of agent split. It runs in OBSERVE, before RenderAgent, and produces:

  • A 2048 × 2048 Depth32Float shadow atlas with 4 layers.
  • A ShadowAtlasView and ShadowComparisonSampler in FrameContext.

RenderAgent declares AgentDependency::Hard(AgentId::ShadowRenderer). The Scheduler enforces ordering. The lit forward pass reads the atlas from the per-frame context.

DetailValue
Atlas size2048 × 2048, Depth32Float
Layers4 (one per cascade)
Light typeDirectional (orthographic projection from camera frustum AABB in light space)
Texel snappingOrtho bounds rounded to texel-aligned boundaries to prevent shimmer
SamplingPCF 3×3 in lit_forward.wgsl with comparison sampler
Inter-agent transportShadowAtlasView + ShadowComparisonSampler slots in FrameContext

Shimmer prevention is the subtle bit. A naive ortho projection re-derived per frame jitters by sub-texel amounts as the camera moves, producing crawl on shadow edges. We snap the ortho bounds to texel boundaries — visible artifacts disappear.

07 — Shader files

All shaders are WGSL files. Inlining shader source as a Rust string is forbidden by the rules.

ShaderPurpose
lit_forward.wgslPBR lit material with shadow sampling
shadow_depth.wgslDepth-only shadow pass
simple_unlit.wgslBasic unlit material
standard_pbr.wgslPBR material model
forward_plus.wgslForward+ light culling
ui.wgslUI rendering

All under crates/khora-lanes/src/render_lane/shaders/.

08 — GPU resource management

All GPU resources are accessed through typed IDs:

TypeRefers to
TextureIdA managed texture allocation
BufferIdA managed buffer allocation
PipelineIdA managed render or compute pipeline
BindGroupIdA managed bind group
SamplerIdA managed sampler

WgpuDevice (the default backend implementation of RenderSystem) manages creation, destruction, and lifetime tracking. SubmissionIndex is stored per submit for GPU sync via wait_for_last_submission().

Public APIs never expose raw wgpu handles. This is the seam that lets us swap the backend.

09 — The default backend — wgpu

The current implementation is wgpu 28.0. It targets Vulkan, Metal, DX12 — and WebGPU once the spec stabilizes for our subset.

FilePurpose
crates/khora-infra/src/graphics/wgpu/system.rsWgpuRenderSystem — implements RenderSystem
crates/khora-infra/src/graphics/wgpu/device.rsWgpuDevice — manages GPU resources

To swap to a different backend (Vulkan-direct, Metal-direct, even a software rasterizer for tests): create crates/khora-infra/src/graphics/<backend>/, implement RenderSystem and the device contract, register it in the SDK’s service initialization. Lanes never see the change — they hold Arc<dyn GraphicsDevice>, not a concrete type.


For game developers

Most rendering work is component setup:

#![allow(unused)]
fn main() {
// A camera
world.spawn((
    Transform::default(),
    GlobalTransform::identity(),
    Camera::new_perspective(std::f32::consts::FRAC_PI_4, 16.0/9.0, 0.1, 1000.0),
    Name::new("Main Camera"),
));

// A light with shadows
world.spawn((
    Transform::from_translation(Vec3::new(2.0, 5.0, 3.0)),
    GlobalTransform::identity(),
    Light::directional(LinearRgba::WHITE, 1.0).with_shadows(true),
));

// A mesh entity
world.spawn((
    Transform::default(),
    GlobalTransform::identity(),
    HandleComponent::new(mesh_handle),
    MaterialComponent::pbr(albedo_handle),
));
}

Behind the scenes, RenderAgent extracts these every frame, picks the strategy GORNA approved, and renders. You do not call render functions; you describe a scene.

To watch the engine’s choices in real time, open the editor and look at the GORNA Stream panel.

For engine contributors

The render pipeline is a stack of lanes orchestrated by two agents (RenderAgent, ShadowAgent). To add a new render strategy:

  1. Create a new lane under crates/khora-lanes/src/render_lane/ implementing Lane.
  2. Add its WGSL shader under crates/khora-lanes/src/render_lane/shaders/.
  3. Wire it into RenderAgent::negotiate as a StrategyOption with cost estimate.
  4. Add a switch in RenderAgent::apply_budget to instantiate the new lane.
  5. Write a benchmark — add the strategy’s cost to MEMORY.md.

Cost estimates calibrate themselves over time through telemetry, but the initial value should reflect a measured baseline.

For shadow work specifically: the atlas size, cascade count, and PCF kernel are tunable in ShadowPassLane. Texel-snapping logic lives in the same lane — leave it alone unless you can prove a bug.

Decisions

We said yes to

  • Strategy-based rendering. Three strategies cover a wide performance envelope. More can be added without restructuring.
  • Shadow as a separate agent. Decoupling shadow encoding from the main pass lets us run them in parallel phases and lets ShadowAgent negotiate atlas density independently.
  • WGSL files on disk, never strings. Hot-reload, syntax highlighting, review.
  • GPU IDs over raw handles. The seam that makes the backend swappable.
  • One acquire, one present per frame. Anything else fights the swapchain abstraction.

We said no to

  • A 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.
  • Inline shader source. Convenient at first, miserable at scale. The rule is absolute.
  • Backend choice exposed in lane code. Lanes hold Arc<dyn GraphicsDevice>. They never know whether wgpu, Vulkan-direct, or anything else is underneath.

Open questions

  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.

Next: physics. See Physics.

Physics

Rigid-body simulation through the PhysicsProvider trait. Default backend is Rapier3D.

  • Document — Khora Physics v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The contract
  2. Pipeline
  3. Components
  4. Fixed timestep
  5. The default backend — Rapier3D
  6. PhysicsAgent and GORNA
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — The contract

The physics surface is a single trait in khora-core:

#![allow(unused)]
fn main() {
pub trait PhysicsProvider: Send + Sync {
    fn step(&mut self, dt: f32);
    fn add_rigid_body(&mut self, ...) -> RigidBodyHandle;
    fn add_collider(&mut self, ...) -> ColliderHandle;
    fn raycast(&self, origin: Vec3, dir: Vec3, max_dist: f32) -> Option<RaycastHit>;
    // ...
}
}

PhysicsAgent does not call Rapier. It calls PhysicsProvider. The default implementation today is the Rapier3D backend in khora-infra. A future native Khora solver (see Roadmap Phase 6) drops in as a new implementation of the same trait without touching agent or lane code.

02 — Pipeline

ECS (RigidBody, Collider, GlobalTransform)
  ↓ sync to PhysicsProvider
StandardPhysicsLane::execute()
  ↓ PhysicsProvider::step(dt)
  ↓ sync back to ECS (updated positions/rotations)
PhysicsDebugLane (optional: visualize collision shapes)

The StandardPhysicsLane is the only required lane. PhysicsDebugLane is opt-in, switched on through the editor for debugging.

03 — Components

ComponentPurpose
TransformLocal pose — physics reads it on body creation, writes back after step
GlobalTransformWorld-space pose — synced from physics every frame
RigidBodyBody type (Dynamic, Static, Kinematic), mass, velocity, CCD flag
ColliderShape descriptor — Cuboid, Sphere, Capsule, TriMesh, ConvexHull

RigidBody::Dynamic participates in dynamics. Static is unmovable terrain. Kinematic is moved by code, not by forces, but pushes other bodies.

Continuous Collision Detection (CCD) is opt-in per body via RigidBody::with_ccd(true). It catches tunneling at the cost of step time; use it for fast-moving small bodies (bullets, thrown objects).

04 — Fixed timestep

PhysicsAgent uses a fixed timestep with an accumulator pattern:

#![allow(unused)]
fn main() {
self.accumulator += dt;
while self.accumulator >= self.fixed_step {
    self.provider.step(self.fixed_step);
    self.accumulator -= self.fixed_step;
}
}

Default: fixed_step = 1.0 / 60.0. GORNA may negotiate this — under heavy load, PhysicsAgent can switch to the Simplified strategy with a longer step or, in extremis, to Disabled (no simulation).

Determinism is the reason for fixed timestep. Variable steps cause subtle simulation drift across machines and replays.

05 — The default backend — Rapier3D

FilePurpose
crates/khora-infra/src/physics/rapier/mod.rsRapierPhysicsProvider — implements PhysicsProvider

Rapier3D 0.x is the dependency. The wrapper translates Khora’s RigidBody / Collider / Vec3 / Quat into Rapier types and back. Raycasts go through Rapier’s QueryPipeline.

Future: a native Khora solver replaces Rapier without touching StandardPhysicsLane or PhysicsAgent. The roadmap targets MLS-MPM for unified simulation, IPC for collision, XPBD + ADMM for constraints. See Roadmap Phase 6.

06 — PhysicsAgent and GORNA

PhysicsAgent exposes three strategies:

StrategyLaneWhen
StandardStandardPhysicsLane, fixed_step = 1/60 sHealthy budget, normal scene
SimplifiedStandardPhysicsLane, fixed_step = 1/30 sMid-pressure — half the simulation cost
DisabledNoneDeath spiral — physics turned off until recovery

GORNA picks based on frame budget, GPU pressure (which can crowd CPU through synchronization), and death-spiral detection. The transition is graceful — bodies keep their state; only the step rate changes.


For game developers

#![allow(unused)]
fn main() {
// A static ground plane
world.spawn((
    Transform::default(),
    GlobalTransform::identity(),
    RigidBody::static_(),
    Collider::cuboid(Vec3::new(50.0, 0.1, 50.0)),
));

// A dynamic falling box
world.spawn((
    Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)),
    GlobalTransform::identity(),
    RigidBody::dynamic().with_mass(1.0),
    Collider::cuboid(Vec3::new(0.5, 0.5, 0.5)),
));

// A bullet — fast, small, CCD on
world.spawn((
    Transform::from_translation(player_pos),
    GlobalTransform::identity(),
    RigidBody::dynamic().with_velocity(forward * 50.0).with_ccd(true),
    Collider::sphere(0.05),
));
}

For raycasts and shape queries from gameplay code, use PhysicsQueryService from khora-agents — an on-demand wrapper that does not require an active PhysicsAgent step:

#![allow(unused)]
fn main() {
let service = ctx.services.get::<Arc<PhysicsQueryService>>().unwrap();
if let Some(hit) = service.raycast(origin, dir, 100.0) {
    log::info!("Hit entity {:?} at {:?}", hit.entity, hit.point);
}
}

For engine contributors

The split is clean:

FilePurpose
crates/khora-core/src/physics/PhysicsProvider trait, body and collider types, raycast types
crates/khora-lanes/src/physics_lane/standard.rsStandardPhysicsLane — calls PhysicsProvider::step
crates/khora-lanes/src/physics_lane/debug.rsPhysicsDebugLane — visualization
crates/khora-agents/src/physics_agent/mod.rsPhysicsAgent — accumulator, GORNA negotiation
crates/khora-infra/src/physics/rapier/Rapier3D backend

To add a backend: implement PhysicsProvider in a new khora-infra/src/physics/<backend>/ folder, register it as a service in the SDK init. Done. The agent and lane are unchanged.

To add a new strategy: today there are three (Standard / Simplified / Disabled). A fourth would be added to PhysicsAgent::negotiate with a different StrategyOption — for example, a SIMD-accelerated path enabled on supported CPUs.

Decisions

We said yes to

  • PhysicsProvider trait, single contract. Everything physics-related goes through it. No agent or lane reaches into Rapier directly.
  • Fixed timestep with accumulator. Determinism beats per-frame variance.
  • CCD as opt-in per body. Free for static and slow-moving objects; available where it matters.
  • Strategy includes Disabled. Better to render a frame with no physics than to drop a frame.

We said no to

  • Calling Rapier from agents or game code. The seam is PhysicsProvider. Anything else couples the engine to one backend.
  • A separate physics tick loop. PhysicsAgent owns its accumulator. The frame loop is one loop.
  • Variable timestep. Considered. Rejected. Determinism is load-bearing for replays, multiplayer, and bug reports.

Open questions

  1. Per-region simulation rate. “Use Standard near the player, Simplified everywhere else” is a stated goal of AGDF — the API for it is not built.
  2. Physics state in serialization. SerializationGoal::FastestLoad does not preserve velocities or contacts (see Serialization). 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.

Next: spatial audio. See Audio.

Audio

3D positional audio through the AudioDevice trait. Default backend is CPAL.

  • Document — Khora Audio v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The contract
  2. Pipeline
  3. Components
  4. Spatial mixing
  5. The default backend — CPAL
  6. AudioAgent and GORNA
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — The contract

The audio surface is a single trait in khora-core:

#![allow(unused)]
fn main() {
pub trait AudioDevice: Send + Sync {
    fn start(&mut self, callback: Box<dyn FnMut(&mut [f32]) + Send>);
    fn stop(&mut self);
    fn sample_rate(&self) -> u32;
    fn channels(&self) -> u16;
}
}

AudioAgent does not call CPAL. It calls AudioDevice. The current implementation is CpalAudioDevice in khora-infra. A different audio backend (XAudio2, OpenAL, web audio in a future browser target) drops in as a new khora-infra/src/audio/<backend>/.

02 — Pipeline

ECS (AudioSource, AudioListener, GlobalTransform)
  ↓
SpatialMixingLane::execute()
  ↓ distance attenuation, directional mixing
AudioDevice::start() → callback fills output buffer

Each frame, SpatialMixingLane:

  1. Finds the active AudioListener in the ECS.
  2. For every AudioSource, computes distance and direction relative to the listener.
  3. Applies attenuation curves and panning.
  4. Mixes the result into the output buffer the audio callback consumes.

The mix runs at frame rate; the callback runs at the device’s sample rate (typically 48 kHz). The two are decoupled through a ring buffer.

03 — Components

ComponentPurpose
AudioSourceAudio clip handle, volume, spatial flag, looping flag
AudioListenerMarks the entity whose position is the listener’s position
GlobalTransformWorld-space pose — provides the source / listener position

The active listener is the entity that has both AudioListener and GlobalTransform. If multiple exist, the first registered is used (this may become a configurable selection in a future version).

04 — Spatial mixing

The SpatialMixingLane does the mathematical work:

StepComputation
Distance`
AttenuationInverse-square with floor and ceiling parameters
DirectionVector from listener to source, transformed into listener space
PanDirection’s lateral component → stereo balance

Sources beyond a distance threshold are culled (no mix work). Sources without spatial flag set are mixed without 3D processing — they are 2D sources (UI sounds, music).

Audio formats supported through Symphonia: WAV, Ogg Vorbis, MP3, FLAC. The decoder is a separate lane (SymphoniaLoaderLane or WavLoaderLane) — see Assets and VFS.

05 — The default backend — CPAL

FilePurpose
crates/khora-infra/src/audio/cpal/CpalAudioDevice — implements AudioDevice

CPAL provides the cross-platform device enumeration, format negotiation, and callback loop. Khora wraps it in the AudioDevice contract and delivers the mixed buffer through the callback.

To swap to another backend: implement AudioDevice in khora-infra/src/audio/<backend>/, register it as a service. Lanes never see the change — they hold the trait object.

06 — AudioAgent and GORNA

AudioAgent exposes three strategies based on the source budget:

StrategyMax sourcesWhen
Full64Healthy budget
Reduced16Mid-pressure
Minimal4Heavy pressure or low battery

Beyond the budget, sources are culled by priority — distance from listener and clip volume. The clip itself is not stopped at the source level; the mixer skips it for that frame.


For game developers

#![allow(unused)]
fn main() {
// The listener (usually attached to the camera)
world.spawn((
    Transform::default(),
    GlobalTransform::identity(),
    Camera::default(),
    AudioListener::default(),
));

// A 3D positional sound
let clip = asset_service.load::<SoundData>("sfx/footstep.wav").await?;
world.spawn((
    Transform::from_translation(footstep_pos),
    GlobalTransform::identity(),
    AudioSource::spatial(clip).with_volume(0.7),
));

// A 2D sound (no spatial processing)
let music = asset_service.load::<SoundData>("music/theme.ogg").await?;
world.spawn((
    AudioSource::ambient(music).with_loop(true),
));
}

To stop a source, despawn the entity. To pause, mute the volume to zero. Audio playback is tied to entity lifetime — there is no global “playing sounds” registry to manage.

For engine contributors

The split mirrors physics:

FilePurpose
crates/khora-core/src/audio/AudioDevice trait, audio types
crates/khora-lanes/src/audio_lane/spatial_mixing.rsSpatialMixingLane — distance, direction, panning
crates/khora-agents/src/audio_agent/mod.rsAudioAgent — source budget, GORNA negotiation
crates/khora-infra/src/audio/cpal/CPAL backend

To add a new mixing strategy (HRTF, ambisonics): create a new lane under audio_lane/, expose it from AudioAgent::negotiate with cost estimate. The current SpatialMixingLane stays as the default.

Decisions

We said yes to

  • A single trait surface. AudioDevice is the only seam between Khora and the audio platform.
  • Source budget as the primary GORNA dimension. Audio scales linearly with source count; the budget is a count.
  • Listener-tied to ECS. The listener follows whatever entity has the component, no global state.
  • 2D and 3D sources distinguished by flag. No separate APIs.

We said no to

  • Calling CPAL directly from anywhere except the backend folder. Same rule as everywhere else.
  • A “global music” channel. Music is just an AudioSource without the spatial flag. Less special-casing.
  • DSP effects in v1. Reverb, EQ, filters — all real, all valuable, not yet implemented. The ring buffer is the seam where they will plug in.

Open questions

  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.

Next: how assets get loaded. See Assets and VFS.

Assets and VFS

How Khora finds, loads, and stores assets — meshes, textures, fonts, audio. Built on a virtual file system and per-format decoders.

  • Document — Khora Assets v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The pipeline
  2. The Virtual File System
  3. AssetSource — file or pack
  4. Decoders
  5. AssetService and handles
  6. .pack archives
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — The pipeline

flowchart LR
    A[AssetUUID] --> B[VirtualFileSystem]
    B --> C{AssetSource}
    C -->|Path| D[FileLoader]
    C -->|Packed| E[PackLoader]
    D --> F[AssetDecoder]
    E --> F
    F --> G["Assets&lt;T&gt;"]
    G --> H["AssetHandle&lt;T&gt;"]
StepComponentPurpose
1AssetUUIDUnique identifier for an asset
2VirtualFileSystemUUID → metadata lookup (O(1))
3AssetSourcePath (dev) or packed offset/size (release)
4AssetIoFileLoader or PackLoader — reads raw bytes
5AssetDecoder<A>Decodes bytes into a typed asset
6Assets<T>Typed storage registry
7AssetHandle<T>Typed handle game code carries around

The whole pipeline is on-demand — assets are services, not agents. There is no “asset agent” because there are no strategies to negotiate.

02 — The Virtual File System

VirtualFileSystem is a UUID → metadata table. UUIDs are stable across builds; paths are not. This is the seam that lets us ship loose files in development and packed archives in release without changing game code.

#![allow(unused)]
fn main() {
let vfs = ctx.services.get::<Arc<VirtualFileSystem>>().unwrap();
let metadata = vfs.lookup(asset_uuid)?;
match metadata.source {
    AssetSource::Path(p) => /* dev mode */,
    AssetSource::Packed { offset, size } => /* release mode */,
}
}

VFS lookups are O(1) — a HashMap<Uuid, AssetMetadata>. The metadata carries the source descriptor and any pre-decode hints (mip levels, vertex count, sample rate).

03 — AssetSource — file or pack

VariantUseReader
AssetSource::Path(PathBuf)Development — loose files on diskFileLoader
AssetSource::Packed { offset, size }Release — single .pack filePackLoader

Both implement the AssetIo trait. The decoder layer above does not know which is in use.

04 — Decoders

Decoders are lanes (the asset-loader lanes). Each decodes one format into one typed asset.

#![allow(unused)]
fn main() {
pub trait AssetDecoder<A: Asset> {
    fn load(&self, bytes: &[u8]) -> Result<A, Box<dyn Error + Send + Sync>>;
}
}
DecoderAsset typeFormat
TextureLoaderLaneCpuTexturePNG, JPG, BMP
GltfLoaderLaneMeshglTF 2.0
ObjLoaderLaneMeshOBJ
FontLoaderLaneFontTTF, OTF
WavLoaderLaneSoundDataWAV
SymphoniaLoaderLaneSoundDataMP3, Ogg, FLAC

Adding a format means adding a decoder and registering it. The DecoderRegistry maps file extensions to decoders.

05 — AssetService and handles

AssetService is the public face of the asset pipeline:

#![allow(unused)]
fn main() {
let service = ctx.services.get::<Arc<AssetService>>().unwrap();
let handle: AssetHandle<Mesh> = service.load("models/character.gltf").await?;
}

AssetHandle<T> is a typed handle that game code stores in components. It is reference-counted and cloneable; the underlying asset is freed when no handle remains.

OperationWhat happens
load("path")VFS lookup → AssetIo read → AssetDecoder decode → store in Assets<T> → return handle
get(handle)Look up by handle in Assets<T>
Drop last handleAsset is removed from Assets<T> on next maintenance tick

Loading is async because file I/O is. The decoder runs on the calling thread today; a future revision moves it to a thread pool for large assets.

06 — .pack archives

In release builds, all assets are bundled into a single .pack file:

.pack file
┌─────────────────────────────────────┐
│ Header                              │
│  Magic: "KHORAPACK"                 │
│  Version                            │
│  Entry count                        │
│  Index offset                       │
├─────────────────────────────────────┤
│ Asset blob 0                        │
│ Asset blob 1                        │
│ ...                                 │
├─────────────────────────────────────┤
│ Index (UUID → offset, size)         │
└─────────────────────────────────────┘

PackLoader reads from .pack using mmap for zero-copy access. The VFS is built from the index at startup.

The pack builder is a separate tool (under construction). Today, development uses FileLoader against loose files.


For game developers

#![allow(unused)]
fn main() {
// In setup or update
let asset_service = ctx.services.get::<Arc<AssetService>>().unwrap();

// Load (await — async)
let mesh: AssetHandle<Mesh> = asset_service.load("models/cube.gltf").await?;
let texture: AssetHandle<CpuTexture> = asset_service.load("textures/wood.png").await?;

// Spawn an entity using the handles
world.spawn((
    Transform::default(),
    GlobalTransform::identity(),
    HandleComponent::new(mesh),
    MaterialComponent::pbr(texture),
));
}

Handles are cheap to clone — they are reference-counted. Multiple entities can share one mesh or texture without duplicating GPU memory.

When an entity is despawned, its handles drop. When the last handle to an asset drops, the asset is queued for unload on the next maintenance tick.

For your own asset types: implement the Asset trait (it is a marker — Send + Sync + 'static), write an AssetDecoder<MyAsset>, register it. The pipeline takes over.

For engine contributors

The split:

FilePurpose
crates/khora-core/src/asset/Asset trait, AssetHandle<T>
crates/khora-core/src/vfs/VirtualFileSystem, AssetMetadata, AssetSource
crates/khora-io/src/asset/AssetService, AssetIo trait, FileLoader, PackLoader, DecoderRegistry
crates/khora-lanes/src/asset_lane/loading/Per-format decoder lanes
crates/khora-data/src/assets/Assets<T> typed storage

Adding a format: write a struct implementing AssetDecoder<MyAsset>, register it in DecoderRegistry::new() keyed on extension. Done.

Adding a backend (e.g., loading from a network CDN): implement AssetIo, swap it through service registration. The VFS, decoders, and handles do not change.

Decisions

We said yes to

  • UUID-based identity. Paths change; UUIDs are forever. Renaming a file or moving it does not break references.
  • Loose files in dev, pack in release. Same code path through AssetIo; only the loader implementation differs.
  • Asset loaders as lanes. Format decoding is a pipeline stage, exactly like rendering. Lane lifecycle (prepare / execute / cleanup) maps cleanly.
  • Reference-counted handles. Game code does not manage asset lifetime. Drop the handle, the asset goes away.

We said no to

  • Asset path strings as identity. Strings are ergonomic; UUIDs are correct. The VFS provides the path → UUID resolution at edit time.
  • An “asset agent.” Loading has no strategies to negotiate. It is a service.
  • Asset hot-reload as a v1 feature. Designed (the VFS layer can detect changes), not yet implemented.

Open questions

  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.

Next: UI. See UI.

UI

In-engine UI built on the LayoutSystem trait. Default backend is Taffy.

  • Document — Khora UI v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The contract
  2. Pipeline
  3. Components
  4. The default backend — Taffy
  5. UiAgent
  6. For game developers
  7. For engine contributors
  8. Decisions
  9. Open questions

01 — The contract

UI layout is one trait in khora-core:

#![allow(unused)]
fn main() {
pub trait LayoutSystem: Send + Sync {
    fn compute(&mut self, root: NodeId, available_space: Vec2) -> Layout;
    fn add_node(&mut self, ...) -> NodeId;
    fn remove_node(&mut self, node: NodeId);
    // ...
}
}

The renderer is a separate concern — UiRenderLane rasterizes the laid-out nodes. The split lets a different layout engine (Yoga, custom) drop in by implementing LayoutSystem.

02 — Pipeline

ECS (UiTransform, UiColor, UiText, UiImage, UiBorder)
  ↓ StandardUiLane (Taffy layout computation)
UiScene (ExtractedUiNode[], ExtractedUiText[])
  ↓ UiRenderLane (rasterize to screen)

Two lanes:

  • StandardUiLane in Observe — reads UI components, runs Taffy, produces a UiScene of pre-laid-out nodes.
  • UiRenderLane in Output — rasterizes the UiScene to the swapchain. Uses LoadOp::Load so it composites over whatever the RenderAgent drew.

UiAgent owns both. Both run in the same frame.

03 — Components

ComponentPurpose
UiTransformPosition, size, anchoring (top-left, center, etc.)
UiColorBackground color
UiTextText content, font handle, color, font size
UiImageTexture handle, scale mode (stretch, tile, fit)
UiBorderBorder width and color

UI entities live in the same ECS as everything else. They use UiTransform instead of the spatial Transform because the coordinate space is screen-space, not world-space.

Hierarchy works through Parent and Children, the same as scene hierarchy. A panel with child elements is just a parent entity with UiTransform and UiColor, plus children with their own UI components.

04 — The default backend — Taffy

FilePurpose
crates/khora-infra/src/ui/taffy/mod.rsTaffyLayoutSystem — implements LayoutSystem

Taffy provides flex and grid layout. Khora maps UiTransform’s anchoring and sizing to Taffy’s Style, runs compute_layout, and reads back per-node positions and sizes.

To swap backends: implement LayoutSystem in khora-infra/src/ui/<backend>/, register it. StandardUiLane is unchanged.

05 — UiAgent

UiAgent runs in editor mode (allowed_modes: vec![EngineMode::Editor]). It exposes one strategy today — layout + render — without GORNA negotiation. As the editor’s UI complexity grows, density-based strategies (full / simplified / hidden chrome) will be added.

The agent’s execute() runs StandardUiLane in Observe and UiRenderLane in Output, both with the per-frame LaneContext.


For game developers

UI is built by spawning entities with UI components:

#![allow(unused)]
fn main() {
// A panel
let panel = world.spawn((
    UiTransform::new()
        .with_size(400.0, 300.0)
        .with_anchor(Anchor::Center),
    UiColor::rgba(0.1, 0.1, 0.12, 0.9),
    UiBorder::new(1.0, LinearRgba::WHITE),
));

// Text inside the panel
let label = world.spawn((
    UiTransform::new()
        .with_size_auto()
        .with_anchor(Anchor::TopLeft)
        .with_offset(16.0, 16.0),
    UiText::new("Hello, world.", font_handle).with_size(14.0),
));
world.add_component(label, Parent::new(panel));
}

For dynamic UI (HUD, inventory, dialog), update the components each frame in update(). The layout re-runs automatically when components change.

In-game (non-editor) UI is on the roadmap. Today, UiAgent runs only in Editor mode; the play-mode game uses external HUD code (or none).

For engine contributors

The split:

FilePurpose
crates/khora-core/src/ui/LayoutSystem trait, layout types
crates/khora-data/src/ui/UiTransform, UiColor, UiText, UiImage, UiBorder
crates/khora-lanes/src/ui_lane/StandardUiLane, UiRenderLane
crates/khora-agents/src/ui_agent/mod.rsUiAgent
crates/khora-infra/src/ui/taffy/Taffy backend

To extend the UI component vocabulary: add the component type with #[derive(Component)], register it, teach StandardUiLane to extract it, teach UiRenderLane to draw it.

To swap layout engines: implement LayoutSystem in a new backend folder, register the implementation. The lanes hold the trait object.

The text rendering uses StandardTextRenderer with a glyph cache and atlas — the cache lives in khora-infra because it depends on the GPU device.

Decisions

We said yes to

  • UI components in the same ECS. No separate UI tree, no separate registry. UI is just entities with different components.
  • LayoutSystem trait. Taffy is the default; the seam exists for replacement.
  • Two-lane split (compute + render). The compute pass is in Observe; the render pass is in Output. Same shape as the scene render path.
  • Hierarchy via Parent / Children. Same components used everywhere else. No UiParent, no UiHierarchy.

We said no to

  • An immediate-mode UI inside the engine. egui exists; we use it in some editor surfaces (currently). The engine’s first-class UI is retained-mode through ECS.
  • A separate UI rendering backend. UI rendering shares the GPU device with everything else. There is no “UI renderer.”
  • Taffy types in components. UiTransform is a Khora type. The Taffy Style mapping happens inside StandardUiLane.

Open questions

  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.

Next: scene save and load. See Serialization.

Serialization

Three strategies, one service, one file format. How Khora saves and loads scenes.

  • Document — Khora Serialization v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. Three strategies, three goals
  2. SerializationGoal
  3. The .kscene file format
  4. SerializationService
  5. Component serialization
  6. Play mode snapshots
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — Three strategies, three goals

A scene file has more than one consumer. The editor wants something readable. Release builds want something tiny. Play mode wants something instant. Khora serializes through whichever strategy fits the goal.

StrategyFormatLaneUse case
DefinitionRON (human-readable)DefinitionSerializationLaneDebug, long-term storage, scene authoring
RecipeBinary commandsRecipeSerializationLaneCompact, editor interchange
ArchetypeBinary layoutArchetypeSerializationLaneFastest load, play-mode snapshot

The strategy is selected by SerializationGoal, not by file extension. The .kscene header records which strategy produced the payload, so loading is symmetric.

02 — SerializationGoal

#![allow(unused)]
fn main() {
pub enum SerializationGoal {
    HumanReadableDebug,   // → Definition
    LongTermStability,    // → Definition
    EditorInterchange,    // → Recipe
    Performance,          // → Archetype
    FastestLoad,          // → Archetype (alias)
}
}

The mapping from goal to strategy lives in SerializationService::pick_strategy. Choosing a goal is a developer decision; choosing a strategy is an engine decision.

03 — The .kscene file format

.kscene file
┌─────────────────────────────────────┐
│ Header (64 bytes)                   │
│  Magic: "KHORASCN" (8 bytes)        │
│  Version: 1 (4 bytes)               │
│  Strategy ID (32 bytes)             │
│  Payload length (8 bytes)           │
│  Reserved (12 bytes)                │
├─────────────────────────────────────┤
│ Payload (bincode or RON encoded)    │
└─────────────────────────────────────┘

The header is fixed-size — 64 bytes — so the loader can parse it without any prior format knowledge. The strategy ID tells the loader which lane to dispatch.

A SceneFile in memory is SceneHeader + SerializedPage[]. Pages map directly to ECS archetype pages, which is how Archetype-strategy load can be near-memcpy fast.

04 — SerializationService

#![allow(unused)]
fn main() {
let service = ctx.services.get::<Arc<SerializationService>>().unwrap();

// Save
let scene_file = service.save_world(&world, SerializationGoal::FastestLoad)?;
std::fs::write("scene.kscene", scene_file.to_bytes())?;

// Load
let bytes = std::fs::read("scene.kscene")?;
let file = SceneFile::from_bytes(&bytes)?;
service.load_world(&file, &mut world)?;
}

The service owns the three strategy lanes. It picks the right one based on the requested goal (for save) or the header strategy ID (for load). Scene I/O is on-demand — there is no “serialization agent” because there are no per-frame strategies to negotiate. See the Agent vs Service rule in Architecture.

05 — Component serialization

Every component derived with #[derive(Component)] gets:

  • A SerializableT mirror struct with Encode / Decode.
  • From<T> for SerializableT and the reverse.
  • An inventory::submit! registration for scene serialization.

The mirror exists because GPU handles, runtime caches, and trait objects do not serialize. Fields marked #[component(skip)] are excluded from the mirror — they are reconstructed on load (typically by the asset system or the agent’s on_initialize).

For components that need a fully manual mirror (unit structs, components holding Box<dyn Trait>), #[component(no_serializable)] skips the auto-generation and you write Serialize / Deserialize by hand.

The registration is the seam: scene loading walks the inventory, instantiates the right SerializableT, decodes it, converts to T, attaches to the entity. No string lookups, no dynamic dispatch in the hot path.

06 — Play mode snapshots

Play mode uses Archetype strategy for fast snapshot/restore:

#![allow(unused)]
fn main() {
// Press Play:
let service = SerializationService::new();
let scene_file = service.save_world(&world, SerializationGoal::FastestLoad)?;
world_snapshot = Some(scene_file.to_bytes());

// Press Stop:
let scene_file = SceneFile::from_bytes(&snapshot)?;
service.load_world(&scene_file, &mut world)?;
}

The snapshot/restore is fast because Archetype strategy serializes pages directly, with minimal transformation. A 10 000-entity scene snapshots and restores in milliseconds.

Physics state is not preserved. When restoring, the physics engine rebuilds from component data. Velocities and contacts are reset to defaults. A “physics snapshot” goal is on the Open questions.

07 — For game developers

#![allow(unused)]
fn main() {
// Save the current scene
let service = services.get::<Arc<SerializationService>>().unwrap();
let scene = service.save_world(&world, SerializationGoal::HumanReadableDebug)?;
std::fs::write("my_scene.kscene", scene.to_bytes())?;

// Load a scene at startup
fn setup(&mut self, world: &mut GameWorld, services: &ServiceRegistry) {
    let service = services.get::<Arc<SerializationService>>().unwrap();
    let bytes = std::fs::read("levels/level_01.kscene").unwrap();
    let scene = SceneFile::from_bytes(&bytes).unwrap();
    service.load_world(&scene, world).unwrap();
}
}

For your own components, derive Component. If a field should not be serialized (a GPU handle, a runtime accumulator), mark it #[component(skip)]. Provide a Default so the field can be reconstructed.

Editor scene files use the Definition strategy — they are RON, hand-editable in a pinch. Release scenes typically use Archetype for load speed.

For engine contributors

The split:

FilePurpose
crates/khora-core/src/scene/SceneFile, SceneHeader, SerializationGoal, SerializationStrategy trait
crates/khora-io/src/serialization/SerializationService, strategy registration
crates/khora-lanes/src/scene_lane/DefinitionSerializationLane, RecipeSerializationLane, ArchetypeSerializationLane
crates/khora-data/src/ecs/components/registrations.rsComponent inventory
crates/khora-macros/src/lib.rs#[derive(Component)] — generates SerializableT

Adding a fourth strategy (e.g., DeltaSerialization for save games and undo/redo, on the roadmap): create a new lane implementing SerializationStrategy, register it in SerializationService, add a SerializationGoal variant that maps to it, write tests covering save → load round-trip.

The hardest part is not the strategy; it is verifying the round-trip is lossless across all 25+ standard components. Existing tests cover this; new strategies must add their own.

Decisions

We said yes to

  • Three strategies, one file format. A single .kscene magic, three payload encodings. The header carries the strategy ID.
  • #[derive(Component)] generates the mirror. Two structs to maintain by hand was the single biggest source of serialization bugs.
  • Play mode uses Archetype. Snapshot speed is load-bearing for editor responsiveness.
  • Editor uses Definition. Hand-editable scenes are useful in CI, in code review, in ten-year-old Git histories.

We said no to

  • Reflection-based serialization. Considered. Rejected. The proc macro is faster, statically checked, no allocation.
  • A “serialization agent.” No strategies to negotiate per-frame. Scene I/O is a service.
  • Preserving physics state across play mode. Considered. Rejected for v1 — physics rebuilds from components, which is consistent and predictable. May change.

Open questions

  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.

Next: how the engine watches itself. See Telemetry.

Telemetry

The nervous system. Where measurements come from, where they go, who reads them.

  • Document — Khora Telemetry v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. Why telemetry is first-class
  2. Architecture
  3. The monitors
  4. SaaTrackingAllocator
  5. MetricsRegistry
  6. The DCC consumes telemetry
  7. For game developers
  8. For engine contributors
  9. Decisions
  10. Open questions

01 — Why telemetry is first-class

A self-optimizing engine is only as smart as its inputs. If the DCC cannot see frame time, GPU utilization, VRAM headroom, heap pressure — it cannot make better decisions than a static configuration would.

So telemetry is not an afterthought. It is the nervous system. Monitors run alongside the workload, the registry collects readings, the DCC reads them every cold-path tick and turns them into budget decisions.

The same readings power the editor’s Control Plane — the workspace where the engine’s mind becomes visible. See Editor design system.

02 — Architecture

Hardware monitors (GPU, Memory, VRAM)
  ↓ register with
TelemetryService
  ├─ MonitorRegistry (poll-based readings)
  └─ MetricsRegistry (push-based events)
       ↓
DCC reads at ~20 Hz → Heuristics → GORNA arbitration
       ↓
Editor reads any time → Control Plane panels

Two collection styles:

  • Poll-based monitorsGpuMonitor, MemoryMonitor, VramMonitor. The TelemetryService asks them for the current value.
  • Push-based metrics — agents and lanes push named counters/gauges through MetricsRegistry. The registry is queried any time.

03 — The monitors

MonitorTracks
GpuMonitorGPU utilization, frame timings, queue depths
MemoryMonitorHeap (resident set), virtual size
VramMonitorVideo memory usage
SaaTrackingAllocatorPer-allocation heap tracking

All implementations live in crates/khora-infra/src/telemetry/ because they call platform APIs. The trait surface (what counts as a monitor) is in khora-core and khora-telemetry.

04 — SaaTrackingAllocator

SaaTrackingAllocator is a global allocator that tracks every heap allocation. Installed once at startup:

#![allow(unused)]
fn main() {
#[global_allocator]
static ALLOC: SaaTrackingAllocator = SaaTrackingAllocator::new();
}

It records counts, sizes, and (in debug builds) call sites. The DCC reads the totals to detect memory pressure trends; the editor’s Control Plane shows the live curve.

The cost is small — atomic counters per allocation — but real. In benchmark builds, it can be replaced with the system allocator. The trait surface is khora-core::memory; the implementation is khora-data::allocators.

05 — MetricsRegistry

For per-subsystem metrics that the agents and lanes emit:

#![allow(unused)]
fn main() {
let metrics = ctx.services.get::<Arc<MetricsRegistry>>().unwrap();
metrics.counter("render.draw_calls").inc_by(123);
metrics.gauge("physics.bodies_active").set(42.0);
metrics.histogram("frame.duration_ms").record(15.7);
}

Counters, gauges, histograms. Names are dot-separated by convention. The registry is concurrent — agents on different threads can write without contention.

The DCC’s heuristics read named metrics by string (cold path). The editor’s panels read by string (out of band). Hot-path code does not query metrics by string — agents that need their own readings hold a Counter / Gauge handle.

06 — The DCC consumes telemetry

The cold-path loop (~20 Hz) does:

  1. Poll each registered monitor.
  2. Read named metrics from MetricsRegistry.
  3. Feed the readings into the nine heuristics. See GORNA.
  4. Arbitrate budgets.
  5. Send through BudgetChannel.

Telemetry → heuristic → budget. The whole loop closes through the engine’s own observation of itself.


For game developers

Most game code does not emit telemetry. The engine handles its own.

If you want to measure something specific to your game (boss attack frequency, level transitions, save count), use MetricsRegistry:

#![allow(unused)]
fn main() {
let metrics = ctx.services.get::<Arc<MetricsRegistry>>().unwrap();
metrics.counter("game.bosses_defeated").inc();
metrics.histogram("game.player_health").record(self.player_health as f64);
}

The values appear in the editor’s Console and GORNA Stream panels (with appropriate filtering).

To read live engine metrics from your game (FPS, frame time, GPU utilization for your own UI), the names are documented in crates/khora-telemetry/src/lib.rs under WELL_KNOWN_METRICS.

For engine contributors

The split:

FilePurpose
crates/khora-core/src/memory/Allocator trait, allocation counters
crates/khora-data/src/allocators/saa_tracking.rsSaaTrackingAllocator implementation
crates/khora-telemetry/src/service.rsTelemetryService, lifecycle
crates/khora-telemetry/src/metrics/MetricsRegistry, MonitorRegistry
crates/khora-infra/src/telemetry/GpuMonitor, MemoryMonitor, VramMonitor

Adding a metric: pick a clear name (subsystem.thing.unit), document it as well-known if it is engine-wide, hold a Counter / Gauge handle in the agent or lane that owns it. Do not look up by string in the hot path.

Adding a monitor: implement the Monitor trait, register with MonitorRegistry::register at startup, the DCC will start polling.

Decisions

We said yes to

  • Telemetry as a first-class service. It feeds the DCC; without it, GORNA is blind.
  • Two styles (poll + push). Hardware monitors are pulled; software metrics are pushed.
  • SaaTrackingAllocator as the default. The cost is small; the visibility is enormous. Benchmarks can swap it out.
  • String-keyed metric registry. The cold path can afford the lookup. The hot path holds typed handles.

We said no to

  • A separate “telemetry agent.” Telemetry has no per-frame strategies. It is a service that runs continuously.
  • Hot-path string lookups for metrics. Agents that emit per-frame metrics hold Counter / Gauge handles, not string names.
  • An external profiler-only dependency. Khora’s editor is the primary surface. External tools (Tracy, perf) work as well, but they are not the design point.

Open questions

  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.

Next: the SDK from a game developer’s chair. See SDK quickstart.

SDK quickstart

A working game in under a hundred lines, built from the actual sandbox example.

  • Document — Khora SDK Quickstart v1.0
  • Status — Tutorial
  • Date — May 2026

Contents

  1. Prerequisites
  2. The pieces you need
  3. The minimum game
  4. Walking through it
  5. The bootstrap closure
  6. Vessel — the spawn helper
  7. Adding behavior
  8. Where to go from here

01 — Prerequisites

  • Rust 1.85+ (edition 2024).
  • A GPU that supports Vulkan, Metal, or DX12.
  • Git, for cloning the repo.
git clone https://github.com/eraflo/KhoraEngine
cd KhoraEngine
cargo build
cargo test --workspace

If cargo test --workspace passes, you have a working environment. The shipping demo is cargo run -p sandbox.

02 — The pieces you need

A Khora game has four moving parts:

PieceWhat it is
Your app structHolds game state. Implements EngineApp, AgentProvider, PhaseProvider.
run_winitThe bootstrap entry point. Generic over a window provider and your app.
WgpuRenderSystemThe default rendering backend. You construct it inside the bootstrap closure and register it as a service.
The bootstrap closureWires services (renderer, custom services) before the engine starts spinning.

The pattern is intentionally explicit. There is no hidden global setup — you can see where every dependency comes from.

03 — The minimum game

Drop this into a fresh crate’s main.rs (or read along with examples/sandbox/src/main.rs):

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, InputEvent,
    PhaseProvider, RenderSystem, ServiceRegistry, 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 Khora Game".to_owned(),
            ..WindowConfig::default()
        }
    }

    fn new() -> Self {
        MyGame
    }

    fn setup(&mut self, world: &mut GameWorld, _services: &ServiceRegistry) {
        // A camera looking at the origin
        let camera = khora_sdk::prelude::ecs::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_rotation(Quaternion::from_axis_angle(Vec3::Y, std::f32::consts::PI))
            .build();

        // A flat ground plane
        khora_sdk::spawn_plane(world, 20.0, 0.0).build();

        // A directional sun light with shadows
        let mut sun = khora_sdk::prelude::ecs::Light::directional();
        if let khora_sdk::prelude::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 red sphere in front of the camera
        let mat = khora_sdk::prelude::materials::StandardMaterial {
            base_color: khora_sdk::prelude::math::LinearRgba::RED,
            roughness: 0.2,
            ..Default::default()
        };
        let mat_handle = world.add_material(mat);
        khora_sdk::spawn_sphere(world, 0.75, 32, 16)
            .at_position(Vec3::new(0.0, 0.5, -5.0))
            .with_component(mat_handle)
            .build();
    }

    fn update(&mut self, _world: &mut GameWorld, _inputs: &[InputEvent]) {
        // Game logic — empty for now
    }
}

impl AgentProvider for MyGame {
    fn register_agents(&self, _dcc: &DccService, _services: &mut ServiceRegistry) {
        // No custom agents in this example
    }
}

impl PhaseProvider for MyGame {
    fn custom_phases(&self) -> Vec<khora_sdk::ExecutionPhase> { Vec::new() }
    fn removed_phases(&self) -> Vec<khora_sdk::ExecutionPhase> { Vec::new() }
}

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

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

Run it with cargo run --release. A window opens. You see a red sphere on a ground plane, lit by a sun. The frame rate appears in the editor’s status bar (or whatever your terminal logs).

04 — Walking through it

MyGame and the three traits

Every Khora app implements three traits:

TraitPurpose
EngineAppLifecycle: window_config, new, setup, update, on_shutdown (and a few optional hooks)
AgentProviderWhere you register custom agents with the DCC
PhaseProviderWhere you declare custom execution phases (or remove default ones)

For a basic game, AgentProvider and PhaseProvider are no-ops. They become interesting when you write your own subsystems — see Extending Khora.

The EngineApp lifecycle

MethodWhenWhat you do
window_config()Once, before window creationReturn a WindowConfig (title, size, icon)
new()Once, after window creationConstruct the struct — no engine context yet
setup(world, services)Once, after engine initSpawn entities. services gives you renderer access if needed
update(world, inputs)Every frameGame logic — read inputs, mutate world
on_shutdown()Once, on exitCleanup
before_frame / before_agents / after_agentsOptional per-frame hooksUsed by the editor for UI overlay; rarely needed in games

setup — spawning the world

setup runs once. You spawn cameras, lights, geometry, and any persistent state. The signature gives you a mutable GameWorld and an immutable &ServiceRegistry:

  • world.spawn(...) for raw tuple bundles.
  • Vessel::at(world, position).with_component(c).build() for the builder path.
  • spawn_plane, spawn_sphere, spawn_cube_at for primitive helpers — all return a Vessel you keep building on.
  • world.add_material(m) registers a material and returns a MaterialComponent you attach via .with_component(...).

update — the per-frame hook

update runs every frame. The inputs slice contains InputEvent values translated from the platform window — keys, mouse buttons, mouse moves, scrolls. You mutate the world to reflect game logic.

After mutating a Transform, call world.sync_global_transform(entity) to propagate to the renderer. (update_transform does the mutation and the sync in one call.)

#[global_allocator]

The example installs SaaTrackingAllocator, the heap-tracking allocator that feeds the DCC’s memory heuristics. It is optional — without it, memory metrics are absent. See Telemetry.

05 — The bootstrap closure

#![allow(unused)]
fn main() {
run_winit::<WinitWindowProvider, MyGame>(|window, services, _event_loop| {
    let mut rs = WgpuRenderSystem::new();
    rs.init(window).expect("renderer init failed");
    services.insert(rs.graphics_device());
    let rs: Box<dyn RenderSystem> = Box::new(rs);
    services.insert(Arc::new(Mutex::new(rs)));
})?;
}

run_winit is the engine entry point. It is generic over:

  • A WindowProvider (here, WinitWindowProvider, the default winit-based implementation).
  • Your EngineApp type.

The closure is your one chance to wire services before the engine starts. The default services (DCC, telemetry, asset service) are registered by the engine itself; the renderer is registered by you because the engine is backend-agnostic. Two registrations are needed:

  1. The graphics device (Arc<dyn GraphicsDevice>) — RenderAgent reads it directly.
  2. The boxed render system (Arc<Mutex<Box<dyn RenderSystem>>>) — used by frame submission.

To swap rendering backends, change these two registrations. Lanes hold Arc<dyn GraphicsDevice> and never know which backend is underneath.

06 — Vessel — the spawn helper

Vessel is the SDK’s spawn builder. It guarantees every entity has a Transform and a GlobalTransform, and lets you attach components fluently.

Construction

#![allow(unused)]
fn main() {
// At the origin
let e = Vessel::new(world).build();

// At a position
let e = Vessel::at(world, Vec3::new(0.0, 2.0, 10.0)).build();
}

Builder methods

#![allow(unused)]
fn main() {
Vessel::at(world, pos)
    .with_transform(custom_transform)        // override the local Transform
    .at_position(other_pos)                  // change just the translation
    .with_rotation(quat)                     // change just the rotation
    .with_scale(Vec3::new(2.0, 1.0, 1.0))    // change just the scale
    .with_component(my_camera)               // attach any Component
    .with_component(my_light)                // chain as many as needed
    .build()                                 // returns EntityId
}

Primitive helpers

For prototyping, three top-level functions return a pre-loaded Vessel:

FunctionMesh
spawn_plane(world, size, y)XZ plane at height y, side length size
spawn_cube_at(world, pos, size)Centered cube at pos, side length size
spawn_sphere(world, radius, segments, rings)UV sphere at the origin

Each returns a Vessel — chain .at_position(...), .with_component(...), .build() to finish.

#![allow(unused)]
fn main() {
spawn_sphere(world, 0.75, 32, 16)
    .at_position(Vec3::new(2.0, 0.5, -10.0))
    .with_component(my_material)
    .build();
}

For non-primitive meshes, load through AssetService (see Assets and VFS) and attach the resulting HandleComponent<Mesh> via .with_component(...).

07 — Adding behavior

To make the sphere move when the player presses W:

#![allow(unused)]
fn main() {
struct MyGame {
    sphere: Option<EntityId>,
    forward: bool,
}

impl EngineApp for MyGame {
    fn new() -> Self {
        MyGame { sphere: None, forward: false }
    }

    fn setup(&mut self, world: &mut GameWorld, _services: &ServiceRegistry) {
        // ... camera, plane, light as before ...
        self.sphere = Some(
            spawn_sphere(world, 0.75, 32, 16)
                .at_position(Vec3::new(0.0, 0.5, -5.0))
                .build(),
        );
    }

    fn update(&mut self, world: &mut GameWorld, inputs: &[InputEvent]) {
        for ev in inputs {
            match ev {
                InputEvent::KeyPressed { key_code } if key_code == "KeyW" => self.forward = true,
                InputEvent::KeyReleased { key_code } if key_code == "KeyW" => self.forward = false,
                _ => {}
            }
        }

        if self.forward {
            if let Some(e) = self.sphere {
                world.update_transform(e, |t| {
                    t.translation += Vec3::new(0.0, 0.0, -0.05);
                });
            }
        }
    }
}
}

update_transform mutates and syncs GlobalTransform in one call — no need to call sync_global_transform separately.

For more substantial behavior — AI, scripting, networking — write a custom agent (see Extending Khora). The update method is for per-frame application logic, not for engine subsystems.

08 — Where to go from here

You have a running, interactive program. From here:

  • SDK reference — full API surface: EngineApp, GameWorld, Vessel, WindowConfig, prelude.
  • ECS — CRPECS — how queries, archetypes, and component bundles work.
  • Assets and VFS — loading meshes, textures, audio.
  • Physics — adding RigidBody and Collider for simulation.
  • Audio — spawning AudioSource for spatial audio.
  • Editor — running khora-editor to author scenes visually.
  • Extending Khora — writing custom agents and lanes.

The full sandbox lives at examples/sandbox/src/main.rs. It adds a free-fly camera controller, multiple lights, and a small entity grid. Read it once you are comfortable with the basics.


Next: the full SDK surface. See SDK reference.

SDK reference

The full SDK surface. Every public type, organized by what you do with it.

  • Document — Khora SDK Reference v1.0
  • Status — Authoritative
  • Date — May 2026

Contents

  1. The three application traits
  2. run_winit — the entry point
  3. WindowConfig and WindowProvider
  4. GameWorld — the ECS facade
  5. Vessel and the spawn helpers
  6. ServiceRegistry
  7. The prelude
  8. Input
  9. Engine modes
  10. SDK re-exports
  11. Where things live

01 — The three application traits

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

EngineApp — lifecycle

#![allow(unused)]
fn main() {
pub trait EngineApp: AgentProvider + PhaseProvider + Send + Sync {
    fn window_config() -> WindowConfig;
    fn new() -> Self;
    fn setup(&mut self, world: &mut GameWorld, services: &ServiceRegistry);
    fn update(&mut self, world: &mut GameWorld, inputs: &[InputEvent]);
    fn on_shutdown(&mut self) {}

    // Optional per-frame hooks (used by the editor for UI overlay)
    fn intercept_window_event(&mut self, event: &dyn Any, window: &dyn KhoraWindow) -> bool { false }
    fn before_frame(&mut self, world: &mut GameWorld, services: &ServiceRegistry, window: &dyn KhoraWindow) {}
    fn before_agents(&mut self, world: &mut GameWorld, services: &ServiceRegistry) {}
    fn after_agents(&mut self, world: &mut GameWorld, services: &ServiceRegistry) {}
}
}
MethodWhenWhat you do
window_config()Once, before window creationReturn a WindowConfig
new()Once, after window creationConstruct the struct — no engine context yet
setup(world, services)Once, after engine initSpawn entities; cache service handles
update(world, inputs)Every frameGame logic
on_shutdown()Once, on exitCleanup

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

AgentProvider — register custom agents

#![allow(unused)]
fn main() {
pub trait AgentProvider {
    fn register_agents(&self, dcc: &DccService, services: &mut ServiceRegistry);
}
}

The engine calls this once during boot. For a vanilla game with no custom subsystems, the body is empty. For an engine with a custom AI agent, scripting agent, networking agent, this is where you call dcc.register_agent(...) or dcc.register_agent_for_mode(...).

PhaseProvider — custom execution phases

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

The built-in phases (defined in khora-core::agent::ExecutionPhase) are INIT, OBSERVE, TRANSFORM, MUTATE, OUTPUT, FINALIZE — IDs 0..=5. The default execution order is INIT → OBSERVE → TRANSFORM → MUTATE → OUTPUT → FINALIZE. Apps can insert custom phases (IDs 6..=254 via ExecutionPhase::custom(id)) — by default Engine inserts every custom phase after OUTPUT. Most games return empty vectors.

02 — run_winit — the entry point

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

run_winit opens a window through the provided WindowProvider, initializes the DCC, registers default services, runs your bootstrap closure, then enters the frame loop. It returns when the window closes or on_shutdown exits.

The bootstrap closure receives:

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

The standard bootstrap registers WgpuRenderSystem:

#![allow(unused)]
fn main() {
run_winit::<WinitWindowProvider, MyGame>(|window, services, _event_loop| {
    let mut rs = WgpuRenderSystem::new();
    rs.init(window).expect("renderer init failed");
    services.insert(rs.graphics_device());
    let rs: Box<dyn RenderSystem> = Box::new(rs);
    services.insert(Arc::new(Mutex::new(rs)));
})?;
}

EngineCore is the underlying engine type, exposed in case you need to construct an engine without run_winit (uncommon — only for embedding inside another runtime).

03 — WindowConfig and WindowProvider

WindowConfig

#![allow(unused)]
fn main() {
pub struct WindowConfig {
    pub title: String,
    pub width: u32,            // default 1024
    pub height: u32,           // default 768
    pub icon: Option<WindowIcon>,
}
}

WindowIcon carries an RGBA8 pixel buffer plus dimensions for the platform window icon.

WindowProvider

#![allow(unused)]
fn main() {
pub trait WindowProvider: 'static {
    fn create(native_loop: &dyn Any, config: &WindowConfig) -> Self where Self: Sized;
    fn request_redraw(&self);
    fn inner_size(&self) -> (u32, u32);
    fn scale_factor(&self) -> f64;
    fn as_khora_window(&self) -> &dyn KhoraWindow;
    fn translate_event(&self, raw_event: &dyn Any) -> Option<InputEvent>;
    fn clone_raw_window_arc(&self) -> Arc<dyn Any + Send + Sync>;
}
}

The default implementation is WinitWindowProvider. Alternative providers (SDL, custom embedded windowing) implement the same trait — run_winit is generic over it.

PRIMARY_VIEWPORT

#![allow(unused)]
fn main() {
pub const PRIMARY_VIEWPORT: ViewportTextureHandle = ViewportTextureHandle(0);
}

Well-known handle for the primary 3D viewport. Use it when you need to refer to “the main rendering target” — for example, when the editor needs to render gizmos over the same view.

04 — GameWorld — the ECS facade

GameWorld is the safe entry point for the ECS. Internal types from khora-data are wrapped behind a stable surface.

Lifecycle

MethodPurpose
GameWorld::new()Empty world
GameWorld::from_world(world)Wrap an existing World (used for play mode restore)
tick_maintenance()Run one ECS GC pass (called by the engine each frame)

Entities

#![allow(unused)]
fn main() {
let entity: EntityId = world.spawn((Transform::identity(), GlobalTransform::identity()));
let removed: bool = world.despawn(entity);
let entity = world.spawn_camera(camera);                 // Camera + GlobalTransform
let entity = world.spawn_entity(&transform);             // Transform + GlobalTransform
for id in world.iter_entities() { /* ... */ }
}

Components

#![allow(unused)]
fn main() {
world.add_component(entity, my_component);
world.remove_component::<MyComponent>(entity);

let r: Option<&MyComponent>     = world.get_component::<MyComponent>(entity);
let m: Option<&mut MyComponent> = world.get_component_mut::<MyComponent>(entity);

// Convenience for Transform (the most-used component)
let r: Option<&Transform>     = world.get_transform(entity);
let m: Option<&mut Transform> = world.get_transform_mut(entity);
}

Queries

#![allow(unused)]
fn main() {
for (t, g) in world.query::<(&Transform, &GlobalTransform)>() { /* read */ }
for (t,)   in world.query_mut::<(&mut Transform,)>()           { /* write */ }
}

Transform synchronization

After mutating a Transform, the renderer reads GlobalTransform. Sync explicitly:

#![allow(unused)]
fn main() {
world.sync_global_transform(entity);

// Or do mutate + sync in one call:
world.update_transform(entity, |t| {
    t.translation += Vec3::Y;
});
}

Assets

#![allow(unused)]
fn main() {
let mesh_handle: HandleComponent<Mesh> = world.add_mesh(my_mesh);
let mat_handle: MaterialComponent      = world.add_material(my_material);
}

Internal access

inner_world() and inner_world_mut() expose the underlying World for low-level operations (serialization, tooling). Use sparingly — the wrapped surface is the supported API.

05 — Vessel and the spawn helpers

Vessel is a builder over a freshly spawned entity. Every Vessel has a Transform and a GlobalTransform from the start.

Construction

#![allow(unused)]
fn main() {
Vessel::new(world)                       // at the origin
Vessel::at(world, position)              // at a specific position
}

Builder methods

MethodEffect
with_transform(t)Replace the local Transform
at_position(p)Replace just the translation
with_rotation(q)Replace just the rotation
with_scale(s)Replace just the scale
with_component(c)Attach any Component (chainable)
entity()Read the EntityId mid-build
build()Finalize, sync GlobalTransform, return EntityId

Primitive helpers (top-level functions)

#![allow(unused)]
fn main() {
spawn_plane(world, size, y) -> Vessel
spawn_cube_at(world, position, size) -> Vessel
spawn_sphere(world, radius, segments, rings) -> Vessel
}

Each returns a Vessel you keep building on with .with_component(...) and finalize with .build().

#![allow(unused)]
fn main() {
spawn_sphere(world, 0.75, 32, 16)
    .at_position(Vec3::new(0.0, 0.5, -5.0))
    .with_component(my_material)
    .build();
}

For non-primitive meshes, load through AssetService (see Assets and VFS) and attach the resulting HandleComponent<Mesh> via .with_component(...).

06 — ServiceRegistry

The ServiceRegistry (re-exported from khora-core) is a typed container for services.

#![allow(unused)]
fn main() {
// Inside setup or update — services are passed in
fn setup(&mut self, world: &mut GameWorld, services: &ServiceRegistry) {
    let asset_service = services.get::<Arc<AssetService>>().unwrap();
    let mesh = asset_service.load_blocking("models/character.gltf").unwrap();
    /* ... */
}

// Inside the bootstrap closure — services are mutable, register your own
run_winit::<WinitWindowProvider, MyGame>(|window, services, _event_loop| {
    let custom = MyCustomService::new();
    services.insert(Arc::new(custom));
    /* ... */
})?;
}

Engine-registered services

The engine inserts these into the registry during EngineCore::initialize, before your setup runs:

ServiceCratePurpose
Arc<DccService>khora-controlDCC orchestration (cold-path thread, GORNA arbitration)
Arc<TelemetryService>khora-telemetryMetrics and monitor registry
GpuCachekhora-dataShared GPU mesh store — handles to uploaded meshes
ProjectionRegistrykhora-dataPer-frame projection / mesh sync (runs sync_all before agents)
SharedFrameGraphkhora-dataArc<Mutex<FrameGraph>> — per-frame pass collector, drained at end_render_frame
RenderWorldStorekhora-dataArc<RwLock<RenderWorld>> populated each frame by extract_scene
UiSceneStorekhora-dataArc<RwLock<UiScene>> populated each frame by extract_ui_scene
PhysicsQueryServicekhora-agentsRaycasts and shape queries (registered only if a PhysicsProvider is present)

Bootstrap-registered services

Your run_winit closure registers the renderer and any custom services:

ServiceCratePurpose
Arc<dyn GraphicsDevice>khora-core (trait)The GPU device — read by RenderAgent directly
Arc<Mutex<Box<dyn RenderSystem>>>khora-core (trait)The render system — used at begin_frame / end_frame
WgpuRenderSystemkhora-infraDefault backend implementation

Frame-scoped services

Inserted into the per-frame service overlay (created fresh each tick):

ServiceCratePurpose
FrameContextkhora-corePer-frame blackboard (color/depth targets, stages, async tasks)
PRIMARY_VIEWPORTkhora-sdkWell-known viewport handle constant

On-demand services available through the SDK

Loaded once and served forever:

ServiceCratePurpose
Arc<AssetService>khora-ioAsset loading through the VFS
Arc<SerializationService>khora-ioSave and load scenes
GpuMonitor, MemoryMonitorkhora-infraHardware monitors feeding the DCC

07 — The prelude

#![allow(unused)]
fn main() {
use khora_sdk::prelude::*;            // Common SDK types
use khora_sdk::prelude::ecs::*;       // ECS components
use khora_sdk::prelude::math::*;      // Math types
use khora_sdk::prelude::materials::*; // Materials
}
ModuleContents
preludeWindowConfig, WindowIcon, PRIMARY_VIEWPORT, AssetHandle, AssetUUID, SaaTrackingAllocator, InputEvent, MouseButton
prelude::ecsEntityId, Transform, GlobalTransform, Camera, Light, LightType, MaterialComponent, RigidBody, Collider, BodyType, ColliderShape, AudioSource, Parent, Children, Name, Without, Component, ComponentBundle, ProjectionType, plus light variants
prelude::materialsStandardMaterial, UnlitMaterial, EmissiveMaterial, WireframeMaterial
prelude::mathVec2, Vec3, Vec4, Mat3, Mat4, Quaternion, Aabb, LinearRgba, plus utilities

The prelude is curated. Adding to it is a deliberate decision; we optimize for the import line being short and the imported names being unambiguous in context.

08 — Input

Inputs arrive in update as a &[InputEvent]. The variants:

#![allow(unused)]
fn main() {
pub enum InputEvent {
    KeyPressed { key_code: String },
    KeyReleased { key_code: String },
    MouseButtonPressed { button: MouseButton },
    MouseButtonReleased { button: MouseButton },
    MouseMoved { x: f32, y: f32 },
    MouseScrolled { delta: f32 },
    // ...
}
}

key_code strings follow the W3C UI Events names — "KeyW", "Space", "ShiftLeft", "Escape". MouseButton covers Left, Right, Middle.

#![allow(unused)]
fn main() {
fn update(&mut self, world: &mut GameWorld, inputs: &[InputEvent]) {
    for ev in inputs {
        match ev {
            InputEvent::KeyPressed { key_code } if key_code == "Escape" => std::process::exit(0),
            InputEvent::MouseMoved { x, y } => self.look(*x, *y),
            _ => {}
        }
    }
}
}

Gamepad and touch are roadmap items.

09 — Engine modes

#![allow(unused)]
fn main() {
pub enum EngineMode {
    /// Simulation — scene cameras, physics, audio, ECS snapshot.
    Playing,
    /// A custom mode injected by a plugin (e.g., `Custom("editor")`).
    Custom(String),
}
}

The base engine knows only Playing. Other modes are injected by plugins. The editor application registers EngineMode::Custom("editor") and an editor-only UiAgent that lists "editor" in its allowed_modes. The Scheduler filters agents by the active mode each frame.

EngineMode controls which agents run. It is distinct from PlayMode (re-exported from khora_core::ui::editor), which is the editor’s own UI-state enum:

#![allow(unused)]
fn main() {
pub enum PlayMode { Editing, Playing, Paused }
}
  • EngineMode lives in khora-core::agent::mode and gates agent execution.
  • PlayMode lives in khora-core::ui::editor::state and drives the editor’s UI (Play / Stop / Pause buttons, panel visibility).

When the editor enters play mode, its PlayMode becomes Playing and it requests EngineMode::Playing from the engine; on stop, the editor restores its Custom("editor") mode and the world is snapshot-restored — see Serialization.

10 — SDK re-exports

The SDK is the single entry point. It re-exports types from internal crates so games never depend on them directly:

Re-exportFromPurpose
EngineCore, GameWorldkhora-sdkEngine + ECS facade
Vessel, spawn_*khora-sdkSpawn helpers
EngineApp, AgentProvider, PhaseProvider, WindowProviderkhora-sdkApp traits
run_winit, WinitAppRunner, WinitWindowProviderkhora-sdkBootstrap
WindowConfig, WindowIcon, PRIMARY_VIEWPORTkhora-sdkWindow types
DccService, EngineMode, EngineContext, ExecutionScheduler, AgentRegistrykhora-controlControl plane
ExecutionPhase, AgentId, StrategyId, ServiceRegistrykhora-coreCore types
TelemetryService, TelemetryEvent, MonitoredResourceTypekhora-telemetryTelemetry
GpuMonitor, MemoryMonitorkhora-infraHardware monitors
WgpuRenderSystemkhora-infraDefault render backend
RenderSystemkhora-coreThe render trait
SerializationService, SceneFile, SerializationGoalkhora-io / khora-coreScene I/O
Meshkhora-coreMesh type
EditorState, EditorTheme, PlayMode, GizmoMode, ViewportTextureHandle, etc.khora-coreEditor UI types (used by the editor)

The khora_sdk::editor_ui module is a convenience namespace for the editor UI types. The khora_sdk::renderer module re-exports the renderer API submodules used by editor gizmos.

11 — 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 assetservices.get::<Arc<AssetService>>()
Save or load a sceneservices.get::<Arc<SerializationService>>()
Read GPU or memory metricsservices.get::<Arc<TelemetryService>>()
Cast a rayservices.get::<Arc<PhysicsQueryService>>()
Switch backendsEdit your run_winit closure
Add a custom agentImplement Agent, register in AgentProvider::register_agents
Add a custom phaseReturn it from PhaseProvider::custom_phases

For deeper internals (writing your own agent, lane, or backend), see Extending Khora.


Next: the editor. See Editor.

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. Gizmos and selection
  7. The Control Plane
  8. For game developers
  9. For engine contributors
  10. Decisions
  11. 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 / Canvas / Graph / Animation / Shader / 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

The Spine offers six modes:

ModePurpose
Scene3D editing (default)
Canvas2D layout and UI
GraphNode-based logic and shader
AnimationTimeline and curves
ShaderCode editor with live preview
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.

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:

ActionWhat happens
Open projectEditor scans the project folder, builds the asset browser, loads default.kscene if present
New sceneEditor creates an empty world, ready for editing
Save sceneEditor serializes the world with SerializationGoal::HumanReadableDebug (Definition / RON)
Save scene asSame, with a new path
Load sceneDouble-click a .kscene in the asset browser, or File → Open

Scene files are RON in development — hand-editable, diff-friendly, useful in Git history. Release builds typically use Recipe or Archetype.

06 — 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.

07 — 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.

Extending Khora

Custom agents, custom lanes, custom backends. A worked example.

  • Document — Khora Extending v1.0
  • Status — Tutorial
  • Date — May 2026

Contents

  1. When to extend
  2. The extension surface
  3. Worked example — adding an AI agent
  4. Adding a custom lane
  5. Adding a custom backend
  6. Decisions
  7. Open questions

01 — When to extend

Khora ships with five agents, ~15 lanes, and four trait surfaces (RenderSystem, PhysicsProvider, AudioDevice, LayoutSystem). For most game work, that is enough.

You extend Khora when:

  • You need a new subsystem with multiple performance strategies. AI, scripting, networking — anything where a simulation step has cost variants. → New agent + new lanes.
  • You need a new strategy in an existing subsystem. A new render technique, a new audio mixer. → New lane in the existing agent.
  • You need to swap a backend. Vulkan-direct rendering, custom physics solver, alternative audio API. → New khora-infra/<area>/<backend>/ implementing the existing trait.
  • You need a fixed-behavior on-demand subsystem. No GORNA, just “do this when called.” → A service, not an agent.

If none of those describe your need, you probably do not need to extend the engine — you need a component, a system, or game-side code.

02 — The extension surface

The contracts you implement, in order of how often they are used.

TraitCrateWhen
Componentkhora-core (via #[derive(Component)])New ECS component
Lanekhora-coreNew strategy for an existing or new agent
Agentkhora-coreNew negotiating subsystem
AssetDecoder<A>khora-lanesNew asset format
RenderSystemkhora-coreNew graphics backend
PhysicsProviderkhora-coreNew physics backend
AudioDevicekhora-coreNew audio backend
LayoutSystemkhora-coreNew UI layout backend

All of these are pure Rust traits — no macros required, no FFI. Compile errors guide you.

03 — Worked example — adding an AI agent

Suppose your game needs an AI subsystem with three quality strategies (Full, Reduced, Disabled) negotiable through GORNA. This is the canonical “new agent” path.

Step 1 — Define the lane(s)

Each strategy is a lane. Three strategies, three lanes.

#![allow(unused)]
fn main() {
// crates/my-game-ai/src/lanes/full_ai.rs
use khora_core::lane::{Lane, LaneContext, LaneError};

#[derive(Default)]
pub struct FullAiLane {
    behavior_tree: BehaviorTreeRunner,
}

impl Lane for FullAiLane {
    fn execute(&mut self, ctx: &mut LaneContext<'_>) -> Result<(), LaneError> {
        let world = ctx.get::<World>().ok_or(LaneError::MissingData)?;
        for (entity, ai) in world.query::<(EntityId, &mut AiState)>() {
            self.behavior_tree.tick(entity, ai, /* full deliberation */);
        }
        Ok(())
    }

    fn strategy_name(&self) -> &'static str { "FullAi" }

    fn estimate_cost(&self, ctx: &LaneContext<'_>) -> f32 {
        // Cost scales with the number of AI agents in the world
        let world = ctx.get::<World>();
        let count = world.map(|w| w.query::<&AiState>().count()).unwrap_or(0);
        (count as f32 / 100.0).min(1.0)
    }
}
}

Repeat for ReducedAiLane (lower-quality decisions, smaller cost) and DisabledAiLane (no work).

Step 2 — Define the agent

The agent owns lane selection. It implements Agent, plus Default. Nothing else.

#![allow(unused)]
fn main() {
// crates/my-game-ai/src/agent.rs
use khora_core::agent::*;
use khora_core::control::gorna::*;

#[derive(Default)]
pub struct AiAgent {
    current_lane: Option<Box<dyn Lane>>,
}

impl Agent for AiAgent {
    fn id(&self) -> AgentId {
        AgentId::Custom("ai".to_string())
    }

    fn execution_timing(&self) -> ExecutionTiming {
        ExecutionTiming {
            allowed_modes: vec![EngineMode::Playing],
            allowed_phases: vec![ExecutionPhase::TRANSFORM],
            default_phase: ExecutionPhase::TRANSFORM,
            priority: 0.7,
            importance: AgentImportance::Important,
            fixed_timestep: None,
            dependencies: vec![
                AgentDependency {
                    target: AgentId::Physics,
                    kind: DependencyKind::Soft,
                    condition: Some(DependencyCondition::IfTargetActive),
                },
            ],
        }
    }

    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
        NegotiationResponse {
            strategies: vec![
                StrategyOption::new("FullAi", Duration::from_micros(2000), 0),
                StrategyOption::new("ReducedAi", Duration::from_micros(700), 0),
                StrategyOption::new("DisabledAi", Duration::ZERO, 0),
            ],
            timing_adjustment: None,
        }
    }

    fn apply_budget(&mut self, budget: ResourceBudget) {
        self.current_lane = Some(match budget.strategy_id.as_str() {
            "FullAi" => Box::new(FullAiLane::default()),
            "ReducedAi" => Box::new(ReducedAiLane::default()),
            _ => Box::new(DisabledAiLane::default()),
        });
    }

    fn execute(&mut self, ctx: &mut EngineContext<'_>) {
        if let Some(lane) = self.current_lane.as_mut() {
            let mut lane_ctx = LaneContext::from(ctx);
            let _ = lane.execute(&mut lane_ctx);
        }
    }

    fn report_status(&self) -> AgentStatus {
        AgentStatus::healthy()
    }

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

Note the absence of methods like start, stop, builders, or accessors. Agents implement only Agent plus Default. Construction goes through Default::default(). Free helper functions in the same file are fine.

Step 3 — Register the agent

Custom agents are registered through the AgentProvider trait your app already implements. The DCC calls register_agents once during boot:

#![allow(unused)]
fn main() {
impl AgentProvider for MyGame {
    fn register_agents(&self, dcc: &DccService, services: &mut ServiceRegistry) {
        // Register an agent active in all engine modes
        dcc.register_agent(AiAgent::default(), /* priority */ 0.7);

        // Or restrict to specific modes
        // dcc.register_agent_for_mode(EditorOnlyAgent::default(), 0.5, &[EngineMode::Editor]);
    }
}
}

No special engine bootstrap is needed — the same run_winit::<W, MyGame>(...) call you write for any Khora app picks up the registration.

Step 4 — Test it

Add a unit test that constructs the agent, calls negotiate with a synthetic request, applies a returned budget, and verifies the right lane was chosen. Add an integration test that runs a full frame with the agent registered and asserts no panics.

#![allow(unused)]
fn main() {
#[test]
fn agent_picks_full_when_budget_is_high() {
    let mut agent = AiAgent::default();
    let response = agent.negotiate(NegotiationRequest::high_budget());
    let chosen = response.strategies.iter().find(|s| s.id == "FullAi").unwrap();
    agent.apply_budget(ResourceBudget::for_strategy("FullAi"));
    assert_eq!(agent.current_lane.as_ref().unwrap().strategy_name(), "FullAi");
}
}

04 — Adding a custom lane

If you want a new strategy in an existing agent — for example, a new render technique — the path is shorter:

  1. Implement Lane in crates/khora-lanes/src/render_lane/your_strategy.rs.
  2. Add a WGSL shader file under crates/khora-lanes/src/render_lane/shaders/.
  3. Wire it into RenderAgent::negotiate as a new StrategyOption.
  4. Add a switch case in RenderAgent::apply_budget to instantiate it.
  5. Write a test — either a unit test for the lane in isolation, or an integration test through the agent.

The hard part is the shader and the cost estimate. Everything else is mechanical.

05 — Adding a custom backend

To swap, say, the graphics backend:

  1. Create crates/khora-infra/src/graphics/<your-backend>/.
  2. Implement RenderSystem and the device contract from khora-core.
  3. Register the new system at SDK init: services.register::<Arc<dyn RenderSystem>>(Arc::new(YourSystem::new())).
  4. Run the workspace tests — render lanes hold Arc<dyn GraphicsDevice>, so they pick up your backend transparently.
  5. Run the sandbox to confirm visual parity.

The default wgpu backend is a reference implementation, not a commitment. Use it as a template. Vulkan-direct, Metal-direct, and software (for tests) backends are all valid targets.

The same pattern works for:

  • PhysicsProvider — replace Rapier3D.
  • AudioDevice — replace CPAL.
  • LayoutSystem — replace Taffy.

Decisions

We said yes to

  • Extension through traits, not callbacks. A trait implementation is a unit of code, testable, swappable, IDE-friendly. Callback registries are not.
  • The agent rule applies to custom agents too. Custom agents implement only Agent + Default. No exceptions.
  • Custom strategies as new lanes. The cleanest unit of new code is a new lane. Adding strategies through configuration would dilute the Lane abstraction.
  • Backends as trait implementations in khora-infra. Custom backends live in the same place as the defaults. Nothing about backend implementation is special.

We said no to

  • Plugin DLLs at v1. Compile-time integration is the model. Runtime plugin loading is on the Roadmap but not the v1 model.
  • A “lite” Agent trait for simple cases. Every agent participates in negotiation. There is no shortcut that skips GORNA — that is what services are for.
  • Custom phases at v1. ExecutionPhase::custom(id) exists but the surrounding tooling (editor visibility, telemetry naming) is incomplete.

Open questions

  1. Agent registration API. EngineConfig::register_agent is illustrative, not stable. The pattern is settling alongside khora-plugins.
  2. Plugin DLL ABI. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
  3. Async lanes. Asset streaming and AI deliberation both want async execution. The current sync-only contract is a known constraint.

Next: the global decisions log. See Decisions.

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 described below when a user clicks “Create new 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.

src/ vs assets/scripts/

These are deliberately separate roles:

LocationWhat lives hereWhen it runs
src/Native Rust code: custom components, agents, traits, anything that wants compile-time guarantees and access to internal Khora APIs.Compiled into the game binary at build time.
assets/scripts/Gameplay scripts treated as data. Today these can be plain text/JSON used by your custom components; the engine roadmap includes a custom scripting language with hot-reload and live-edit.Loaded at runtime by an asset loader, not compiled.

You can ship a game with one, the other, or both. There is no overlap.

project.json schema

{
  "name": "MyGame",
  "engine_version": "0.3.0",
  "created_at": 1714659000
}
FieldTypeSourceMeaning
namestringUser input in the hub, sanitised (alphanumerics, _, -, spaces → _)Human-readable project name. Distinct from the on-disk folder name when sanitisation changed it.
engine_versionstringSelected from the hub’s available-engines dropdownThe Khora SDK release the project targets. The editor displays this in the status bar and the command palette footer.
created_atu64Unix epoch seconds at hub creation timeInformational. Not used for runtime logic.

The descriptor type lives in hub/src/project.rs (ProjectDescriptor) — it is 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 will 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 + parent folder in the hub. The hub calls project::create_project(...) which writes the layout above.
  2. Openkhora-editor --project <path> reads project.json, scans assets/, optionally reads the git branch from .git/HEAD, and populates EditorState.
  3. First open — the editor writes assets/scenes/default.kscene if it doesn’t exist.
  4. Edit — every save mutates files under assets/. The editor doesn’t touch project.json or src/ after creation.
  5. Build — out of scope today; future work will compile src/ into the game binary and bundle assets/ next to it.

What the editor reads from project.json 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.

WIP / future fields

These are not part of the contract yet but are obvious extensions:

  • description — long-form project blurb, surfaced in the hub.
  • default_scene — relative path of the scene to auto-load (currently always assets/scenes/default.kscene).
  • default_camera — entity name to focus on at first open.
  • engine_features — toggle list to opt into experimental subsystems.

Add a field by extending ProjectDescriptor in hub/src/project.rs and reading it in crates/khora-editor/src/main.rs::setup. The JSON is untyped on read, so older project files keep working.

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 (Scene, Canvas, Graph, Animation, Shader, Control Plane). 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. Six modes as icon buttons; 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.

API reference

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.

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.

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 (fully dynamic), Stable (predictable), Manual (locked strategies) are designed but not implemented. The contract for switching between them at runtime is open.
  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.

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. Live AGDF triggers. The architecture supports adding/removing components based on context, but the policy — who decides, when, with what hysteresis — is open.
  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.

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 stated goal of AGDF — the API for it 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.

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.