70 %

Bevy Observer Filters

Using On<Event, Filter> for fun and profit

Let's say you have a HealthPointsPlugin that provides an Hp component. This plugin automatically fires Death events for an entity when hp is lowered to 0.

Let's also say you have a bunch of different enemy types. Blobs, Zombies, etc, that all despawn on Death, but also a PracticeEnemy that lives in the lobby before a game. This PracticeEnemy has different logic On<Death> as it just refills its health meter (or maybe another enemy splits into two enemies, etc. Specific logic is the point.)

It seems straightforward to use a global observer and a match to dispatch/execute On<Death> logic, but what if you want to encapsulate PracticeEnemy in its own Plugin?

How would an observer be written such that a user can commands.trigger(Death{entity}) and listen for what people think is On<Death, PracticeEnemy>.

Given an HpPlugin that looks vaguely like this:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, startup)
        .add_observer(|trigger: On<Death>| {
            info!(
                observer=?trigger.observer(),
                target= ?trigger.entity,
                event=?trigger.event(),
                "unfiltered",
            );
        })
        .add_observer(health)
        .run();
}

#[derive(Component)]
struct Hp(u32);

/// Hit does 10 damage
#[derive(EntityEvent)]
struct Hit {
    entity: Entity,
}

fn health(
    hit: On<Hit>,
    mut hps: Query<&mut Hp>,
    mut commands: Commands,
) {
    let Ok(mut hp) = hps.get_mut(hit.entity) else {
        return;
    };
    // every hit does 10 damage. such a fancy system.
    match hp.0.checked_sub(10) {
        Some(new_health) => {
            hp.0 = new_health;
        }
        None => {
            hp.0 = 0;
            commands.trigger(Death { entity: hit.entity })
        }
    }
}

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct PracticeEnemy;

fn startup(mut commands: Commands) {
    let id = commands.spawn((Player, Hp(5)));
    let id2 = commands.spawn((Enemy, Hp(5)));
    let id3 = commands.spawn((PracticeEnemy, Hp(5))).id();

    commands.trigger(Hit { entity: id3 });
}

#[derive(Debug, EntityEvent)]
struct Death {
    entity: Entity,
}

We have a few options:

Option 1: Entity Observers

Add new observer to every entity (entity observers) This works; but why are we adding new observers when every type has "one handler" each.

fn practice_enemy_death(death: On<Death>) {}
fn generic_death(death: On<Death>) {}

Option 2: A global observer

This requires the observer filter all components.

downsides include it being application-level logic that therefore doesn't fit in a well-scoped Plugin

On the upside: 1 observer, can handle everything in one place. Good for application-wide style code.

fn all_deaths(
    death: On<Death>,
    query: Query<Entity, With<PracticeEnemy>>,
) {
    match query.get(death.entity) {
        Ok(_) => todo!("practice_enemy logic"),
        Err(_) => todo!("generic logic"),
    }
}

Option 3: Dedicated Events

Dedicated events per type work, but require a user to know which one to fire for which entity. Alternatively, they can be dispatched from a global "routing" observer. This means requiring a general Death event, an application-level router, and specific PracticeEnemyDeath observers in plugins.

#[derive(EntityEvent)]
struct PracticeEnemyDeath {
    entity: Entity,
}
#[derive(EntityEvent)]
struct GenericDeath {
    entity: Entity,
}

The Goal

Ideally this would fit into its own plugin that decides what happens to itself when it dies. HpPlugin would be a generic system firing Death events, but the functionality for reacting to death, etc is "opt in".

current downsides to this next codeblock:

  • Death triggers all observers for all entities; filtering happens afterwards
  • "generic" Death observer is now invalid, since the "PracticeEnemy observer" is trying to override its behavior
  • this results in the "global death observer" needing to filter out PracticeEnemy, or running additionally, causing two Death handlers
struct PracticeEnemyPlugin;

impl Plugin for PracticeEnemyPlugin {
    fn build(&self, app: &mut App) {
        app.add_observer(practice_enemy_death_observer);
    }
}

fn practice_enemy_death_observer(
    death: On<Death>,
    query: Query<Entity, With<PracticeEnemy>>,
) {
    let Ok(entity) = query.get(death.entity) else {
        return
    };
    // do specific logic
}

The Option Everyone Reaches For

Something I've seen people try to do an observer that "filters" on existing components. This does not work as expected... but could it?

fn fake_filtered_observer(death: On<Death, PracticeEnemy>) {
}

The Working Code

Which brings us to the implementation. Here's an example application that filters Death events and only fires them for the components that are actually on an entity. This allows the definition of filtered On<Death, Player> observers.

It's a decent amount of code, but maybe that could be improved ergonomically. This example is just proving the feasibility, not the ideal end-user api.

That said, the end-user api once implemented is

commands.queue(DeathCommand(hit.entity))

and

fn death(death: On<Death, PracticeEnemy>) {
    info!(
        observer=?death.observer(),
        target= ?death.entity,
        event=?death.event(),
        "death to practice enemy",
    );
}

Which is pretty close to the original goal, even though it doesn't quite "fit" in the Observers .trigger sense.

use bevy::prelude::*;

use crate::{health::*, practice::*};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .register_type::<Player>()
        .add_plugins((
            HealthPlugin::<(PracticeEnemy, Player)>::default(),
            PracticeEnemyPlugin,
        ))
        .add_systems(Startup, startup)
        .add_observer(|trigger: On<Death>| {
            info!(
                observer=?trigger.observer(),
                target= ?trigger.entity,
                event=?trigger.event(),
                "unfiltered",
            );
        })
        .add_observer(|death: On<Death, Player>| {
            info!(
                observer=?death.observer(),
                target= ?death.entity,
                event=?death.event(),
                "death to Player",
            )
        })
        .run();
}

#[derive(Component, Reflect)]
#[reflect(Component)]
struct Player;

#[derive(Component)]
struct Enemy;

fn startup(mut commands: Commands) {
    let id1 = commands.spawn((Player, Hp(5))).id();
    let id2 = commands.spawn((Enemy, Hp(5))).id();
    let id3 = commands.spawn((PracticeEnemy, Hp(5))).id();

    commands.trigger(Hit { entity: id1 });
    commands.trigger(Hit { entity: id2 });
    commands.trigger(Hit { entity: id3 });
}

mod health {
    use bevy::{
        ecs::{
            component::ComponentId,
            event::EntityComponentsTrigger,
        },
        prelude::*,
    };
    use std::marker::PhantomData;

    #[derive(Resource)]
    struct DeathComponentsFilters(Vec<ComponentId>);

    pub struct HealthPlugin<T> {
        death_markers: PhantomData<T>,
    }

    impl<T: bevy::prelude::Bundle> Plugin for HealthPlugin<T> {
        fn build(&self, app: &mut App) {
            app.add_observer(health);
        }

        fn finish(&self, app: &mut App) {
            // register bundle regardless, because we need its ids
            // on the next line and some components aren't registered yet
            let components = app
                .world_mut()
                .register_bundle::<T>()
                .contributed_components()
                .to_vec();
            app.insert_resource(DeathComponentsFilters(
                components,
            ));
        }
    }
    impl<T> Default for HealthPlugin<T> {
        fn default() -> Self {
            Self {
                death_markers: Default::default(),
            }
        }
    }

    #[derive(Component)]
    pub struct Hp(pub u32);

    /// Hit does 10 damage
    #[derive(EntityEvent)]
    pub struct Hit {
        pub entity: Entity,
    }

    #[derive(Debug, EntityEvent)]
    #[entity_event(trigger = EntityComponentsTrigger<'a>)]
    pub struct Death {
        pub entity: Entity,
    }

    fn health(
        hit: On<Hit>,
        mut hps: Query<&mut Hp>,
        mut commands: Commands,
    ) {
        let Ok(mut hp) = hps.get_mut(hit.entity) else {
            return;
        };
        // every hit does 10 damage. such a fancy system.
        match hp.0.checked_sub(10) {
            Some(new_health) => {
                hp.0 = new_health;
            }
            None => {
                hp.0 = 0;
                // regular EntityEvent trigger
                // commands
                //     .trigger(Death { entity: hit.entity })
                info!(entity=?hit.entity,"hit");
                commands.queue(DeathCommand(hit.entity));
            }
        }
    }
    struct DeathCommand(Entity);
    impl Command for DeathCommand {
        fn apply(self, world: &mut World) -> () {
            let components = world
                .get_resource::<DeathComponentsFilters>()
                .unwrap();

            let active_components = components
                .0
                .iter()
                .filter(|id| {
                    world.entity(self.0).contains_id(**id)
                })
                .cloned()
                .collect::<Vec<ComponentId>>();

            world.trigger_with(
                Death { entity: self.0 },
                EntityComponentsTrigger {
                    components: &active_components,
                },
            );
        }
    }
}

mod practice {
    use bevy::prelude::*;

    use crate::health::Death;

    pub struct PracticeEnemyPlugin;

    impl Plugin for PracticeEnemyPlugin {
        fn build(&self, app: &mut App) {
            app.add_observer(death);
        }
    }

    #[derive(Component)]
    pub struct PracticeEnemy;

    fn death(death: On<Death, PracticeEnemy>) {
        info!(
            observer=?death.observer(),
            target= ?death.entity,
            event=?death.event(),
            "death to practice enemy",
        );
    }
}