Tests verificados contra la API real de Bevy 0.19.
Ver código y tests en GitHub →
— Carter, sobre el modelo observer en flecs (2017).
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 entityes 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 entity 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 evento se dispara (world.trigger o commands.trigger), 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":
world.observe(...) dentro de sí mismo (observer local al sistema).Observer (observer "en el mundo", que puede persistir y consultar entityes).world.trigger(event) para disparar el evento manualmente.Bevy 0.16 introdujo On<E> como primitiva de primera clase. Bevy 0.18 lo consolidó con On<E> y Event. 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.
#[derive(Event)]: el evento que lleva una entityHay dos grandes familias de eventos en Bevy:
Event: evento "global". No asociado a una entity concreta. commands.trigger(MyEvent) lo lanza, todos los observers lo reciben.Event: evento asociado a una entity específica. Lleva un campo entity: Entity obligatorio. commands.trigger(MyEvent { entity, .. }) lo lanza sobre esa entity; los observers la reciben.Para nuestro ejemplo de minas, lo natural es un Event:
//! cap-10 — sección 10.2: definir el event.
use bevy::prelude::*;
/// Event: una mine ha explotado.
#[derive(Event, Debug)]
pub struct MineExploded {
pub entity: Entity, // <-- obligatorio: la mine que explotó.
pub position: Vec2,
pub radius: f32,
pub damage: u32,
}
El derive hace tres cosas:
Event y Event.commands.trigger(MineExploded { entity, .. }) lance el evento.Nota técnica:
#[derive(Event)]requiere que el struct tenga un campoentity: Entity(o#[event_target]sobre otro campo). Sin él, Bevy no compila.
On<E>: cómo se ve un observerEn Bevy 0.19, todo observer tiene como primer parámetro On<E>, donde E es el tipo de evento que escucha. Este parámetro te da acceso al evento disparado y a su contexto:
//! cap-10 — sección 10.3: observer con On (Bevy 0.19).
fn on_mine_exploded(
on: On<MineExploded>,
mut explosion_commands: Commands,
) {
let event = on.event(); // <-- acceso al payload.
info!("BOOM en {:?} con radius {}", event.position, event.radius);
// Podemos spawn damage, partículas, etc.
explosion_commands.spawn(/* ... */);
}
On<E> es el único tipo válido para el primer parámetro de un observer en Bevy 0.19. (En versiones anteriores existía también Trigger<E>, pero fue reemplazado por On<E> en 0.18 y eliminado en 0.19.)
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.
World::trigger vs Commands::triggerTienes dos puertas para emitir un evento:
//! cap-10 — sección 10.4: trigger desde World y desde Commands.
fn immediate_system(world: &mut World, mine: Entity) {
// Disparo SÍNCRONO. Los observers corren antes de que esta línea retorne.
world.trigger(MineExploded {
entity: mine,
position: Vec2::ZERO,
radius: 50.0,
damage: 100,
});
}
fn deferred_system(mut commands: Commands, mine: Entity) {
// Disparo DIFERIDO. Se ejecuta al final del frame, al apply commands.
commands.trigger(MineExploded {
entity: mine,
position: Vec2::ZERO,
radius: 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 entity que tú estás iterando, tienes un conflicto de borrow. 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::triggerpor defecto. UsaWorld::triggersolo cuando necesites la reacción inmediata (ej. un observer que registra otras entityes como listeners).
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 event.
fn register_observers(app: &mut App) {
// Observer 1: spawn partículas.
app.add_observer(on_mine_particles);
// Observer 2: apply damage a entityes en el radius.
app.add_observer(on_mine_damage);
// Observer 3: emitir event de audio.
app.add_observer(on_mine_audio);
// Observer 4: sumar al counter de stats.
app.add_observer(on_mine_stats);
}
fn on_mine_particles(on: On<MineExploded>, mut commands: Commands) {
commands.spawn(/* sprite de explosión */);
}
fn on_mine_damage(
on: On<MineExploded>,
mut commands: Commands,
query: Query<(Entity, &Transform), With<Vulnerable>>,
) {
let event = on.event();
for (e, t) in &query {
if t.translation.truncate().distance(event.position) < event.radius {
commands.trigger(DamageReceived {
entity: e,
amount: event.damage,
});
}
}
}
fn on_mine_audio(
on: On<MineExploded>,
server: Res<AudioServer>,
) {
server.play_sfx("explosion.ogg");
}
fn on_mine_stats(
on: On<MineExploded>,
mut stats: ResMut<GameStats>,
) {
stats.mines_exploded += 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.
Además de eventos custom, Bevy dispara observers automáticamente cuando un componente se inserta, reemplaza o remueve en una entity. 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.current, h.max);
}
}
// Message para game over (usa el trait Message, no Event).
struct GameOver;
impl Message for GameOver {}
fn on_player_removed(
trigger: On<Remove, Player>,
mut game_over: MessageWriter<GameOver>,
) {
info!("Player {:?} eliminado. Game over.", trigger.entity());
game_over.send_default();
}
// Registrar:
app.add_observer(on_health_added);
app.add_observer(on_player_removed);
Las tres variantes del primer parámetro genérico son:
On<Add, T>: disparado cuando T se añade por primera vez.On<Insert, T>: disparado en cada inserción (incluye reemplazos).On<Replace, T>: disparado justo antes de un reemplazo.On<Remove, T>: disparado cuando T se remueve.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.
Cerramos con un ejemplo realista. Tenemos un campo de minas en un juego de acción 2D. Cada mina es una entity con Mine, Transform, TriggerRadius. Cuando el jugador entra en su radio, explota.
//! cap-10 — sección 10.7: campo de mines complete.
use bevy::prelude::*;
#[derive(Component)]
struct Mine {
damage: u32,
explosion_radius: f32,
trigger_radius: f32,
}
#[derive(Component)]
struct TriggerRadius(f32);
#[derive(Component)]
struct Player;
#[derive(Event, Debug)]
struct MineExploded {
entity: Entity,
position: Vec2,
radius: f32,
damage: u32,
}
#[derive(Event, Debug)]
struct DamageReceived {
entity: Entity,
amount: u32,
}
Sistemas:
//! cap-10 — sección 10.7: spawn + system de proximidad.
fn spawn_mine(mut commands: Commands, transform: Transform) {
commands.spawn((
Mine { damage: 50, explosion_radius: 60.0, trigger_radius: 20.0 },
Transform::from_translation(transform.translation),
));
}
fn detect_proximity(
mines: Query<(Entity, &Transform, &Mine)>,
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 (mine_e, t, m) in &mines {
let distance = t.translation.truncate().distance(player_pos);
if distance <= m.trigger_radius {
commands.trigger(MineExploded {
entity: mine_e,
position: t.translation.truncate(),
radius: m.explosion_radius,
damage: m.damage,
});
// La mine se autodestruirá en otro observer (más abajo).
}
}
}
Observers:
//! cap-10 — sección 10.7: cuatro observers sobre MineExploded.
fn explosion_particles(
on: On<MineExploded>,
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.position.extend(0.0)),
// Animación: se despawná tras 0.5 s.
Lifetime { remaining: 0.5 },
));
}
fn explosion_damage(
on: On<MineExploded>,
mut commands: Commands,
victims: Query<(Entity, &Transform), With<Vulnerable>>,
) {
let ev = on.event();
for (e, t) in &victims {
let d = t.translation.truncate().distance(ev.position);
if d <= ev.radius {
commands.trigger(DamageReceived {
entity: e,
amount: ev.damage,
});
}
}
}
fn explosion_audio(
on: On<MineExploded>,
audio: Res<bevy_kira_audio::Audio>,
) {
audio.play(bevy_kira_audio::AudioSource::new("boom.ogg"));
}
fn mine_despawn(
on: On<MineExploded>,
mut commands: Commands,
) {
commands.entity(on.entity).despawn();
}
#[derive(Component)]
struct Lifetime { remaining: 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| {
// Player
commands.spawn((Player, Transform::default(), Vulnerable));
// Mines
commands.spawn((
Mine { damage: 50, explosion_radius: 60.0, trigger_radius: 20.0 },
Transform::from_xyz(100.0, 0.0, 0.0),
));
})
.add_systems(Update, detect_proximity)
// Registrar observers:
.add_observer(explosion_particles)
.add_observer(explosion_damage)
.add_observer(explosion_audio)
.add_observer(mine_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 events 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 event ocurre.
`Event` (trait): event "global" en Bevy, no asociado a una entity.
`Event` (trait): event asociado a una entity concreta; requiere campo `entity: Entity` o `#[event_target]`.
`On<E>` (tipo): argumento de un observer que envuelve el evento disparado.
`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 entity.
`On<Remove, T>` (tipo): observer disparado cuando `T` se remueve de una entity.
`app.add_observer(fn)` (API): registra una función como observer sobre el evento declarado en su firma.
Vale la pena cuantificar el ahorro. Imagina 10.000 entityes con Health. Con pull:
fn pull_query(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 entityes, 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 entityes 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.
Dos bugs típicos cuando empiezas con observers:
Bug 1: observer que asume componente presente. Tu evento es MineExploded { entity, .. }, pero el observer hace query.get(entity) sobre Mine. 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 counter de profundidad.
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.
Problema. Una entity cambia de estado (muere, explota, se transforma). Múltiples sistemas dispersos 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 Event 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(Event)]
struct EntityDied {
entity: Entity,
cause: DeathCause,
}
fn on_death_particles(on: On<EntityDied>, mut c: Commands) { /* ... */ }
fn on_death_audio(on: On<EntityDied>, audio: Res<Audio>) { /* ... */ }
fn on_death_stats(on: On<EntityDied>, mut s: ResMut<Stats>) { /* ... */ }
app.add_observer(on_death_particles)
.add_observer(on_death_audio)
.add_observer(on_death_stats);
Cuándo sí.
Cuándo no.
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 entity del mundo:
//! cap-10 — sección 10.8.1: observer como entity.
fn spawn_curious_observer(mut commands: Commands, target: Entity) {
// Esta entity "vigila" a `target`. Si target recibe Damage,
// el observer se ejecuta. Si la entity-observer se despawea,
// deja de watch.
commands.spawn(Observer::new(
|on: On<DamageReceived>, mut stats: ResMut<DebugStats>| {
stats.damage_events += 1;
}
));
}
¿Por qué querrías un observer-como-entity? Tres razones:
query.get(observer_entity).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.
Cerramos con un resumen del stack que tienes disponible para reaccionar a cambios en Bevy 0.18/0.19, de menor a mayor alcance:
| Mecanismo | Cuándo | Datos accesibles | Múltiples instancias | Caso típico |
|---|---|---|---|---|
Hook (#[component(on_insert)]) | Inserción / reemplazo / remoción de un componente concreto | DeferredWorld + entity + ComponentId | No (uno por hook declarado) | Clamps, sincronización local, normalización |
Observer sobre componente (On<Add, T>) | Cambio de un componente, registrado como función global | Todo (sistema completo) | Sí | Logs, side-effects cross-cutting |
Observer sobre Event (On<MyEvent>) | Evento custom disparado por código | Datos del evento + deps del sistema | Sí (N observers por evento) | Muerte, daño, spawn, transiciones |
Observer como entity (Observer::new) | Evento, atado a una entity concreta | Todo, más estado del observer | Sí (una entity por filtro) | Buffs temporales, debug, modos de juego |
Sistema con Changed<T> | Cada frame, todos los cambios | Todo | N/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)?"
Event (global, no asociado a una entity) y Event (lleva un campo entity: Entity obligatorio). #[derive(Event)] no compila sin ese campo.On<E>, donde E es el tipo de evento. En Bevy 0.19, On<E> es el único tipo válido (el antiguo Trigger<E> fue eliminado). Los argumentos extra del observer funcionan como en un sistema normal.World::trigger dispara síncronamente (los observers corren antes de retornar); Commands::trigger dispara diferido (al final del frame). Usa Commands::trigger por defecto.On<Add, T>, On<Insert, T>, On<Remove, T>) son el equivalente "externo" de los hooks del cap. 9. Pueden ser varios por hook declarado.commands.spawn(Observer::new(closure))), con ciclo de vida, datos y filtros dinámicos.Changed<T>.En el cap 11 subimos a los tres primitivos que llevan los observers al siguiente level: 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 entity, 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é.