> Cinder 0.1.0 documentation: ECS guide (cinder/ecs)
> Canonical page: https://cinder.ash10.dev/docs/guides/ecs/ · Source: `docs/guides/ecs.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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`](https://cinder.ash10.dev/docs/api/ecs.md). Tests: `tests/ecs/test_ecs.mojo`.
Decision record: [ADR-0011](https://cinder.ash10.dev/docs/adr/0011-ecs-mojo-vs-flecs.md) (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](https://cinder.ash10.dev/docs/adr/0014-sample-game-and-performance.md)).

## 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`](https://cinder.ash10.dev/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`](https://cinder.ash10.dev/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).
