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, andexcludedTags. 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. TheonEntityAddedandonEntityRemovedhooks fire as entities enter and leave the set. New here: a query decides membership, not a per-frameif, 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.
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.


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".
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:
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:
| Field | An entity matches when it has… | Reach for it when |
|---|---|---|
required | all of these components | The system can't run without them (Position + Velocity) |
excluded | none of these components | A component should switch the system off for that entity (Frozen) |
anyOf | at least one of these | One system serves several flavors — say a human or an AI pilot (a hypothetical case; no shipped arcade component needs it yet) |
tags | all 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) |
excludedTags | none of these tags | You 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:
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:
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.

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):
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.
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.
| Hook | Fires | Scope |
|---|---|---|
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 query | Per entity, per match |
onEntityRemoved(entity) | An entity stops matching the query | Per 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.