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
- What Khora is
- The problem with rigid engines
- The Khora answer
- Who this book is for
- How to read this book
- 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.
| Problem | Impact |
|---|---|
| Static resource allocation | Underutilization or bottlenecks |
| Manual per-platform tuning | Tedious, fragile, expensive |
| No contextual awareness | Cannot 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:
| Audience | What you get |
|---|---|
| Game developers | A 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 contributors | A 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:
- Principles for the why.
- SDK quickstart for a working program.
- Roadmap for what is committed and what is planned.
If you are extending Khora — writing a custom agent, lane, or backend — read:
- Agents and Lanes for the contracts.
- Extending Khora for a worked example.
- Decisions for the constraints we accept and reject.
If you are interested in the editor, read:
- Editor for the panels and play mode.
- Editor design system for the visual language and the voice.
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
- The Symbiotic Adaptive Architecture
- The seven pillars
- Cold path and hot path
- Five engineering principles
- Decisions
- 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 monitors | Examples |
|---|---|
| Hardware load | CPU cores, GPU utilization, VRAM, memory bandwidth |
| Game state | Scene complexity, entity counts, light count, physics interactions |
| Performance goals | Target 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.
| Capability | Description |
|---|---|
| Self-assessment | Constantly measures its own performance and resource consumption |
| Multi-strategy | Possesses multiple algorithms with different performance characteristics |
| Cost estimation | Predicts 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
| Step | Action |
|---|---|
| 1. Request | Agents submit desired resource needs with strategy costs |
| 2. Arbitration | DCC analyzes all requests against the global model and goals |
| 3. Allocation | DCC grants a final budget to each agent (may be less than requested) |
| 4. Adaptation | Agent 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:
| Scenario | AGDF action |
|---|---|
| Entity far from player | Remove physics components, reduce update frequency |
| Entity enters player vicinity | Add physics components, increase update frequency |
| Scene complexity exceeds budget | Merge 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 type | Example |
|---|---|
| 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
TelemetryServiceprovides 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.
| Mechanism | Purpose |
|---|---|
| Constraints | Define 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
| Aspect | Cold path (DCC) | Hot path (Scheduler + agents) |
|---|---|---|
| Thread | Background (std::thread) | Main |
| Frequency | ~20 Hz | 60+ Hz (every frame) |
| Responsibility | Observe, analyze, negotiate | Execute agents, dispatch lanes, produce output |
| Communication | Unidirectional BudgetChannel | Agents 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 inOBSERVEandRenderAgentdeclare 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_LIGHTSconstant 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
LaneKindvariants. 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.
- Adaptation modes.
Learning,Stable,Manualare designed but not yet implemented. The contract for switching between them at runtime is open. - Constraints API. “In this zone, physics > graphics” is a stated capability with no concrete API yet.
PriorityVolumeis in the roadmap. - 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
- SAA, meet CLAD
- The mapping
- Crate dependency graph
- Dependency rules
- The eleven crates
- Trait map
- Standard components
- Decisions
- 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 why | The how | |
|---|---|---|
| Name | SAA — Symbiotic Adaptive Architecture | CLAD — Control / Lanes / Agents / Data |
| Form | Philosophical blueprint | Concrete crate structure |
| Concern | Self-optimizing, adaptive engine | Strict 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 & GORNA | khora-control | Strategic brain — observes telemetry, allocates budgets, runs the Scheduler |
| Intelligent Subsystem Agents | khora-agents | Tactical managers — each responsible for a LaneKind (rendering, shadow, physics, audio, UI) |
| Multiple agent strategies | khora-lanes | Fast, deterministic workers — algorithms an agent can choose from |
| Adaptive Game Data Flows | khora-data | Foundation — CRPECS enables flexible data layouts, dynamic component change |
| Semantic interfaces and contracts | khora-core | Universal language — traits, core types, math, GORNA types |
| I/O services | khora-io | Asset loading, VFS, serialization — on-demand services, not agents |
| Observability and telemetry | khora-telemetry | Nervous system — gathers performance data for the DCC |
| Hardware and OS interaction | khora-infra | Bridge 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.
| Rule | Description |
|---|---|
| No upward deps | khora-core cannot depend on any other crate |
| No lateral deps | khora-agents cannot depend on khora-control |
| I/O is shared | khora-io is used by both agents and the SDK |
| Traits in core | Abstract traits live in khora-core, implementations in specific crates |
| Backends in infra | Per-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-infrais one implementation, not the implementation. Every backend inkhora-infraimplements a trait that lives inkhora-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 underkhora-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
| Crate | Layer | Responsibility |
|---|---|---|
khora-core | Foundation | Trait definitions, math types, GORNA types, ServiceRegistry, EngineContext, error hierarchy, memory tracking |
khora-data | Data | CRPECS ECS (archetype SoA), components, scene definitions, EcsMaintenance, allocators |
khora-io | Data | VFS, asset loading (FileLoader / PackLoader), serialization strategies, AssetService, SerializationService |
khora-lanes | Lanes | Hot-path pipelines: render strategies, physics steps, audio mixing, asset decoders, scene transforms, ECS compaction, UI |
khora-agents | Agents | Intelligent subsystem managers: RenderAgent, ShadowAgent, PhysicsAgent, UiAgent, AudioAgent, plus PhysicsQueryService |
khora-control | Control | DCC orchestration, GORNA protocol, ExecutionScheduler, BudgetChannel, EnginePlugin, HeuristicEngine |
khora-infra | Infrastructure | wgpu backend, winit window, Rapier3D physics, CPAL audio, Taffy layout, GPU/Memory/VRAM monitors |
khora-telemetry | Telemetry | TelemetryService, MetricsRegistry, MonitorRegistry, resource monitors |
khora-sdk | Public API | EngineCore, GameWorld, EngineApp / AgentProvider / PhaseProvider traits, Vessel + spawn helpers, run_winit, WindowConfig |
khora-editor | Editor | Editor application — panels, gizmos, scene I/O, play mode |
khora-macros | Support | #[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.
| Trait | Defined in | Implemented by |
|---|---|---|
Lane | khora-core | All lane types in khora-lanes |
Agent | khora-core | All agent types in khora-agents |
RenderSystem | khora-core | WgpuRenderSystem in khora-infra |
PhysicsProvider | khora-core | Rapier3D backend in khora-infra |
AudioDevice | khora-core | CPAL backend in khora-infra |
LayoutSystem | khora-core | TaffyLayoutSystem in khora-infra |
Asset | khora-core | All loadable asset types |
Component | khora-data | All ECS components (via derive macro) |
AssetDecoder<A> | khora-lanes | Per-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.
| Component | Domain | Purpose |
|---|---|---|
Transform | All | Local position / rotation / scale |
GlobalTransform | All | World-space computed transform |
Camera | Render | Projection + view configuration |
Light | Render | Light type, color, intensity, shadow config |
MaterialComponent | Render | Material reference (handle) |
RigidBody | Physics | Body type, mass, velocity, CCD |
Collider | Physics | Shape descriptor for collision |
AudioSource | Audio | Audio clip, volume, spatial flags |
AudioListener | Audio | Listener position for 3D audio |
Parent / Children | Scene | Entity hierarchy |
HandleComponent | Asset | Generic asset handle wrapper |
UiTransform | UI | Position, size, anchoring |
UiColor | UI | Background color |
UiText | UI | Text content, font, color |
UiImage | UI | Texture handle, scale mode |
UiBorder | UI | Border width, color |
08 — Decisions
We said yes to
- Splitting
khora-iofromkhora-data. Asset loading and serialization are I/O concerns; ECS storage is not. Separating them avoids cyclic constraints. - Backends are swappable. Every
khora-infrabackend implements akhora-coretrait. wgpu, Rapier3D, CPAL, Taffy are current defaults, not architectural commitments. Adding a new graphics, physics, audio, or layout backend means a new sibling folder underkhora-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. NoBox<dyn Any>lookup at runtime in the hot path.
09 — Open questions
khora-pluginsAPI. The plugin model is real but its public API is still settling alongside editor needs.- Editor as a contributor crate.
khora-editordepends directly onkhora-agentsandkhora-iofor performance. Is that a violation of “SDK is the public API,” or a justified pragmatic shortcut? - 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
- The big picture
- Startup
- The frame loop
- Cold path — DCC thread
- Execution phases
- Engine modes
- Decisions
- 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
| Step | What 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 closure | Receives 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:
app.update(world, &inputs)— user game logic.world.tick_maintenance()— drain ECS cleanup / vacuum queues, compact pages.- GPU mesh sync — handles freshly added meshes are uploaded through
GpuCache. - Scene extraction —
khora_data::render::extract_scenepopulatesRenderWorldStore;khora_data::ui::extract_ui_scenepopulatesUiSceneStore.
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:
- Syncs budgets from the DCC via
BudgetChannel::sync(). - Runs registered
EnginePluginhooks for this phase. - Topologically sorts the agents declared for this phase + current
EngineMode, using their hard dependencies as edges; tiebreaks byAgentImportancethenpriority. - Executes agents sequentially, skipping
Optionalagents under budget pressure (frame elapsed > 16 ms). - Marks completion in an
AgentCompletionMapso dependent agents can tell their preconditions ran.
Stage 5 — end_render_frame
- Drain
FrameGraph— agents that recorded passes duringOUTPUTnow have their command buffers topologically ordered by resource reads/writes and submitted to the device. 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:
- Collect telemetry from agents and hardware monitors.
- Analyze with the heuristic engine (thermal, battery, load).
- Negotiate via GORNA — request strategies from agents.
- Arbitrate — select an optimal strategy per agent based on the budget.
- 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.
| Phase | Purpose | Example agents |
|---|---|---|
Init | Frame setup, reset | — |
Observe | Read-only extraction | RenderAgent, ShadowAgent, UiAgent |
Transform | Simulation, computation | PhysicsAgent, AudioAgent |
Mutate | Write results, sync | — |
Output | External output (present) | RenderAgent, UiAgent |
Finalize | Cleanup, 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.
| Mode | Typical active agents | Purpose |
|---|---|---|
Custom("editor") | Render, Shadow, UI | Scene editing, UI panels, gizmos (editor application) |
Playing | Render, Shadow, Physics, Audio | Full 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_maintenanceoutside 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
Transformlike everything else. One frame loop is enough.
08 — Open questions
- 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.
- 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.
- 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
- The graph at a glance
- Foundation crates
- Data crates
- Lane and agent crates
- Control and infrastructure
- Public API
- Editor
- Where things live
- 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.
| Module | Contents |
|---|---|
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.rs | ServiceRegistry, EngineContext |
renderer/error.rs | Error 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.
| Module | Contents |
|---|---|
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.
| Module | Contents |
|---|---|
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.
| Folder | Lanes |
|---|---|
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.
| Agent | Folder | LaneKind |
|---|---|---|
RenderAgent | render_agent/ | Render |
ShadowAgent | shadow_agent/ | Shadow |
UiAgent | ui_agent/ | Ui |
PhysicsAgent | physics_agent/ | Physics |
AudioAgent | audio_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.
| Module | Contents |
|---|---|
service.rs | DccService — agent lifecycle, tick loop |
gorna/ | GornaArbitrator — budget fitting algorithm |
analysis.rs | HeuristicEngine — nine heuristics, death-spiral detection |
scheduler.rs | ExecutionScheduler — hot-path orchestration |
budget_channel.rs | BudgetChannel — cold→hot pipe |
plugin.rs | EnginePlugin — 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.
| Folder | Backend | Implements |
|---|---|---|
graphics/wgpu/ | wgpu 28.0 | RenderSystem (WgpuRenderSystem, WgpuDevice) |
physics/rapier/ | Rapier3D | PhysicsProvider |
audio/cpal/ | CPAL | AudioDevice |
ui/taffy/ | Taffy | LayoutSystem |
platform/window/ | winit | Window creation, event loop |
platform/input.rs | winit | InputEvent translation |
telemetry/ | Native APIs | GpuMonitor, MemoryMonitor, VramMonitor |
khora-telemetry
Observability infrastructure.
| Module | Contents |
|---|---|
service.rs | TelemetryService |
metrics/ | MetricsRegistry, MonitorRegistry |
06 — Public API
khora-sdk
The user-facing surface. Everything else is implementation detail.
| Module | Contents |
|---|---|
lib.rs | Re-exports + prelude. Defines WindowConfig, WindowIcon, PRIMARY_VIEWPORT |
engine.rs | EngineCore — the engine type |
game_world.rs | GameWorld — safe ECS facade |
traits.rs | EngineApp, AgentProvider, PhaseProvider, WindowProvider |
vessel.rs | Vessel builder + spawn_plane / spawn_cube_at / spawn_sphere helpers |
winit_adapters.rs | run_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.
| Folder | Contents |
|---|---|
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.”
| Concern | Crate / module |
|---|---|
| Lane trait | khora-core::lane |
| Agent trait | khora-core::agent |
| Math types | khora-core::math |
| GORNA types | khora-core::control::gorna |
| ECS World and components | khora-data::ecs |
| Tracking allocator | khora-data::allocators |
| VFS and asset loading | khora-io |
| Serialization strategies | khora-io::serialization |
| Render pipelines | khora-lanes::render_lane |
| WGSL shaders | khora-lanes::render_lane::shaders |
| Physics lanes | khora-lanes::physics_lane |
| Audio lanes | khora-lanes::audio_lane |
| Scene transforms | khora-lanes::scene_lane |
| Agent implementations | khora-agents |
| Scheduler and GORNA | khora-control |
| wgpu backend | khora-infra::graphics::wgpu |
| Rapier backend | khora-infra::physics::rapier |
| CPAL backend | khora-infra::audio::cpal |
| Taffy backend | khora-infra::ui::taffy |
| Resource monitors | khora-infra::telemetry |
| User-facing API | khora-sdk |
| Editor UI | khora-editor |
| Sandbox app | examples/sandbox |
09 — Open questions
- Should
khora-editordepend onkhora-sdkonly? Today it reaches intokhora-agentsandkhora-iofor performance. Justified, but a violation of the “SDK is the public API” principle. - Plugin crate scope.
khora-pluginsexists 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
- Why CRPECS
- Architecture overview
- Entities
- Components
- Archetype pages
- Semantic domains
- Queries
- ECS maintenance
- Memory layout
- For game developers
- For engine contributors
- Decisions
- 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:
impl Component for Transform- Generates
SerializableTransformwithEncode/Decode(via bincode / serde). - Generates
From<Transform> for SerializableTransformand the reverse. - Registers the component for scene serialization through
inventory::submit!.
Two attributes refine the behavior:
| Attribute | Use |
|---|---|
#[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:
| Page | Components | Entities |
|---|---|---|
| 0 | Transform, GlobalTransform | 1, 2, 3 |
| 1 | Transform, GlobalTransform, RigidBody, Collider | 4, 5 |
| 2 | Transform, GlobalTransform, Camera | 6 |
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:
| Domain | Components |
|---|---|
Spatial | Transform, GlobalTransform, RigidBody, Collider |
Render | Camera, Light, HandleComponent<Mesh>, MaterialComponent |
UI | UiTransform, UiColor, UiText |
Audio | AudioSource, 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.
| Operation | Trigger | Effect |
|---|---|---|
queue_cleanup() | Component removal | Marks orphaned data for cleanup |
queue_vacuum() | Entity despawn | Marks page holes for compaction |
tick() | Every frame, before agents | Drains 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):
| File | Purpose |
|---|---|
crates/khora-data/src/ecs/world.rs | World — entity store, page registry, query entry point |
crates/khora-data/src/ecs/archetype.rs | Archetype — component combination identity |
crates/khora-data/src/ecs/page.rs | Page — SoA storage, bitset, compaction |
crates/khora-data/src/ecs/query.rs | Query — type-safe iteration, planner |
crates/khora-data/src/ecs/components/registrations.rs | Standard component registrations |
crates/khora-data/src/ecs/maintenance.rs | EcsMaintenance — 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 returnNoneinstead 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_componentis local. The structural cost is anO(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
- 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.
- 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.
- 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
- What an agent is
- The Agent trait
- The five agents
- ExecutionTiming
- Agent dependencies
- The Scheduler
- BudgetChannel
- EnginePlugin
- For game developers
- For engine contributors
- Decisions
- 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.
| Agent | LaneKind | Allowed modes | Allowed phases | Importance | Fixed timestep |
|---|---|---|---|---|---|
RenderAgent | Render | Playing, Custom("editor") | Observe, Output | Critical | No |
ShadowAgent | Shadow | Playing, Custom("editor") | Observe | Critical | No |
PhysicsAgent | Physics | Playing | Transform | Critical | Yes (1/60 s) |
UiAgent | Ui | Custom("editor") | Observe, Output | Important | No |
AudioAgent | Audio | Playing | Transform | Important | No |
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![],
}
}
}
| Field | Purpose |
|---|---|
allowed_modes | Engine modes where this agent can run (Editor, Playing) |
allowed_phases | Frame phases where this agent can run |
default_phase | Phase to use if GORNA does not specify one |
priority | Order within the same phase (higher = earlier) |
importance | Critical / Important / Optional — determines skip behavior under budget pressure |
fixed_timestep | If set, agent only runs when accumulator exceeds this duration |
dependencies | Other 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),
},
]
}
| Kind | Behavior |
|---|---|
| Hard | Target must run first. If target is skipped, this agent is also skipped. |
| Soft | Prefers target first, but can run without it. |
| Parallel | No 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:
- Budget sync.
budget_channel.sync()drains every per-agent crossbeam channel, keeping the latest budget for each agent. - Per-frame service overlay. A
ServiceRegistry::with_parentwraps the global registry — frame-scoped services (theFrameContextitself, theSharedFrameGraph) live in the overlay. - AgentCompletionMap. Built fresh each frame. Every agent execution writes into it; agents with hard dependencies read it before running.
- Per-phase work. For each phase in order:
- Plugin hooks fire first (one closure per phase, signature
Fn(&mut World)). - The
AgentRegistryreturns every agent declared for this phase and the activeEngineMode. - The set is topologically sorted by hard dependencies; cycles are detected and reported.
- Within an order-equivalent group, sort by
AgentImportance(Critical / Important / Optional) thenpriority.
- Plugin hooks fire first (one closure per phase, signature
- Execute. Each agent’s
execute(&mut EngineContext)is called sequentially, after a budget-pressure check (Optionalagents are skipped ifframe_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>>,
}
}
| Property | Detail |
|---|---|
| Transport | One crossbeam_channel per agent |
| Semantics | Last wins — sync() drains every channel, keeps only the latest budget per agent |
| Cold-side write | send(agent_id, budget) — non-blocking via try_send |
| Hot-side read at sync | sync() drains all channels, updates the current map under RwLock |
| Hot-side read in-frame | get(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
FrameContextslots. The ShadowAgent → RenderAgent path goes through the context, not through a shared field. - Optional methods that take input.
on_initializeandexecuteare the only inputs. Everything else is configuration viaexecution_timing().
Open questions
- 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. - Multi-
LaneKindagents. 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. - 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
- What a lane is
- The Lane trait
- Lane lifecycle
- The three contexts
- LaneContext in detail
- Lane types by subsystem
- Cost estimation
- For game developers
- For engine contributors
- Decisions
- 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])
| Phase | When | Purpose | Example |
|---|---|---|---|
on_initialize | Once, at boot | Cache services, create GPU resources | Create render pipeline |
prepare | Every frame, before execute | Read-only extraction from ECS | Extract meshes, cameras |
execute | Every frame, after prepare | The actual work | Encode GPU commands |
cleanup | Every frame, after execute | Reset state for next frame | Clear 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.
| Context | Crate / module | Scope | Carries |
|---|---|---|---|
EngineContext | khora-core::context | Per agent invocation | world: Option<&'a mut dyn Any>, services: Arc<ServiceRegistry> |
LaneContext | khora-core::lane | Per lane invocation | Type-map of Slot<T> / Ref<T> plus arbitrary inserted values |
FrameContext | khora-core::renderer::api::core::frame_context | One frame | Type-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)andget::<T>() -> Option<Arc<T>>. The renderer insertsColorTarget/DepthTargethere atbegin_frame; agents read them. - Stage synchronization.
insert_stage::<MyStage>()returns aStageHandle<T>backed bytokio::sync::watch. Producersmark_done(); consumersawait stage.wait(). Cross-agent ordering is normally handled by the Scheduler’sAgentCompletionMap—StageHandleis 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 callswait_for_all().awaitafterengine.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));
}
| Type | Purpose |
|---|---|
Slot<T> | Mutable access to owned data |
Ref<T> | Immutable reference |
ColorTarget | Render target for encoding |
DepthTarget | Depth/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
| Subsystem | Lanes | Strategy variants |
|---|---|---|
| Render | LitForward, ForwardPlus, SimpleUnlit, ShadowPass | Quality versus performance |
| Physics | StandardPhysicsLane, PhysicsDebugLane | Fixed timestep + optional debug overlay |
| Audio | SpatialMixingLane | 3D positional mixing |
| Scene | TransformPropagationLane | Hierarchy updates |
| Asset | TextureLoader, MeshLoader, FontLoader, AudioDecoder | Format-specific decoding |
| UI | StandardUiLane, UiRenderLane | Layout + render |
| ECS | CompactionLane | Page 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 budget1.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:
- No long-lived references. Anything that survives between frames belongs in
self. Anything frame-scoped belongs inLaneContext. cleanupactually cleans. A lane that leaves stale data inLaneContextis a frame-leak waiting to happen.- 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_costreturningf32. 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
LitForwardLanedoes not depend on aShadowPassLaneinstance — it depends onShadowAtlasViewandShadowComparisonSamplerslots 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.wgslis a file. It is editable, reviewable, and (in a future iteration) hot-reloadable.
Open questions
- 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. - Shader hot-reload. Files-on-disk make this trivial in principle. The wgpu pipeline cache invalidation policy is not yet decided.
- 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
- Why GORNA
- The five phases
- Data structures
- The nine heuristics
- Compliance today
- Cold path and hot path, again
- For game developers
- For engine contributors
- Decisions
- 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
| Phase | Duration | Action |
|---|---|---|
| Awareness | Instant | Collect telemetry from agents and hardware monitors |
| Analysis | ~1 ms | Run the heuristic engine — thermal, battery, load |
| Negotiation | ~2 ms | Request strategy options from each agent |
| Arbitration | ~1 ms | Select an optimal strategy per agent within budget |
| Application | Instant | Call 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:
| Heuristic | Input | Output |
|---|---|---|
| Phase | Current EnginePhase (Boot, Menu, Simulation, Background) | Multiplier favoring relevant subsystems |
| Thermal | GPU/CPU temperature | Reduce budget multiplier when hot |
| Battery | Battery level + AC state | Reduce budget on low battery, prefer LowPower strategies |
| Frame Time | Recent frame durations | Tighten budgets if frames are over target |
| Stutter | Frame time variance | Penalize strategies that produce inconsistent timings |
| Trend | Frame time slope | Anticipate degradation before it triggers a stutter |
| CPU Pressure | CPU utilization | Rebalance time budgets toward CPU-light strategies |
| GPU Pressure | GPU utilization | Rebalance toward GPU-light strategies |
| Death Spiral | Consecutive over-budget frames | Force 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 viaallowed_phases.
05 — Compliance today
| Agent | Negotiates | Applies budget | Reports status |
|---|---|---|---|
RenderAgent | 3 strategies (Unlit / LitForward / Forward+) | Switches lane strategy | Frame time, draw calls, lights |
ShadowAgent | 1 strategy (atlas) | (no-op, single strategy) | Atlas usage, cascade count |
PhysicsAgent | 3 strategies (Standard / Simplified / Disabled) | Adjusts fixed timestep | Step time, body count, collider count |
UiAgent | 1 strategy (layout + render) | (no-op, single strategy) | Node count, text count |
AudioAgent | 3 strategies (Full / Reduced / Minimal) | Adjusts max sources | Source 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:
| File | Purpose |
|---|---|
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.rs | HeuristicEngine — nine heuristics, death-spiral detection |
crates/khora-control/src/service.rs | DccService — 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
- User constraints. “In this volume, physics > graphics” is a stated capability without a concrete API.
PriorityVolumeis in the roadmap. - Adaptation modes.
Learning(fully dynamic),Stable(predictable),Manual(locked). The contract for switching at runtime is open. - 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
- Why this design
- Frame lifecycle
- The frame data containers
- The frame graph
- Render strategies
- Shadow system
- Shader files
- GPU resource management
- The default backend — wgpu
- For game developers
- For engine contributors
- Decisions
- 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:
| Service | Crate | Purpose |
|---|---|---|
GpuCache | khora-data::gpu::cache | Shared GPU mesh store — handles to uploaded mesh buffers, keyed by ECS handle |
ProjectionRegistry | khora-data | Runs sync_all() once per frame before agents — uploads new meshes through GpuCache, syncs projection state |
RenderWorldStore | khora-data::render | Arc<RwLock<RenderWorld>> populated each frame by extract_scene from the ECS |
UiSceneStore | khora-data::ui | Arc<RwLock<UiScene>> populated each frame by extract_ui_scene |
SharedFrameGraph | khora-data::render::frame_graph | Arc<Mutex<FrameGraph>> — pass collector; agents append, the engine drains |
FrameContext | khora-core::renderer::api::core::frame_context | Per-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:
- Builds the dependency graph from
reads/writesoverlap. - Topologically orders passes — a
ShadowAtlas-write pass must precede aShadowAtlas-read pass, even if the lanes were registered in a different order. - 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.
| Strategy | Lane | Description |
|---|---|---|
| Unlit | SimpleUnlitLane | No lighting, baseline cost |
| Forward | LitForwardLane | PBR with per-light passes, shadow sampling (PCF 3×3) |
| Forward+ | ForwardPlusLane | Tile-based light culling, many lights |
| Shadow | ShadowPassLane | Depth-only shadow map rendering (owned by ShadowAgent) |
| UI | UiRenderLane | 2D UI primitives (owned by UiAgent) |
| Extract | ExtractLane | ECS → 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
ShadowAtlasViewandShadowComparisonSamplerinFrameContext.
RenderAgent declares AgentDependency::Hard(AgentId::ShadowRenderer). The Scheduler enforces ordering. The lit forward pass reads the atlas from the per-frame context.
| Detail | Value |
|---|---|
| Atlas size | 2048 × 2048, Depth32Float |
| Layers | 4 (one per cascade) |
| Light type | Directional (orthographic projection from camera frustum AABB in light space) |
| Texel snapping | Ortho bounds rounded to texel-aligned boundaries to prevent shimmer |
| Sampling | PCF 3×3 in lit_forward.wgsl with comparison sampler |
| Inter-agent transport | ShadowAtlasView + 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.
| Shader | Purpose |
|---|---|
lit_forward.wgsl | PBR lit material with shadow sampling |
shadow_depth.wgsl | Depth-only shadow pass |
simple_unlit.wgsl | Basic unlit material |
standard_pbr.wgsl | PBR material model |
forward_plus.wgsl | Forward+ light culling |
ui.wgsl | UI rendering |
All under crates/khora-lanes/src/render_lane/shaders/.
08 — GPU resource management
All GPU resources are accessed through typed IDs:
| Type | Refers to |
|---|---|
TextureId | A managed texture allocation |
BufferId | A managed buffer allocation |
PipelineId | A managed render or compute pipeline |
BindGroupId | A managed bind group |
SamplerId | A 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.
| File | Purpose |
|---|---|
crates/khora-infra/src/graphics/wgpu/system.rs | WgpuRenderSystem — implements RenderSystem |
crates/khora-infra/src/graphics/wgpu/device.rs | WgpuDevice — 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:
- Create a new lane under
crates/khora-lanes/src/render_lane/implementingLane. - Add its WGSL shader under
crates/khora-lanes/src/render_lane/shaders/. - Wire it into
RenderAgent::negotiateas aStrategyOptionwith cost estimate. - Add a switch in
RenderAgent::apply_budgetto instantiate the new lane. - 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
ShadowAgentnegotiate 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
- Forward+ tile size and light limits. Tunable in
forward_plus.wgsl. Defaults work; the optimal is hardware-dependent and deserves a heuristic. - 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.
- 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
- The contract
- Pipeline
- Components
- Fixed timestep
- The default backend — Rapier3D
- PhysicsAgent and GORNA
- For game developers
- For engine contributors
- Decisions
- 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
| Component | Purpose |
|---|---|
Transform | Local pose — physics reads it on body creation, writes back after step |
GlobalTransform | World-space pose — synced from physics every frame |
RigidBody | Body type (Dynamic, Static, Kinematic), mass, velocity, CCD flag |
Collider | Shape 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
| File | Purpose |
|---|---|
crates/khora-infra/src/physics/rapier/mod.rs | RapierPhysicsProvider — 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:
| Strategy | Lane | When |
|---|---|---|
| Standard | StandardPhysicsLane, fixed_step = 1/60 s | Healthy budget, normal scene |
| Simplified | StandardPhysicsLane, fixed_step = 1/30 s | Mid-pressure — half the simulation cost |
| Disabled | None | Death 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:
| File | Purpose |
|---|---|
crates/khora-core/src/physics/ | PhysicsProvider trait, body and collider types, raycast types |
crates/khora-lanes/src/physics_lane/standard.rs | StandardPhysicsLane — calls PhysicsProvider::step |
crates/khora-lanes/src/physics_lane/debug.rs | PhysicsDebugLane — visualization |
crates/khora-agents/src/physics_agent/mod.rs | PhysicsAgent — 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
PhysicsProvidertrait, 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
- 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.
- Physics state in serialization.
SerializationGoal::FastestLoaddoes not preserve velocities or contacts (see Serialization). Whether to add a “snapshot with physics” goal is open. - 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
- The contract
- Pipeline
- Components
- Spatial mixing
- The default backend — CPAL
- AudioAgent and GORNA
- For game developers
- For engine contributors
- Decisions
- 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:
- Finds the active
AudioListenerin the ECS. - For every
AudioSource, computes distance and direction relative to the listener. - Applies attenuation curves and panning.
- 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
| Component | Purpose |
|---|---|
AudioSource | Audio clip handle, volume, spatial flag, looping flag |
AudioListener | Marks the entity whose position is the listener’s position |
GlobalTransform | World-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:
| Step | Computation |
|---|---|
| Distance | ` |
| Attenuation | Inverse-square with floor and ceiling parameters |
| Direction | Vector from listener to source, transformed into listener space |
| Pan | Direction’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
| File | Purpose |
|---|---|
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:
| Strategy | Max sources | When |
|---|---|---|
| Full | 64 | Healthy budget |
| Reduced | 16 | Mid-pressure |
| Minimal | 4 | Heavy 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:
| File | Purpose |
|---|---|
crates/khora-core/src/audio/ | AudioDevice trait, audio types |
crates/khora-lanes/src/audio_lane/spatial_mixing.rs | SpatialMixingLane — distance, direction, panning |
crates/khora-agents/src/audio_agent/mod.rs | AudioAgent — 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.
AudioDeviceis 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
AudioSourcewithout 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
- HRTF (head-related transfer function) for headphones. Better spatialization for headphone users. Library candidates exist; integration is not designed.
- Listener selection. Today, first-registered wins. Multiple listeners (split-screen, recording) need an explicit selection model.
- 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
- The pipeline
- The Virtual File System
- AssetSource — file or pack
- Decoders
- AssetService and handles
- .pack archives
- For game developers
- For engine contributors
- Decisions
- 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<T>"]
G --> H["AssetHandle<T>"]
| Step | Component | Purpose |
|---|---|---|
| 1 | AssetUUID | Unique identifier for an asset |
| 2 | VirtualFileSystem | UUID → metadata lookup (O(1)) |
| 3 | AssetSource | Path (dev) or packed offset/size (release) |
| 4 | AssetIo | FileLoader or PackLoader — reads raw bytes |
| 5 | AssetDecoder<A> | Decodes bytes into a typed asset |
| 6 | Assets<T> | Typed storage registry |
| 7 | AssetHandle<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
| Variant | Use | Reader |
|---|---|---|
AssetSource::Path(PathBuf) | Development — loose files on disk | FileLoader |
AssetSource::Packed { offset, size } | Release — single .pack file | PackLoader |
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>>;
}
}
| Decoder | Asset type | Format |
|---|---|---|
TextureLoaderLane | CpuTexture | PNG, JPG, BMP |
GltfLoaderLane | Mesh | glTF 2.0 |
ObjLoaderLane | Mesh | OBJ |
FontLoaderLane | Font | TTF, OTF |
WavLoaderLane | SoundData | WAV |
SymphoniaLoaderLane | SoundData | MP3, 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.
| Operation | What 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 handle | Asset 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:
| File | Purpose |
|---|---|
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
- Streaming. Today assets load entirely into memory. Streaming meshes (Nanite-style) and textures (sparse residency) are roadmap items.
- Async decoder execution. The decoder runs on the calling thread. Large assets should use a thread pool — the contract is undecided.
- Pack builder. A working
.packbuilder tool is needed to move releases offFileLoader. 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
- The contract
- Pipeline
- Components
- The default backend — Taffy
- UiAgent
- For game developers
- For engine contributors
- Decisions
- 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:
StandardUiLaneinObserve— reads UI components, runs Taffy, produces aUiSceneof pre-laid-out nodes.UiRenderLaneinOutput— rasterizes theUiSceneto the swapchain. UsesLoadOp::Loadso it composites over whatever theRenderAgentdrew.
UiAgent owns both. Both run in the same frame.
03 — Components
| Component | Purpose |
|---|---|
UiTransform | Position, size, anchoring (top-left, center, etc.) |
UiColor | Background color |
UiText | Text content, font handle, color, font size |
UiImage | Texture handle, scale mode (stretch, tile, fit) |
UiBorder | Border 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
| File | Purpose |
|---|---|
crates/khora-infra/src/ui/taffy/mod.rs | TaffyLayoutSystem — 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:
| File | Purpose |
|---|---|
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.rs | UiAgent |
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.
LayoutSystemtrait. Taffy is the default; the seam exists for replacement.- Two-lane split (compute + render). The compute pass is in
Observe; the render pass is inOutput. Same shape as the scene render path. - Hierarchy via
Parent/Children. Same components used everywhere else. NoUiParent, noUiHierarchy.
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.
UiTransformis a Khora type. The TaffyStylemapping happens insideStandardUiLane.
Open questions
- In-game UI.
UiAgentis currently editor-only. The path to a play-mode HUD is mostly a matter of changingallowed_modes, plus deciding the input model. - Animations on UI. No tween / spring system today. Probably belongs as a separate lane that mutates UI components over time.
- 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
- Three strategies, three goals
- SerializationGoal
- The .kscene file format
- SerializationService
- Component serialization
- Play mode snapshots
- For game developers
- For engine contributors
- Decisions
- 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.
| Strategy | Format | Lane | Use case |
|---|---|---|---|
| Definition | RON (human-readable) | DefinitionSerializationLane | Debug, long-term storage, scene authoring |
| Recipe | Binary commands | RecipeSerializationLane | Compact, editor interchange |
| Archetype | Binary layout | ArchetypeSerializationLane | Fastest 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
SerializableTmirror struct withEncode/Decode. From<T>forSerializableTand 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:
| File | Purpose |
|---|---|
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.rs | Component 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
.kscenemagic, 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
- DeltaSerialization. Roadmap item. Save games and undo/redo both want incremental snapshots. The trait surface is sketched, not implemented.
- Physics snapshot goal. Should there be a
SerializationGoal::IncludePhysicsStatethat captures velocities, sleep state, contacts? - 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
- Why telemetry is first-class
- Architecture
- The monitors
- SaaTrackingAllocator
- MetricsRegistry
- The DCC consumes telemetry
- For game developers
- For engine contributors
- Decisions
- 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 monitors —
GpuMonitor,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
| Monitor | Tracks |
|---|---|
GpuMonitor | GPU utilization, frame timings, queue depths |
MemoryMonitor | Heap (resident set), virtual size |
VramMonitor | Video memory usage |
SaaTrackingAllocator | Per-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:
- Poll each registered monitor.
- Read named metrics from
MetricsRegistry. - Feed the readings into the nine heuristics. See GORNA.
- Arbitrate budgets.
- 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:
| File | Purpose |
|---|---|
crates/khora-core/src/memory/ | Allocator trait, allocation counters |
crates/khora-data/src/allocators/saa_tracking.rs | SaaTrackingAllocator implementation |
crates/khora-telemetry/src/service.rs | TelemetryService, 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.
SaaTrackingAllocatoras 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/Gaugehandles, 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
- Histogram exporter. Histograms collect, but the export format (Prometheus, OpenMetrics) is not yet committed.
- Per-frame trace records. Tracy integration would be valuable. The telemetry pipeline is compatible; the hookup is undecided.
- 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
- Prerequisites
- The pieces you need
- The minimum game
- Walking through it
- The bootstrap closure
- Vessel — the spawn helper
- Adding behavior
- 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:
| Piece | What it is |
|---|---|
| Your app struct | Holds game state. Implements EngineApp, AgentProvider, PhaseProvider. |
run_winit | The bootstrap entry point. Generic over a window provider and your app. |
WgpuRenderSystem | The default rendering backend. You construct it inside the bootstrap closure and register it as a service. |
| The bootstrap closure | Wires 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:
| Trait | Purpose |
|---|---|
EngineApp | Lifecycle: window_config, new, setup, update, on_shutdown (and a few optional hooks) |
AgentProvider | Where you register custom agents with the DCC |
PhaseProvider | Where 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
| Method | When | What you do |
|---|---|---|
window_config() | Once, before window creation | Return a WindowConfig (title, size, icon) |
new() | Once, after window creation | Construct the struct — no engine context yet |
setup(world, services) | Once, after engine init | Spawn entities. services gives you renderer access if needed |
update(world, inputs) | Every frame | Game logic — read inputs, mutate world |
on_shutdown() | Once, on exit | Cleanup |
before_frame / before_agents / after_agents | Optional per-frame hooks | Used 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_atfor primitive helpers — all return aVesselyou keep building on.world.add_material(m)registers a material and returns aMaterialComponentyou 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
EngineApptype.
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:
- The graphics device (
Arc<dyn GraphicsDevice>) —RenderAgentreads it directly. - 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:
| Function | Mesh |
|---|---|
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
RigidBodyandColliderfor simulation. - Audio — spawning
AudioSourcefor spatial audio. - Editor — running
khora-editorto 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
- The three application traits
run_winit— the entry pointWindowConfigandWindowProviderGameWorld— the ECS facadeVesseland the spawn helpersServiceRegistry- The prelude
- Input
- Engine modes
- SDK re-exports
- 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) {}
}
}
| Method | When | What you do |
|---|---|---|
window_config() | Once, before window creation | Return a WindowConfig |
new() | Once, after window creation | Construct the struct — no engine context yet |
setup(world, services) | Once, after engine init | Spawn entities; cache service handles |
update(world, inputs) | Every frame | Game logic |
on_shutdown() | Once, on exit | Cleanup |
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
| Method | Purpose |
|---|---|
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
| Method | Effect |
|---|---|
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:
| Service | Crate | Purpose |
|---|---|---|
Arc<DccService> | khora-control | DCC orchestration (cold-path thread, GORNA arbitration) |
Arc<TelemetryService> | khora-telemetry | Metrics and monitor registry |
GpuCache | khora-data | Shared GPU mesh store — handles to uploaded meshes |
ProjectionRegistry | khora-data | Per-frame projection / mesh sync (runs sync_all before agents) |
SharedFrameGraph | khora-data | Arc<Mutex<FrameGraph>> — per-frame pass collector, drained at end_render_frame |
RenderWorldStore | khora-data | Arc<RwLock<RenderWorld>> populated each frame by extract_scene |
UiSceneStore | khora-data | Arc<RwLock<UiScene>> populated each frame by extract_ui_scene |
PhysicsQueryService | khora-agents | Raycasts and shape queries (registered only if a PhysicsProvider is present) |
Bootstrap-registered services
Your run_winit closure registers the renderer and any custom services:
| Service | Crate | Purpose |
|---|---|---|
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 |
WgpuRenderSystem | khora-infra | Default backend implementation |
Frame-scoped services
Inserted into the per-frame service overlay (created fresh each tick):
| Service | Crate | Purpose |
|---|---|---|
FrameContext | khora-core | Per-frame blackboard (color/depth targets, stages, async tasks) |
PRIMARY_VIEWPORT | khora-sdk | Well-known viewport handle constant |
On-demand services available through the SDK
Loaded once and served forever:
| Service | Crate | Purpose |
|---|---|---|
Arc<AssetService> | khora-io | Asset loading through the VFS |
Arc<SerializationService> | khora-io | Save and load scenes |
GpuMonitor, MemoryMonitor | khora-infra | Hardware 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
}
| Module | Contents |
|---|---|
prelude | WindowConfig, WindowIcon, PRIMARY_VIEWPORT, AssetHandle, AssetUUID, SaaTrackingAllocator, InputEvent, MouseButton |
prelude::ecs | EntityId, Transform, GlobalTransform, Camera, Light, LightType, MaterialComponent, RigidBody, Collider, BodyType, ColliderShape, AudioSource, Parent, Children, Name, Without, Component, ComponentBundle, ProjectionType, plus light variants |
prelude::materials | StandardMaterial, UnlitMaterial, EmissiveMaterial, WireframeMaterial |
prelude::math | Vec2, 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 }
}
EngineModelives inkhora-core::agent::modeand gates agent execution.PlayModelives inkhora-core::ui::editor::stateand 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-export | From | Purpose |
|---|---|---|
EngineCore, GameWorld | khora-sdk | Engine + ECS facade |
Vessel, spawn_* | khora-sdk | Spawn helpers |
EngineApp, AgentProvider, PhaseProvider, WindowProvider | khora-sdk | App traits |
run_winit, WinitAppRunner, WinitWindowProvider | khora-sdk | Bootstrap |
WindowConfig, WindowIcon, PRIMARY_VIEWPORT | khora-sdk | Window types |
DccService, EngineMode, EngineContext, ExecutionScheduler, AgentRegistry | khora-control | Control plane |
ExecutionPhase, AgentId, StrategyId, ServiceRegistry | khora-core | Core types |
TelemetryService, TelemetryEvent, MonitoredResourceType | khora-telemetry | Telemetry |
GpuMonitor, MemoryMonitor | khora-infra | Hardware monitors |
WgpuRenderSystem | khora-infra | Default render backend |
RenderSystem | khora-core | The render trait |
SerializationService, SceneFile, SerializationGoal | khora-io / khora-core | Scene I/O |
Mesh | khora-core | Mesh type |
EditorState, EditorTheme, PlayMode, GizmoMode, ViewportTextureHandle, etc. | khora-core | Editor 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 shape | Vessel::at(...) + spawn_* helpers |
| Read or mutate a component | world.get_component<T> / world.get_component_mut<T> |
| Run a query | world.query::<...>() / world.query_mut::<...>() |
| Load an asset | services.get::<Arc<AssetService>>() |
| Save or load a scene | services.get::<Arc<SerializationService>>() |
| Read GPU or memory metrics | services.get::<Arc<TelemetryService>>() |
| Cast a ray | services.get::<Arc<PhysicsQueryService>>() |
| Switch backends | Edit your run_winit closure |
| Add a custom agent | Implement Agent, register in AgentProvider::register_agents |
| Add a custom phase | Return 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
- What the editor is
- Workspace anatomy
- Modes
- Play mode
- Scene I/O
- Gizmos and selection
- The Control Plane
- For game developers
- For engine contributors
- Decisions
- 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 |
+--------------------------------------------------------------+
| Region | Purpose |
|---|---|
| Title bar | Brand, project name, window controls |
| Spine | Mode rail (Scene / Canvas / Graph / Animation / Shader / Control Plane) |
| Hierarchy | Tree of entities, left of the viewport |
| Viewport | The 3D scene with floating gizmos |
| Inspector | Components of the selected entity, right of the viewport |
| Bottom dock | Assets browser, console, GORNA stream |
| Status bar | Engine state, FPS, build status, GORNA pulse |
Full anatomy and pixel-level layout in Editor design system.
03 — Modes
The Spine offers six modes:
| Mode | Purpose |
|---|---|
| Scene | 3D editing (default) |
| Canvas | 2D layout and UI |
| Graph | Node-based logic and shader |
| Animation | Timeline and curves |
| Shader | Code editor with live preview |
| Control Plane | Engine 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:
- The current world is serialized using
SerializationGoal::FastestLoad(Archetype strategy). - The snapshot is stored in memory.
- The editor’s
PlayMode(UI-state) becomesPlaying. EngineModeswitches fromCustom("editor")toPlaying—PhysicsAgentandAudioAgentstart running,UiAgentstops.- The play camera takes over from the editor camera.
When you press Stop:
- The editor’s
PlayModebecomesEditing. EngineModeswitches back toCustom("editor").- The snapshot is deserialized into the world.
- The editor camera resumes.
The snapshot is fast — milliseconds for a 10 000-entity scene — because Archetype strategy serializes ECS pages directly. See Serialization.
| Aspect | Editor (Custom("editor")) | Playing (Playing) |
|---|---|---|
| Active agents | Render, Shadow, UI | Render, Shadow, Physics, Audio |
| Camera | Editor camera (free orbit) | Scene cameras (active ones) |
| Input | Editor input (gizmos, selection) | Game input (player controls) |
| ECS | Mutable — user edits directly | Snapshot-based — original world preserved |
| Rendering | Viewport texture + gizmos + overlay | Full 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 setsPlayMode::Playing→ editor requestsEngineMode::Playingfrom 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:
| Action | What happens |
|---|---|
| Open project | Editor scans the project folder, builds the asset browser, loads default.kscene if present |
| New scene | Editor creates an empty world, ready for editing |
| Save scene | Editor serializes the world with SerializationGoal::HumanReadableDebug (Definition / RON) |
| Save scene as | Same, with a new path |
| Load scene | Double-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.
| Region | Purpose |
|---|---|
| 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:
| Folder | Contents |
|---|---|
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.rs | EditorState — 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-agentsandkhora-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
- 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.
- Plugin UI surface. Third-party plugins need a place to live — probably the Inspector as additional component cards, but the contract is undefined.
- 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
- When to extend
- The extension surface
- Worked example — adding an AI agent
- Adding a custom lane
- Adding a custom backend
- Decisions
- 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.
| Trait | Crate | When |
|---|---|---|
Component | khora-core (via #[derive(Component)]) | New ECS component |
Lane | khora-core | New strategy for an existing or new agent |
Agent | khora-core | New negotiating subsystem |
AssetDecoder<A> | khora-lanes | New asset format |
RenderSystem | khora-core | New graphics backend |
PhysicsProvider | khora-core | New physics backend |
AudioDevice | khora-core | New audio backend |
LayoutSystem | khora-core | New 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:
- Implement
Laneincrates/khora-lanes/src/render_lane/your_strategy.rs. - Add a WGSL shader file under
crates/khora-lanes/src/render_lane/shaders/. - Wire it into
RenderAgent::negotiateas a newStrategyOption. - Add a switch case in
RenderAgent::apply_budgetto instantiate it. - 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:
- Create
crates/khora-infra/src/graphics/<your-backend>/. - Implement
RenderSystemand the device contract fromkhora-core. - Register the new system at SDK init:
services.register::<Arc<dyn RenderSystem>>(Arc::new(YourSystem::new())). - Run the workspace tests — render lanes hold
Arc<dyn GraphicsDevice>, so they pick up your backend transparently. - 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
- Agent registration API.
EngineConfig::register_agentis illustrative, not stable. The pattern is settling alongsidekhora-plugins. - Plugin DLL ABI. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
- Async lanes. Asset streaming and AI deliberation both want
asyncexecution. 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:
| Location | What lives here | When 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
}
| Field | Type | Source | Meaning |
|---|---|---|---|
name | string | User input in the hub, sanitised (alphanumerics, _, -, spaces → _) | Human-readable project name. Distinct from the on-disk folder name when sanitisation changed it. |
engine_version | string | Selected from the hub’s available-engines dropdown | The Khora SDK release the project targets. The editor displays this in the status bar and the command palette footer. |
created_at | u64 | Unix epoch seconds at hub creation time | Informational. 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.
| Type | Recognised 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
- Creation — the user picks a name + engine version + parent folder in the hub. The hub calls
project::create_project(...)which writes the layout above. - Open —
khora-editor --project <path>readsproject.json, scansassets/, optionally reads the git branch from.git/HEAD, and populatesEditorState. - First open — the editor writes
assets/scenes/default.ksceneif it doesn’t exist. - Edit — every save mutates files under
assets/. The editor doesn’t touchproject.jsonorsrc/after creation. - Build — out of scope today; future work will compile
src/into the game binary and bundleassets/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 alwaysassets/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
- Design principles
- Brand & identity
- Color system
- Typography
- Layout & spine
- Panels
- Viewport
- Command palette
- Control Plane
- Interactions
- Decisions log
- 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.
| deep | precise | awake | honest |
| Surface | Type | Motion | Voice |
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.
| Role | OKLCH | Use |
|---|---|---|
| bg-0 Void | oklch(0.14 0.020 265) | App background — rarely seen directly |
| bg-1 Shell | oklch(0.16 0.022 265) | Panel surfaces, title bar |
| bg-2 Raised | oklch(0.18 0.022 265) | Cards, hover states |
| bg-3 Elevated | oklch(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
| Role | Sample | Spec |
|---|---|---|
| Hero | An editor that thinks. | Fraunces 56, opsz 144, italic |
| H2 | Color system | Fraunces 32, regular |
| H3 | Section title | Geist 18, weight 600 |
| Body | Default reading text | Geist 14, line-height 1.6 |
| Label | EXECUTION TIMING | Geist Mono 11, uppercase, letter-spacing 0.08em |
| Engine | khora-agents · 7.04 / 16.67ms | Geist 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.rs17 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, notApplying. 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
- Scene — 3D editing (default)
- Canvas — 2D layout & UI
- Graph — Node-based logic / shader
- Animation — Timeline & curves
- Shader — Code editor with live preview
- 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.
- 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.
- 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.
- Plugin UI. Third-party plugins need a place to live. Probably the Inspector as additional component cards, but the contract isn’t defined.
- Mobile viewer. Out of scope for v1, but the Spine pattern probably translates well to a tablet-sized viewer.
- 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
- Architecture
- Subsystems
- SDK and editor
- 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-iofromkhora-data. Asset loading and serialization are I/O concerns; ECS storage is not. - Backends are swappable. Every
khora-infrabackend implements akhora-coretrait. 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_LIGHTSconstant has no place in an engine that adapts. - Synchronous DCC calls from agents. Agents must never wait on the DCC.
- Adding more agents than
LaneKindvariants. 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
Transformlike 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 perLaneKind; Hard / Soft / Parallel dependency model; last-wins onBudgetChannel. - No: agent-managed concurrency (DCC handles cold-path concurrency); agents reading from each other directly (cross-agent data flows through
FrameContextslots).
Lanes
- Yes: three-phase lifecycle (prepare / execute / cleanup); type-erased
LaneContext;estimate_costreturningf32. - 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:
PhysicsProvidertrait 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;
LayoutSystemtrait; two-lane split (compute + render); hierarchy viaParent/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);
SaaTrackingAllocatoras 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/PhaseProvidertraits,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-agentsandkhora-iodirectly (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-verifyis forbidden unless explicitly requested by the user. - Untyped configuration. No magic strings, no untyped JSON. Configuration is Rust types.
- A “stable” version of
dev.devis the development branch. Stable releases live onmain.
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
- Adaptive core
- ECS and data
- Agents and lanes
- Rendering
- Physics
- Audio
- Assets
- UI
- Serialization
- Telemetry
- SDK and editor
- Extension model
01 — Adaptive core
- 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. - Constraints API. “In this volume, physics > graphics” is a stated capability without a concrete API.
PriorityVolumeis in the roadmap. - 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?
- 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.
- 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
- 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.
- Live AGDF triggers. The architecture supports adding/removing components based on context, but the policy — who decides, when, with what hysteresis — is open.
- Page-size tuning. Pages start at 8 entries and grow geometrically. Whether 64 or 256 would be better at scale is unmeasured.
khora-pluginsAPI. The plugin model is real but its public API is still settling alongside editor needs.
03 — Agents and lanes
asset_laneandecs_laneshould not be lanes. ALaneis 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.- Plugin agents. Agents are added at compile time via registration. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
- Multi-
LaneKindagents. 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. - Async agent work. Some lanes (asset streaming) want async I/O. The contract for an agent that yields control mid-frame is open.
- 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. - Shader hot-reload. Files-on-disk make this trivial in principle. The wgpu pipeline cache invalidation policy is not yet decided.
- Asynchronous lanes. Asset streaming wants
async fn execute. The current sync-only contract is a known constraint.
04 — Rendering
- Forward+ tile size and light limits. Tunable in
forward_plus.wgsl. Defaults work; the optimal is hardware-dependent and deserves a heuristic. - 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.
- 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. - 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
- 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.
- Physics state in serialization.
SerializationGoal::FastestLoaddoes not preserve velocities or contacts. Whether to add a “snapshot with physics” goal is open. - Native solver migration. Roadmap Phase 6. The trait surface is stable enough; the implementation is a multi-quarter effort.
06 — Audio
- HRTF (head-related transfer function) for headphones. Better spatialization for headphone users. Library candidates exist; integration is not designed.
- Listener selection. Today, first-registered wins. Multiple listeners (split-screen, recording) need an explicit selection model.
- Convolution reverb. Real-time convolution is feasible on modern hardware; the API for impulse responses is undecided.
07 — Assets
- Streaming. Today assets load entirely into memory. Streaming meshes (Nanite-style) and textures (sparse residency) are roadmap items.
- Async decoder execution. The decoder runs on the calling thread. Large assets should use a thread pool — the contract is undecided.
- Pack builder. A working
.packbuilder tool is needed to move releases offFileLoader. Designed; in development. - Asset hot-reload. The VFS layer can detect changes; the policy for invalidating in-flight handles is undecided.
08 — UI
- In-game UI.
UiAgentis currently editor-only. The path to a play-mode HUD is mostly a matter of changingallowed_modes, plus deciding the input model. - Animations on UI. No tween / spring system today. Probably belongs as a separate lane that mutates UI components over time.
- Accessibility. Screen reader hooks, contrast modes. Not designed yet.
09 — Serialization
- DeltaSerialization. Roadmap item. Save games and undo/redo both want incremental snapshots. The trait surface is sketched, not implemented.
- Physics snapshot goal. Should there be a
SerializationGoal::IncludePhysicsStatethat captures velocities, sleep state, contacts? - 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
- Histogram exporter. Histograms collect, but the export format (Prometheus, OpenMetrics) is not yet committed.
- Per-frame trace records. Tracy integration would be valuable. The telemetry pipeline is compatible; the hookup is undecided.
- Telemetry retention. The DCC reads the latest value. Long-term retention (for replay-after-incident analysis) needs a storage policy.
11 — SDK and editor
khora-editordependencies. The editor depends directly onkhora-agentsandkhora-iofor performance. Justified but a violation of “SDK is the public API.” Worth revisiting.- 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.
- 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. - Multi-window editor. Popping the viewport to a second monitor — does the popped window keep its own Spine?
- Plugin UI surface. Third-party plugins need a place to live in the Inspector. The contract is undefined.
- Collaboration. Real-time multi-user editing. No roadmap, but the architecture does not preclude it.
12 — Extension model
- Agent registration API.
EngineConfig::register_agentis illustrative, not stable. Settling alongsidekhora-plugins. - Plugin DLL ABI. Hot-loaded plugin agents need a stable ABI we have not yet committed to.
- 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
- Phase 1 — Foundational architecture
- Phase 2 — Scene, assets, basic capabilities
- Phase 3 — The adaptive core
- Phase 4 — Tooling, usability, scripting
- Phase 5 — Advanced intelligence
- Phase 6 — Native physics
- 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_laneandecs_laneout of the Lane abstraction. Per the Agent vs Service rule, aLaneis 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 implementLane. Targets:- Replace
AssetDecoder<A>lane implementations with plainAssetDecoder<A>services registered inDecoderRegistry. TheAssetDecoder<A>trait already exists inkhora-laneswithout aLanebound — finish moving the decoders to use it cleanly and drop the lane scaffolding. - Move
CompactionLanework directly intoEcsMaintenance::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/andecs_lane/for accuracy with the current code, but those entries should disappear after this refacto.
- Replace
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
AssetAgentinto 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
DeltaSerializationLanefor 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
EditorGizmoRenderLane - #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
PhysicsAgentand 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
SimpleUnlitRenderLane - #46 Implement Camera System and Uniforms
- #47 Implement Material System
- #48 Implement Basic Lighting Models (track shader complexity / perf)
- #160 Implement
Forward+ LightingRenderLane - #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 (
RenderAgentBase) - #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 (
GornaArbitrator→apply_budgetflow) - #82 Demonstrate Automatic Renderer Strategy Switching (Auto mode + GORNA negotiation, 16 tests)
- #224 Implement
RenderLaneResource Ownership (pipelines, buffers, bind groups; properon_shutdown) - #225 Implement Light Uniform Buffer System (
UniformRingBufferin 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,GpuReportingestion) - #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.