Systems and queries¶
Layer: kernel (good)
A GameSystem is where per-tick work lives. It declares its queries once and
walks the matching rows every step.
class MovementSystem extends GameSystem with FixedTickable {
late final Query movers;
@override
void describeQuery(QueryDescriptor descriptor) {
super.describeQuery(descriptor);
movers = descriptor.query()
.withAll(Transform2D, Velocity)
.withNone(Child) // roots only; parented movers follow theirs
.build();
}
@override
void onFixedUpdate() {
final dt = game.fixedTimeStep.inMicroseconds / 1000000.0;
for (final group in movers.groups()) {
final transform = group.get<Transform2D>();
final velocity = group.get<Velocity>();
for (final entity in group) {
transform
..transformOffsetX[entity] += velocity.x[entity] * dt
..transformOffsetY[entity] += velocity.y[entity] * dt;
}
}
}
}
Declare it on the state, because a system exists only on the isolate that ticks it:
class MyState extends GameState2D<MyGame> {
int score = 0;
@override
void describeSystems(SystemDescriptor descriptor) {
super.describeSystems(descriptor);
descriptor.has(MovementSystem());
}
}
Calling super matters — GameState2D declares the renderer and the world
transform system there.
Two tick mixins¶
| Mixin | Called | Use for |
|---|---|---|
FixedTickable |
onFixedUpdate(), once per fixed step |
Gameplay, physics, anything that writes component data |
Tickable |
onTick(Duration delta), once per presentation pass |
Reading finished results — publishing to the UI, rendering |
Choosing between them¶
One question settles almost every case: does this system write component
data? If it does, it is FixedTickable, and there is no judgement call to
make. onFixedUpdate runs inside the tick window, between beginTick and
commitTick, and that window is the only place a component write survives —
the next beginTick copies the published snapshot over the write slot, so
anything written outside it is gone before anyone reads it.
If instead the system takes what the simulation finished and sends it somewhere
else — a score into a state channel, a transform into the renderer, a position
into an audio backend — it is Tickable. onTick runs after commitTick, so
it reads the snapshot the step just published, including everything derived
during that step.
Three details decide the rest.
They do not run the same number of times. One advance spends whatever
wall-clock time has accumulated in whole fixed steps, up to
maxFixedStepsPerAdvance, then presents once. So a stuttering frame can run
three onFixedUpdate calls and exactly one onTick, and a frame that afforded
no step at all still gets its onTick — which is what lets a camera keep easing
toward its target on a frame where nothing simulated.
Only one of them gets a delta. onFixedUpdate takes no argument because
the answer is always game.fixedTimeStep; anything integrating over time wants
that fixed number, since a variable one makes the same input produce different
results on different machines. onTick is handed the wall-clock Duration
since the previous presentation pass, which is the right input for something
smoothing on screen and the wrong input for anything the simulation has to
reproduce.
Presentation is not stale. Moving a system out of the fixed step costs no
freshness. During step N a FixedTickable reads state as of the end of N-1; a
Tickable running after N commits reads values derived during N from that same
end-of-N-1 state. One frame of latency either way, so a renderer belongs in
presentation and stops recomputing what the simulation already worked out.
The one that catches people out is publishing timings. Phase totals are only
complete once the fixed step has returned, so a system reporting them from
inside onFixedUpdate publishes a half-accumulated figure — a number that is
wrong and looks plausible.
class HudPublisher extends GameSystem with Tickable {
@override
void onTick(Duration delta) {
getGame<MyGame>().score.value = getState<MyState>().score;
}
}
Nothing stops one system mixing in both, and a few want to: read input and move
things in onFixedUpdate, publish what happened in onTick. Splitting it into
two systems is usually clearer, and costs nothing either way.
The full order of what happens inside one advance is in
Architecture.
Building queries¶
descriptor.query() returns a builder. Every clause is repeatable and the
result is compiled once into an archetype match — nothing searches at tick
time.
| Clause | Meaning |
|---|---|
.withAll(A, B, ...) |
Every listed component must be present |
.withNone(A, B, ...) |
Every listed component must be absent |
.withAny(A, B, ...) |
At least one of the listed must be present |
.withOptional(A, ...) |
Documentation only — does not narrow the match |
.build() |
Compiles it. Call once |
Each clause takes up to ten types.
withAny groups per call
.withAny(A, B).withAny(C, D) means (A or B) and (C or D) — each call
is its own group and every group must be satisfied. It is not one flat
"any of these four".
withOptional narrows nothing; it signals to the reader that the loop body
branches on entity.tryGet<T>(). WorldTransformSystem is the reference usage:
it matches every Transform2D entity, hierarchy-linked or not, and tests
tryGet<Child>() inside.
Walking results¶
for (final group in query.groups()) { // one group per matching archetype
final transform = group.get<Transform2D>(); // resolved once for the group
for (final entity in group) {
transform.transformOffsetX[entity] += 1;
}
}
The two-level shape is the point. group.get<T>() resolves that archetype's
component once; the inner loop is then pure indexed access. Resolving inside
the inner loop repeats a registry lookup per entity, which is the single most
common performance mistake in a good system.
query.run() yields entities directly when you do not need per-group resolution
— fine for a handful of entities like cameras, wasteful for thousands.
No closures in a system tick
.map, .where, .any, .fold and .forEach allocate a closure per
call, and usually an Iterable too. Write the indexed for. See
Hot-path rules.
Ordering systems¶
By default systems run in declaration order. When a system genuinely depends on
another, say so with compareTo:
class MovementSystem extends GameSystem with FixedTickable {
/// Writes the *local* transforms `WorldTransformSystem` then composes, so it
/// has to run first. The other way round shows every entity one frame behind.
@override
int compareTo(GameSystem other) => other is WorldTransformSystem ? -1 : 0;
}
Return -1 for "before", 1 for "after", 0 for no opinion — which is the
default, and the right answer for most systems. A system that must run after
physics does the mirror:
Enabling and disabling¶
state.disableSystem<AiSystem>();
state.enableSystem<AiSystem>();
state.setSystemEnabled(AiSystem, false);
state.disableSystems(<Type>[AiSystem, SpawnSystem]);
A disabled system stops receiving every event, not just its tick — it is excluded from event dispatch, so it costs nothing while off. This is also reachable from the Flutter isolate through a control message, which is how a pause menu switches simulation systems off without unloading the scene.
Reaching the rest of the game¶
| Member | What it gives |
|---|---|
state |
The GameState this system belongs to |
game |
The Game — declarations, timing, state channels |
getState<S>() |
The state, narrowed to your subclass |
getGame<G>() |
The game, narrowed to your subclass |
getSystem<S>() |
Another system, for reading its results |
singleScene<S>() |
The one loaded scene's declaration. Throws when a second is resident |
@override
void onTick(Duration delta) {
// Read another system's finished results and publish them to the UI.
final physics = getSystem<Box2DPhysicsSystem>();
getGame<MyGame>().contactCount.value = physics.touchingPairCount;
}
Systems can declare too¶
A system is a full declaration host. It can declare its own queries, inputs, state channels, commands and events:
class PlayerSystem extends GameSystem with FixedTickable {
late final Input<Vector2> movement;
late final Input<bool> fire;
@override
void describeInputs(InputDescriptor descriptor) {
super.describeInputs(descriptor);
movement = descriptor.has<Vector2>(
const Vec2Binding(up: InputKey.w, down: InputKey.s,
left: InputKey.a, right: InputKey.d),
);
fire = descriptor.has<bool>(const TriggerBinding(InputKey.spacebar));
}
}
Keeping the action beside the loop that reads it is usually better than
declaring every input on the Game — the declaration and its only consumer stay
in one file.
Lifecycle¶
class SpawnSystem extends GameSystem
with FixedTickable, GameSystemLifecycleListener {
@override
void onMounted() {
super.onMounted();
// subscribe to input events, seed state
}
@override
void onUnmounted() {
super.onUnmounted();
}
}
Subscribe to input events from onMounted, not from a tick — pressed += in a
tick adds a subscriber sixty times a second.