Systems & Per-Entity Iteration

Systems are a convention, not part of the library — a system is just a callable that takes the World and does something to it. World only knows entities and components; behaviour lives at application level, wired as hooks or callbacks.

class MotionSystem:
    def __call__(self, world: World):
        qr = world.query(HasPosition, HasVelocity)          # 1. select the entities you care about
        qr.position = qr.position + qr.velocity * DT     # 2. operate (here: vectorized)

Every system has the same shape: query, then operate. There are three ways to operate — pick by what the work needs.

1. Vectorized (the default, and the whole point)

Batch the whole query in one numpy op. Two equivalent forms:

qr = world.query(HasPosition, HasVelocity)
qr.position = qr.position + qr.velocity * DT     # writes back to every underlying pool (numpy rules)

# per-pool variant: less ergonomic, but avoids the Field object -- maybe faster in extreme cases
for pool in qr.pool_list:
    pool.position[:] = pool.position + pool.velocity * DT

Push branches into numpy too — np.where / np.clip instead of a per-entity if:

qr.velocity = np.where(hit_wall, -qr.velocity, qr.velocity)      # data-parallel bounce, all entities at once

This is what microecs is for. Kept vectorized, a system runs at 1–2 ns/entity — see Benchmarks.

2. Per-entity via zip (when a system can't vectorize)

Not every system vectorizes. A renderer calls a draw function per primitive; same for any per-entity foreign API. The cheapest way to iterate through all entities is zip() over the QueryResult fields you need:

qr = world.query(HasPosition, HasColor, HasRadius)
for pos, color, radius in zip(qr.position, qr.color, qr.radius):    # one entity per step, fields aligned
    rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color)   # per-entity by necessity (no "draw all")

There is no qr.field[i] shortcut — the entity axis is off-limits on a query result. For random single-entity access, go by id (next section): world.get_entity(qr.entity_ids[i]). (It happens to work when a query matches exactly one archetype, and raises as soon as a second one exists — don't rely on it; see the known gap in Primitives.)

3. The Entity API (a single entity, structure, foreign formats)

world.get_entity(eid) returns an Entity: an OOP-like view of one row. Reach for it when you address a single entity by id, when you change an entity's structure (add/remove a component), or when you convert to a foreign format (e.g. JSON). It always re-checks which pool the entity lives in, so it is slower than the vectorized path — keep it out of hot loops (see Benchmarks).

Reads and data writes both go straight to the pool row — only structural changes are buffered. So a write is visible to the next read, and read-modify-write composes. See Mutation timing.

e = world.get_entity(eid)
e.position += np.float32([1, 0])      # lands now; so do `e.position = v` and `e.position[0] = v`
if e.is_colliding.item():             # pull a python scalar out for control flow
    e.velocity = 0.0                  # numpy's rules: a scalar fills the row

set_data is for the cases where several fields must land together: any number of fields across any number of components in one call, validated first and written after — if one field is rejected (dtype, shape, unknown name), nothing is written at all.

e.set_data(position=np.float32([1, 0]), velocity=np.float32([0, 0]))   # both, or neither

Capabilities are additive (add / remove components live)

has_component / add_component / remove_component let a system grant or revoke a capability at runtime. A static object becomes a mover the moment it gains a HasVelocity; drop the component and it is static again — there is no Frozen flag to juggle, "static" simply means "lacks HasVelocity". Structural changes are buffered, so call world.update() before the same tick reads them:

e = world.get_entity(eid)
if not e.has_component(HasVelocity):
    e.add_component(HasVelocity, velocity=np.float32([0.5, 0]))
else:
    e.remove_component(HasVelocity)
world.update()                        # commit the structural change before this tick uses it

This is exactly how a plugin toggles behaviour on a scene object at runtime: press a key → grant HasVelocity (and maybe a collider) → the universal motion system starts integrating it; press again → remove the component → it goes static.

Per-entity serialization (to_dict)

Serialization is per-entity and targets a foreign format (JSON), so it is a loop over get_entity, not a vectorized op. entity.to_dict(serialization_field="serializable") dumps only the fields whose World(extra_metadata=[...]) flag is set (so a derived, per-frame field can opt out):

def world_to_dict(world: World) -> dict:
    res = {"entities": [], "components": world.component_names, "extra_metadata": world.extra_metadata}
    for eid in world.live_entities:
        res["entities"].append(world.get_entity(eid).to_dict(serialization_field="serializable"))
    return res

Runnable in Example 3 — Serialization: F5 saves the world to JSON, F6 reloads it.

Which one?

Vectorize by default. Per-entity loops are a cliff, not a tie — every per-entity microecs path is 15–30× slower than idiomatic float-based OOP, because microecs is numpy-backed and a per-entity step pays numpy's tiny-array overhead. One unavoidable per-entity pass costs ~500× a vectorized op and will dominate the frame. If a system is irreducibly per-entity (data-dependent control flow), plain python objects beat microecs there — use them. microecs is the right tool for vectorizable simulation. The Benchmarks page has the numbers.