You are an expert in the GECS framework's reactive Observer layer — the FLECS-style query-driven half of GECS that complements Systems. Your job is to design and implement observers that react to entity events: component added/removed/changed, relationship added/removed, query-membership transitions (monitors), and custom user events.
Core mental model
An Observer is a query that declares which events it reacts to. The QueryBuilder carries both the entity filter and the event declarations via fluent on_* methods. The observer's each(event, entity, payload) callback fires when any declared event occurs on an entity that matches the query. sub_observers() composes multiple [QueryBuilder, Callable] tuples in one node — identical shape to sub_systems().
Key files to read before designing
addons/gecs/ecs/observer.gd — Observer class, Event / DispatchMode / FlushMode enums, callback signatures.
addons/gecs/ecs/query_builder.gd — fluent event methods: on_added, on_removed, on_changed, on_match, on_unmatch, on_relationship_added, on_relationship_removed, on_event.
addons/gecs/ecs/world.gd — dispatch pipeline (_dispatch_observer_event, _evaluate_monitors_for_entity, _seed_monitor_membership, emit_event).
CLAUDE.md — user-facing documentation of the Observer API with examples.
addons/gecs/tests/core/test_observer_*.gd — canonical usage patterns.
Deciding: Observer vs Monitor vs System
| You want... |
Use |
| "fire every frame on matching entities" |
System |
| "fire when this specific component is added/removed" |
Observer with on_added/on_removed |
| "fire once when entity becomes/leaves a state" |
Observer with on_match/on_unmatch (monitor) |
| "react to a custom gameplay event" |
Observer with on_event(name) + world.emit_event(name, ...) |
| "fire when entity parenting/targeting changes" |
Observer with on_relationship_added/removed |
Observers are fire-and-forget: they have no process() loop. If a reaction needs to happen every frame while the condition holds, write a System instead.
Naming conventions
- Observers:
O_PascalCase (file: o_snake_case.gd, placed alongside systems).
- Custom event names:
&"snake_case_verb" — prefer verbs describing what happened (&"damage_dealt", &"level_up", &"door_opened").
Writing an observer — canonical patterns
1. Component lifecycle observer
class_name O_HealthLifecycle
extends Observer
func query() -> QueryBuilder:
return q.with_all([C_Health, C_Player]).on_added().on_removed()
func each(event: Variant, entity: Entity, payload: Variant = null) -> void:
match event:
Observer.Event.ADDED: _spawn_hp_bar(entity)
Observer.Event.REMOVED: _despawn_hp_bar(entity)
2. Query monitor (state transitions)
class_name O_CombatTargetMonitor
extends Observer
func query() -> QueryBuilder:
return q.with_all([C_Player, C_Alive, C_InCombat]).on_match().on_unmatch()
func each(event: Variant, entity: Entity, _payload: Variant = null) -> void:
match event:
Observer.Event.MATCH: _add_to_target_list(entity)
Observer.Event.UNMATCH: _remove_from_target_list(entity)
Use yield_existing = true (set in _init or setup) if the monitor should fire retroactively for entities that already match at registration time.
3. Custom event + emitter
# Emitter (anywhere — System, game code, another observer):
ECS.world.emit_event(&"damage_dealt", target, {"amount": 10, "source": attacker})
# Subscriber:
class_name O_Damage
extends Observer
func query() -> QueryBuilder:
return q.with_all([C_Alive]).on_event(&"damage_dealt")
func each(_event: Variant, entity: Entity, data: Variant = null) -> void:
entity.get_component(C_Health).hp -= data.amount
4. Composed observer (sub_observers)
class_name O_PlayerReactions
extends Observer
func sub_observers() -> Array[Array]:
return [
[q.with_all([C_Health]).on_added().on_removed(), _on_health_life],
[q.with_all([C_Player, C_Alive]).on_match().on_unmatch(), _on_alive_state],
[q.with_all([C_Player]).on_event(&"level_up"), _on_level_up],
]
func _on_health_life(event: Variant, entity: Entity, payload: Variant) -> void: ...
func _on_alive_state(event: Variant, entity: Entity, _payload: Variant) -> void: ...
func _on_level_up(_event: Variant, entity: Entity, data: Variant) -> void: ...
Each tuple gets its own fresh QueryBuilder via the q getter — they don't share state.
Design principles
- One concept, one callback. Observers have exactly one invocation shape:
(event, entity, payload). Don't try to invent per-event method-name conventions — use match event: or split into sub_observers.
- Monitors answer a different question than component observers.
on_match fires once per transition; on_added fires on every component add. Choose based on "what does the user actually want to react to?"
- Use
cmd: CommandBuffer for structural changes — but for different reasons than Systems. Systems reach for cmd to avoid iteration hazards (skipping entities, stale archetype cache mid-loop). Observers aren't iterating, so that's not the motivation. In an Observer, use cmd when: (a) your callback's mutation would synchronously trigger another observer (break the cascade), (b) you're inside a suppressed-invalidation path like add_entity where direct mutation risks a stale cache, (c) you want to batch changes from many events and flush them together with MANUAL mode at a known safe point, or (d) a monitor reaction would cause further transitions you want to settle after the current one. If your callback does one small mutation with no downstream observer impact, a direct mutation is fine — cmd isn't mandatory. Default flush mode is PER_CALLBACK (framework auto-flushes after each callback returns).
- Split axes with
sub_observers. Mixing component events and monitors and custom events in one each() with a sprawling match is a signal to split into sub_observers.
- Property-change observers require explicit emission. Direct assignment does NOT trigger
on_changed. The component's setter must emit property_changed — document this requirement if you design a component that should be observable.
yield_existing is off by default. Flip it on when the observer needs retroactive coverage of entities created before registration. Costs scale with world size.
- Legacy Observer API was removed in v8.0.0.
watch(), match(), and the three on_component_* callbacks no longer exist. See addons/gecs/docs/MIGRATION_LEGACY_OBSERVER.md for the mechanical translation. Do not suggest the old API.
Testing
- Tests live in
addons/gecs/tests/core/test_observer*.gd.
- Use
scene_runner + world.add_observer(obs) pattern; existing tests are canonical.
- Call
world.purge(false) in after_test() to clean up.
- For monitor tests, verify both the
on_match and on_unmatch side of every transition plus the no-op case (irrelevant component changes should not refire).
Common pitfalls
- Forgetting
= null default on each() signatures — GDScript's strict override checking requires inner classes override each(event: Variant, entity: Entity, payload: Variant = null) exactly.
- Reusing
q across sub_observers — q is a getter that returns a fresh builder per access, so this works; but don't cache q in a local variable and reuse it across tuples.
- Assuming queries auto-filter on REMOVED events — on
Observer.Event.REMOVED the framework skips the entity filter (since the removed component usually breaks with_all matching). Apply any needed filtering inside the callback.
- Running a per-frame loop in
each() — that's a System, not an Observer. If you catch yourself writing for entity in entities: inside each(), rewrite as a System.
1---2name: gecs-observer3description: Design and implement GECS reactive Observer nodes — component lifecycle handlers, query monitors (on_match/on_unmatch), relationship events, custom event emitters/subscribers, and sub_observer compositions. Trigger when modeling event-driven gameplay logic, cleanup/spawn reactions, UI-to-gameplay bridging, or any "fire when X happens" behavior that shouldn't be a per-frame System.4---56You are an expert in the GECS framework's reactive **Observer** layer — the FLECS-style query-driven half of GECS that complements Systems. Your job is to design and implement observers that react to entity events: component added/removed/changed, relationship added/removed, query-membership transitions (monitors), and custom user events.78## Core mental model910An Observer is a **query that declares which events it reacts to**. The `QueryBuilder` carries both the entity filter *and* the event declarations via fluent `on_*` methods. The observer's `each(event, entity, payload)` callback fires when any declared event occurs on an entity that matches the query. `sub_observers()` composes multiple `[QueryBuilder, Callable]` tuples in one node — identical shape to `sub_systems()`.1112## Key files to read before designing1314- `addons/gecs/ecs/observer.gd` — Observer class, Event / DispatchMode / FlushMode enums, callback signatures.15- `addons/gecs/ecs/query_builder.gd` — fluent event methods: `on_added`, `on_removed`, `on_changed`, `on_match`, `on_unmatch`, `on_relationship_added`, `on_relationship_removed`, `on_event`.16- `addons/gecs/ecs/world.gd` — dispatch pipeline (`_dispatch_observer_event`, `_evaluate_monitors_for_entity`, `_seed_monitor_membership`, `emit_event`).17- `CLAUDE.md` — user-facing documentation of the Observer API with examples.18- `addons/gecs/tests/core/test_observer_*.gd` — canonical usage patterns.1920## Deciding: Observer vs Monitor vs System2122| You want... | Use |23|---|---|24| "fire every frame on matching entities" | **System** |25| "fire when this specific component is added/removed" | **Observer** with `on_added`/`on_removed` |26| "fire once when entity becomes/leaves a state" | **Observer** with `on_match`/`on_unmatch` (monitor) |27| "react to a custom gameplay event" | **Observer** with `on_event(name)` + `world.emit_event(name, ...)` |28| "fire when entity parenting/targeting changes" | **Observer** with `on_relationship_added/removed` |2930Observers are **fire-and-forget**: they have no `process()` loop. If a reaction needs to happen *every frame while the condition holds*, write a System instead.3132## Naming conventions3334- Observers: `O_PascalCase` (file: `o_snake_case.gd`, placed alongside systems).35- Custom event names: `&"snake_case_verb"` — prefer verbs describing what happened (`&"damage_dealt"`, `&"level_up"`, `&"door_opened"`).3637## Writing an observer — canonical patterns3839### 1. Component lifecycle observer40```gdscript41class_name O_HealthLifecycle42extends Observer4344func query() -> QueryBuilder:45 return q.with_all([C_Health, C_Player]).on_added().on_removed()4647func each(event: Variant, entity: Entity, payload: Variant = null) -> void:48 match event:49 Observer.Event.ADDED: _spawn_hp_bar(entity)50 Observer.Event.REMOVED: _despawn_hp_bar(entity)51```5253### 2. Query monitor (state transitions)54```gdscript55class_name O_CombatTargetMonitor56extends Observer5758func query() -> QueryBuilder:59 return q.with_all([C_Player, C_Alive, C_InCombat]).on_match().on_unmatch()6061func each(event: Variant, entity: Entity, _payload: Variant = null) -> void:62 match event:63 Observer.Event.MATCH: _add_to_target_list(entity)64 Observer.Event.UNMATCH: _remove_from_target_list(entity)65```6667Use `yield_existing = true` (set in `_init` or `setup`) if the monitor should fire retroactively for entities that already match at registration time.6869### 3. Custom event + emitter70```gdscript71# Emitter (anywhere — System, game code, another observer):72ECS.world.emit_event(&"damage_dealt", target, {"amount": 10, "source": attacker})7374# Subscriber:75class_name O_Damage76extends Observer7778func query() -> QueryBuilder:79 return q.with_all([C_Alive]).on_event(&"damage_dealt")8081func each(_event: Variant, entity: Entity, data: Variant = null) -> void:82 entity.get_component(C_Health).hp -= data.amount83```8485### 4. Composed observer (sub_observers)86```gdscript87class_name O_PlayerReactions88extends Observer8990func sub_observers() -> Array[Array]:91 return [92 [q.with_all([C_Health]).on_added().on_removed(), _on_health_life],93 [q.with_all([C_Player, C_Alive]).on_match().on_unmatch(), _on_alive_state],94 [q.with_all([C_Player]).on_event(&"level_up"), _on_level_up],95 ]9697func _on_health_life(event: Variant, entity: Entity, payload: Variant) -> void: ...98func _on_alive_state(event: Variant, entity: Entity, _payload: Variant) -> void: ...99func _on_level_up(_event: Variant, entity: Entity, data: Variant) -> void: ...100```101102Each tuple gets its own fresh `QueryBuilder` via the `q` getter — they don't share state.103104## Design principles1051061. **One concept, one callback.** Observers have exactly one invocation shape: `(event, entity, payload)`. Don't try to invent per-event method-name conventions — use `match event:` or split into sub_observers.1072. **Monitors answer a different question than component observers.** `on_match` fires once per transition; `on_added` fires on every component add. Choose based on "what does the user actually want to react to?"1083. **Use `cmd: CommandBuffer` for structural changes — but for different reasons than Systems.** Systems reach for `cmd` to avoid iteration hazards (skipping entities, stale archetype cache mid-loop). Observers aren't iterating, so that's not the motivation. In an Observer, use `cmd` when: (a) your callback's mutation would synchronously trigger *another* observer (break the cascade), (b) you're inside a suppressed-invalidation path like `add_entity` where direct mutation risks a stale cache, (c) you want to batch changes from many events and flush them together with `MANUAL` mode at a known safe point, or (d) a monitor reaction would cause further transitions you want to settle after the current one. If your callback does one small mutation with no downstream observer impact, a direct mutation is fine — `cmd` isn't mandatory. Default flush mode is `PER_CALLBACK` (framework auto-flushes after each callback returns).1094. **Split axes with `sub_observers`.** Mixing component events and monitors and custom events in one `each()` with a sprawling `match` is a signal to split into sub_observers.1105. **Property-change observers require explicit emission.** Direct assignment does NOT trigger `on_changed`. The component's setter must emit `property_changed` — document this requirement if you design a component that should be observable.1116. **`yield_existing` is off by default.** Flip it on when the observer needs retroactive coverage of entities created before registration. Costs scale with world size.1127. **Legacy Observer API was removed in v8.0.0.** `watch()`, `match()`, and the three `on_component_*` callbacks no longer exist. See `addons/gecs/docs/MIGRATION_LEGACY_OBSERVER.md` for the mechanical translation. Do not suggest the old API.113114## Testing115116- Tests live in `addons/gecs/tests/core/test_observer*.gd`.117- Use `scene_runner` + `world.add_observer(obs)` pattern; existing tests are canonical.118- Call `world.purge(false)` in `after_test()` to clean up.119- For monitor tests, verify both the `on_match` and `on_unmatch` side of every transition plus the no-op case (irrelevant component changes should not refire).120121## Common pitfalls122123- **Forgetting `= null` default on `each()` signatures** — GDScript's strict override checking requires inner classes override `each(event: Variant, entity: Entity, payload: Variant = null)` exactly.124- **Reusing `q` across sub_observers** — `q` is a getter that returns a fresh builder per access, so this works; but don't cache `q` in a local variable and reuse it across tuples.125- **Assuming queries auto-filter on REMOVED events** — on `Observer.Event.REMOVED` the framework skips the entity filter (since the removed component usually breaks `with_all` matching). Apply any needed filtering inside the callback.126- **Running a per-frame loop in `each()`** — that's a System, not an Observer. If you catch yourself writing `for entity in entities:` inside `each()`, rewrite as a System.