Capítulo 10. Observers y Entity Events: reacciones push al estilo flecs

CAP 10 · Bevy 0.18/0.19
"No le preguntes al mundo si algo pasó; deja que el mundo te avise."

— Carter, sobre el modelo observer en flecs (2017).

10.1 ¿Por qué observers?

Hasta ahora, todos nuestros sistemas han seguido el patrón pull: cada frame, el sistema pregunta "¿hay minas activas?", "¿hay enemigos con vida baja?", "¿se pulsó la tecla de saltar?" y actúa. Si no hay nada, el sistema se ejecuta igual, consume su tiempo y devuelve. Para 50 entidades no pasa nada; para 50.000, es un problema.

Los observers invierten el flujo: en vez de preguntar, registras tu interés en un evento y Bevy te llama cuando ocurre. Es el patrón clásico de la programación dirigida por eventos (Observer pattern, del GoF, 1994), pero integrado en el ECS: el "evento" lleva consigo la entidad que lo causó y opcionalmente componentes asociados.

Analogía absurda: un observer es como una alarma de incendio. En el modelo pull, cada minuto te levantas, vas hasta el detector y miras si hay humo. En el modelo observer, te sientas a leer el libro, y la alarma te avisa cuando hay humo. Si no hay humo, no haces nada. Si lo hay, la alarma "te dispara" y tú reaccionas. Bevy hace exactamente eso: cuando un Trigger ocurre, todos los observers registrados para ese evento se ejecutan, en el orden correcto, antes de que el frame termine.

Hay tres modos de "registrar interés":

  1. Sistema que llama world.observe(...) dentro de sí mismo (observer local al sistema).
  2. Entidad con el componente Observer (observer "en el mundo", que puede persistir y consultar entidades).
  3. world.trigger(event) para disparar el evento manualmente.

Bevy 0.16 introdujo Trigger<E> como primitiva de primera clase. Bevy 0.18 lo consolidó con On<E> y EntityEvent. Bevy 0.19 lo redondeó con run conditions sobre observers (cap. 11). Es uno de los API surfaces que más han cambiado en los últimos seis meses, así que conviene tener el modelo claro antes de usarlo en producción.

10.2 #[derive(EntityEvent)]: el evento que lleva una entidad

Hay dos grandes familias de eventos en Bevy:

Para nuestro ejemplo de minas, lo natural es un EntityEvent:

//! cap-10 — sección 10.2: definir el evento.
use bevy::prelude::*;

/// Evento: una mina ha explotado.
#[derive(EntityEvent, Debug)]
pub struct MinaExplotada {
    pub entity: Entity,     // <-- obligatorio: la mina que explotó.
    pub posicion: Vec2,
    pub radio: f32,
    pub damage: u32,
}

El derive hace tres cosas:

  1. Implementa el trait Event y EntityEvent.
  2. Permite que commands.trigger(MinaExplotada { entity, .. }) lance el evento.
  3. Lo registra en el sistema de dispatching del mundo.

Nota técnica: #[derive(EntityEvent)] requiere que el struct tenga un campo entity: Entity (o #[event_target] sobre otro campo). Sin él, Bevy no compila.

10.3 Trigger<E> y On<E>: cómo se ve un observer

Hay dos formas sintácticas equivalentes de escribir un observer:

//! cap-10 — sección 10.3: observer con Trigger (legado).
fn on_mina_explotada(
    trigger: Trigger<MinaExplotada>,
    mut comandos_explosion: Commands,
) {
    let evento = trigger.event();   // <-- acceso al payload.
    info!("BOOM en {:?} con radio {}", evento.posicion, evento.radio);

    // Podemos spawnear daño, partículas, etc.
    comandos_explosion.spawn(/* ... */);
}
//! cap-10 — sección 10.3: observer con On (moderno, 0.18+).
fn on_mina_explotada_v2(
    on: On<MinaExplotada>,
    mut comandos_explosion: Commands,
) {
    let evento = on.event();
    info!("BOOM en {:?}", evento.posicion);
    // ...
}

On<E> y Trigger<E> son sinónimos funcionales. La diferencia es de estilo: On<E> se lee más natural cuando el observer es lo único en la firma, y Trigger<E> cuando quieres dejar claro que el evento es "el disparador" de la reacción.

Los argumentos extra del observer (Commands, Query, Res, etc.) funcionan igual que en un sistema: Bevy analiza la firma e inyecta las dependencias. La única restricción es que el evento debe ser el primer argumento.

10.4 Disparar el evento: World::trigger vs Commands::trigger

Tienes dos puertas para emitir un evento:

//! cap-10 — sección 10.4: trigger desde World y desde Commands.
fn sistema_inmediato(world: &mut World, mina: Entity) {
    // Disparo SÍNCRONO. Los observers corren antes de que esta línea retorne.
    world.trigger(MinaExplotada {
        entity: mina,
        posicion: Vec2::ZERO,
        radio: 50.0,
        damage: 100,
    });
}

fn sistema_diferido(mut commands: Commands, mina: Entity) {
    // Disparo DIFERIDO. Se ejecuta al final del frame, al aplicar commands.
    commands.trigger(MinaExplotada {
        entity: mina,
        posicion: Vec2::ZERO,
        radio: 50.0,
        damage: 100,
    });
}

Diferencia práctica. World::trigger ejecuta los observers ahora mismo, en mitad de tu sistema. Eso es potente pero peligroso: si el observer despawea una entidad que tú estás iterando, tienes un借用 conflict. Commands::trigger lo difiere al final del frame, después de que todos los sistemas hayan terminado. Es la opción por defecto para la mayoría de los casos.

Regla práctica: usa Commands::trigger por defecto. Usa World::trigger solo cuando necesites la reacción inmediata (ej. un observer que registra otras entidades como listeners).

10.5 Múltiples observers por evento

Un mismo evento puede tener N observers. Bevy los ejecuta todos, en el orden en que se registraron. Útil para separar responsabilidades:

//! cap-10 — sección 10.5: varios observers sobre el mismo evento.
fn registrar_observers(app: &mut App) {
    // Observer 1: spawnear partículas.
    app.observe(on_mina_particulas);

    // Observer 2: aplicar daño a entidades en el radio.
    app.observe(on_mina_damage);

    // Observer 3: emitir evento de audio.
    app.observe(on_mina_audio);

    // Observer 4: sumar al contador de stats.
    app.observe(on_mina_stats);
}

fn on_mina_particulas(on: On<MinaExplotada>, mut commands: Commands) {
    commands.spawn(/* sprite de explosión */);
}

fn on_mina_damage(
    on: On<MinaExplotada>,
    mut comandos: Commands,
    query: Query<(Entity, &Transform), With<Vulnerable>>,
) {
    let evento = on.event();
    for (e, t) in &query {
        if t.translation.truncate().distance(evento.posicion) < evento.radio {
            comandos.trigger(DamageReceived {
                entity: e,
                cantidad: evento.damage,
            });
        }
    }
}

fn on_mina_audio(
    on: On<MinaExplotada>,
    servidor: Res<AudioServer>,
) {
    servidor.play_sfx("explosion.ogg");
}

fn on_mina_stats(
    on: On<MinaExplotada>,
    mut stats: ResMut<GameStats>,
) {
    stats.minas_explotadas += 1;
}

Fíjate en cómo cada observer hace una cosa. La lógica de daño no sabe nada de partículas; la de audio no sabe nada de daño. Cohesión alta, acoplamiento bajo, que es justo lo que queremos en una arquitectura de juego.

10.6 Observers sobre componentes: vigilar cambios

Además de eventos custom, Bevy dispara observers automáticamente cuando un componente se inserta, reemplaza o remueve en una entidad. Es el equivalente "externo" de los hooks del cap. 9.

//! cap-10 — sección 10.6: observer sobre componente.
use bevy::ecs::component::ComponentId;

fn on_health_added(
    trigger: On<Add, Health>,
    query: Query<&Health>,
) {
    let entity = trigger.entity();
    if let Ok(h) = query.get(entity) {
        info!("Health añadida a {:?}: {} / {}", entity, h.actual, h.max);
    }
}

fn on_player_removed(
    trigger: On<Remove, Player>,
    mut game_over: EventWriter<GameOver>,
) {
    info!("Player {:?} eliminado. Game over.", trigger.entity());
    game_over.send_default();
}

// Registrar:
app.observe(on_health_added);
app.observe(on_player_removed);

Las tres variantes del primer parámetro genérico son:

La diferencia con los hooks del cap. 9 es el alcance: los hooks viven en el #[derive(Component)] del componente y solo puede haber uno por hook; los observers sobre componentes son funciones sueltas que registras en cualquier punto del programa, y puede haber varios.

10.7 Ejemplo completo: el campo de minas

Cerramos con un ejemplo realista. Tenemos un campo de minas en un juego de acción 2D. Cada mina es una entidad con Mina, Transform, TriggerRadius. Cuando el jugador entra en su radio, explota.

//! cap-10 — sección 10.7: campo de minas completo.
use bevy::prelude::*;

#[derive(Component)]
struct Mina {
    damage: u32,
    radio_explosion: f32,
    radio_trigger: f32,
}

#[derive(Component)]
struct TriggerRadius(f32);

#[derive(Component)]
struct Player;

#[derive(EntityEvent, Debug)]
struct MinaExplotada {
    entity: Entity,
    posicion: Vec2,
    radio: f32,
    damage: u32,
}

#[derive(EntityEvent, Debug)]
struct DamageReceived {
    entity: Entity,
    cantidad: u32,
}

Sistemas:

//! cap-10 — sección 10.7: spawn + sistema de proximidad.
fn spawn_mina(mut commands: Commands, transform: Transform) {
    commands.spawn((
        Mina { damage: 50, radio_explosion: 60.0, radio_trigger: 20.0 },
        Transform::from_translation(transform.translation),
    ));
}

fn detectar_proximidad(
    minas: Query<(Entity, &Transform, &Mina)>,
    player: Query<&Transform, With<Player>>,
    mut commands: Commands,
) {
    let Ok(player_pos) = player.get_single() else { return; };
    let player_pos = player_pos.translation.truncate();

    for (mina_e, t, m) in &minas {
        let distancia = t.translation.truncate().distance(player_pos);
        if distancia <= m.radio_trigger {
            commands.trigger(MinaExplotada {
                entity: mina_e,
                posicion: t.translation.truncate(),
                radio: m.radio_explosion,
                damage: m.damage,
            });
            // La mina se autodestruirá en otro observer (más abajo).
        }
    }
}

Observers:

//! cap-10 — sección 10.7: cuatro observers sobre MinaExplotada.
fn explosion_particulas(
    on: On<MinaExplotada>,
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    let ev = on.event();
    commands.spawn((
        Sprite::from_image(asset_server.load("explosion.png")),
        Transform::from_translation(ev.posicion.extend(0.0)),
        // Animación: se despawneará tras 0.5 s.
        Vida { restante: 0.5 },
    ));
}

fn explosion_damage(
    on: On<MinaExplotada>,
    mut commands: Commands,
    victimas: Query<(Entity, &Transform), With<Vulnerable>>,
) {
    let ev = on.event();
    for (e, t) in &victimas {
        let d = t.translation.truncate().distance(ev.posicion);
        if d <= ev.radio {
            commands.trigger(DamageReceived {
                entity: e,
                cantidad: ev.damage,
            });
        }
    }
}

fn explosion_audio(
    on: On<MinaExplotada>,
    audio: Res<bevy_kira_audio::Audio>,
) {
    audio.play(bevy_kira_audio::AudioSource::new("boom.ogg"));
}

fn mina_despawn(
    on: On<MinaExplotada>,
    mut commands: Commands,
) {
    commands.entity(on.entity()).despawn();
}

#[derive(Component)]
struct Vida { restante: f32 }

#[derive(Component)]
struct Vulnerable;
//! cap-10 — sección 10.7: bootstrap.
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, |mut commands: Commands| {
            // Jugador
            commands.spawn((Player, Transform::default(), Vulnerable));
            // Minas
            commands.spawn((
                Mina { damage: 50, radio_explosion: 60.0, radio_trigger: 20.0 },
                Transform::from_xyz(100.0, 0.0, 0.0),
            ));
        })
        .add_systems(Update, detectar_proximidad)
        // Registrar observers:
        .observe(explosion_particulas)
        .observe(explosion_damage)
        .observe(explosion_audio)
        .observe(mina_despawn)
        .run();
}

Cuatro observers separados para una sola explosión. Cada uno hace una sola cosa, y el sistema de proximidad no sabe nada de ellos. Si mañana quieres añadir "mina explota y rompe cofres cercanos", añades un quinto observer y listo. Esa es la potencia del modelo push.

Observer pattern (concepto): patrón GoF (1994) en el que un objeto "registra interés" en eventos de otro y es notificado automáticamente.
Pull model (concepto): el sistema pregunta cada frame si algo ocurrió (modelo clásico de Bevy ECS pre-0.16).
Push model (concepto): el sistema registra su interés una vez; Bevy lo llama cuando el evento ocurre.
`Event` (trait): evento "global" en Bevy, no asociado a una entidad.
`EntityEvent` (trait): evento asociado a una entidad concreta; requiere campo `entity: Entity` o `#[event_target]`.
`Trigger<E>` (tipo): argumento de un observer que envuelve el evento disparado.
`On<E>` (tipo): sinónimo moderno de `Trigger<E>`, preferido en 0.18+.
`commands.trigger(event)` (API): dispara un evento de forma diferida (al final del frame).
`world.trigger(event)` (API): dispara un evento de forma inmediata (durante el sistema actual).
`On<Add, T>` (tipo): observer disparado cuando `T` se añade a una entidad.
`On<Remove, T>` (tipo): observer disparado cuando `T` se remueve de una entidad.
`app.observe(fn)` (API): registra una función como observer sobre el evento declarado en su firma.

10.7.1 Consideraciones de rendimiento: dispatch vs poll

Vale la pena cuantificar el ahorro. Imagina 10.000 entidades con Health. Con pull:

fn sistema_pull(query: Query<&Health, Changed<Health>>) {
    for h in &query { /* ... */ }
}

Esa query itera la columna de Health cada frame, filtrando las que tengan el flag "cambió". Para 10.000 entidades, son ~10.000 lecturas aunque ninguna haya cambiado. Si el sistema hace algo caro (escribir a un log, calcular partículas), ese coste escala con la población.

Con push (observers), el sistema solo se ejecuta cuando un evento HealthChanged se dispara. Si en un frame nadie pierde vida, el observer no se invoca. Si 50 entidades pierden vida, el observer se invoca 50 veces (una por trigger), con datos empaquetados. La comparación, en la práctica:

Para juegos con muchos enemigos pero pocas muertes por segundo (Hollow Knight, Hades, un roguelite en general), push gana. Para simulaciones donde todos cambian cada frame (un sistema de partículas con 50.000 sparks), pull sigue siendo lo correcto.

10.7.2 Errores comunes con el lifecycle del evento

Dos bugs típicos cuando empiezas con observers:

Bug 1: observer que asume componente presente. Tu evento es MinaExplotada { entity, .. }, pero el observer hace query.get(entity) sobre Mina. Si el observer de despawn se ejecuta antes que el de daño, la mina ya no existe y el query.get falla. Solución: ordena los observers con .before() o diseña el evento para llevar todos los datos necesarios (radio, posición) sin depender del estado actual del componente.

Bug 2: observer que dispara otro observer en cascada. Un observer dispara un evento que dispara otro observer que dispara otro... Si la cadena no termina, tienes una recursión infinita. Solución: usa commands.trigger (diferido) en vez de world.trigger (inmediato) para que la cascada se ejecute solo al final del frame, donde puedes poner un watchdog o un contador de profundidad.

10.7.3 Observers sobre relaciones

Bevy 0.19 también dispara observers cuando se añade o remueve una arista de relación (cap. 11). El evento tiene la forma On<Add, ChildOf> o On<Remove, Likes>. Lo verás cuando montemos el grafo de Likes/LikedBy.

🎯 10.8 Patrón del capítulo: reacción local al cambio

Problema. Una entidad cambia de estado (muere, explota, se transforma). Múltiples sistemas分散 deben reaccionar: efectos visuales, audio, stats, lógica de victoria. Si cada sistema hace polling sobre "¿ha muerto alguien este frame?", tienes N queries redundantes; si los acoplas dentro de un único sistema, tienes un monstruo de 300 líneas.

Solución. Declara un EntityEvent para el cambio. Registra N observers, cada uno con una sola responsabilidad. Los sistemas que causan el cambio llaman a commands.trigger(event). Bevy se encarga del dispatch.

#[derive(EntityEvent)]
struct EntidadMurio {
    entity: Entity,
    causa: CausaMuerte,
}

fn al_morir_particulas(on: On<EntidadMurio>, mut c: Commands) { /* ... */ }
fn al_morir_audio(on: On<EntidadMurio>, audio: Res<Audio>)     { /* ... */ }
fn al_morir_stats(on: On<EntidadMurio>, mut s: ResMut<Stats>)  { /* ... */ }

app.observe(al_morir_particulas)
   .observe(al_morir_audio)
   .observe(al_morir_stats);

Cuándo sí.

Cuándo no.

10.8.1 Observers como entidades: world.spawn(Observer::new(...))

Hasta ahora hemos visto observers que son funciones globales registradas con app.observe(fn). Pero Bevy también permite que un observer sea él mismo una entidad del mundo:

//! cap-10 — sección 10.8.1: observer como entidad.
fn spawn_observador_inquisitivo(mut commands: Commands, target: Entity) {
    // Esta entidad "vigila" a `target`. Si target recibe Damage,
    // el observer se ejecuta. Si la entidad-observer se despawea,
    // deja de vigilar.
    commands.spawn(Observer::new(
        |on: On<DamageReceived>, mut stats: ResMut<DebugStats>| {
            stats.damage_events += 1;
        }
    ));
}

¿Por qué querrías un observer-como-entidad? Tres razones:

  1. Ciclo de vida atado a otra entidad. Si spawneas un enemigo "jefe" y quieres que solo mientras exista como jefe, sus ataques disparen un evento especial. Spawneas el observer junto con el jefe; cuando el jefe muere y se despawnea, el observer también.
  2. Datos asociados. La entidad-observer puede tener componentes propios (configuración, estado, referencias). El observer los lee con query.get(observer_entity).
  3. Filtros dinámicos. Puedes despawnear y re-spawnear observers con diferentes firmas según el modo de juego.

La sintaxis moderna en 0.18+ usa Observer::new(closure). El closure tiene la misma forma que un observer tradicional, con On<E> como primer argumento y deps adicionales.

10.8.2 Stack completo: hooks + observers + eventos globales

Cerramos con un resumen del stack que tienes disponible para reaccionar a cambios en Bevy 0.18/0.19, de menor a mayor alcance:

MecanismoCuándoDatos accesiblesMúltiples instanciasCaso típico
Hook (#[component(on_insert)])Inserción / reemplazo / remoción de un componente concretoDeferredWorld + entidad + ComponentIdNo (uno por hook declarado)Clamps, sincronización local, normalización
Observer sobre componente (On<Add, T>)Cambio de un componente, registrado como función globalTodo (sistema completo)Logs, side-effects cross-cutting
Observer sobre EntityEvent (On<MyEvent>)Evento custom disparado por códigoDatos del evento + deps del sistemaSí (N observers por evento)Muerte, daño, spawn, transiciones
Observer como entidad (Observer::new)Evento, atado a una entidad concretaTodo, más estado del observerSí (una entidad por filtro)Buffs temporales, debug, modos de juego
Sistema con Changed<T>Cada frame, todos los cambiosTodoN/A (es un sistema)Lógica continua que necesita ver cambios recientes

La regla nemotécnica: hooks = invariantes; observers = reacciones; sistemas = flujo continuo. Cuando dudes, pregúntate "¿esto vigila una propiedad (hook) o reacciona a una transición (observer)?"

Lo que vimos

En el siguiente

En el cap 11 subimos a los tres primitivos que llevan los observers al siguiente nivel: bubbling (que un observer en un hijo reciba el evento del padre), run conditions sobre observers (filtros dinámicos que decidimos en runtime), y Relationship/RelationshipTarget (el grafo de aristas tipadas que hace todo lo anterior posible). Si el cap 9 era "vigila una propiedad", el cap 10 era "reacciona a un cambio", el cap 11 es "reacciona a un cambio en mi entidad, en mi padre, o en mi hijo, pero solo si se cumple esta condición". Y el cap 12 cambia completamente de tercio: bsn!, una macro para definir escenas enteras en una sola línea. Programa doble café.