DocsGuides

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 Entity is 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 is Copyable, Deinitable, Movable plus a static component_id() -> Int that must be unique per type and lie in 0 .. MAX_COMPONENTS - 1 (256). Copyable is required (so World.get can hand out a copy); ImplicitlyCopyable is not. The sample marks its components ImplicitlyCopyable for convenience, the engine's own (Parent, MeshRenderer, ...) do not.
  • Entities with the same component set share an Archetype: one Column per component, each a contiguous, 16-byte-aligned byte array with one element per row, parallel to the archetype's entity list. Element stride is component_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 (a String, a List) 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 add keeps 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:

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:

mojo
_ = 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:

mojo
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:

mojo
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:

mojo
_ = 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 0

Payloads 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:

mojo
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 rewritten

Rules, all from the implementation:

  • Every entity with a LocalTransform gets WorldTransform = parent.world * local; the component is added when missing. An entity with a Parent but no LocalTransform is treated as identity for its children but gets no WorldTransform itself.
  • 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 on WorldTransform are meaningful. The return value is the number of entities updated.
  • Scratch lists (prop_computed, prop_results, prop_todo, prop_chunks, prop_spec) live in the World and 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:

mojo
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 result

build() (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 World has no locks and is owned by the main thread. Spawn, despawn, add, remove, register, advance_tick, propagate_transforms, CommandBuffer.apply and anything that can create an archetype are main-thread operations.
  • Systems inside a Schedule.run stage 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 marked exclusive (which serialises them into their own stage). Record structural changes into a CommandBuffer and apply it on the main thread after run.
  • 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.run blocks the caller until every stage has finished; it is called from the main thread, which also executes jobs while waiting (see docs/THREADING.md).

Pitfalls #

  • A pointer from get_ptr, get_mut or a Chunk dangles after the next structural change. Re-query after add/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.
  • Entity indices are reused after despawn with a bumped generation; compare whole entities (or packed()), never bare indices, and check is_alive before trusting a stored handle.
  • World.entities() allocates a fresh list; it is for tests and tooling, not per-frame code. Query.chunks() also allocates; use QuerySpec + collect_chunks in systems.
  • Two component types with the same component_id() and the same size are not detected.
  • propagate_transforms ignores a Parent pointing at a dead entity instead of raising; a child whose parent was despawned silently becomes a root.
  • See docs/MOJO_NOTES.md for the lifetime rules behind the MutUntrackedOrigin pointers used throughout (keep after the last C call, no copies of function pointers out of their owning struct).
View as MarkdownSource: docs/guides/ecs.md