logo

Babylon.js Market

By Lawrence

8 minutes

The Five Filters: How a System Finds Its Entities

TL;DR — A system sets five filters on this.query: required, excluded, anyOf, tags, and excludedTags. These filters pick which entities the system acts on. The query is live. It re-matches the moment a component or tagA free-form string label you attach to an entity to group it by category instead of by data. changes, and that is how the freeze ray works. The onEntityAdded and onEntityRemoved hooks fire as entities enter and leave the set. New here: a query decides membership, not a per-frame if, and every later lesson builds on it.

Last lesson you built a ship from four components. You watched MoveSystem loop over this.entities. That set arrived pre-filtered to only the entities carrying a Position and a Velocity. You never filled that set yourself. This lesson shows who does, and how you shape what lands in it.

A freeze ray hits your UFO and it stops dead. But nobody wrote if (frozen) return anywhere. No flag is checked. The movement code doesn't even know the word "frozen." Yet the UFO is stuck. The moment the freeze wears off, it moves on at the same speed it had before. You might expect a loop that checks a flag every frame. The real answer is simpler. Adding one component pulled the UFO out of the movement system's set. There are no branches (the if-checks you write to handle special cases) and no per-frame cost. The query did it.

A system declares a query; the framework hands it the matches

A system never goes looking for entities. It declares the shape it wants once, in its constructorThe setup code that runs once when an object is created - where a system declares its query. (the setup code that runs when the system is created). Then the framework keeps the matching set up to date for the system's whole life. That declaration is ISystemQuery. Here it is from source, with five optional fields.

Here is how to read the block below. Each line names a field. The ?: marks it optional. And ComponentType[] means "a list of component types" (the [] is the list). The five fields are the five filters.

@babylonjsmarket/ecs/src/ECS/System/System.ts
export interface ISystemQuery {
  /** Entity must have ALL of these components */
  required?: ComponentType[];

  /** Entity must have NONE of these components */
  excluded?: ComponentType[];

  /** Entity must have AT LEAST ONE of these components */
  anyOf?: ComponentType[];

  /** Entity must have ALL of these tags */
  tags?: string[];

  /** Entity must have NONE of these tags */
  excludedTags?: string[];
}

Every field is optional. The framework combines them with AND. An entity is in the set only if it passes required and excluded and anyOf and tags and excludedTags. Leave a field out and that clause is skipped. A system with { required: [PositionComponent, VelocityComponent] } matches everything that has both. Nothing else has to run to make that happen.

Funnel diagram on a dark near-black background showing five glowing entity nodes labeled ship, asteroid, bullet, UFO, and score-pickup entering the top of a neon-cyan wireframe funnel labeled query: required Position and Velocity, with only the ship and asteroid nodes passing through to a magenta-outlined box labeled this.entities at the bottom while the rejected entities deflect off the funnel walls with small red X marks, flat vector diagram style with neon green and cyan accents

Pass/fail grid on a dark near-black background showing five vertical lanes labeled required, excluded, anyOf, tags, and excludedTags, each lane drawn as a neon-cyan gate; a row of glowing entity tokens flows left to right through all five gates, every gate stamping a green check or a red X, and only the tokens that earn a check at every gate emerge on the right into a magenta-outlined box labeled this.entities, flat vector diagram style with thin glowing lines and small monospace labels

Moving an if-check into the query

Here's the movement code most people write before they trust the query system. It works, and that is the trap. Two symbols to read. The trailing ! on entity.get(...)! means "trust me, this exists" (we already know the entity has the component). And pos.x += ... means "add to pos.x".

protected onUpdate(dt: number): void {
  for (const entity of this.entities) {
    const frozen = entity.get(FrozenComponent);
    if (frozen) continue;            // skip frozen ones by hand
    const pos = entity.get(PositionComponent)!;
    const vel = entity.get(VelocityComponent)!;
    pos.x += vel.x * dt;
    pos.y += vel.y * dt;
  }
}

That if (frozen) continue runs every frame for every entity. That includes the hundred entities that will never be frozen. It also throws off your tooling. getEntityCount() reports entities that the loop quietly skips. The branchAn if-check in code that handles a special case - the path the code takes when a condition is true. is doing the query's job, and doing it badly.

Move the filter into the declaration. The loop gets simpler, and the extra work goes away:

export class MoveSystem extends System {
  constructor(eventBus: EventBus) {
    super(eventBus);
    this.query = {
      required: [PositionComponent, VelocityComponent],
      excluded: [FrozenComponent],
    };
  }

  protected onUpdate(dt: number): void {
    for (const entity of this.entities) {
      const pos = entity.get(PositionComponent)!;
      const vel = entity.get(VelocityComponent)!;
      pos.x += vel.x * dt;
      pos.y += vel.y * dt;
    }
  }
}

Now the freeze ray works, and the movement code never knows it exists. Somewhere else, a weapon system runs entity.add(new FrozenComponent({ duration: 2 })). The UFO drops out of MoveSystem's set. It is not skipped. It is gone. Two seconds later a timer removes the component. The UFO comes back into the set with its velocity untouched, and it drifts on. The movement loop paid nothing for any of this.

Here is a rule worth keeping. When you find an if-check at the top of a loop that tests for a component or a tag, that check probably belongs in a query instead.

All five filters, and when you'd reach for each

required and excluded handle most games. The other three are useful in specific spots. Here is the full object:

this.query = {
  required: [PositionComponent, VelocityComponent],  // must have ALL of these
  excluded: [FrozenComponent],                       // must have NONE of these
  anyOf: [PlayerInputComponent, AIPilotComponent],   // must have AT LEAST ONE
  tags: ['enemy'],                                   // must have ALL these tags
  excludedTags: ['invulnerable'],                    // must have NONE of these tags
};
FieldAn entity matches when it has…Reach for it when
requiredall of these componentsThe system can't run without them (Position + Velocity)
excludednone of these componentsA component should switch the system off for that entity (Frozen)
anyOfat least one of theseOne system serves several flavors — say a human or an AI pilot (a hypothetical case; no shipped arcade component needs it yet)
tagsall of these tagsQuery field: an entity matches only if it carries all of these free-form string tags.You group by category, not data (everything tagged enemy)
excludedTagsnone of these tagsYou want everything but a category (skip anything invulnerable)

Components and tags answer different questions. A component is data. Health is a number. A tag is a category label. You attach it with entity.addTag('enemy') and check it by stringA piece of text, written in quotes - 'enemy' is a string. (by the text label itself). Two entities can carry the same components but belong to different teams. The tag is what tells them apart.

The arcade library uses required and excluded/excludedTags the most. BulletSystem wants only things that are a bullet and have a mesh to fly. That is two required components, nothing more:

@babylonjsmarket/arcade/src/Components/Bullet/Bullet.ts
export class BulletSystem extends System {
  protected query = { required: [BulletComponent, MeshPrimitiveComponent] }

EnemySystem adds an excludedTags clause. The comment in the real source shows the freeze-ray trick used for death. An enemy playing its death animation gets a dying tag. It then leaves the chase-and-attack set while its corpse falls. The comment also mentions parked enemies. These are kept alive but inactive so they can be reused instead of recreated. We cover that in the poolingReusing the same entities instead of constantly creating and destroying them; a parked entity is kept alive but inactive so it can be reused. Covered in a later lesson. lesson:

@babylonjsmarket/arcade/src/Components/Enemy/Enemy.ts
export class EnemySystem extends System {
  // Parked (pooled) enemies are inactive, so the query already excludes them —
  // no tag needed. A `dying` enemy (playing its death animation before the
  // pool re-parks it) is excluded so it stops chasing / dealing contact damage
  // while the corpse falls.
  protected query = { required: [EnemyComponent, MeshPrimitiveComponent], excludedTags: ['dying'] }

This is the same mechanism as the freeze ray, shipped in production (in the real, released game). Add a tag and the entity leaves the set. Remove it and the entity returns. The dead enemy still exists. It still has its mesh and its position. It is just no longer something EnemySystem acts on.

How a live query re-matches when components change

The set is not computed once at startup and then left alone. Every add, remove, addTag, and removeTag re-checks that one entity against every system in the world. When the check changes the answer, the entity moves into or out of that system's set right away. This re-check on change is how the freeze ray works. There is no scan and no per-frame sweep. Only the one entity that changed gets re-checked. That entity is re-checked against every system in the world. So the cost grows with how many systems you have, not how many entities. It is cheap per change. But it is worth knowing if you change tags every frame.

There is a second, simpler way out of every query at once. SetA JavaScript collection of unique values; this.entities is a Set of the entities matching a system's query. entity.active = false and the entity drops from every system's set right away, no matter what components it carries. Re-activate it and every query it qualifies for picks it back up. That flag is how pooling parks an entity without destroying it. You will use it a lot in the pooling lesson, where parked bullets and enemies sit inactive between lives.

Funnel diagram on a dark near-black background: a loose crowd of a dozen glowing entity tokens of mixed colors swirls at the wide top of a neon-cyan wireframe funnel labeled query: required Bullet + MeshPrimitive, with most tokens deflecting off the funnel walls under small red X marks while a few matching tokens stream down the neck into a tidy magenta-outlined row labeled this.entities (a Set) at the bottom, one token mid-fall shown crossing back upward with a dashed arrow labeled tag changed to illustrate live re-matching, flat vector neon style

Knowing the moment an entity joins or leaves

Sometimes a system needs to do something the moment an entity enters or leaves its set. It might wire up a mesh handle when a bullet becomes active, then release one when it parks. The framework gives you two hooks for this. Here they are from source, empty and waiting for an overrideWrite your own version of an empty method the framework provided, so it actually does something. (write your own version of the empty method so it does something):

@babylonjsmarket/ecs/src/ECS/System/System.ts
protected onEntityAdded(_entity: Entity): void {}

/**
 * Override to react when an entity is removed from this system.
 * Called after the entity is removed from the entities set.
 *
 * @param _entity - The entity that was removed
 */
protected onEntityRemoved(_entity: Entity): void {}

These are membership hooks, and the word matters. onEntityAdded fires when an entity newly matches this system's query. That can happen long after the entity was created, or never. Thaw the frozen UFO and MoveSystem.onEntityAdded fires for it, even though the entity has existed the whole time. onEntityRemoved fires the moment an entity stops matching. Maybe it gained a Frozen, lost a Velocity, went inactive, or was destroyed. The hook can't tell you which one. It only tells you the entity left.

export class MoveSystem extends System {
  protected onEntityAdded(entity: Entity): void {
    // Newly matches: spawned with the right components,
    // re-activated from a pool, or just thawed of Frozen.
  }

  protected onEntityRemoved(entity: Entity): void {
    // Stopped matching: destroyed, deactivated, or someone
    // slapped a FrozenComponent on it.
  }
}

It is easy to read these as "created" and "destroyed" hooks. They are not. An entity that is never in a system's query never triggers either one. And an entity can enter and leave the same set a dozen times in one match as components come and go.

Two pairs of hooks, two different jobs

The membership hooks mark an entity's time in one system's set. A different pair marks the system's own life. Don't confuse the two. The tool that checks your code for mistakes won't catch this one, because both pairs are just methods you may or may not override.

HookFiresScope
onInitialize()The system is added via world.addSystem() — or, if the world isn't initialized yet, at world.initialize() (which runs lazily on the first update())Once, the system's whole life
onEntityAdded(entity)An entity newly matches the queryPer entity, per match
onEntityRemoved(entity)An entity stops matching the queryPer entity, per un-match
onShutdown()The system is removed via world.removeSystem()Once, the system's whole life

onInitialize is where a system subscribes to events. onShutdown is where it releases anything the framework won't reclaim for it. Both run exactly once. The membership hooks run as often as entities move through the query. Use the lifetime pair to set up and tear down the system. Use the membership pair to react to entities arriving and leaving.

Keep this straight against the trap from the last lesson. onAttachOverride and onDetachOverride are component hooks. They have no match on System. Write either one on a system and it looks valid. It runs without an error, but it does nothing, and it can cost you an afternoon. Components get attach/detach. Systems get initialize/shutdown plus the two membership hooks above.

What you've got now

You now have a movement system that gains and loses entities live as their components and tags change. There is no per-frame filtering. The freeze ray and the dying-enemy trick both work for free, because a query decides who is in the set, not a branch. You still can't watch this happen on screen. The Entities panel that shows a system's set filling and draining comes a few lessons later. When it does, this lesson is what makes it readable.

But a query only tells a system which entities to act on. It says nothing about how two systems work together. How does the bullet that just hit an enemy tell the score system, when neither one imports the other? The next lesson is built on that question.

Next: Events, Not References — how systems talk through the EventBus instead of holding references to each other, the naming rules that keep two systems from talking past each other, and the listen() habit that disposes subscriptions for you.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search