ECS guide (cinder/ecs)
cinder/ecs is the archetype entity component system every gameplay-facing module builds on.
It sits directly above cinder/core (it uses core/math for Mat4 and core/jobs for the
scheduler) and below render, physics, anim, audio, assets and scene, which define
their own components on top of it. The App runtime owns one World; the sample game and
every bridge (PhysicsSync, the animation system, RenderBridge) read and write that world.
Source: cinder/ecs/world.mojo, commands.mojo, hierarchy.mojo, schedule.mojo.
API reference: docs/api/ecs.md. Tests: tests/ecs/test_ecs.mojo.
Decision record: ADR-0011 (why this ECS and not flecs, and the
benchmark that keeps it honest).
Model #
- An
Entityis a generational id (index,generation).Entity.null()is never live;World.is_alive(e)is false for despawned entities even after their slot is reused. - A component is any struct conforming to
Component. The trait isCopyable, Deinitable, Movableplus a staticcomponent_id() -> Intthat must be unique per type and lie in0 .. MAX_COMPONENTS - 1(256).Copyableis required (soWorld.getcan hand out a copy);ImplicitlyCopyableis not. The sample marks its componentsImplicitlyCopyablefor convenience, the engine's own (Parent,MeshRenderer, ...) do not. - Entities with the same component set share an
Archetype: oneColumnper component, each a contiguous, 16-byte-aligned byte array with one element per row, parallel to the archetype's entity list. Element stride iscomponent_stride[T](): the size rounded up to the type's alignment, so an 8-byte component costs 8 bytes per row. A type whose alignment exceeds 16 is rejected at registration. - Columns are type-erased. On first use
World.register[T]()records size, alignment, stride, the type name (reflect[T].name()) and two thin function pointers (_destroy[T],_move[T]), so heap-owning components (aString, aList) are destroyed and moved correctly. Registering a second type under an id already taken raises (two types), even when both types have the same size: the type name, size and alignment must all match. Rows are relocated bytewise on archetype moves and column growth, so a component must not hold pointers into itself. - Adding or removing a component moves the entity to another archetype (creating it on demand);
despawning swap-removes the row. Replacing an existing component with
addkeeps the archetype and marks the row changed.
Component ids in use #
| id | component | module |
|---|---|---|
| 1, 2, 3 | Parent, LocalTransform, WorldTransform |
cinder/ecs/hierarchy.mojo |
| 4, 5, 6 | MeshRenderer, LightSource, CameraComponent |
cinder/scene/render_sync.mojo |
| 7 | RigidBody |
cinder/physics/sync.mojo |
| 8 | AnimatorRef |
cinder/anim/system.mojo |
| 9 | JointPalette |
cinder/scene/render_sync.mojo |
| 10, 11 | AudioSource, AudioListener |
cinder/audio/sync.mojo |
| 100 .. 105 | sample game components | sample/game/components.mojo |
Two types sharing an id is caught only when their sizes differ (register raises); pick an
unused id and keep game components well above the engine's range.
Entities and components #
From tests/ecs/test_ecs.mojo:
from cinder.ecs import Component, Entity, World
@fieldwise_init
struct Position(Component):
var x: Float32
var y: Float32
@staticmethod
def component_id() -> Int:
return 10
var world = World()
var a = world.spawn()
world.add[Position](a, Position(1.0, 2.0))
world.add[Velocity](a, Velocity(0.5, 0.0))
assert_true(world.has[Position](a) and not world.has[Label](a))
assert_equal(world.get[Position](a).x, 1.0) # a copy
world.add[Position](a, Position(3.0, 3.0)) # replace: same archetype, marked changed
assert_true(world.remove[Velocity](a)) # False when absent
world.despawn(a)
assert_false(world.is_alive(a))Access paths and what they do to change ticks:
| call | returns | marks changed |
|---|---|---|
get[T](e) |
copy | no |
get_ptr[T](e) |
Pointer[T, MutUntrackedOrigin] |
no |
get_mut[T](e) |
same pointer | yes |
set[T](e, v) / add[T](e, v) |
- | yes |
Pointers from get_ptr/get_mut/Chunk are valid until the next structural change (spawn,
despawn, add, remove) on the world. get, get_ptr, get_mut, set, remove and despawn
raise on a dead entity ("not alive") or a missing component ("no component").
Queries and chunks #
world.query() builds a filter; .chunks() returns one Chunk per non-empty archetype whose
signature has every with_ component and none of the without ones. A chunk gives typed access
to that archetype's rows:
_ = world.advance_tick()
for chunk in world.query().with_[Position]().with_[Velocity]().chunks():
var pos = chunk.column[Position]() # tight typed column base
var vel = chunk.column[Velocity]()
for row in range(chunk.count):
pos[unsafe_offset=row].x += vel[unsafe_offset=row].dx
pos[unsafe_offset=row].y += vel[unsafe_offset=row].dy
chunk.mark_changed[Position](row)(The loop body is the one bench/ecs_bench.mojo times; the test does the same through
chunk.get[T](row)[].)
chunk.get[T](row) returns a pointer to one element, chunk.entity(row) the owning entity,
chunk.has[T]() whether the archetype carries a component you did not filter on (the render
bridge uses this for the optional JointPalette). Chunk.stride[T]() is the element stride if
you step through the column by bytes. Writing through column/get does not touch ticks; call
mark_changed[T](row) yourself. column, get, mark_changed and changed_since abort
(cinder_check, kept in release builds: one compare per call) when the chunk's archetype has no
T; test chunk.has[T]() first for a component you did not filter on. ADR-0011 records that the typed column[T]() loop is what the
compiler vectorises at flecs parity; byte stepping with a runtime stride was slower.
A Query holds a pointer to the world, so it is built and consumed in one place. Systems that
run every frame keep a QuerySpec (the same filters without the pointer) and a chunk list and
call world.collect_chunks(spec, chunks), which clears and refills the list without allocating
once it has grown to size:
struct MySystem(Movable):
var spec: QuerySpec
var chunks: List[Chunk]
def __init__(out self):
self.spec = QuerySpec().with_[Position]().with_[Velocity]()
self.chunks = List[Chunk]()
def run(mut self, world: World):
world.collect_chunks(self.spec, self.chunks)
for ci in range(len(self.chunks)):
var chunk = self.chunks[ci]
...This is the pattern behind the zero-allocation frame (ADR-0014).
Change detection #
Every column keeps a UInt32 tick per row. World.tick starts at 1 and advance_tick()
increments it; the App calls it once per fixed step. A row's tick is set to the current tick
by add, set, get_mut, Chunk.mark_changed and by CommandBuffer.apply. Readers compare:
var start_tick = world.tick
_ = world.advance_tick()
# ... systems run ...
for chunk in world.query().with_[Position]().chunks():
for row in range(chunk.count):
if chunk.changed_since[Position](row, start_tick):
...changed_since is a strict > against the stored tick, so record the tick before advancing
and writing. world.changed_tick[T](e) reads one entity's tick. A newly spawned row carries the
tick of its spawn; a component carried across an archetype move keeps its tick.
Command buffers #
Structural changes are immediate on the World, which makes them illegal while iterating chunks
(the row you are on may be swap-removed) and from jobs. CommandBuffer records them instead:
_ = world.register[Position]() # the world must know every type a buffer will add
_ = world.register[Label]()
var cmds = CommandBuffer()
var p0 = cmds.spawn() # placeholder index, not an Entity
cmds.add_to_spawned[Position](p0, Position(1.0, 1.0))
cmds.add_to_spawned[Label](p0, Label(String("deferred")))
cmds.add[Label](existing, Label(String("late label")))
cmds.remove[Position](existing)
cmds.despawn(existing)
var applied = cmds.apply(world) # in order; clears the buffer
var e0 = cmds.resolved[0] # real entity for placeholder 0Payloads are stored as raw 16-byte blocks together with the type's destroy function, so a buffer
dropped without apply destroys what it owns (checked under ASan in the test). apply moves each
payload into the world through the registered move function; adding a type the world has never
seen raises never registered rather than corrupting a column, so register (or add once
directly) before the first deferred add. A despawn of an entity that died in the meantime is
skipped silently; a remove of an absent component is a no-op.
When a command raises (unregistered type, an add or remove on a dead entity), apply
propagates the error with the buffer in a consistent state: the commands before the failing one
have taken effect and left the buffer (their payloads now belong to the world), the failing one
and those after it remain and still own their values. Fix the cause and call apply again to
continue (spawns already made keep their entity in resolved), or call cmds.clear() (or drop
the buffer) to discard the rest, which destroys their values.
Hierarchy and transforms #
hierarchy.mojo defines Parent(entity), LocalTransform(matrix) and WorldTransform(matrix)
(all Mat4, column-major, right-handed Y-up as everywhere in Cinder) and
propagate_transforms(world) -> Int:
world.add[LocalTransform](root, LocalTransform(Mat4.translation(Vec3(1.0, 0.0, 0.0))))
world.add[LocalTransform](child, LocalTransform(Mat4.translation(Vec3(0.0, 2.0, 0.0))))
world.add[Parent](child, Parent(root))
assert_equal(propagate_transforms(world), 2) # both WorldTransforms written
assert_equal(propagate_transforms(world), 0) # unchanged values are not rewrittenRules, all from the implementation:
- Every entity with a
LocalTransformgetsWorldTransform = parent.world * local; the component is added when missing. An entity with aParentbut noLocalTransformis treated as identity for its children but gets noWorldTransformitself. - Parents are resolved recursively with memoisation, so archetype order does not matter and each
entity is computed once per call. A dead or self-referencing parent is ignored (the entity
becomes a root); a chain deeper than 256 raises
cycle?. - Only values that actually changed are written through
set, so change ticks onWorldTransformare meaningful. The return value is the number of entities updated. - Scratch lists (
prop_computed,prop_results,prop_todo,prop_chunks,prop_spec) live in theWorldand are reused, so a steady-state call allocates nothing.
The App calls propagate_transforms once per frame after animation and physics have written
local transforms and before RenderBridge.build reads the world transforms.
Schedule #
Schedule runs systems whose component access is declared up front. A system is a thin function
def(Pointer[World, MutUntrackedOrigin]) -> None (SystemFn); SystemDesc carries its name,
the components it reads and writes, and an exclusive flag for systems that do structural
changes:
def _integrate(world: Pointer[World, MutUntrackedOrigin]):
for chunk in world[].query().with_[Position]().with_[Velocity]().chunks():
for row in range(chunk.count):
var p = chunk.get[Position](row)
var v = chunk.get[Velocity](row)
p[].x += v[].dx
p[].y += v[].dy
var integrate: def(Pointer[World, MutUntrackedOrigin]) thin -> None = _integrate
var schedule = Schedule()
schedule.add(SystemDesc(String("integrate"), integrate).reads_[Velocity]().writes_[Position]())
schedule.add(SystemDesc(String("decay"), decay).writes_[Health]()) # same stage
schedule.add(SystemDesc(String("read positions"), read).reads_[Position]()) # new stage
assert_equal(schedule.stage_count(), 2)
schedule.run(world, Optional[Pointer[JobSystem, MutUntrackedOrigin]](jobs_ptr))
schedule.run(world, Optional[Pointer[JobSystem, MutUntrackedOrigin]]()) # inline, same resultbuild() (called lazily by run and stage_count) walks systems in declaration order: a system
joins the current stage unless it conflicts with any system already there (write/write or
read/write on the same component id, or either side exclusive), in which case a new stage
starts. Later systems can still join an earlier-opened stage only if it is the last one, so
declaration order affects packing. Stages run one after another; within a stage every system is
submitted as a job on the given JobSystem and run waits on a Counter before the next stage.
Without a job system, or for a single-system stage, systems run inline on the calling thread.
Threading rules #
- The
Worldhas no locks and is owned by the main thread. Spawn, despawn, add, remove,register,advance_tick,propagate_transforms,CommandBuffer.applyand anything that can create an archetype are main-thread operations. - Systems inside a
Schedule.runstage run concurrently on job workers. They may read and write component data through chunks, restricted to what they declared; they must not perform structural changes unless markedexclusive(which serialises them into their own stage). Record structural changes into aCommandBufferand apply it on the main thread afterrun. - Anything else that touches the world from a job (the animation system's pose jobs, for instance) follows the same rule: read declared data, publish back on the main thread.
Schedule.runblocks the caller until every stage has finished; it is called from the main thread, which also executes jobs while waiting (seedocs/THREADING.md).
Pitfalls #
- A pointer from
get_ptr,get_mutor aChunkdangles after the next structural change. Re-query afteradd/remove/spawn/despawn/apply. remove[T]on the last component of an entity moves it back to the empty archetype; the entity stays alive.- Components are relocated bytewise (
unsafe_memcpy): never store a pointer to a component's own field inside it, and never keep raw pointers to components across frames. Entityindices are reused after despawn with a bumped generation; compare whole entities (orpacked()), never bare indices, and checkis_alivebefore trusting a stored handle.World.entities()allocates a fresh list; it is for tests and tooling, not per-frame code.Query.chunks()also allocates; useQuerySpec+collect_chunksin systems.- Two component types with the same
component_id()and the same size are not detected. propagate_transformsignores aParentpointing at a dead entity instead of raising; a child whose parent was despawned silently becomes a root.- See
docs/MOJO_NOTES.mdfor the lifetime rules behind theMutUntrackedOriginpointers used throughout (keepafter the last C call, no copies of function pointers out of their owning struct).