Queries
Queries match live entities and project typed component data. A query has two parts:
datacontrols matching requirements, projected fields, and read/write access.wherefilters matching entities without adding fields to the result type.
Defining queries
Section titled “Defining queries”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.
Query data
Section titled “Query data”The data tuple determines both projection and value-access metadata:
| Entry | Matching | Projection | Access |
|---|---|---|---|
Component | Requires the component | Readonly | Read |
Mut(Component) | Requires the component | Mutable | Write |
Optional(Component) | Does not require the component | Optional readonly | Read |
Optional(Mut(Component)) | Does not require the component | Optional mutable | Write |
AnyOf(A, Mut(B)) | Requires at least one member | Every member is optional | Mixed |
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.
Filter expressions
Section titled “Filter expressions”Use where to compose structural and temporal filters:
| Filter | Behavior |
|---|---|
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.
Temporal windows
Section titled “Temporal windows”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
Section titled “Entity iteration”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.
Dense batch iteration
Section titled “Dense batch iteration”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.
Runtime ownership
Section titled “Runtime ownership”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.
Query universe
Section titled “Query universe”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.
Entry hooks
Section titled “Entry hooks”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.