Skip to content
Code:

Queries

A query is a set of constraints to select entities based on the components they have. Queries are always defined in systems at construction time. It's not possible to run new ad-hoc queries once the world has been created.

A query is always updated with the entities that match the components' condition immediately before a system is executed. The work needed to keep a query updated is proportional to the number of shape changes (component additions and removals) in the world rather than the total number of entities.

Basic query syntax

Queries use a small domain-specific language to express their constraints and are assigned to system properties at construction time:

ts
@system class SystemA extends System {
  // Query for all entities with an Enemy component but no Dead component.
  private activeEnemies = this.query(
    q => q.current.with(Enemy).and.withAny(stateEnum).but.without(Dead));

  execute(): void {
    for (const entity of this.activeEnemies.current) {
      const enemy = entity.read(Enemy);  // guaranteed to have an Enemy component
    }
  }
}
js
class SystemA extends System {
  constructor() {
    // Query for all entities with an Enemy component but no Dead component.
    this.activeEnemies = this.query(
      q => q.current.with(Enemy).and.withAny(stateEnum).but.without(Dead));
  }

  execute() {
    for (const entity of this.activeEnemies.current) {
      const enemy = entity.read(Enemy);  // guaranteed to have an Enemy component
    }
  }
}

First you specify that you want all current entities that satisfy the constraints; we'll introduce other options later. Then you constrain what component types an entity must and must not have to satisfy the query:

  • an entity must have all the components listed in with clauses;
  • an entity must have at least one of the component listed in each withAny clause;
  • an entity must not have any of the components listed in without clauses.

Each clause can list any number of component types. Enum types and enums can be used in most of the clauses, but check the API docs as some combinations cannot be evaluated efficiently.

The query object will have a current property that's an array of entities you can iterate over in your execute hook.

TIP

Queries are only updated between system executions so you don't need to worry about accidentally mutating the entity array while you're iterating over it by adding or removing components.

Declaring entitlements

Query definitions also have a secondary function: they declare what component types the system will be reading, writing, creating and updating. These declarations are not query-specific — the entitlements from all of a system's queries are combined together and applied to the system — but it's a convenient place to express them as you'll often need to read and write the component types that your queries are constrained on.

You can only read, write, create and update component types for which you declared entitlements, otherwise you'll get an error. Becsy also uses the entitlements to help order system execution and determine which systems can safely run concurrently.

You declare entitlements by following any clause that mentions component types with a read, write, create or update:

ts
@system class Namer extends System {
  // Select all Players that don't have a Name component yet.
  private uninitializedPlayers =
    this.query(q => q.current.with(Player).but.without(Name).write);

  execute(): void {
    for (const player of this.uninitializedPlayers.current) {
      // Add a name to each player, which will also remove it from the query.
      player.add(Name, {value: getRandomName()});
    }
  }
}
js
class Namer extends System {
  constructor() {
    // Select all Players that don't have a Name component yet.
    this.uninitializedPlayers =
      this.query(q => q.current.with(Player).but.without(Name).write);
  }

  execute() {
    for (const player of this.uninitializedPlayers.current) {
      // Add a name to each player, which will also remove it from the query.
      // This is a typical "factory" pattern in ECS.
      player.add(Name, {value: getRandomName()});
    }
  }
}

Above, we declared that we'll be writing the Name component; adding and removing count as writing, as does calling Entity.write. Any with or without component types are automatically marked as read so you don't need to say it explicitly (but it's allowed). If you want to declare an entitlement for a component type not used as a query constraint you can employ the using clause, which doesn't affect the query in any way, only supplies component types for entitlement suffixes: this.query(q => q.using(RandomComponent).write).

TIP

write implicitly includes read, create and update, so you don't need to declare those separately. read and write also grant you access to the has family of methods, but create and update do not, as a trade-off for being able to run concurrently.

Reactive queries

Using reactive queries make it possible to react to changes on entities and its components.

TIP

A single query can include any or all of the various lists described below (each of which will be iterable separately), and this is more efficient than creating separate queries for them.

Added and removed entities

One common use case is to detect whenever an entity has been added or removed from a query:

ts
@system class SystemA extends System {
  // Query for entities that either became a Box with a Transform, or stopped being one.
  private boxes = this.query(q => q.added.and.removed.with(Box, Transform));

  execute(): void {
    for (const addedBox of this.boxes.added) { /* ... */ }
    for (const removedbox of this.boxes.removed) { /* ... */ }
  }
}
js
class SystemA extends System {
  constructor() {
    // Query for entities that either became a Box with a Transform, or stopped being one.
    this.boxes = this.query(q => q.added.and.removed.with(Box, Transform));
  }

  execute() {
    for (const addedBox of this.boxes.added) { /* ... */ }
    for (const removedbox of this.boxes.removed) { /* ... */ }
  }
}

The added and removed lists are computed just before the system executes, and will include all entities that would have been added to or removed from the current list since the system last executed (usually the previous frame).

TIP

If an entity was both added and then removed between system executions, it will not be included in the added list. (And similarly for the removed list.) There's currently no way to query for such ephemeral entities in Becsy.

Changed entities

Another common use case is to detect when a component's field values have been changed, whether due to a call to Entity.write or because the field's value was automatically updated:

ts
// Get entities with Box and Transform, where Transform fields changed since last time.
this.query(q => q.changed.with(Box).and.with(Transform).trackWrites);
js
// Get entities with Box and Transform, where Transform fields changed since last time.
this.query(q => q.changed.with(Box).and.with(Transform).trackWrites);

We express the query as usual, but append trackWrites to any component types whose changes we want to track. (You must track at least one component type.) Note that when tracking specific enum component types, a write to another component in the same enum can sometimes trigger the query too.

Not all state changes are expressed by writes to a component's fields: sometimes, the combination of components matching a query encodes an implicit state instead. This is especially common when using component enums but works with normal components too. You mark withAny clauses with trackMatches, and they'll add entities to the changed list whenever set the set of components matching the withAny clause changes:

ts
// Get entities with Menu, where their open/closed state changed since last time.
this.query(q => q.changed.with(Menu).and.withAny(Open, Closed).trackMatches);
js
// Get entities with Menu, where their open/closed state changed since last time.
this.query(q => q.changed.with(Menu).and.withAny(Open, Closed).trackMatches);

You can mix trackWrites and trackMatches within a query but there's no way to tell which one caused an entity to become changed.

Newly added entities will not be included in the changed list, even if their fields were written to after the component was added. Basically, an entity will be in at most one of the added, removed, and changed lists — they never overlap. For convenience, you can request a list that combines any of these attributes instead:

ts
// Get entities that became a Box with Transform, or whose Transform was changed.
this.query(q => q.addedOrChanged.with(Box).and.with(Transform).trackWrites);
js
// Get entities that became a Box with Transform, or whose Transform was changed.
this.query(q => q.addedOrChanged.with(Box).and.with(Transform).trackWrites);

Ordering query results

Query results are not guaranteed to be in any specific order by default, but you can request that they be sorted using any kind expression over their entities:

ts
@system class Renderer extends System {
  // Query for all Sprites and order by ascending zIndex.
  private sprites = this.query(
    q => q.current.with(Sprite).orderBy(entity => entity.read(Sprite).zIndex)
  );

  execute(): void {
    // Iterate over all sprites in order of zIndex.
    for (const entity of this.sprites.current) {
      render(entity.read(Sprite));
    }
  }
}
js
class Renderer extends System {
  constructor() {
    // Query for all Sprites and order by ascending zIndex.
    this.sprites = this.query(
      q => q.current.with(Sprite).orderBy(entity => entity.read(Sprite).zIndex)
    );
  }

  execute() {
    // Iterate over all sprites in order of zIndex.
    for (const entity of this.sprites.current) {
      render(entity.read(Sprite));
    }
  }
}

A common case is ordering entities by order of creation, for example to execute queued commands in the right order:

ts
this.query(q => q.current.with(Command).write.orderBy(entity => entity.ordinal))
js
this.query(q => q.current.with(Command).write.orderBy(entity => entity.ordinal))

Note that ordering entities can get expensive (though we apply some optimizations for common cases) so use this feature judiciously!

MIT Licensed