On this page
  1. Queries
  2. Archetype iteration
  3. Structural changes
    1. Early exit
  4. Persistent and temporary queries
  5. Disabled entities
  6. Paused entities
  7. One-component archetype scans
  8. Module contents
    1. Submodules

Queries

A query tracks archetypes whose component signatures match one descriptor:

local Transform2D <const> = tecs.Transform2D
local movers <const> = world:newQuery({
    name = "game.Movers",
    include = {Transform2D, Velocity},
    exclude = {Frozen},
    type = "logic",
})

for archetype, length, entities in movers:iter() do
    local transforms <const> = archetype:getMut(Transform2D)
    local velocities <const> = archetype:get(Velocity)

    for row = 1, length do
        transforms[row].x = transforms[row].x + velocities[row].x * dt
        print(entities[row])
    end
end

include requires every listed component. exclude rejects every archetype with a listed component. includeAny adds an OR group:

local drawn <const> = world:newQuery({
    include = {tecs.Transform2D, tecs.gfx.Renderable2D},
    includeAny = {tecs.gfx.Sprite, tecs.gfx.Material},
    type = "render",
})

A query exposes its descriptor for inspection. Tecs owns the compiled masks, subscriptions, and grouping state; callers must treat the descriptor as read-only after construction. Changing it does not rebuild the query.

Archetype iteration

query:iter() yields each non-empty matching archetype, its row count, and its entity-ID column. Tecs owns the entity-ID column; callers treat it as read-only.

Bind each component column once per archetype. archetype:get gives a read-only access path. archetype:getMut gives caller-writable values and marks that component dirty:

for archetype, length in movers:iter() do
    local transforms <const> = archetype:getMut(Transform2D)
    local velocities <const> = archetype:get(Velocity)

    for row = 1, length do
        local transform <const> = transforms[row]
        local velocity <const> = velocities[row]
        transform.x = transform.x + velocity.x * dt
        transform.y = transform.y + velocity.y * dt
    end
end

LuaJIT cannot enforce const cdata, so writing through get may change memory without dirtying it. Use getMut for unconditional writes. For a conditional write, read through get and call archetype:markComponentDirty(Component) only when the write occurs.

query:count() sums archetype lengths without visiting entity rows.

Iteration supports nesting, including two loops over the same query. Each loop owns a scope, and mutations drain after the outermost loop finishes.

Structural changes

Archetype iteration opens a deferred scope. Structural calls such as spawn, despawn, set, remove, and batch operations stage until the loop finishes:

local expiring <const> = world:newQuery({
    include = {tecs.ecs.TTL},
    type = "logic",
})

for archetype, length, entities in expiring:iter() do
    local ttls <const> = archetype:getMut(tecs.ecs.TTL)
    for row = 1, length do
        ttls[row].remaining = ttls[row].remaining - dt
        if ttls[row].remaining <= 0 then
            world:despawn(entities[row])
        end
    end
end

The drain applies despawns, spawns, and archetype moves after iterator exhaustion. The mutation model defines visibility and ordering.

Early exit

query:iter() closes its scope only when the archetype loop reaches exhaustion. A loop that may break or return must use a cursor:

local cursor <const> = query:newCursor()

for archetype, _length, entities in cursor:iter() do
    if matchesSelection(archetype) then
        selected = entities[1]
        break
    end
end

cursor:close()

cursor:close() tolerates repeated calls and natural exhaustion. Call it immediately before returning from inside the loop. Cursors also support groups() and group(id), but each cursor owns one traversal and rejects a second.

Breaking only the inner row loop leaves the archetype iterator running, so it does not require a cursor.

Persistent and temporary queries

Persistent queries subscribe to new archetypes and remain suitable for systems that run every frame. Build them once during plugin setup.

temp = true takes a one-shot view of the current archetype set without registering observers:

for archetype, length in world:newQuery({
    include = {tecs.gfx.PointLight2D},
    temp = true,
}):iter() do
    inspectLights(archetype, length)
end

A temporary query cannot define onEntitiesAdded or onEntitiesRemoved. Query callbacks cover persistent match-set reactions. Grouping sorts matching archetypes under integer keys.

Disabled entities

Every query excludes tecs.ecs.Disabled unless include explicitly names the tag. Renderer queries follow the same rule.

local disabledRenderables <const> = world:newQuery({
    include = {
        tecs.Transform2D,
        tecs.gfx.Renderable2D,
        tecs.ecs.Disabled,
    },
})

Paused entities

type = "logic" excludes tecs.ecs.Paused. type = "render" records that paused entities should continue to match. An omitted type applies no pause filter.

local movement <const> = world:newQuery({
    include = {tecs.Transform2D, Velocity},
    type = "logic",
})

local sprites <const> = world:newQuery({
    include = {tecs.Transform2D, tecs.gfx.Sprite},
    type = "render",
})

Explicitly including Paused overrides the filter. Listing it under exclude matches the logic behavior.

One-component archetype scans

world:findArchetypes(Component) walks the component-to-archetype index without constructing a query:

for archetype, length, entities in world:findArchetypes(
    tecs.gfx.PointLight2D
) do
    local lights <const> = archetype:get(tecs.gfx.PointLight2D)
    for row = 1, length do
        print(entities[row], lights[row].radius)
    end
end

This iterator opens no deferred scope. Do not make structural changes while it runs.

Module contents

Submodules

Submodule Description
Query callbacks Batch onEntitiesAdded and onEntitiesRemoved query hooks with row ranges and deferred-drain semantics
Query grouping Grouping matching archetypes by integer key with groupBy, groups, group, getGroup, and getGroupCount