Events and listeners¶
Layer: kernel (good)
Every callback the engine hands you arrives the same way. The fixed tick, an
entity mounting, a scene loading, the game coming up: all of them are events,
delivered to listener lists the engine resolved once at boot. There is no event
class to write, no emitter to construct, and no subscribe call anywhere in
the API. You mix in a listener type and the engine has already worked out that
you are one.
This page is the mechanism, and it is the same mechanism you use for events of your own.
A dispatcher is a field¶
An event is an EventDispatcher<L, E> held in a late final field. L is the
listener type it delivers to; E is the payload it carries. You declare it in
describeEvents, which runs once, and you fire it by calling it.
This is the declaration behind onEntityMounted — EntityStruct holds it, and
your prefab receives the event because it mixes in the listener type:
late final EventDispatcher<EntityLifecycleListener, Entity> mountedEvent;
@override
void describeEvents(EventDescriptor descriptor) {
super.describeEvents(descriptor);
mountedEvent = descriptor.has(
(listener, entity) => listener.onEntityMounted(entity),
);
}
Firing it is one call, and the dispatcher is named call so the parentheses
work directly:
The closure handed to has is the whole of delivery, and it is built once,
during the declaration pass. Nothing is constructed per dispatch: the payload
travels as an argument, so there is no event object at all and firing an event
allocates nothing whatever the payload is. That matters most for the tick,
which fires sixty times a second forever.
For an event that carries nothing, use hasSignal and hold a
SignalDispatcher<L>. The fixed tick is the case — it happened, and that is
the entire message:
late final SignalDispatcher<FixedTickable> fixedTickEvent;
fixedTickEvent = descriptor.hasSignal((listener) => listener.onFixedUpdate());
Keep the handle the descriptor returns. Nothing is addressable by name, so
there is nothing to look up later — the same shape every other describe* pass
uses.
Who can declare one¶
Anything that mixes in EventBus, whose bound is on GameListener. Four
framework types qualify — GameState, SceneStruct, EntityStruct and
GameSystem — and they are exactly the four that live on the game isolate.
Game is not a GameListener, so it cannot declare or receive an event. Every
event in the engine happens on the simulating isolate; traffic to Flutter goes
out through state channels and comes back
as commands.
These are the built-in dispatchers, with the mixin you apply to hear each one:
| Dispatcher | Declared on | Listener mixin |
|---|---|---|
fixedTickEvent |
GameState |
FixedTickable |
tickEvent |
GameState |
Tickable |
gameMountedEvent, gameUnmountedEvent |
GameState |
GameLifecycleListener |
entitySpawnedEvent, entityDespawnedEvent |
GameState |
EntitySpawnListener |
sceneLoadedEvent, sceneUnloadedEvent |
GameState |
SceneLoadListener |
mountedEvent, unmountedEvent |
SceneStruct |
SceneLifecycleListener |
mountedEvent, unmountedEvent |
EntityStruct |
EntityLifecycleListener |
mountEvent, unmountEvent |
GameSystem |
GameSystemLifecycleListener |
The pairs are not redundant. SceneLifecycleListener on a SceneStruct means
"an instance of me mounted"; SceneLoadListener on a system means "a
scene mounted, tell me which". Which one you want depends on whether you are
the thing coming up or an observer watching the world. The entity pair splits
the same way.
How listeners are collected¶
Two passes run over each owner at boot, in this order:
describeEventscreates every dispatcher that owner declares.collectListenerswalks that owner's composition and offers each candidate to every dispatcher it just created. A dispatcher accepts a candidate when it is anL, and ignores it otherwise.
After that the lists are settled. Dispatch is then an indexed for over a
plain list — no walking, no type tests, no allocation, and no work at all for
an object that could never have received the event.
The default collectListeners offers this, which is what makes a prefab hear
its own mount. An owner that composes other things overrides it and offers them
too, and those overrides are the whole of how far an event travels:
flowchart TD
gs["<b>GameState</b><br/><i>offers itself</i>"] --> sys["every declared GameSystem"]
gs --> sc["every declared SceneStruct<br/><i>offers itself</i>"]
sc --> pf["every prefab that scene registered"]
So an event reaches its declaring owner's composition and nothing wider.
Declared on the GameState it reaches every system, every scene and every
prefab. Declared on a SceneStruct it reaches that scene and the prefabs it
registered. Declared on an EntityStruct or a GameSystem it reaches that one
object, because a prefab composes nothing further and a system's default walk
offers only itself.
That last line explains something otherwise surprising: a dispatcher you declare on your own system delivers back to your own system and to nobody else. Put a game-wide event on the state.
You can widen your own walk by overriding collectListeners, calling super
first:
@override
void collectListeners(ListenerCollector collector) {
super.collectListeners(collector);
collector.offer(getSystem<MusicSystem>());
}
Skipping super drops everything the framework was about to offer — every
system and every scene, in the GameState case. Offering the same object twice
is harmless: the collector deduplicates by identity, so a listener never
receives one event twice.
A disabled system stays in the list
state.disableSystem<AiSystem>() does not rebuild anything. The system is
still in every dispatcher that collected it and now answers false to
listensToEvents, which every dispatch checks before delivering. One bool
read per listener buys a membership list that never has to change.
Ordering¶
Delivery follows collection order, and collection order is declaration order:
systems in the order describeSystems declared them (then compareTo), scenes
in describeScenes order, prefabs in describeScene order. Nothing sorts at
dispatch time.
Bring-up runs outside-in — the owner first, then what it composes — so
onSceneMounted on the scene struct itself has already spawned the starting
entities by the time a watching system hears about it.
Teardown has to run the other way. A listener told the world is going away
after its owner has already taken it apart is looking at rubble. Pass
reverse: true and the dispatcher reads its collected list backwards, which is
one list serving both orders instead of two that could drift apart:
That is GameSystem's own teardown signal. GameState.sceneUnloadedEvent does
the same with a payload. The rule for your own events: forward for anything
meaning "this now exists", reverse for anything meaning "this is going away".
Declaring an event of your own¶
Three pieces. A listener mixin, a dispatcher on the owner whose reach you want, and a call.
The listener mixin. Bound on GameListener, with no-op bodies so a
listener overrides only the hooks it cares about:
The bound is doing real work. Game is not a GameListener, so
class MyGame extends Game with WaveListener fails to compile instead of
compiling cleanly and never firing.
The dispatcher. A wave clearing is game-wide, so it goes on the state, whose walk reaches everything:
class ArenaState extends GameState2D<ArenaGame> {
late final EventDispatcher<WaveListener, int> waveCleared;
int wave = 1;
@override
void describeEvents(EventDescriptor descriptor) {
super.describeEvents(descriptor);
waveCleared = descriptor.has(
(listener, wave) => listener.onWaveCleared(wave),
);
}
void clearWave() {
waveCleared(wave);
wave++;
}
}
The listeners. Anything the state collects opts in by mixing WaveListener
in. It needs nothing else — no registration call, no handle to keep:
class MusicSystem extends GameSystem with WaveListener {
@override
void onWaveCleared(int wave) {
// swap the track
}
}
class Orc extends EntityStruct with Transform2D, Renderable2D, WaveListener {
@override
void onWaveCleared(int wave) {
// fires once for the prefab, not once per orc
}
}
A prefab is one object, so it hears an event once
There is one Orc instance in the whole game — see
Entities and components.
A broadcast event calls its handler a single time, with no entity attached,
even if ten thousand orcs are alive. Work that has to touch every orc
belongs in a system with a query; the prefab handler is for setting a flag
that the system then reads.
Carrying more than one value means a record, exactly as commands do:
typedef WaveResult = ({int wave, int survivors});
late final EventDispatcher<WaveListener, WaveResult> waveFinished;
waveFinished((wave: 3, survivors: 12));
When a plain method call is the better answer¶
A dispatcher earns its keep when an event has to reach a whole composition of listeners you do not know at declare time — a spatial index, a replication table, three prefabs and a music system that each want the same news.
For "this system tells that system", write the method call. Both ends live on
the same isolate, getSystem<T>() gives you a typed handle, and one direct call
is shorter to read than a mixin, a dispatcher and a declaration pass. Cache the
handle in a field if the call is per-contact or per-entity, because
getSystem is a lookup.
And an event a Flutter widget has to show is not an event on this side at all. Numbers go out through state channels and actions come back as commands.
Two things that look like events and are not¶
The physics callbacks. CollisionListener — onCollisionEnter2D and its
five siblings — is bound on Component, not on GameListener, so none of the
machinery on this page touches it. The physics system resolves it at the
contact with entity.tryGet<CollisionListener>() and calls your override
directly. Same "no-op defaults, override what you need" shape; different
delivery. See Physics.
GameState.onMounted(). A plain virtual method. One receiver, the
framework is the only caller, and there is nobody else it could be dispatched
to.