Lesson 11
Hooks and observers, in order
Exactly when component hooks and observers run relative to the storage changes they react to, and what each one can see at that moment.
flecs has two ways to react to a component being added, set or removed:
- Hooks (
on_add,on_set,on_remove) are part of a component’s definition, like a constructor. There is at most one of each per component, they can change the component, and they’re cheap. Each can be set only once in C++ (setting it again asserts), and in C the set of hooks can’t change once the component is in use. - Observers are subscribers. Any part of the game can register as many as it likes, for any events, with a full query, including several terms. They’re more flexible, and they cost a query evaluation per event.
The order matters, because it decides what the callback can see. Rather than memorise it, step through it:
Loading the flecs debugger…
What the trace shows on current flecs:
| Operation | Order |
|---|---|
set<Position> on an entity without it |
move to the new table → on_add hook → OnAdd observers → value written → on_set hook → OnSet observers |
set<Position> on an entity that has it |
on_set hook → OnSet observers (no move, no OnAdd) |
add<Position> (no value) |
move → on_add hook → OnAdd observers; no OnSet |
remove<Position> |
OnRemove observers (the value is still readable) → row appended to the new table → on_remove hook → old row deleted |
destruct() |
OnRemove observers for each component → on_remove hooks (the row is still intact) → row deleted |
Some details worth knowing:
OnAddcan’t see the value fromset. It runs before the value is written. If you need the value, observeOnSet.OnAddfires once, when the component actually arrives. Setting a component the entity already has only producesOnSet.- Multi-term observers check their whole query.
StartsMovinglistens forPosition, Velocity. It is invoked whenever either is added, but only calls back once both are present. The first time, Ship has noVelocityyet, and the log shows it being checked and not called. - Custom events work like any other.
Collidedisn’t a component change at all.world.event<Collided>()...emit()delivers it to observers listening for that event on the entity’sPosition.
Observers that write what they observe
Two very common patterns, both slightly wrong:
- an
OnAddobserver that sets a default value (“new Health starts at 100”); - an
OnSetobserver that corrects the value (“shields always reset to 100”, or clamping).
Loading the flecs debugger…
Step through and watch the Watch observers:
a.set<Health>({50}):DefaultHealthruns onOnAddand enqueuesset<Health>({100}), which is applied straight after the observer. SoWatchHealthsees 100. Then the caller’s own value is written, andWatchHealthsees 50. The default is overwritten (last write wins), and anything observingOnSetsaw a value that was never meant to exist.b.set<Shield>({50}):WatchShieldsees 50, thenResetShieldsets 100, which triggersOnSetagain. Both observers run a second time, andResetShieldonly stops because it checks the value. Without the check it would recurse. The caller’s 50 is silently replaced.
The rules that fall out of this:
- Put defaults in the component’s constructor (or an
on_addhook), not in anOnAddobserver. Those run before any value is set, so they can’t fight it. - Don’t
setthe observed component inside its ownOnSetobserver. If a value must always be clamped or derived, do it where it’s written, in a hook (on_setcan change the value in place), or in a system that runs before the readers. - When several observers write the same component, the last write wins, and the order between observers isn’t something to build on.