On this page
  1. Query callbacks
  2. Entities entering the query
  3. Entities leaving the query
  4. Commit drain
  5. Archetype-local observers

Query callbacks

onEntitiesAdded and onEntitiesRemoved react to changes in a query's match set. Each callback receives one contiguous row range:

local physicsBodies <const> = world:newQuery({
    include = {tecs.Transform2D, tecs.physics.RigidBody},

    onEntitiesAdded = function(
        archetype: tecs.ecs.Archetype,
        firstRow: integer,
        lastRow: integer,
        count: integer
    )
        local transforms <const> = archetype:get(tecs.Transform2D)
        local bodies <const> = archetype:get(tecs.physics.RigidBody)

        reservePhysicsBodies(count)
        for row = firstRow, lastRow do
            attachBody(bodies[row], transforms[row])
        end
    end,

    onEntitiesRemoved = function(
        archetype: tecs.ecs.Archetype,
        firstRow: integer,
        lastRow: integer,
        _count: integer
    )
        local bodies <const> = archetype:get(tecs.physics.RigidBody)

        for row = firstRow, lastRow do
            detachBody(bodies[row])
        end
    end,
})

The inclusive bounds use one-based rows. count equals the range length, so a batch path can reserve external storage once.

A temporary query cannot define either callback.

Entities entering the query

onEntitiesAdded runs when entities first match the descriptor:

  • A spawn places matching components.
  • A component addition satisfies include.
  • A component removal satisfies exclude.

Moving between two archetypes that both match does not run the callback. The entity never left the query.

Use requires when one component always implies another. Use a callback when matching should trigger work in an external system or allocate a resource.

Entities leaving the query

onEntitiesRemoved runs before rows leave their archetype, so the callback can still read every included column. These changes remove a match:

  • Despawn.
  • Removal of an included component.
  • Addition of an excluded component.

A one-component query reacts to that component alone. A wider descriptor can express a lifecycle boundary such as {Enemy, Stunned} or {Transform2D, RigidBody} without coordinating separate hooks.

Commit drain

Query callbacks run inside the commit drain. A drain applies changes in waves: despawns first, then spawns, then archetype moves. It applies batch mutations and sparse relationship writes between waves.

A callback may stage more work. The next wave applies that work:

  • Structural calls through set, remove, spawn, despawn, and the batch* APIs do not change committed structure immediately.
  • get, has, and queries see committed structure. They do not see staged additions, removals, spawns, or despawns.
  • A value-only set on a committed entity writes immediately when no staged structural change blocks it, and it marks the component dirty.
  • The callback may read the supplied archetype and row range until it returns. Read needed values before staging changes to those entities.

A finite callback cascade settles through later waves. Tecs stops an unbounded cascade with an error after 64 waves.

The mutation model defines the complete ordering and visibility contract.

Archetype-local observers

archetype:addEntityObserver attaches lower-level hooks to one archetype, including activation, deactivation, moves, and destruction. It does not find other archetypes with the same signature. Query callbacks follow the query as new archetypes begin to match, so game and subsystem code should normally use them.