UkoeHB/bevy_cobweb

Observers?

Opened this issue · 0 comments

Hooks and observers landed in Bevy v0.14. Below I discuss and document their benefits and limitations in the context of this crate.

The comments here are not conclusive and will be updated as my understanding/thoughts evolve.

Hooks

If you manually implement Component on a struct, then you can define exclusive callbacks that run when the component is inserted/overwritten/removed from an entity.

Benefits

  • Allows inserting React<C> components directly in bundles, rather than needing ReactCommands::insert.
  • Allows reacting to React<C> component removals potentially more efficiently than polling for removals. There are still lookups required to see if anything is listening to the removal (entity_removal::<C>(entity) triggers targeting an entity, and removal::<C>() triggers targeting any removal).
  • Maybe allows reacting to despawns without polling. The problem here is 'on despawn' reactors should run after the target entity is fully despawned, otherwise there may be subtle footguns.

Limitations

  • Does not include a hook for component mutation, so we still need React::get_mut(&mut commands). This isn't a huge problem because despite the ugliness of get_mut(), it makes it very explicit and intentional when reactive components should/will trigger mutation reactions.
  • The on_remove hook does not indicate if the entity is being despawned. This may not be important.
  • Within on_remove hooks the entity is essentially 'still alive', so any reaction trees executed from within an on_remove will still see the entity even if it's dying.
    • This may be an advantage for removal reactors because it means reactors can fully access the relevant entity in its pre-mutation state.
    • What happens if you remove a component from an entity from within the component's on_remove reactor when it is already reacting to that removal? This would need careful recursion-detection (maybe set a flag/build a stack when recursing?).

Comments

  • Ideally 'on despawn' and 'on remove' reactors would have separated semantics. 'On despawn' should run after entities are fully despawned, and 'on remove' should run before component removal (and entity despawn) with careful recursion detection.
    • Despawn reactors could be hyper-optimized by inserting them to EntityReactors and then when EntityReactors is removed just hook it to schedule all despawn reactors to run once the entity is fully despawned.
      • Problem: there's no way to guarantee another on_remove hook doesn't flush the world. There's no way to say 'please only apply these commands once despawned fully' (other than running everything inside a reaction tree).
    • Problem: reaction trees are designed to move reactions outside the inner commands loop. Since hooks are executed at the apply_deferred point, recursive component removals will be handled after the components are fully removed. At the same time, the root of a reaction tree always runs inside a command, so root-level removal reactors will always run before components are fully removed.
      • This is a deep problem for this crate, because any foreign code can manually run systems within hooks that internally trigger cobweb reactions, which will then fire off a reaction tree within the hooks if that foreign code flushes the world (e.g. by naively calling world.syscall(...) from within a hook). It may be necessary to rethink the reaction tree design.

Observers

Observers are oneshot systems that can be inserted to entities in an Observer<E, B> component. The In parameter of a oneshot system must be Trigger<E, B>, where Trigger<E, B> is essentially a mix between the 'broadcast' and 'entity' event trigger in this crate (for user-defined event type E). When an Observer<E, B> component is inserted, Bevy automatically detects it and registers that oneshot to receive Trigger<E, B> broadcast events, and to receive Trigger<E, B> entity events targeting the entity with that Observer<E, B> component (by 'registers' I just mean analogous to how this crate works).

You can then send broadcast or multi-entity events with world.trigger(E) or world.trigger_targets(E, entities) respectively.

Note: The B: Bundle type parameter appears to allow you to subscribe to B-targeted entity events as subcategories of E events. For example, the built-in OnAdd/OnInsert/OnRemove event types that are triggered immediately after the associated hooks target specific components in B.

Benefits

  • TODO

Limitations

  • Only one event type per observer.
  • No exclusive observers?
  • Trigger is mandatory and not a system parameter so it can't be wrapped in anything more ergonomic or elided entirely.
  • How to run an observer manually?
  • No ability to listen to 'any entity event' when targeted triggering is used. Targeted triggering just activates the targeted entities.
  • Only one observer of a given event type per entity.
  • How to have multiple observers in different places that react to an event sent to a specific entity?
  • Observers can run recursively, but you will stack overflow eventually.

Comments

  • How to listen to an event for entity A from an observer on entity B? For example, a text entity listening to mutations of a reactive component on other entity.
    • You'd need an indirection: when mutation on A is detected, send a targeted event to entity B. Entity B then has an observer for receiving targeted mutation events (separate from non-targeted mutation events, which needs to be sent as a separate event). Entity A would need to know all the targets where it should send the mutation event. You end up with a data structure like the EntityReactors we have in this crate.