@Where field predicates¶
@With and @Without gate a system on component presence. @Where goes one level deeper — it gates on a component field value. A system annotated @Where("hp > 0") only runs for entities whose Health.hp field is strictly positive. This is convenient, but it has a real cost: it drops the system out of the tier-1 bytecode path.
Syntax¶
@Where is a parameter-level annotation on a @Read or @Write component parameter. Its string value is a simple expression that references one accessor on the component record.
import zzuegg.ecs.system.Where;
@System
void healLiving(@Write @Where("hp > 0") Mut<Health> hp) {
var cur = hp.get();
hp.set(new Health(Math.min(cur.hp() + 1f, 100f)));
}
The framework parses the string with zzuegg.ecs.query.FieldFilter.parse, looks up the record accessor via MethodHandle, and evaluates the predicate once per entity at iteration time.
Supported grammar¶
field-nameis a record accessor: forrecord Health(float hp, float max), usehpormax. It must be a zero-argument method on the component record type.opis one of>=,<=,==,!=,>,<. Operators are matched longest-first so>=never accidentally parses as>.valueis one of:- an integer literal (
0,42,-3), - a double literal (
1.5,0.0,-2.5e3), - a single-quoted string (
'alice','warrior').
- an integer literal (
Whitespace between tokens is optional — hp>0 and hp > 0 parse identically.
@Where("hp >= 10") // healthy enough to fight
@Where("hp < 50") // wounded
@Where("level == 1") // fresh recruits
@Where("faction == 'red'") // red team only
@Where("morale != 0") // not demoralised
@Where is @Repeatable. Multiple @Where annotations on the same parameter are combined with AND via FieldFilter.and(...):
// Must be alive AND wounded.
@System
void triage(@Write @Where("hp > 0") @Where("hp < 50") Mut<Health> hp) { ... }
Different parameters can each carry their own @Wheres — they all AND together.
Tier-2 fallback¶
Here is the catch. The tier-1 generator (GeneratedChunkProcessor) walks its eligibility checks against the system descriptor and bails if whereFilters is non-empty. The reason is plain: the generated bytecode iterates chunk columns directly and would have to generate a predicate chain per @Where, which the current generator does not do.
The system still works, it just runs on the reflective tier-2 SystemExecutionPlan path — MethodHandle.invoke per row instead of a monomorphic invokevirtual. Benchmark-wise that is somewhere between 2× and 5× slower per entity for simple arithmetic systems.
@Where is never zero-cost
Even on tier-2, every row visited by the system pays a MethodHandle.invoke for the accessor plus a boxed comparison. The filter does not skip the entity's storage — it skips the system body. A @Where("hp > 0") still pays the cost of asking every entity "is hp > 0?".
The preferred alternative¶
For 95% of cases, an if at the top of the system body is faster and stays on tier-1:
// Preferred — tier-1 eligible, body exits early on the tight loop.
@System
void healLiving(@Write Mut<Health> hp) {
var cur = hp.get();
if (cur.hp() <= 0f) return; // identical semantics to @Where("hp > 0")
hp.set(new Health(Math.min(cur.hp() + 1f, 100f)));
}
The tier-1 generator compiles this into a hot loop over the chunk, and the JIT recognises the early-out as a cheap branch. You get:
- Monomorphic dispatch via a generated hidden class — no
MethodHandle.invoke. - Direct backing-array loads for
Healthvalues — no per-row predicate callback. - The same iteration over the same entities — the filter is just lifted into Java code.
Hoist the condition into the system body
Whenever you reach for @Where, ask whether a one-line if at the top of the method gives you the same result. If it does, prefer the if — you save tier-1 eligibility.
Valid reasons to actually use @Where¶
A few cases where @Where is still the right call:
- Declarative filter documentation. If the filter is part of the system's public identity ("this system runs on alive entities"), the annotation puts that contract at the top of the method instead of hiding it in the first line of the body.
- Composition with other filters. When you already pay for tier-2 because of a change filter the generator doesn't support, the extra
@Whereis free-ish. - Very selective filters on cold systems. A startup or cleanup system that runs a handful of times per second doesn't care about tier-1.
- String-match on enum-like fields.
@Where("faction == 'red'")reads well and is hard to get wrong; the equivalentifcompares strings, which is fine but verbose.
Semantics of the filter¶
The check is evaluated per entity, per tick, after the archetype filter passes. The field accessor is resolved once at parse time via MethodHandles.privateLookupIn, so private record accessors work even across module boundaries. The predicate is executed on whatever thread the system is running on — there is no synchronization, so reads of the component are consistent with what the system's parameter would have observed.
If the expression fails to parse (missing operator, empty field, wrong type), the world build throws IllegalArgumentException pointing at the offending string. Typos are caught at build time, not at tick time.
Complete example¶
public record Health(float hp, float max) {}
public record Mana(float amount) {}
public record Wizard() {}
class Combat {
// Tier-2 (uses @Where): only heal wounded wizards with enough mana.
@With(Wizard.class)
@System
void regenMana(
@Write @Where("hp > 0") Mut<Mana> mana,
@Read Health hp) {
var cur = mana.get();
mana.set(new Mana(Math.min(cur.amount() + 0.1f, 100f)));
}
// Tier-1 equivalent: same result, faster hot loop.
@With(Wizard.class)
@System
void regenManaFast(
@Write Mut<Mana> mana,
@Read Health hp) {
if (hp.hp() <= 0f) return;
var cur = mana.get();
mana.set(new Mana(Math.min(cur.amount() + 0.1f, 100f)));
}
}
Both systems do the same thing. The second is strictly faster because it compiles down to a tier-1 hidden class, and the early-out is a predictable branch.
Combining @Where with @With / @Without¶
@Where narrows on field values. @With and @Without narrow on component presence. They compose: the archetype filter is applied first, then @Where acts as a per-row refinement on the matches.
@With(Player.class)
@Without(Dead.class)
@System
void healWoundedPlayers(@Write @Where("hp > 0") @Where("hp < 50") Mut<Health> hp) {
var cur = hp.get();
hp.set(new Health(Math.min(cur.hp() + 1f, 100f)));
}
The archetype filter selects only living player archetypes. @Where then skips entities in those archetypes whose hp isn't within the healing band. Again, the same logic written as an if in the body is tier-1 eligible, and the difference is measurable on large queries.
Performance cheat sheet¶
| Pattern | Tier | Verdict |
|---|---|---|
@Read Foo f and if (f.x() > 0) ... |
tier-1 | Fastest |
@Read @Where("x > 0") Foo f |
tier-2 | 2-5× slower |
@With(Marker.class) @System |
tier-1 | Free |
@Read Marker m for a marker component |
tier-1 | Wastes a slot |
The only case where @Where is the right trade-off is when the readability gain outweighs the tier drop and the system isn't in the hot path. For everything else, hoist into the body.
What's next¶
@Exclusivesystems — run a system against the whole world, not per entity.- Related basics: Queries, Change detection.