Query callbacks
Query callbacks let you react when entities match or stop matching a query. onEntitiesAdded and onEntitiesRemoved provide batch-friendly hooks that enable use cases like registering and deregistering entities from external systems (e.g., physics, GPU, etc).
onEntitiesAdded callback
The onEntitiesAdded callback fires once per contiguous range of entities when they first match the query. For example, a newly-spawned batch of entities, a single spawn, an entity that gained a required component, or lost an excluded component. This callback does not fire when an entity moves between archetypes that both match the query.
world:query({
include = {tecs.builtins.Transform, MyPhysicsComponent},
onEntitiesAdded = function(
archetype: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
local transforms = archetype:get(tecs.builtins.Transform)
local comps = archetype:get(MyPhysicsComponent)
for row = firstRow, lastRow do
local transform = transforms[row]
local comp = comps[row]
-- add to your physics world...
end
end
})The callback receives the archetype and 1-based firstRow / lastRow (inclusive) bounds plus the count.
onEntitiesRemoved callback
onEntitiesRemoved fires once per contiguous range of entities that no longer match the query. It's triggered by removing a required component, adding an excluded component, or a despawn. This callback is fired before entities are removed from archetypes.
world:query({
include = {tecs.builtins.Transform, MyPhysicsComponent},
onEntitiesRemoved = function(
archetype: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
local transforms = archetype:get(tecs.builtins.Transform)
local comps = archetype:get(MyPhysicsComponent)
for row = firstRow, lastRow do
local transform = transforms[row]
local comp = comps[row]
-- remove comps[row] from external system...
end
end,
})Callback signature
Callbacks receive a range of rows as 1-based inclusive bounds: firstRow, lastRow, and a count. Count is 1 for single spawns and archetype transitions, N for batch spawns (world:batchSpawn) and batch despawns. This lets you amortize per-entity work (slot allocation, buffer sizing, dirty marking) across a whole batch in one call. Iterate the range directly with for row = firstRow, lastRow do.
For auto-attaching companion components (e.g. Velocity should imply Position), use requires on the component declaration rather than query callbacks.
Reacting to a single component
To run code when a specific component appears on or leaves an entity, build a query whose include contains just that component. onEntitiesAdded fires when the entity first gains the component (via spawn, world:set, or an archetype transition that pulls it in); onEntitiesRemoved fires when the entity loses it (via world:remove, despawn, or an exclusion flipping).
-- React when Health appears or goes away, regardless of whatever
-- other components the entity carries.
world:query({
include = {Health},
onEntitiesAdded = function(
arch: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
local healths = arch:get(Health)
local entities = arch.entities
for row = firstRow, lastRow do
print("gained Health:", entities[row], healths[row].current)
end
end,
onEntitiesRemoved = function(
arch: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
local entities = arch.entities
for row = firstRow, lastRow do
print("lost Health:", entities[row])
end
end,
})Combine include with exclude to react to more specific transitions. For example, to fire exactly when an entity enters a "stunned" state and again when it leaves:
world:query({
include = {Enemy, Stunned},
onEntitiesAdded = function(
arch: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
-- Entity acquired Stunned (and was already Enemy).
end,
onEntitiesRemoved = function(
arch: tecs.Archetype,
firstRow: integer,
lastRow: integer,
count: integer
)
-- Entity lost Stunned or Enemy (or was despawned).
end,
})Deferred scope
Query callbacks run inside the world's drain, the phase that commits staged mutations after a system or batch finishes. The drain proceeds in waves: a wave snapshots the currently-dirty archetype list, applies despawns, spawns, and moves across those archetypes, and fires matching callbacks along the way. If a callback stages more mutations, the newly-dirtied archetypes form the next wave. The drain loops until no archetypes are dirty.
Inside a callback:
world:set,world:remove,world:spawn,world:despawn, and thebatch*APIs all stage; they apply in the next wave, not immediately.world:getandworld:hasstill see the pre-callback archetype state. Your staged writes are invisible until the drain finishes.- Reading the passed
archetype's columns is safe and reflects the current state of the entities in the range. Don't add or remove components on entities you're iterating within the callback; if you must, read all the data you need out of the archetype first, then stage the mutations.
Finite cascades settle automatically. Unbounded cascades (hook A adds a component that fires hook B which re-adds what hook A removed, etc.) trip a wave-count safety cap and raise an error.