Change Detection¶
Most systems do not need to run against every entity every tick — they only care about the ones that were just spawned or just mutated. japes ships with a per-component change tracker that remembers which slots were touched since a given tick, and lets a system filter its iteration down to just those slots.
Changed ≠ Added
Added fires on the tick an entity is spawned (or gains the component
via addComponent) and once only. Changed fires whenever Mut.set is
called with a new value and then flushed — i.e., the component was
actually modified. Newly-spawned entities trigger Added but not
Changed, by design.
The @Filter annotation¶
Change filters go on the @System method, not the parameters. The
annotation lives at zzuegg.ecs.system.Filter and takes two values:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Filter.List.class)
public @interface Filter {
Class<?> value(); // Added.class | Changed.class | Removed.class
Class<? extends Record> target(); // the component you're filtering on
}
Minimal example:
import zzuegg.ecs.system.*;
public class SpawnWatcher {
@System
@Filter(value = Added.class, target = Position.class)
void onSpawn(@Read Position pos) {
java.lang.System.out.println("new entity at " + pos);
}
}
This method runs only on entities whose Position component was freshly
added since this system last ran. On the first tick after two spawns you
see two invocations; on the tick after that — zero.
Added — fires once per new entity¶
@Filter(value = Added.class, target = X.class) matches any entity whose
X component has been added since the previous execution of this system.
That includes:
world.spawn(new X(...))world.addComponent(entity, new X(...))commands.spawn(new X(...))/commands.add(entity, new X(...))at command flush
public class GreetPlayers {
@System
@Filter(value = Added.class, target = Player.class)
void greet(@Read Position pos, Entity self) {
java.lang.System.out.println("Welcome, " + self + " at " + pos);
}
}
Each new player is visited exactly once — on the tick after they appear.
Changed — fires whenever Mut.set is flushed¶
@Filter(value = Changed.class, target = X.class) runs only on entities
whose X component has had Mut.set(...) called on it since this system
last ran, and the new value differed from the old (for records marked
@ValueTracked — a separate advanced topic).
The canonical pair: one system writes, another reacts.
public class Mover {
@System
void move(@Read Velocity v, @Write Mut<Position> p) {
var cur = p.get();
p.set(new Position(cur.x() + v.dx(), cur.y() + v.dy()));
}
}
public class GridReindexer {
@System(after = "Mover.move")
@Filter(value = Changed.class, target = Position.class)
void reindex(@Read Position pos, Entity self) {
grid.move(self, pos);
}
}
GridReindexer.reindex runs only on entities Mover.move actually
mutated. Static entities whose Velocity was zero (so Position did not
actually change — at least for @ValueTracked records) never reach the
reindexer.
set(...) without a true change
Calling mut.set(currentValue) sets the isChanged flag, but when
Mut.flush() notices the new and old values are .equals() for a
@ValueTracked record it skips marking the tracker. For non
value-tracked records, any set call is treated as a change. In
practice: prefer @ValueTracked for components where "no-op writes"
are common.
Combining filters¶
Because @Filter is @Repeatable, you can stack multiple filters on a
single system. They combine with AND semantics — an entity must satisfy
every filter to be iterated:
@System
@Filter(value = Changed.class, target = Position.class)
@Filter(value = Changed.class, target = Velocity.class)
void both(@Read Position p, @Read Velocity v) {
// runs only for entities whose Position AND Velocity changed
// since this system last ran
}
How it works (the 30-second version)¶
Every chunk carries one ChangeTracker per component stored in it. The
tracker has two parallel tick arrays — addedTicks[slot] and
changedTicks[slot] — plus a deduplicated dirty list of slots that
have been touched since the last prune.
ChangeTracker (Position in chunk 0)
addedTicks = [103, 103, 0, 107, 0, ...]
changedTicks = [0, 104, 0, 0, 0, ...]
dirtyList = [0, 1, 3] // slots 0, 1, and 3 were touched
Each system keeps its own "last seen tick" — the tick number when it
last finished running. @Filter(Added) translates to "is `addedTicks[slot]
lastSeenTick
?".@Filter(Changed)is the same test onchangedTicks. Because the comparison is strict>, a system that spawned an entity on tick *T* will still see it as "Added" on its next run (which sees tickT+1` or later).
The sparse iteration path¶
For systems with change filters, SystemExecutionPlan.processChunk takes a
sparse path:
- Grab the dirty list from the first filter's tracker.
- Iterate only those slot indices.
- For each one, confirm every filter still matches (slots may have been swap-removed, or this filter may be stricter than the primary).
- Invoke the system body only on slots that pass.
A chunk with one million entities, of which ten changed this frame, costs
roughly ten invocations for a @Filter(Changed) system — not one million.
Systems without any @Filter fall back to a dense loop over the whole
chunk, so you only pay for sparse iteration when you ask for it.
When dirty bookkeeping is on¶
The tracker's dirty-list maintenance is opt-in per component. If no
system in your world observes Position via a change filter (and no
system consumes RemovedComponents<Position>), the world flips the
Position tracker to fullyUntracked mode at plan-build time and every
markAdded / markChanged call becomes a no-op. Pure-write workloads do
not pay a cent for bookkeeping they never read.
The prune pass¶
At the end of every tick, the world prunes each dirty list: entries whose
added- and changed-ticks are both <= min(lastSeenTick across all
observers) are dropped and their bits cleared. A tracker with ten thousand
dirty slots that every observer has already processed shrinks back to
empty on the next tick, so the sparse iteration starts cheap again.
Multi-target @Filter¶
A single @Filter annotation can target multiple component types with OR semantics — the system fires once per entity where any of the targets changed:
@System
@Filter(value = Changed.class, target = {Position.class, Velocity.class, Health.class})
void onAnyChanged(@Read Position p, @Read Velocity v, @Read Health h) {
// fires if Position OR Velocity OR Health was mutated
// the entity is visited exactly once even if multiple components changed
}
When to use multi-target
Multi-target shines when one observer logically watches several component types and doesn't care which one triggered the change. Without it, you'd need N separate systems — one per component — each with its own scheduler dispatch overhead.
The rules:
- Within one
@Filterannotation: targets are OR'd. Any match fires. - Across multiple stacked
@Filterannotations: still AND'd (same as before). - Deduplication: an entity that changed on 2 of the 3 targets is visited exactly once.
- Tier-1 supported: the generated chunk processor calls
MultiFilterHelper.unionDirtySlotsto merge the dirty lists, then iterates with inlineinvokevirtual.
// OR within, AND across — fires for entities where
// (Position OR Velocity changed) AND (Health was added)
@System
@Filter(value = Changed.class, target = {Position.class, Velocity.class})
@Filter(value = Added.class, target = Health.class)
void complexFilter(@Read Position p, @Read Velocity v, @Read Health h) { ... }
@Filter(Removed) — reacting to deletions¶
@Filter(Removed) is the third leg of the symmetric API. It fires once per entity that lost any target component since the last tick, with @Read params bound to the last-known values before removal:
@System
@Filter(value = Removed.class, target = {State.class, Health.class, Mana.class})
void onRemoved(@Read State s, @Read Health h, @Read Mana m, Entity self) {
// s, h, m are the values BEFORE removal
// For a component that was stripped: last value from the removal log
// For components still live: current value from the entity
// For a fully despawned entity: all values from the removal log
}
This replaced RemovedComponents<T> for multi-type observation
Previously you needed 3 separate RemovedComponents<T> systems to watch 3 component types. Now one @Filter(Removed, target = {A, B, C}) does the same work in one system dispatch with type-safe @Read binding.
How it works under the hood:
- The entity that lost a component is no longer in a matching archetype — normal chunk iteration can't find it.
- Instead,
@Filter(Removed)systems are dispatched via a dedicatedGeneratedRemovedFilterProcessorthat walks the removal log (not the dirty list). - The removal log captures
(entity, lastValue, tick)at everyremoveComponent/despawncall. - Multi-target deduplication: a despawned entity with 3 components produces 3 log entries but only 1 observer call.
- Tier-1 supported: the generated hidden class calls
RemovedFilterHelper.resolve()for dedup + value resolution, then iterates with inlineinvokevirtual.
RemovedComponents<T> still works and is simpler for single-type drains — see the next chapter.
Quick recipe list¶
| You want... | Use |
|---|---|
React the first tick after an entity with Health spawns |
@Filter(value = Added.class, target = Health.class) |
React every tick a Position was actually mutated |
@Filter(value = Changed.class, target = Position.class) |
| React when ANY of several components changed | @Filter(value = Changed.class, target = {A.class, B.class}) |
React only when both Position AND Velocity changed |
Two stacked @Filter(Changed) annotations |
| React when a component was just removed (with last values) | @Filter(value = Removed.class, target = Health.class) |
| React when ANY of several components were removed | @Filter(value = Removed.class, target = {A.class, B.class, C.class}) |
| Simple per-type removal drain (no @Read binding) | RemovedComponents<T> (next chapter) |
What's next¶
RemovedComponents<T> is the simpler alternative for single-type removal drains — useful when you don't need @Read binding or multi-type observation.
Continue to Removed Components.