Skip to content

Queries

Queries match live entities and project typed component data. A query has two parts:

  • data controls matching requirements, projected fields, and read/write access.
  • where filters matching entities without adding fields to the result type.

Create an immutable, reusable definition with Define.query(), then bind it to one or more systems:

import { All, AnyOf, Changed, Define, Mut, Optional, Without } from '@shell/ecs';
const MovementQuery = Define.query({
data: [Mut(Transform), Velocity, Optional(Target), AnyOf(LocalState, RemoteState)],
where: All(Without(Disabled), Changed(Transform)),
});
const MovementSystem = Define.system()
.queries({ movement: MovementQuery })
.update(({ queries }) => {
queries.movement.forEach((entity) => {
entity.$Transform.x += entity.$Velocity.x;
});
});

Definitions contain no world or temporal state. Each .queries() binding creates an independent runtime query, even when several bindings or systems reuse the same definition.

For a query used by only one system, .queries() also accepts the definition config directly:

const MovementSystem = Define.system().queries({
movement: { data: [Mut(Transform), Velocity], where: Without(Disabled) },
});

The system converts inline configs to immutable definitions. Existing QueryDefinition values are kept unchanged so their compiled plans can be shared.

The data tuple determines both projection and value-access metadata:

EntryMatchingProjectionAccess
ComponentRequires the componentReadonlyRead
Mut(Component)Requires the componentMutableWrite
Optional(Component)Does not require the componentOptional readonlyRead
Optional(Mut(Component))Does not require the componentOptional mutableWrite
AnyOf(A, Mut(B))Requires at least one memberEvery member is optionalMixed

Use Mut() for every component written by the query. Optional projections are undefined when the component is absent. AnyOf() requires at least one member but projects every member as optional.

Only data adds $Component fields to entity and batch result types. An empty data: [] tuple is valid and matches every live entity allowed by where.

AnyOf() requires at least two distinct members and accepts components and Mut() entries, but not Optional(), nested AnyOf(), or filter nodes. Optional(Optional(T)) and Optional(AnyOf(...)) are invalid. Exact duplicate projections are deduplicated; conflicting readonly and mutable projections are rejected with a definition path.

Use where to compose structural and temporal filters:

FilterBehavior
With(Component)Requires the component without projecting it
Without(Component)Excludes entities with the component
ParentWith(...Components)Requires the direct parent to have every listed component
ParentWithout(...Components)Requires the direct parent to lack every listed component
Changed(Component)Matches changed component columns at archetype granularity
Spawned()Matches newly committed entity generations
Entered()Matches entities that started matching the non-temporal predicate
All(...filters)Boolean AND for the same entity
Any(...filters)Boolean OR for the same entity, yielding it at most once

Omitting where means true. Requirements implied by data are ANDed with where. Filters never project components onto the matching entity, so Changed(Transform) can filter a query that does not include Transform in its results. ParentWith(A, B) additionally types the entity returned by getParent() with readonly $A and $B aliases after the usual undefined check. ParentWithout() does not add parent aliases because absence does not prove that a component can be read.

All() and Any() require at least one filter. Nested expressions are flattened, duplicate structural leaves are deduplicated, and direct contradictions such as required A with Without(A) are rejected. where accepts only filter expressions, not arbitrary JavaScript predicates.

During planning, each ParentWith(Component) or ParentWithout(Component) leaf is approximated as With(Parent) in the same boolean position. Passing multiple components is equivalent to combining individual parent filters with All(). A conjunctive parent filter therefore requires Parent; under Any(), other branches may still admit unparented archetypes. Components that occur only inside Any() branches are not added to the inferred parent type because they are not universally guaranteed. Each candidate entity is then checked against its direct parent’s committed archetype. ParentWithout(A, B) requires a direct parent with neither component, so unparented entities do not match. Parent filters cannot be combined with Entered().

const AbilityQuery = Define.query({
data: [Ability],
where: ParentWith(Player, Position, Velocity),
});
const CastAbilities = Define.system()
.queries({ abilities: AbilityQuery })
.update(({ queries }) => {
queries.abilities.forEach((ability) => {
const player = ability.getParent();
if (!player) return;
console.log(player.$Position.x, player.$Velocity.x);
});
});
const ChangedNames = Define.query({
data: [EntityName],
where: Any(
All(With(PlayerOwned), Changed(Transform)),
All(With(NetworkOwned), Changed(NetworkState)),
),
});

Spawned() describes initial entity creation. Entered() describes query membership. Adding a required component to an existing entity can produce Entered(), but not Spawned(). Matching-to-matching archetype movement does not enter again. Entering and leaving before observation produces no result.

Removal is not a query filter, and removed components or despawned entities are never query results. Use World.onComponentRemoved() or World.onEntityDespawn() for synchronous external cleanup. Model gameplay death with an explicit component or event before despawning.

Every runtime query owns an independent observation baseline. At the start of a system update, the query captures one evaluation revision and uses the stable window (previousRevision, evaluationRevision] for every read during that update.

  • Mutations made during the update become visible to that query on its next evaluation.
  • A skipped system does not advance its query baselines, so temporal changes accumulate.
  • If the update or an entry hook throws, the window is not consumed.
  • After a successful update, the baseline advances to the captured evaluation revision.

Changed(Component) is intentionally archetype-granular. If one component instance is mutated, all live entities in that matching archetype qualify. ChangedAll(A, B) is shorthand for All(Changed(A), Changed(B)) and requires both columns to have changed during the window. ChangedAny(A, B) is shorthand for Any(Changed(A), Changed(B)) and requires either, while still returning each entity once. Both shorthands require at least two components. Component insertion does not count as a value change.

Spawned() is generation-aware, and a query does not report entities committed before that runtime query was created. Existing matches are pending results when an Entered() query is created.

Entity iteration supports every dense and sparse query plan:

queries.movement.forEach((entity) => {
entity.$Transform.x += entity.$Velocity.x;
});
for (const entity of queries.movement) {
entity.$Transform.y += entity.$Velocity.y;
}
const first = queries.movement.firstEntity;
const count = queries.movement.numOfEntities;

Readonly projections expose readonly component proxies. Mut() projections expose writable proxies whose writes mark their archetype component column changed.

iter() yields one complete, dense storage batch per matching archetype:

for (const { size, $Transform, $Velocity, entities } of queries.movement.iter()) {
for (let i = 0; i < size; i++) {
$Transform.x[i] += $Velocity.x[i];
}
}

Each batch contains size, entities, projected $ComponentName containers, and getComponent(builder). Optional and AnyOf() containers can be undefined. Yielding a mutable batch marks every present mutable component column on that archetype changed.

Structural filters and Changed()-only expressions are archetype-level and remain dense. Spawned(), Entered(), ParentWith(), and ParentWithout() select individual rows and make a plan sparse. Calling iter() on a sparse plan throws immediately; use forEach, entity iteration, firstEntity, or numOfEntities instead.

Systems create one runtime query per binding and dispose those queries automatically when the system is unregistered or destroyed. Integrations that create standalone queries own their runtime handles and must dispose them:

const query = world.get('queries').create(MovementQuery);
try {
query.observe(() => query.forEach(updateMovement));
} finally {
query.dispose();
}

Do not retain a runtime query after disposal. Reusable QueryDefinition values can safely outlive worlds and can be used to create new runtime queries later. Use query.observe() around standalone temporal reads so successful work advances the query baseline and failed work preserves it.

Every query sees matching entities from the complete shared root-world tree. Queries created by the root, child, sibling, or nested child worlds all search the same entity universe. Scene replacement or unload removes only entities owned by the affected registry from those shared results.

Use marker components with With() and Without() when a system needs a logical partition. The world that owns a system does not implicitly limit its queries.

Entered() queries expose hooks.added:

queries.entities.hooks.added.add((entered) => {
for (const entity of entered) initialize(entity);
});

For system-owned queries, hooks run after the update callback during successful evaluation and before the temporal window is committed. Standalone consumers can call query.flush() when manually managing transition delivery.