Run conditions¶
A run condition is a named boolean check. Tag a method with @RunCondition, reference it from @RunIf("name") on any @System, and the scheduler will skip that system on ticks where the condition returns false. Unlike @Where, this gates the entire system, not individual rows — and it costs nothing if the condition is cheap.
The annotations¶
Two annotations are involved. Both live in zzuegg.ecs.system.
@Target(ElementType.METHOD) @Retention(RUNTIME)
public @interface RunCondition {
String value() default ""; // optional name override; default = method name
}
@Target(ElementType.METHOD) @Retention(RUNTIME)
public @interface RunIf {
String value(); // required — names the condition to evaluate
}
@RunConditionmarks a no-argumentbooleanmethod as a named gate.@RunIf("name")sits on an@Systemmethod and requests that the gate be evaluated before the system runs.
Declaring a condition¶
Put the condition method on any system class you register with the world. It can be an instance method (the framework creates a single instance via the class's no-arg constructor) or static.
import zzuegg.ecs.system.RunCondition;
import zzuegg.ecs.system.RunIf;
class DebugSystems {
@RunCondition
boolean debugEnabled() {
return System.getProperty("game.debug") != null;
}
@System
@RunIf("debugEnabled")
void dumpFps(Res<FrameStats> stats) {
System.out.println("fps = " + stats.get().fps());
}
}
When the world is built, it scans every registered system class for methods annotated with @RunCondition, creates an instance of the class if needed, and registers a BooleanSupplier under the condition's name.
@RunCondition must be explicitly applied
An older revision of the framework auto-registered any no-arg boolean method. That is no longer the case — you must annotate the method. Helper getters like isInitialized() are left alone, and you can't accidentally shadow a real condition with a namesake helper.
The name the framework registers is:
- The explicit
value()on the annotation, if non-empty. - Otherwise, the method name.
@RunCondition("debug-mode") // referred to as "debug-mode"
boolean checkDebug() { return ... }
@RunCondition // referred to as "checkDebug"
boolean checkDebug() { return ... }
Referencing a condition¶
@RunIf("name") on an @System method binds the system to a condition. The string must match a registered name — mismatched names are silently ignored (the system runs unconditionally), so pay attention to typos.
At tick time, just before the scheduler invokes the system, the world looks up the condition and calls it:
// simplified from World.executeSystem
if (desc.runIf() != null) {
var condition = runConditions.get(desc.runIf());
if (condition != null && !condition.getAsBoolean()) {
return; // skip this system this tick
}
}
Skipped systems still count for the DAG — dependencies still resolve, and parallelism is unaffected. The skip is purely "do not invoke the user method".
Use case: gate a debug system on a flag¶
The canonical example. A debug overlay system runs every tick in dev builds, never in release.
class Debug {
@RunCondition
boolean showOverlay() {
return Boolean.getBoolean("game.overlay");
}
@System(stage = "Last")
@RunIf("showOverlay")
void drawOverlay(Res<FrameStats> stats, ResMut<Canvas> canvas) {
canvas.get().drawText(10, 10, "fps = " + stats.get().fps());
}
}
No #ifdef, no build-time branching — the release build ships the system, and the condition method simply returns false.
Use case: gate on a resource¶
Conditions are plain Java, so they can read anything accessible from the class. A common pattern is gating on a resource toggle.
public record GameMode(boolean paused, boolean debug) {}
class ModeGates {
// The condition needs access to the world to read resources, which
// @RunCondition doesn't give it directly. Hold a reference to the
// GameMode via a static field, a singleton, or a plain field that
// some setup system writes.
static volatile GameMode mode = new GameMode(false, false);
@RunCondition boolean notPaused() { return !mode.paused(); }
@RunCondition boolean isDebug() { return mode.debug(); }
}
class Gameplay {
@System @RunIf("notPaused")
void tickEnemies(@Write Mut<Position> p, @Read Velocity v) { ... }
}
The condition method has no service-parameter injection — it's a plain method invoked via reflection. If you need resource access inside a condition, either hold a volatile reference updated by a regular system, or flip the dependency: have an @Exclusive system in First read the resource and write a boolean into a field the conditions consult.
Keep condition methods trivial
Conditions are called once per system, per tick — a handful of times per frame is typical. A boolean field read is ideal; a hash-map lookup is fine; anything involving I/O or heavy computation becomes a per-tick cost that the user didn't ask for.
Use case: cadence gates¶
Run-conditions make it easy to run a system on a cadence without putting counters inside the system itself.
class Cadences {
private long lastTick;
@RunCondition
boolean everyTenTicks() {
var now = java.lang.System.nanoTime();
if (now - lastTick > 10_000_000) { lastTick = now; return true; }
return false;
}
}
class Stats {
@System @RunIf("everyTenTicks")
void reportStats(Res<FrameStats> stats) { ... }
}
Compared with a Local<int[]> counter inside the system, a run-condition keeps the cadence logic in one place and reusable — two systems can share @RunIf("everyTenTicks") without duplicating the counter.
Behavior under parallel execution¶
When the multi-threaded executor runs a wave of systems in parallel, each worker independently evaluates its own @RunIf. Conditions must be thread-safe: two parallel systems gated on the same condition will call the boolean supplier from two threads at once. Pure reads of volatile fields are fine; stateful conditions that increment a counter need synchronisation, or — cleaner — move the state into the systems themselves via Local.
Interaction with RemovedComponents GC¶
Systems skipped by a @RunIf hold a stale watermark for any RemovedComponents<T> they consume. The world's end-of-tick garbage collection of the removal log walks every plan and keeps the minimum lastSeenTick() across plans that consume the component. A system that is often disabled can keep removal-log entries around longer than expected; when it finally runs, it observes every removal that happened while it was asleep, up to the point its watermark advances.
A permanently-disabled system pins removal logs
If a @RunIf condition returns false forever, the world will still keep removal-log entries alive for every component type that system consumes — they can never be garbage-collected below that plan's watermark. Disable systems by removing them from the world build, not by letting a run condition always return false.
Troubleshooting¶
- System never runs. Double-check the condition name in
@RunIfmatches the@RunConditionname. Typos are not errors — the lookup returnsnulland the system runs unconditionally, so the bug is worse than silent. - Condition throws. The framework catches exceptions from the condition and treats them as
false. The system is skipped and the exception is swallowed — log inside the condition if you suspect this. - Condition not found when it should be. Did you add the class containing the
@RunConditionto the world viaaddSystem? Conditions are registered only for classes actually registered.
What's next¶
You've reached the end of the Advanced track. From here:
- Relations — read the Relations section for
@ForEachPair,PairReader, target/source iteration, and the Flecs-style relation DAG. - Reference — the tier fallbacks page explains why a system ran on tier-2 instead of tier-1.
- Related basics: Resources, Commands.