Queries

Queries allow to select Entities that have a certain set of Components and to manipulate them.

Queries are the heart of every ECS and the main reason for its flexibility and performance. In Ark, queries are blazing fast and should be used to write the game or model logic where possible. For cases where components of a particular entity are required, see section Accessing components.

Basic queries

By default, a Query lets you iterate over all entities that have the query's components, and provides efficient access to these components.

Here, we are interested only in non-exclusive sets. So the entities that are processed may have further components, but they are not of interest for that particular piece of game or model logic.

for (entities, positions, velocities) in @Query(world, (Position, Velocity))
    @inbounds for i in eachindex(entities)
        pos = positions[i]
        vel = velocities[i]
        positions[i] = Position(pos.x + vel.dx, pos.y + vel.dy)
    end
end

Note the nested loop! In the outer loop, the query iterates the archetypes, and for each one returns a vector of entities and the columns for the queried components. In the inner loop, we iterate over the entities within the archetype and perform the actual logic.

Also note the last line in the inner loop. Here, we assign a new Position value to the current entity. This is necessary as Position is immutable, which is the default and highly recommended. See section Component types for details.

The @inbounds macro in front of the inner loop is optional, but it is safe to use here and makes the iteration faster as it allows the compiler to skip bounds checks.

Advanced queries

Query filters can be configured further, to include or exclude additional components.

with

Queries provide an optional with argument that filters for additional components that entities must have, but that are not used by the query.

for (entities, positions, velocities) in @Query(world,
            (Position, Velocity);
            with=(Health,)
        )
    @inbounds for i in eachindex(entities)
        # ...
    end
end

without

The optional without argument allows to exclude entities that have certain components:

for (entities, positions, velocities) in @Query(world,
            (Position, Velocity);
            without=(Health,)
        )
    @inbounds for i in eachindex(entities)
        # ...
    end
end

exclusive

The optional exclusive argument excludes entities that have any other then the query's components and those specified by with. So it acts like an exhaustive without:

for (entities, positions, velocities) in @Query(world,
            (Position, Velocity);
            exclusive=true
        )
    @inbounds for i in eachindex(entities)
        # ...
    end
end

optional

The optional optional argument adds optional component. 😉

Entities are included irrespective of presence of these components on them. Columns for these components are added at the end of the query iterator tuple. They are nothing if the current archetype does not have them.

for (entities, positions, velocities, healths) in @Query(world,
            (Position, Velocity);
            optional=(Health,)
        )
    has_healths = healths !== nothing
    @inbounds for i in eachindex(entities)
        # ...
    end
end

Note that it is possible to branch already outside of the inner loop, as all entities in an archetype either have a component or don't.

Component field views

Individual fields of components can be accessed as vectors in queries, e.g. using @unpack. This is particularly useful for components that use the StructArray storage modes, as it allows for SIMD-accelerated vectorized operations.

world = World(
    Position => StructArrayStorage,
    Velocity => StructArrayStorage,
)

# ...

for columns in Query(world, Val.((Position, Velocity)))
    @unpack entities, (x, y), (dx, sy) = columns
    @inbounds x .+= dx
    @inbounds y .+= dy
end

However, when iterating components that use StructArray storage without unpacking individual fields, there is a certain overhead and SIMD optimization may not be possible.

World lock

During query iteration, the World is locked for modifications like entity creation and removal and component addition and removal. This is necessary to prevent changes to the inner storage structure of the World that would result in undefined behavior of the query.

Whenever the game or model logic demands one of these forbidden operations, the entities to be affected must first be collected into a Vector, and the operations must be applied only after the query iteration has finished.

# vector for entities to be removed from te world
to_remove = Entity[]

for (entities, positions) in @Query(world, (Position,))
    @inbounds for i in eachindex(entities)
        pos = positions[i]

        # collect entities for removal
        if pos.y < 0
            push!(to_remove, entities[i])
        end
    end
end

# actual removal
for entity in to_remove
    remove_entity!(world, entity)
end

# clear the vector for re-use
resize!(to_remove, 0)

For the best performance, such a Vector should be stored persistently and re-used to avoid repeated memory allocations.

The world is automatically unlocked when query iteration finishes. When breaking out of a query loop, however, it must be unlocked by calling close! on the query.