ECS
DownDraft uses an archetype-based Entity Component System inspired by Bevy and Unity DOTS, with generational indices proven in production.
Core Concepts
Section titled “Core Concepts”Entity
Section titled “Entity”An entity is a lightweight handle: { index: u32, generation: u32 } — 8 bytes. Generational indices prevent stale-handle corruption.
Component
Section titled “Component”Components are plain data classes implementing the Component interface. Each component slot includes a lastChanged: u32 tick counter for change detection.
Archetype
Section titled “Archetype”An archetype is a set of component types. All entities with the same archetype share a table with SoA (Structure of Arrays) column storage. Iterating “all entities with Transform + Velocity” walks two contiguous arrays — cache-friendly.
Queries declare read/write access to component types and return matching archetypes. Queries can filter by change detection: query(Transform, Changed(Velocity)) only iterates entities where Velocity was written this tick.
System
Section titled “System”Systems are functions (query, resources, commands) => void that declare their access via query types.
Schedule
Section titled “Schedule”The schedule is single-threaded. Systems run in dependency order on one thread — there is no built-in parallel system scheduler. CPU-heavy work is offloaded to dedicated workers instead: the task pool (worker/task-pool.ts), library-owned workers (meshing, UI raster, physics realms), and plugin workers.
Events
Section titled “Events”Events use double-buffered channels. Events written this tick are readable next tick, preventing mutation-during-iteration bugs.
Commands
Section titled “Commands”Deferred operations (spawn/despawn entity, add/remove component) are queued during system execution and applied at stage boundaries.
import { Component, World, Stage, system } from "@downdraft/engine";
// Define a componentconst Health = Component.register("Health", { current: 100, max: 100,});
// Create a worldconst world = new World();
// Spawn an entity and add componentsconst entity = world.spawn(new Map());world.addComponent(entity, Health.id, Health.create({ max: 200 }));
// Define and register a systemconst damageSystem = system("damage", Stage.Update, (ctx) => { const { world, dt } = ctx; // Iterate entities with Health component via query});
world.schedule.addSystem(damageSystem);
// Step the simulationworld.step(dt);Hierarchy
Section titled “Hierarchy”Entities can be parented in a DOM-shaped tree:
- Implicit root — Every entity is parented at minimum to the World root. No orphan entities.
- Structural components — Parent/child stored in a side table, not in archetype columns. This avoids archetype moves on reparenting.
- Dirty-flag propagation — When a parent’s Transform changes, a
dirtyboolean on each child is set. This is a derived-data invalidation flag, distinct fromlastChanged. - Lazy world matrix —
worldTransform = parent.worldTransform * localTransform. Computed on read whendirtyis set, then cleared. - Reparenting —
setParent(entity, newParent)updates the side table, adjusts Children arrays, marks entity + descendants dirty. No recursion for transform — propagation is lazy.
Change Detection
Section titled “Change Detection”Every component instance has a lastChanged: u32 tick counter. When a system writes to a component, the write wrapper increments lastChanged to the current tick. Consumers poll lastChanged >= lastReadTick — a single integer comparison, O(1) per entity.
For SAB-backed data, Atomics.store / Atomics.load on the SAB header’s sequence counter serves as the cross-thread change signal. Each SAB channel has its own independent sequence counter, enabling fine-grained frame synchronization.