Architecture
Everything flows from a few core decisions. If these don't make sense yet, the rest of the engine won't either. Come back to this page when something breaks in a confusing way.
Tick Loop
Sim runs at a fixed dt. Always. TickClock in engine_core/src/clock.rs accumulates real time and issues integer tick counts. The host calls sim.tick() each frame and the clock decides how many simulation ticks actually happen.
real time delta into TickClock::advance_realtime()
└─ TickClock::should_tick() returns true N times
N = floor(accumulated_time / dt)
└─ run_single_tick() x N, capped at max_catch_up_ticks
└─ excess just gets dropped, TimingInfo::timing_drops goes up
The cap is there to stop death-spiral catch-up after a stall. TickClock is the only thing that touches real time. Inside a tick everything runs on the fixed dt, which is why replay works.
Five Stages
Every tick runs the same five stages in order. Stage is in engine_core/src/stage.rs; the ECS mirror is SystemStage in ecs/src/system.rs; they map 1:1 through stage_to_system_stage().
Input -> PreUpdate -> Update -> PostUpdate -> PreExtract
| Stage | repr(u8) | Purpose |
|---|---|---|
Input | 0 | Drain InputEvents, update InputState and GamepadState |
PreUpdate | 1 | Pre-physics prep, animation, data marshalling |
Update | 2 | Game logic, physics, AI |
PostUpdate | 3 | Reactions, cleanup, PropagateTransforms |
PreExtract | 4 | Fill RenderSnapshot, UiSnapshot, push audio commands |
Stage::ALL is fixed, not configurable. Stage::next() returns None after PreExtract. Systems are assigned by stage and ordered by access conflicts.
Command Application at Stage Boundaries
Systems can't structurally mutate the World while other systems may still be reading it. They write to EcsCommandBuffer, and the executor flushes that buffer after each stage completes.
[Input stage runs]
commands flushed: spawns, despawns, component changes all applied
[PreUpdate stage runs]
commands flushed
...all the way through PreExtract
The 256-byte pooled slots exist to avoid per-command heap allocation in steady state. Oversize commands fall back to boxed allocation unless forbid_boxed_commands turns that fallback into a compile-time panic.
SimLoop and World as Blackboard
SimLoop in ecs/src/bridge.rs holds the tick loop, schedule, plugin registry, and change-detection tick counter. Per tick it injects TickInfo and TimingInfo, runs the schedule through all stages, flushes command buffers at boundaries, and advances change_tick.
pub struct SimLoop {
tick_loop: TickLoop,
schedule: Schedule,
plugin_registry: PluginRegistry,
change_tick: u64,
}
Crates do not talk to each other directly. They communicate through World using resources, typed events, queries, command buffers, and extracted snapshots. The Plugin trait inserts resources and registers systems during build(); coupling is through shared data types, not direct crate-to-crate calls.
Events, Command Buffers, and Snapshot Extraction
Events<T> is a resource-backed event channel with dense session-stable producer IDs, deterministic merge order by (ProducerId, SequenceNum), and next-boundary delivery for background-thread emissions. Overflow is bounded and counted.
There are two command buffers: EcsCommandBuffer for structural ECS mutation at stage boundaries, and TypedCommandBuffer/CommandBuffer in engine_core for lower-level tick-granularity deferred work.
PreExtract builds snapshots for downstream systems. RenderSnapshot is filled by extraction systems, written into the back buffer of a double-buffered snapshot buffer, and then consumed on the render thread. Data only flows outward. GPU handles never go back into sim.
Parallel Execution and Entity Lifecycle
Non-conflicting systems run concurrently. The scheduler builds a dependency DAG from declared reads() and writes() access, then packs systems into waves. The executor uses WorldCell to allow simultaneous World access without fighting the borrow checker.
pub struct WorldCell(*mut World);
unsafe impl Send for WorldCell {}
unsafe impl Sync for WorldCell {}
This is the biggest unsafe surface in the engine. Debug builds run AccessValidator; release builds trust declarations. Entities are generational. Archetypes are unique component sets. Structural changes move entities between archetypes through EcsCommandBuffer at stage boundaries. Chunks are ~32KiB and transitions are cached through an edge graph in ecs/src/table.rs.
Runtime Tiers and Plugin Registration Order
Tier 1 crates are static and compiled in. Tier 2 modules live under modules/, implement Plugin, and can be added or removed at build time with no runtime cost for unused modules. Tier 3 plugins load at runtime through Starlark scripting or native dynamic libraries using NativePluginVTable.
1. CoreTimingPlugin
2. RngPlugin
3. ReplayPlugin
4. SchedulerPlugin
5. RenderPlugin
6. ScenePlugin
7. AssetPlugin
8. MeshImportPlugin
9. IoPlugin
10. UiPlugin
11. AudioPlugin
12. HostPlugin
user plugins
then Tier 2 module auto-discovery from modules.toml
Order matters because later plugins may read resources inserted by earlier plugins during build().