Capítulo 9C. La caja de herramientas 2D de Bevy

CAP 9C · Bevy 0.19 · avian2d 0.7 · leafwing-input-manager 0.21 · bevy_hanabi 0.19 · bevy_tweening 0.16

"No reinventes la rueda. Bevy ya te regaló un coche entero; lo único que tendrías que hacer es aprender dónde están los pedales."

— Una voz en el Discord de Bevy, cuando un novato intentaba escribir su propio sistema de jerarquía.

Acabas de pasar por los capítulos de ECS puro (4–9B). Ya sabes qué es un componente, qué es un sistema, qué es una relación, qué es un hook. Sabes cómo se programa en Bevy. Lo que todavía no sabes es qué te llevas gratis cuando escribes App::new().add_plugins(DefaultPlugins). Y eso importa, porque es justo ahí donde casi todo el mundo tropieza: gente reescribiendo Transform, reimplantando un sistema de eventos buffered, inventando un observer para el click, programando un tween a mano, escribiendo un pick system con raycasts sobre sprites… todo eso ya existe.

Este capítulo es la caja de herramientas: un inventario conversacional de lo que Bevy 0.19 trae de fábrica, qué hace cada plugin, cómo se llaman las APIs esta semana (porque en Bevy, a veces, cambian), y cuándo conviene irse al ecosistema en lugar de seguir con el nativo. Para que no sea teoría, lo construimos todo sobre una sola demo incremental llamada "Clicker Squared": empiezas con una ventana vacía y, sección a sección, le vas sumando primitivas hasta tener ~200 líneas que usan ocho subsistemas distintos.

Glosario rápido del capítulo.

  • Plugin nativo: el que viene con DefaultPlugins (lo pones a cero configuración).
  • Plugin del ecosistema: crate externo (avian, hanabi, leafwing…) que se añade con add_plugins.
  • Required component: componente que se inserta solo porque otro lo exige (cap. 8).
  • Lifecycle event: Add, Insert, Discard, Remove, Despawn. Se observan con Trigger<E>.
  • BSN: Bevy Scene Notation, la macro bsn!{...} para declarar escenas en código.

Antes de empezar, una nota sobre versiones. Todo el código de este capítulo compila contra bevy = "0.19". Cuando usemos crates del ecosistema, los anotamos con la versión verificada en julio de 2026: avian2d = "0.7", leafwing-input-manager = "0.21", bevy_hanabi = "0.19", bevy_tweening = "0.16". Si copias el código tal cual y te falla el cargo build, lo primero a mirar es la versión del crate del ecosistema: los crates del ecosistema NO siguen la numeración de Bevy (pueden estar en 0.7 mientras Bevy está en 0.19).

9C.1 Lo que ya tienes gratis con DefaultPlugins

Vamos a empezar por lo más obvio, pero que casi nadie mira con detalle. Cuando escribes esto:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

…no estás añadiendo "un motor". Estás añadiendo treinta y pico plugins encadenados, cada uno responsable de un subsistema distinto. Los más relevantes para 2D, con lo que hacen:

PluginTe regala…
WindowPluginUna ventana con evento loop, input crudo, multitouch.
InputPluginButtonInput<KeyCode>, ButtonInput<MouseButton>, Axis<GamepadAxis>, Gamepads.
TimePluginEl recurso Time con delta, instant, reloj virtual pausable y fixed-step.
TransformPlugin + HierarchyPluginTransform, GlobalTransform, propagación automática padre→hijo.
SpritePluginRender de sprites, TextureAtlas, Text2d, Anchor.
CameraPluginCámara 2D/3D, RenderLayers, Projection.
TextPluginTipografía con Parley (nuevo en 0.19, antes era Cosmic Text), fuentes, TextFont, TextLayout.
UiPlugin + InputDispatchPluginbevy_ui moderno (en 0.19 ahora viene por defecto; antes había que pedirlo).
AudioPluginAudioPlayer, PlaybackSettings, AudioSink, GlobalVolume. Rodio por debajo.
AssetPluginAssetServer, Assets<T>, hot-reload opcional con feature file_watcher.
PickingPlugin (default en 0.18/0.19)Pointer<E> events, backends para Sprite/Mesh/UI. Click sin raycast manual.
StatesPluginState<S>, NextState, OnEnter/OnExit, DespawnOnEnter/DespawnOnExit.
ScenePluginEl nuevo sistema BSN (bsn!{...} y world.spawn_scene).
ImagePlugin, MeshPluginCarga y registro de Image y Mesh como assets.
LogPlugin, FrameCountPlugin, TaskPoolPlugin, DiagnosticsPluginLogging, contador de frames, pools multihilo, métricas internas.

El detalle que más gente se pierde: en 0.19, bevy_ui y bevy_picking ahora vienen activados por defecto. Antes, en 0.17/0.18, tenías que habilitar la feature bevy_ui explícitamente. Y picking se upstreamed en 0.18. Así que si vienes de un tutorial viejo, ya puedes borrar esos features = ["bevy_ui"] del Cargo.toml.

El schedule Main en una página

Estos plugins no viven en el vacío: registran sistemas en puntos muy concretos del schedule. Conocer ese schedule es lo que te permite decidir dónde poner tu sistema. En 0.19, el schedule principal tiene esta estructura (simplificada):

// Schedule de un frame típico en Bevy 0.19 (orden cronológico)
First              // un punto fijo al inicio; casi nada va aquí
  └─ PreUpdate     // input, picking, preparación de datos
       └─ StateTransition  // cambios de estado (corre OnEnter/OnExit)
  └─ RunFixedMainLoop       // 0..N ciclos FixedUpdate (física, lógica fija)
       └─ FixedUpdate
  └─ Update        // aquí vive el 90% de tu gameplay
  └─ SpawnScene    // spawn de escenas BSN resueltas
  └─ PostUpdate    // transform propagate, clonar escenas, detección de cambios
       └─ TransformSystems::Propagate
Last               // limpieza, sleep, present

La regla mnemotécnica: gameplay en Update, física/tick fijo en FixedUpdate, render-prep en PostUpdate. Si pones la física en Update, perdón: en el momento que el framerate caiga a 30fps, tu simulación se hace dos veces más lenta. Lo verás en M5.

Event vs Message: el split de 0.16/0.17 que sigue vivo en 0.19

Aquí hay una distinción que el resto del libro había tratado por encima y que conviene fijar de una vez. En Bevy 0.19 coexisten dos cosas que se parecen pero no son lo mismo:

Event (observers)Message (buffered queue)
Derive#[derive(Event)]#[derive(Message)]
Registrose observa con .add_observerapp.add_message::<T>()
Escrituracommands.trigger(E)MessageWriter::write
Lecturaobserver Trigger<E>MessageReader en el siguiente frame
Timingsíncrono, en el punto del triggerbuffered, leído en frames posteriores

Ojo con la trampa histórica: la vieja API app.add_event::<T>() + EventWriter::send sigue existiendo como compatibilidad para el patrón buffered, pero la documentación actual te anima a migrar a Message para cosas que son estrictamente "cola de eventos entre frames" y a reservar Event para cosas que disparan observers. Mnemotecnia: si la pregunta es "¿esto reacciona algo ahora mismo cuando lo disparo?", es Event; si es "¿esto lo van a leer varios sistemas en los próximos frames?", es Message.

Metedura de pata común. Mezclar add_message con EventReader, o add_event con MessageReader. No compila, pero el mensaje del compilador es confuso porque los traits se parecen. Si te dice algo como "expected Message, found Event", ya sabes qué hacer: revisa el derive y el método de registro.

Ahora sí, vamos a la demo. M0: una ventana que dice hola.

// M0 — Clicker Squared: hola, ventana.
// Cargo.toml: bevy = "0.19"
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Clicker Squared".into(),
                resolution: (800., 600.).into(),
                ..default()
            }),
            ..default()
        }))
        .add_systems(Startup, spawn_camera)
        .run();
}

fn spawn_camera(mut commands: Commands) {
    commands.spawn(Camera2d::default()); // ← ya viene con Projection ortográfica
}

Compila, abre una ventana negra con título. Eso es M0. Todo lo demás del capítulo son capas que añadimos encima de estas cuatro líneas. La cámara ya trae su proyección 2D gratis; no tienes que montar un bundle ni preocuparte por el aspect ratio todavía.

9C.2 Entidad 2D mínima: Sprite con required components

La entidad 2D más útil del mundo es un cuadrado de color. En Bevy 0.19 se hace así:

// M1 — Clicker Squared: un cubo azul.
fn spawn_cubo(mut commands: Commands) {
    commands.spawn((
        Sprite::from_color(Color::srgb(0.3, 0.6, 1.0), Vec2::splat(80.)),
        Transform::from_translation(Vec3::new(0., 0., 1.)),
    ));
}

Y listo. Sin bundle, sin SpriteBundle (que está deprecated desde hace varias releases). El secreto es que Sprite en 0.19 tiene required components declarados con #[require(...)], así que al spawnear Sprite solo, Bevy inserta automáticamente:

Si los pones a mano, no pasa nada; Bevy ve que ya están y no los reinserta. Si los olvidas, aparecen con su valor por defecto. Esa es la magia de los required components del cap. 8.

Campos útiles de Sprite

let s = Sprite {
    image:        asset_server.load("icono.png"), // opcional: si no hay, sale el color sólido
    color:        Color::srgb(1.0, 0.4, 0.4),     // multiplica la textura (tinte)
    custom_size:  Some(Vec2::new(64., 64.)),      // fuerza tamaño en unidades mundo
    flip_x:       false,
    flip_y:       false,
    anchor:       Anchor::Center,                  // o TopLeft, BottomRight, Custom(vec)
    texture_atlas: None,                           // Alguno(TextureAtlas) si es atlas
};

Dos detalles que confunden a los recién llegados:

  1. El tamaño del sprite. Si hay image y no hay custom_size, el sprite sale del tamaño natural de la textura en píxeles (1 unidad mundo = 1 píxel). Si pones custom_size, se reescala a ese tamaño. Si solo hay color (sin imagen), custom_size es obligatorio: si no, el sprite no tiene área.
  2. texture_atlas es un campo de Sprite, no un componente separado. Antes (pre-0.15) era TextureAtlas suelto; desde 0.15 va dentro del propio Sprite. Constrúyelo con TextureAtlasLayout::from_grid(tile_size, cols, rows, padding, offset) y guárdalo en Assets<TextureAtlasLayout>.

¿Y si quieres 9-patch o tiling? Hay un SpriteImageMode con variantes Stretch (default), Tiled { tile_x, tile_y, stretch_value } y Sliced { slices, center_scale, sides_scale, corners_scale, flip_x, flip_y }. Este último es el famoso 9-slice de las UI: usas una imagen de 32×32 y Bevy la estira solo por el centro para que los bordes no se deformen.

Metedura de pata común. Usar MaterialMesh2dBundle para un sprite estático. Sprite está optimizado a nivel de renderer para una imagen con tinte; si lo cambias por un mesh + material, pierdes automáticamente instancing y culling. Solo usa mesh 2D cuando de verdad necesites geometría custom (polígonos animados, efectos de shader raros). Para lo demás, Sprite.

Ahora tu M1 debería verse: ventana 800×600, un cuadrado azul de 80×80 píxeles centrado. Avancemos.

9C.3 Transform, jerarquía y propagación

El siguiente movimiento: que el cubo lleve una etiqueta encima. Una etiqueta con un número "5". Esto es la excusa perfecta para ver la jerarquía de Bevy.

En Bevy 0.19, la jerarquía es una relación (lo del cap. 9B). La pareja canónica:

// En bevy::hierarchy (no tienes que escribir esto; ya está en el prelude).
#[derive(Component)]
#[relationship(relationship_target = Children)]
pub struct ChildOf(pub Entity);

#[derive(Component)]
#[relationship_target(relationship = ChildOf, linked_spawn)]
pub struct Children(Vec<Entity>);

Lo importante: cuando una entidad hija tiene ChildOf(padre), su Transform se interpreta en el espacio del padre. El sistema TransformSystems::Propagate (corre en PostUpdate) recorre el árbol y calcula el GlobalTransform de cada uno acumulando matrices. Tú no propagas nada a mano.

Hay tres APIs para construir jerarquías, todas válidas:

// (1) Con Commands, método .with_children
commands.spawn(Sprite::from_color(Color::WHITE, Vec2::splat(80.)))
    .with_children(|parent| {
        parent.spawn((
            Text2d("5".into()),
            Transform::from_xyz(0., 60., 0.1), // 60 px arriba del cubo
        ));
    });

// (2) Con Commands, API fluent
let padre = commands.spawn(Sprite::from_color(Color::WHITE, Vec2::splat(80.))).id();
let hija   = commands.spawn((Text2d("5".into()), Transform::from_xyz(0., 60., 0.1))).id();
commands.entity(padre).add_child(hija);

// (3) Con World directo (menos común en sistemas)
world.send::(AddChild { child: hija, parent: padre });

Tres cosas que conviene fijar:

  1. El hijo hereda el Transform del padre. Si mueves el padre, el hijo se mueve con él. Si giras el padre 45°, el hijo también gira 45° alrededor del padre. Si escalas el padre ×2, el hijo se ve el doble de grande y el doble de lejos.
  2. linked_spawn en la definición de Children significa que si despawneas al padre, los hijos se despawnean con él. Es opt-in: si una relación no lo trae, el padre muere solo.
  3. El orden Z de los hermanos es el de inserción. Para crear capas explícitas, usa el campo Transform.translation.z o, mejor, RenderLayers (lo verás en §9C.5).

Hay una trampa sutil: en 0.19 Bevy rechaza por defecto las relaciones que apuntan a la propia entidad. Es decir, ChildOf(self) da panic. Eso es nuevo; antes se tragaba ciclos y luego reventaba la propagación. Si migras de 0.18, tenlo en cuenta.

Vamos a montar el M2 sobre el M1, sólo añadiendo un hijo Text2d al cubo.

// M2 — Clicker Squared: cubo con etiqueta "5" que lo sigue.
use bevy::sprite::Text2d;  // ← path correcto en 0.19 (NO bevy::text::Text2d)

#[derive(Component)]
struct Cubo;

fn spawn_cubo(mut commands: Commands) {
    commands.spawn((
        Cubo,
        Sprite::from_color(Color::srgb(0.3, 0.6, 1.0), Vec2::splat(80.)),
        Transform::from_xyz(0., 0., 1.),
    ))
    .with_children(|p| {
        p.spawn((
            Text2d("5".to_string()),
            // (TextFont y TextLayout vienen con valores por defecto; los vemos en §9C.4)
            Transform::from_xyz(0., 60., 0.1),
        ));
    });
}

Compila, y la etiqueta "5" aparece 60 píxeles por encima del cubo, en la fuente por defecto. Si mueves el cubo, la etiqueta sigue. Propagación gratis. Ya entendiste el 70% de lo que te ahorras al no reimplementar jerarquías.

9C.4 Texto en 2D: Text2d (no Text)

El spawn de la sección anterior tenía un Text2d("5".to_string()) que nos saltamos explicar. Vamos a ello. Esto es una de las confusiones más clásicas del libro, así que vale la pena clavarlo con clavos.

Bevy 0.19 tiene dos componentes de texto distintos:

ComponenteVive enSirve para
Text2dbevy::sprite::Text2dTexto en el mundo 2D: se mueve con la cámara, se propaga con el Transform, puede estar detrás o delante de un sprite.
Textbevy::ui::TextTexto dentro de un nodo de UI (bevy_ui). Vive en el layout, no en el mundo.

Si intentas hacer Text en el mundo (sin un Node), no compila o no se ve. Si intentas hacer Text2d dentro de un nodo de UI, lo mismo. Son componentes distintos para casos distintos. El path correcto para mundo 2D es bevy::sprite::Text2d (no bevy::text::Text2d, que no existe; bevy::text aloja los componentes compartidos como TextFont, TextLayout, TextColor).

Los acompañantes de Text2d

Un texto en mundo se compone de hasta cuatro componentes:

use bevy::sprite::Text2d;
use bevy::text::{TextFont, TextLayout, TextColor, LineBreak, JustifyText};

commands.spawn((
    Text2d("Hola mundo".into()),
    TextFont {
        font: asset_server.load("fonts/PressStart2P.ttf"),
        font_size: 24.0,
        ..default()
    },
    TextLayout::new_with_justify(JustifyText::Center).with_linebreak(LineBreak::WordBoundary),
    TextColor(Color::WHITE),
    Transform::from_xyz(0., 100., 1.),
));

Cada uno controla una cosa:

La fuente por defecto

Bevy 0.19 incluye una fuente por defecto embebida en el binario: FiraMono-subset.ttf. Por eso M2 funcionaba sin cargar nada. Útil para prototipos; no la uses en producción: si tu juego va en español con tildes, en cirílico, en árabe o en chino, ese subset no tiene los glifos. Carga tu propia fuente con asset_server.load("fonts/mifuente.ttf").

En 0.19 se migró todo el sistema de texto de Cosmic Text a Parley. Para ti, usuario final, el cambio se nota en: mejor soporte de fuentes variables, selección de texto funcionando en UI, y un nuevo componente EditableText para campos de input (en bevy::text::EditableText, con método .value() para leer el contenido). El método .text() no existe; es .value().

Metedura de pata común. Importar use bevy::text::Text2d;. El compilador te dirá "no Text2d in bevy::text". El path correcto es bevy::sprite::Text2d. Lo mismo con TextStyle: no existe en 0.19; los estilos van en TextFont.

La demo M2 ya no nos hace falta tocar nada más en esta sección: Text2d("5") con TextFont::default() y la fuente embebida basta. Pero si quieres que se vea más bonito, prueba:

// M2 (mejorado) — texto más grande y centrado
p.spawn((
    Text2d("5".to_string()),
    TextFont { font_size: 40.0, ..default() },
    TextColor(Color::WHITE),
    Transform::from_xyz(0., 60., 0.1),
));

9C.5 Cámara 2D y proyección

Volvamos a la cámara del M0. Spawnear una cámara 2D en 0.19 es trivial:

commands.spawn(Camera2d::default());

Esto inserta automáticamente Camera, CameraRenderConfig, una Projection::Orthographic(OrthographicProjection::default_2d()), y Transform::IDENTITY. Otra vez: required components haciendo su magia.

OrthographicProjection y ScalingMode

Para 2D casi nunca necesitas tocar la proyección. Pero cuando tu juego se escala a distintas resoluciones, hay una decisión de diseño: ¿cuánto del mundo se ve? Ahí entra ScalingMode:

use bevy::camera::{OrthographicProjection, ScalingMode, Projection};

// Variante A: el mundo se ve siempre igual de ancho (en unidades),
// sin importar la resolución. Ideal para juegos tipo Plataformer.
commands.spawn((
    Camera2d::default(),
    Projection::Orthographic(OrthographicProjection {
        scaling_mode: ScalingMode::FixedVertical { viewport_world_size: 360. },
        ..default()
    }),
));

// Variante B: 1 unidad mundo == 1 píxel. Para juegos que se escalan por DPI.
// ScalingMode::WindowSize(1.0)

// Variante C: que se vea siempre lo máximo posible sin estirar. Útil para top-down.
// ScalingMode::AutoMin { min_width: 800., min_height: 600. }

Metedura de pata que se repite siempre. Llamar a projection.with_scaling(2.0) con un f32. No compila. with_scaling recibe un ScalingMode (un enum), no un f32. Y el campo OrthographicProjection::scale fue eliminado hace tiempo (PR #15075). Si quieres zoom, usa ScalingMode::WindowSize(z) o muta el Transform.scale de la cámara.

RenderLayers para aislar capas

A veces quieres que la cámara principal vea el mundo pero no el HUD, o viceversa. Para eso están los RenderLayers: un bitmask (32 capas en 0.19) que permite filtrar qué entidades ve cada cámara.

use bevy::render::view::RenderLayers;

// Cámara 1: mundo (capa 0)
commands.spawn((
    Camera2d::default(),
    RenderLayers::layer(0),
));

// Cámara 2: HUD (capa 1)
commands.spawn((
    Camera2d::default(),
    Camera::default(), // order determina qué se dibuja encima
    RenderLayers::layer(1),
));

// Una entidad solo visible para la cámara 1
commands.spawn((
    Sprite::from_color(Color::RED, Vec2::splat(40.)),
    RenderLayers::layer(0),
));

RenderLayers no son una feature nueva de 0.18 ni 0.19; existen desde hace muchas releases, aunque a veces el libro se confunde y las atribuye a versiones equivocadas. Son estables y solemnes. Constrúyelas con RenderLayers::layer(n) (una sola capa) o RenderLayers::from_layers(&[0, 2, 5]) (varias).

M3 (la sección siguiente) es donde por fin le damos vida al cubo: lo hacemos clickable. Antes de eso, actualiza tu M0 para que use una proyección explícita si quieres probar la cámara con FixedVertical — pero para la demo nos basta con el Camera2d::default() del M0.

9C.6 Color: 10 espacios en un enum

Hasta ahora hemos estado escribiendo Color::srgb(0.3, 0.6, 1.0) sin explicarlo. El tipo Color de Bevy 0.19 es un enum con diez variantes, una por espacio de color:

pub enum Color {
    Srgba(Srgba),       // SRGB con alfa (lo más común para UI/texto)
    LinearRgba(LinearRgba), // espacio lineal con alfa (shaders, lighting)
    Hsla(Hsla),         // HSL clásico
    Hsva(Hsva),         // HSV
    Hwba(Hwba),         // HWB
    Laba(Laba),         // CIELAB
    Lcha(Lcha),         // LCH
    Oklaba(Oklaba),     // OKLab (perceptualmente uniforme)
    Oklcha(Oklcha),     // OKLCH (el preferido de los diseñadores modernos)
    Xyza(Xyza),         // CIEXYZ
}

Los constructores que más vas a usar:

// RGB clásico (0..1)
let rojo: Color = Color::srgb(1.0, 0.0, 0.0);            // opaco
let semi: Color = Color::srgba(1.0, 0.0, 0.0, 0.5);       // 50% transparente

// RGB en bytes (0..255)
let verde: Color = Color::rgb_u8(0, 255, 0);

// Espacios perceptuales
let pastel: Color = Color::hsl(0.6, 0.4, 0.7);            // HSL
let moderno: Color = Color::oklch(0.7, 0.15, 240.);       // OKLCH

// Constantes (en Srgba)
Color::WHITE, Color::BLACK, Color::RED, Color::GREEN, Color::BLUE,
Color::CYAN, Color::MAGENTA, Color::YELLOW, Color::NONE  // NONE = transparente

Cuál usar y cuándo

Para…Usa…Porque…
UI, sprites, texto, almacenar coloresSrgbaEs lo que entienden los formatos (PNG, CSS) y tu monitor.
Shaders WGSL, iluminación, blendingLinearRgbaLas matemáticas de luz solo son correctas en espacio lineal.
Generar paletas en runtimeOklcha o LchaPerceptualmente uniformes: cambiar el hue rota el color sin cambiar el brillo percibido.
Decir "rojo + brillante"Hsla o HsvaIntuitivo para artistas, aunque no perceptualmente perfecto.

El error clásico de los recién llegados: usar Color::srgb dentro de un shader y luego verlo "lavado" o demasiado oscuro. Eso pasa porque srgb está en espacio gamma: cuando lo multiplicas por una luz, el resultado está mal. Regla de oro: almacena en srgb, opera en linear, devuelve a srgb al final. Bevy lo hace automáticamente cuando pones Hdr: true en la cámara y la tubería hace el tonemap.

Para Clicker Squared, vamos a hacer que al clickar el cubo, su color cambie a un tono aleatorio perceptualmente uniforme:

// (Vista parcial; el sistema completo llega en §9C.8 con picking)
fn color_aleatorio() -> Color {
    let mut rng = rand::thread_rng();
    Color::oklch(0.7, 0.15, rng.gen_range(0...360.))
}

Más adelante lo integraremos al observer de click.

9C.7 Tiempo: 4 clocks, Timer, Stopwatch

Bevy tiene cuatro relojes distintos en 0.19, todos accesibles vía el recurso Time con distintos type parameters. Es fácil confundirse, así que pongámoslo en claro.

RelojTipoQué mideCómo se accede
RealTime<Real>Tiempo de pared (lo que dice el reloj del OS).Res<Time<Real>>
Virtual (default)Time<Virtual>Tiempo de juego; puede pausarse o escalarse.Res<Time> (sin genérico) o Res<Time<Virtual>>
FixedTime<Fixed>Tick fijo acumulado (e.g. 60 Hz sin importar framerate).Res<Time<Fixed>>
(efímero)el que venga de un paquete interno

El detalle que casi todos olvidan: cuando escribes Res<Time> a secas, estás leyendo Time<Virtual>. Eso significa que si pones time_wrapping_pause(true) en tu app, ese delta se vuelve cero. Si necesitas algo que siga corriendo aunque el juego esté en pausa (menús, animaciones de UI, timers de debug), usa Res<Time<Real>>.

Las APIs correctas en 0.19

fn sistema(time: Res<Time>) {
    let delta: f32 = time.delta_secs();      // ← delta_secs (NO delta_seconds, que ya no existe)
    let elapsed: f32 = time.elapsed_secs();   // segundos desde que arrancó
    let fps: f64 = time.fps();                // FPS medidos este frame
}

Metedura de pata histórica. Escribir time.delta_seconds(). En 0.19 el método se llama delta_secs() (singular, sin seconds). El cambio fue en 0.16 o así; algunas guías viejas siguen con delta_seconds y no compila. Lo mismo con elapsed_seconds()elapsed_secs().

Timer y Stopwatch

Para medir intervalos tienes dos estructuras de utilidad:

use bevy::time::{Timer, TimerMode, Stopwatch};

// Timer: cuenta atrás (o cuenta ascendente hacia una duración).
let mut t = Timer::new(std::time::Duration::from_secs(3), TimerMode::Once);
// Repeating para loops: TimerMode::Repeating
t.tick(time.delta());     // hay que llamarlo cada frame
if t.just_finished() { /* pasaron 3 segundos */ }
if t.finished() { /* sigue en su estado final */ }

// Stopwatch: mide tiempo transcurrido acumulado, sin "objetivo".
let mut sw = Stopwatch::new();
sw.tick(time.delta());
let elapsed: std::time::Duration = sw.elapsed();

La trampa: Timer no se actualiza solo. Tienes que llamar .tick(delta) en algún sistema, normalmente en Update. Si lo guardas como recurso, basta con un sistema dummy que lo ticke:

app.insert_resource(SpawnTimer(Timer::new(Duration::from_secs(2), TimerMode::Repeating)))
   .add_systems(Update, tick_spawn_timer);

fn tick_spawn_timer(time: Res<Time>, mut timer: ResMut<SpawnTimer>) {
    timer.0.tick(time.delta());
    if timer.0.just_finished() {
        // spawnear enemigo
    }
}

FixedUpdate vs Update

La pregunta de los 64 mil millones: ¿dónde pongo mi sistema de física o de lógica de gameplay? Respuesta corta: si afecta a simulación, en FixedUpdate. Si afecta a input o UI, en Update.

app.add_systems(FixedUpdate, (
    mover_personaje,
    aplicar_fisica,
    cooldowns_combate,
))
.add_systems(Update, (
    leer_input,
    actualizar_hud,
    animar_particulas_puras,
));

FixedUpdate corre en un bucle dentro de RunFixedMainLoop: si el frame tardó mucho, corre varias veces para "alcanzar" el tiempo perdido; si fue muy rápido, no corre. Esto garantiza que tu simulación avance a velocidad constante sin importar el framerate. Lo vas a necesitar en M5 (salto con física).

9C.8 Input clásico y picking unificado ★

Ahora sí, hagamos que el cubo responda al click. Hay tres maneras de leer input en Bevy 0.19; te las presento a las tres, en orden de comodidad.

(1) Input crudo: ButtonInput y Axis

La API más básica, perfecta para "detectar si la tecla está pulsada":

fn input_crudo(
    keys: Res<ButtonInput<KeyCode>>,
    mouse: Res<ButtonInput<MouseButton>>,
    gamepad_axes: Res<Axis<GamepadAxis>>,
    gamepads: Res<Gamepads>,
) {
    if keys.just_pressed(KeyCode::Space) {
        // saltar
    }
    if keys.pressed(KeyCode::KeyW) {
        // mover adelante
    }
    if mouse.just_released(MouseButton::Left) {
        // soltar
    }
    if let Some(gp) = gamepads.iter().next() {
        let lx = gamepad_axes.get(GamepadAxis::new(gp, GamepadAxisType::LeftStickX)).unwrap_or(0.);
        // usar lx
    }
}

Está bien para prototipos, pero no escala: cuando tienes 12 acciones distintas (saltar, agacharse, interactuar, abrir mapa, pausar, hacer dash…) acabas con un monster if. Ahí entra el ecosistema.

(2) Input por acciones: leafwing-input-manager / bevy_enhanced_input

Cuando tu juego crece, quieres definir "acciones" (Jump, Move, Interact) y mapearlas a cualquier tecla, botón o eje. Aquí hay dos contendientes:

CrateVersión (julio 2026)Estilo
leafwing-input-manager0.21Resource-based. Estable, documentado. Define Actionlike enum y un InputMap.
bevy_enhanced_input0.26Observer-based. Más moderno y flexible. Curva de aprendizaje algo mayor.

Para Clicker Squared usamos leafwing en M5 (cuando añadamos salto). Aquí nos basta con la tercera opción.

(3) Picking: Pointer events ★

Si lo que quieres es "saber cuándo han hecho click en esta entidad", no necesitas raycast. En 0.18/0.19 viene DefaultPickingPlugins activado por defecto, con backends para Sprite, Mesh y UI. Se interactúa vía observers sobre el evento Pointer<E>:

use bevy::picking::{Pointer, Pickable, pointer::PointerAction};

// M3 — Clicker Squared: click para cambiar color
#[derive(Component)]
struct Cubo;

fn setup_cubo(mut commands: Commands) {
    commands
        .spawn((
            Cubo,
            Sprite::from_color(Color::srgb(0.3, 0.6, 1.0), Vec2::splat(80.)),
            Pickable::default(), // clickable
        ))
        // Observer sobre la propia entidad: "cuando hagan Click en mí, ejecuta esto"
        .observe(on_click_cubo);
}

fn on_click_cubo(
    trigger: Trigger<Pointer<Click>>,     // ← Trigger (NO On, que se eliminó en 0.15)
    mut query: Query<&mut Sprite, With<Cubo>>,
) {
    let entity = trigger.target();          // entidad que recibió el click
    if let Ok(mut sprite) = query.get_mut(entity) {
        sprite.color = color_aleatorio();
    }
}

La metedura histórica. Escribir |_: On<Pointer<Click>>| en el observer. En 0.19 se usa Trigger<E>. On<E> se eliminó en 0.15. Y el parámetro no es _: …: necesitas trigger para acceder a trigger.target() (la entidad receptora) y trigger.event() (el payload).

Los eventos Pointer disponibles

El enum PointerAction (o directamente las variantes del genérico Pointer<E>) te da:

Si quieres desactivar el picking en una entidad concreta, ponle Pickable::IGNORE. Útil para elementos decorativos que no quieres que bloqueen clicks sobre lo que hay detrás.

Tras M3, al clickar el cubo se ve cómo cambia de color. ¡Tu primera interacción! Lo siguiente: añadir un HUD que aparezca y desaparezca según el estado de juego.

9C.9 States y state-scoped entities

Cuando tu juego crece, llega el momento de "estamos en el menú principal", "estamos jugando", "estamos en pausa", "estamos en el game over". Para eso está el sistema de estados.

API canónica en 0.19

use bevy::state::{State, NextState, States, OnEnter, OnExit};

#[derive(States, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
enum GameState {
    #[default]
    Menu,
    Playing,
    Paused,
    GameOver,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_state::<GameState>()             // ← init_state (NO add_state, que está deprecated)
        .add_systems(OnEnter(GameState::Playing), spawn_hud)
        .add_systems(OnExit(GameState::Playing), despawn_hud_viejo)
        .run();
}

Metedura de pata clásica. Llamar a app.add_state::<GameState>(). En 0.19 el método es init_state::<GameState>(). add_state está deprecated/eliminado. Si lo ves en un tutorial viejo, cámbialo.

Transiciones y eventos

Para disparar una transición desde un sistema, escribes en el recurso NextState:

fn click_jugar(mut next: ResMut<NextState<GameState>>) {
    next.set(GameState::Playing);
}

La transición ocurre al inicio del siguiente frame, en el set StateTransition (entre PreUpdate y RunFixedMainLoop). En ese momento se ejecutan los sistemas registrados en OnExit(estado_viejo) y OnEnter(estado_nuevo). También puedes leer el evento StateTransitionEvent<S> con campos exited y entered (ambos Option<S>):

fn log_transiciones(mut reader: EventReader<StateTransitionEvent<GameState>>) {
    for ev in reader.read() {
        info!("Sali de {:?}, entré a {:?}", ev.exited, ev.entered);
    }
}

Cuidado con los nombres. Existe StateTransitionEvent<S> (con Event, campos exited/entered). NO existe StateTransition a secas ni StateChangeEvent con from/to. Y por cierto: en 0.19 el derive es #[derive(Event)]; no #[derive(EntityEvent)], que no existe.

DespawnOnEnter / DespawnOnExit (lo nuevo de 0.19)

El patrón típico de "HUD que aparece al entrar en Playing y desaparece al salir" necesita, históricamente, escribir dos sistemas manuales para spawnear y despawnear. En 0.19 hay un componente que automatiza esto: DespawnOnExit.

// M4 — Clicker Squared: HUD que vive solo en Playing
use bevy::state::DespawnOnExit;

fn spawn_hud(mut commands: Commands) {
    commands
        .spawn((
            Node {                            // ← bevy_ui moderno: Node, no Style
                width: Val::Percent(100.),
                height: Val::Px(60.),
                ..default()
            },
            BackgroundColor(Color::srgba(0., 0., 0., 0.6)),
            DespawnOnExit(GameState::Playing), // ← se limpia solo al salir del estado
        ))
        .with_children(|p| {
            p.spawn((
                Text::new("Clicker Squared"),
                TextFont { font_size: 30., ..default() },
                TextColor(Color::WHITE),
            ));
        });
}

En 0.19, DespawnOnEnter y DespawnOnExit ahora pueden dispararse incluso en transiciones al mismo estado (gracias a set_if_neq para evitarlo cuando lo prefieras). Antes no, lo que era una fuente de bugs. Esto está documentado en la migración 0.18→0.19.

SubStates y ComputedStates (avanzado)

Cuando un estado depende de otro (e.g. Shop solo tiene sentido dentro de Playing), usas SubStates. Cuando se deriva de varios (e.g. IsPaused si GameState == Paused o el menú de opciones está abierto), usas ComputedStates. Los dos existen en 0.19 y se derivan con traits. No los profundizamos aquí; lo verás en el capítulo de architecture.

9C.10 Lifecycle con 5 eventos y 4 hooks

Esta sección es la que más errores del libro corrige de una vez por todas. Presta atención.

Los 4 hooks derivables

Cuando declaras un componente, puedes colgarle funciones que se ejecutan automáticamente en distintos momentos de su ciclo de vida. En 0.19 son cuatro:

HookCuándo se dispara
on_addEl componente se añade por primera vez a una entidad (no lo tenía).
on_insertEl componente se inserta (incluyendo sobreescrituras; sea o no la primera vez).
on_discardEl valor antiguo se va a descartar (sobrescrito por uno nuevo, o removido). Nuevo nombre en 0.19, antes on_replace.
on_removeEl componente se va a remover de la entidad (el valor aún está accesible).
#[derive(Component)]
#[component(on_add = mi_on_add, on_insert = mi_on_insert, on_discard = mi_on_discard, on_remove = mi_on_remove)]
struct MiComp { /* ... */ }

fn mi_on_add(mut world: DeferredWorld, entity: Entity, id: ComponentId) { /* ... */ }
fn mi_on_insert(mut world: DeferredWorld, entity: Entity, id: ComponentId) { /* ... */ }
fn mi_on_discard(mut world: DeferredWorld, entity: Entity, id: ComponentId) { /* ... */ }
fn mi_on_remove(mut world: DeferredWorld, entity: Entity, id: ComponentId) { /* ... */ }

Metedura de pata histórica. Escribir on_replace. En 0.19 se renombró a on_discard (PR #22789). Si migras desde 0.18, cambia #[component(on_replace = …)] por #[component(on_discard = …)], y ComponentHooks::on_replace por ComponentHooks::on_discard. El rename fue porque on_replace sugería "cuando se reemplaza", pero el hook también se dispara cuando se remueve; on_discard deja más clara la semántica: "cuando el valor antiguo se descarta".

Los 5 lifecycle events observables

Aparte de los 4 hooks, hay 5 lifecycle events observables que puedes capturar con observers. Módulo bevy::ecs::lifecycle:

EventoStructCuándo
Addstruct AddComponente añadido por primera vez. Corre antes de Insert.
Insertstruct InsertComponente insertado, haya o no estado previo. Corre después de Add.
Discardstruct DiscardValor antiguo se descarta (sobrescribe o se remueve).
Removestruct RemoveComponente se está removiendo. Corre antes de la remoción.
Despawnstruct DespawnEntidad despawneada. Se dispara uno por cada componente.
use bevy::ecs::lifecycle::Insert;

// Observer a nivel global: "cuando se inserte MiComp en cualquier entidad, loggea"
app.add_observer(|trigger: Trigger<Insert, MiComp>| {
    info!("Insert de MiComp en {:?}", trigger.target());
});

// Observer a nivel entidad: "cuando se inserte algo en ESTA entidad, suena un click"
commands
    .spawn(Cubo)
    .observe(|trigger: Trigger<Insert>| {
        info!("Algo se insertó en {:?}", trigger.target());
    });

Cuidado con los nombres. Los eventos son Add, Insert, Discard, Remove, Despawn (sin prefijo On). Si en algún sitio ves OnAdd, OnInsert, OnRemove, eso era una nomenclatura antigua; en 0.19 los structs se llaman sin el On y se capturan con Trigger<Add>, Trigger<Insert>, etc.

Hook vs observer: cuándo cuál

CriterioHookObserver
¿Es una invariante local del componente?
¿Reacciona a un cambio concreto y dispara efectos colaterales?
¿Se define por tipo (todos los Health)?✓ (con Trigger<E, T>)
¿Se define por entidad (esta entidad concreta)?✓ (con .observe sobre commands)
¿Necesitas spawnear entidades o disparar eventos?trabajoso (DeferredWorld)✓ (Commands directo)
¿Corre síncrono en el punto del trigger?

Hay un cuarto mecanismo, RemovedComponents<T>, que es como un "MessageReader de remociones" para cuando necesitas un patrón pull en vez de observer. Y un quinto, Added<T> como filtro de Query. Ya cubrimos esos en caps. 5 y 9.

Para Clicker Squared, usemos un observer de Despawn para limpiar recursos cuando un cubo desaparece. Lo verás integrado en M7.

9C.11 Assets y hot-reload

Llegamos al subsistema que sostiene todo lo demás. Cuando escribes asset_server.load("player.png"), pasa lo siguiente:

  1. El AssetServer encola una petición de carga.
  2. Un hilo del IoTaskPool lee el archivo y lo parsea al tipo correspondiente (Image, Mesh, Font, AudioSource, etc.).
  3. El asset se almacena en el recurso Assets<T> y se notifica vía AssetEvent.
  4. Tu Handle<T> deja de ser "débil" y pasa a resolver el contenido.
fn setup(
    asset_server: Res<AssetServer>,
    mut commands: Commands,
) {
    // Strong handle: mantiene el asset cargado mientras viva.
    let texture: Handle<Image> = asset_server.load("sprites/player.png");

    commands.spawn((
        Sprite::from_image(texture.clone()),
        Transform::from_xyz(0., 0., 1.),
    ));
}

Assets<T> y AssetEvent

El recurso Assets<T> es la "colección" de todos los assets cargados de tipo T. Para acceder al contenido en runtime:

fn leer_imagen(
    images: Res<Assets<Image>>,
    query: Query<&Handle<Image>, With<Cubo>>,
) {
    for handle in &query {
        if let Some(img) = images.get(handle) {
            // img.size(), img.data(), ...
        }
    }
}

Y para reaccionar a cargas/modificaciones, lees AssetEvent<T>:

fn on_image_loaded(mut events: EventReader<AssetEvent<Image>>) {
    for ev in events.read() {
        match ev {
            AssetEvent::LoadedWithDependencies { id } => info!("Imagen cargada: {:?}", id),
            AssetEvent::Modified { id } => info!("Imagen modificada: {:?}", id),
            AssetEvent::Removed { id } => info!("Imagen eliminada: {:?}", id),
            _ => {}
        }
    }
}

Las variantes son: Added, LoadedWithDependencies, Modified, Removed, Unused.

Hot-reload

Activa la feature file_watcher en tu Cargo.toml:

[dependencies]
bevy = { version = "0.19", features = ["file_watcher"] }

Ahora, cada vez que guardas un asset que ya está cargado (PNG, GLTF, fuente, shader…), Bevy lo recarga en caliente. Cambias un sprite, das Ctrl+S, y lo ves actualizado sin reiniciar. Cambia tu vida en prototipado.

El hot-reload solo funciona para assets cargados desde archivo. Si creas un asset programáticamente con assets.add(Image::new(...)), no hay archivo que observar y no se recarga. Para eso tienes que mutar el asset vía Assets<T> y propagar el cambio manualmente.

M6 (la siguiente sección con audio) ya va a usar AssetServer::load para los sonidos.

9C.12 Audio nativo: AudioPlayer y PlaybackSettings

Bevy 0.19 trae audio ECS moderno. Olvídate del viejo Res<Audio> con play() (aunque sigue ahí para compat). El patrón idiomático es spawnear una entidad con AudioPlayer:

use bevy::audio::{AudioPlayer, PlaybackSettings, AudioSink, GlobalVolume};

// M6 — Clicker Squared: BGM que suena siempre + SFX efímero

fn spawn_bgm(mut commands: Commands, asset_server: Res<AssetServer>) {
    // BGM en loop, volumen 0.4
    commands.spawn((
        AudioPlayer::new(asset_server.load("audio/bgm.ogg")),
        PlaybackSettings::LOOP.with_volume(0.4),
    ));
}

fn play_click_sfx(
    mut commands: Commands,
    asset_server: Res<AssetServer>) {
    // SFX que suena una vez y despawnea la entidad automáticamente
    commands.spawn((
        AudioPlayer::new(asset_server.load("audio/click.ogg")),
        PlaybackSettings::DESPAWN,        // ← se limpia solo
    ));
}

PlaybackSettings

Tres presets y métodos para combinarlos:

AudioSink: control en runtime

Si quieres pausar el BGM cuando entras al menú, o cambiar su volumen según una opción del usuario, necesitas el componente AudioSink (se inserta automáticamente sobre la entidad con AudioPlayer):

fn pausar_bgm(
    state: Res<State<GameState>>,
    mut sinks: Query<&mut AudioSink>) {
    if *state == GameState::Paused {
        for mut sink in &mut sinks {
            sink.pause();
        }
    } else {
        for mut sink in &mut sinks {
            sink.play();
        }
    }
}

GlobalVolume y audio espacial

Para un control maestro (un slider de "volumen general" en opciones), usa el recurso GlobalVolume:

app.insert_resource(GlobalVolume { volume: 0.8 });

// Cambiarlo en runtime:
fn set_volumen(mut gv: ResMut<GlobalVolume>) {
    gv.volume = 0.5;
}

Y para audio espacial (sonido que viene "desde la izquierda" porque el emisor está a la izquierda), añade un SpatialListener a tu cámara y un SpatialBundle en el emisor. Es opcional y no lo usamos en Clicker Squared.

Formatos y feature flags (importante en 0.19)

La migración 0.18→0.19 hizo un cambio importante en audio: los formatos ahora son features opt-in, con OGG/Vorbis activado por defecto. Para otros formatos, hay que pedirlos:

[dependencies]
bevy = { version = "0.19", features = [
    # "vorbis" viene por defecto
    "wav",    # soporte WAV
    "mp3",    # soporte MP3 (vía symphonia)
    "flac",   # soporte FLAC
    "aac",    # soporte AAC
]}

Metedura de pata frecuente tras migrar. "Cargo, mi MP3 no suena". La razón: no activaste la feature mp3. En 0.18 era al revés: venía todo activado. Es una de las trampas más comunes de la migración.

Si necesitas algo más avanzado (buses con volúmenes independientes, efectos DSP, secuenciación procedural), el ecosistema te ofrece bevy_kira_audio 0.26 (estable) o bevy_seedling 0.7 / Firewheel 0.12 (experimental, next-gen). Para Clicker Squared nos basta con el nativo.

9C.13 Física ligera con Avian (Mapa ecosistema #1)

Llegamos al primer plugin del ecosistema que merece su propia sección: Avian2D, un motor de física en puro Rust (no FFI con C++), moderno y con integración ECS nativa. En julio de 2026 está en 0.7, compatible con Bevy 0.19.

[dependencies]
bevy = "0.19"
avian2d = "0.7"

Setup mínimo

use avian2d::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::default())  // ← PLURAL: PhysicsPlugins
        .run();
}

Cuidado con el plural. Es PhysicsPlugins (un PluginGroup). Si escribes PhysicsPlugin (singular), no existe como tal y no compila.

Componentes básicos

ComponentePara qué
RigidBody::DynamicCuerpo que reacciona a fuerzas y colisiones (caen, chocan).
RigidBody::StaticCuerpo inmóvil (suelo, paredes).
RigidBody::KinematicCuerpo controlado manualmente; no reacciona a fuerzas pero sí empuja.
Collider::rectangle(w, h)Hitbox rectangular. También circle(r), capsule, convex_hull(points).
SensorTrigger: detecta colisión pero no responde físicamente (pickups, zonas).
ConstantForce(Vec2)Fuerza constante aplicada cada step. Renombrado en 0.7; antes ExternalForce.
ConstantTorque(f32)Torque constante. Mismo rename.
CollisionLayers::new(memberships, filters)Bitmask para "qué choca con qué". Pares con #[derive(PhysicsLayer)].

Renames de 0.7 que confunden al migrar. ExternalForceConstantForce. ExternalTorqueConstantTorque. Si venías de Avian 0.5/0.6, cámbialos.

Eventos de colisión

// CollisionStart y CollisionEnd (SINGULAR, no CollisionStarted/CollisionEnded)
fn detectar_colisiones(mut events: EventReader<CollisionStart>) {
    for ev in events.read() {
        info!("Choque entre {:?} y {:?}", ev.entity1, ev.entity2);
        // ← campos con nombre, NO ev.0 / ev.1
    }
}

Ojo al singular. Es CollisionStart y CollisionEnd. CollisionStarted / CollisionEnded (con -ed) no existen en Avian 0.7. Y los campos son entity1 y entity2 (con nombre), no tuplas .0/.1.

Character controller: MoveAndSlide

Para personajes que se mueven por input y no quieren lidiar con fuerzas físicas (típico platformer, RPG top-down, FPS), Avian 0.7 ofrece MoveAndSlide como SystemParam. Esto no es KinematicCharacterController (que existe en bevy_rapier, no en Avian).

// M5 — Clicker Squared: salto del cubo con MoveAndSlide + leafwing
use avian2d::character_controller::move_and_slide::{MoveAndSlide, MoveAndSlideConfig};
use leafwing_input_manager::prelude::*;

#[derive(Actionlike, Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Action {
    Jump,
    #[actionlike(DualAxis)]
    Move,
}

fn setup_cubo_fisico(mut commands: Commands) {
    commands.spawn((
        Cubo,
        RigidBody::Dynamic,
        Collider::circle(40.),                       // hitbox circular de 40 px de radio
        // MoveAndSlide es un SystemParam, no un componente:
        // se invoca en un sistema con la velocidad deseada.
        Sprite::from_color(Color::srgb(0.3, 0.6, 1.0), Vec2::splat(80.)),
        Transform::from_xyz(0., -100., 1.),
        LockedAxes::ROTATION_LOCKED_Z,                // no caiga de cabeza
    ));
}

fn saltar(
    mut query: Query<&mut LinearVelocity, With<Cubo>>,
    actions: Query<&ActionState<Action>>,
) {
    let Ok(action_state) = actions.get_single() else { return; };
    if action_state.just_pressed(&Action::Jump) {
        for mut vel in &mut query {
            vel.y = 400.;   // impulso vertical
        }
    }
}

// MoveAndSlide en uso (si quieres movimiento que resbale por paredes):
fn movimiento_personaje(
    mut move_and_slide: MoveAndSlide,
    time: Res<Time>,
    mut query: Query<(&Transform, &mut LinearVelocity), With<Cubo>>,
) {
    for (transform, mut velocity) in &mut query {
        let _config = MoveAndSlideConfig::default();
        // move_and_slide.move_and_slide(&mut velocity, transform, &collider, &config);
        // ← API exacta dependiente de la firma del SystemParam; ver docs.rs/avian2d/0.7
        let _ = (time.elapsed_secs(), transform.translation());
    }
}

Patrón del ecosistema: cuando Avian te da una API con SystemParam (MoveAndSlide, SpatialQuery), significa que es un "servicio" que pides en tu sistema y llamas. No es un componente. Es lo contrario al patrón "component-driven" de Bevy puro, y conviene no mezclarlo: si tienes un controller por input, va en un sistema con MoveAndSlide; si tienes física reactiva (cajas que caen), basta con RigidBody::Dynamic.

Avian vs Rapier vs física custom

EscenarioRecomendación
Plataformer 2D, top-down, arcade con colisiones simplesAvian 0.7
Necesitas KinematicCharacterController o feature muy específica de Rapierbevy_rapier2d 0.34
Asteroids-style sin gravedad, pocas entidadesFísica custom (un sistema con velocity + AABB propio)
Miles de cuerpos, alta performance críticaRapier (mejor performance en benchmarks pesados)

En el capítulo 17.8 profundizamos en física 2D; aquí solo te dejamos el mapa.

9C.14 Escenas declarativas con BSN ★

El sistema estrella de 0.19: BSN (Bevy Scene Notation). Es una macro, bsn!{...}, que te deja declarar entidades y componentes en una sintaxis parecida a Rust, con composición, patching y relaciones first-class.

BSN llega como "subset inicial" en 0.19: solo el formato en código. El formato .bsn como asset llegará en una release futura. Lo que se publica ahora es la macro; lo demás queda aparcado.

Sintaxis básica

use bevy::scene::bsn;

#[derive(Component, Default, Clone)]
struct Player { score: usize, coins: usize }

#[derive(Component, Default)]
struct Sword;
#[derive(Component, Default)]
struct Shield;

let escena = bsn! {
    Player { score: 0 }   // puedes omitir campos; toman Default
    Team::Blue            // o solo el tipo si todo es Default
};

// Y una relación Children inline:
let con_hijos = bsn! {
    Player
    Children [
        Sword,
        Shield,
    ]
};

// Campo con expresión Rust arbitraria:
let puntuacion = 42;
let con_expr = bsn! {
    Player { score: {puntuacion + 10} }
};

Scene functions

bsn! devuelve algo que implementa el trait Scene. Eso te permite definir funciones de escena reutilizables:

fn player(name: &str) -> impl Scene {
    bsn! {
        Name(name)
        Player
        Children [ Sword, Shield ]
    }
}

fn enemy() -> impl Scene {
    bsn! {
        Enemy
        Sprite::from_color(Color::RED, Vec2::splat(40.))
    }
}

Composición por patching

Una función Scene se puede componer con otra: los componentes se acumulan y los campos se sobrescriben. Como si fueran capas:

fn button() -> impl Scene {
    bsn! {
        Button
        Node { width: px(100) }
    }
}

fn my_button() -> impl Scene {
    bsn! {
        button()        // ← "llama" a otra escena
        Node { height: px(100) }
    }
}
// my_button resulta en: Button + Node { width: 100, height: 100 }

Spawneo

Hay tres formas de instanciar una escena BSN:

// (1) Vía Commands, inmediatamente
commands.spawn(bsn! { Player });

// (2) Vía World, también inmediato
let entity = world.spawn_scene(bsn! { Player });

// (3) Vía Commands, esperando dependencias de assets (cola de spawn)
commands.queue_spawn_scene(bsn! {
    :"player.bsn"   // ← referencia a un asset .bsn (cuando el loader exista)
    Transform { translation: Vec3 { x: 10. } }
});

queue_spawn_scene vs spawn_scene: el primero espera a que se carguen las dependencias (como texturas referenciadas); el segundo intenta spawnear ya y falla si algo no está listo. Para el caso habitual en código, basta con commands.spawn(bsn!{...}).

M8: enemigos con BSN

// M8 — Clicker Squared: spawn de varios enemigos con BSN
use bevy::scene::{bsn, Scene};

#[derive(Component, Default, Clone)]
struct Enemy;
#[derive(Component, Default, Clone)]
struct Health(u32);

fn enemy_template() -> impl Scene {
    bsn! {
        Enemy
        Health(3)
        Sprite::from_color(Color::srgb(1.0, 0.3, 0.3), Vec2::splat(40.))
    }
}

fn spawn_enemies(mut commands: Commands) {
    for x in [-200., 0., 200.] {
        commands.spawn_scene(bsn! {
            enemy_template()
            Transform { translation: Vec3 { x, y: 150. } }
        });
    }
}

Errores que verás en guías viejas. Cosas como bsn_list!, SceneComponent, o una API alternativa de spawn_scene con otra firma… no existen. La API canónica en 0.19 es bsn!{...}, commands.spawn(bsn!{...}), world.spawn_scene(bsn!{...}), commands.queue_spawn_scene(bsn!{...}). Punto.

BSN está pensado también como base del futuro Bevy Editor. Cuando ese editor llegue, los prefab que guardes serán archivos .bsn. Por eso importa aprender la macro ahora, aunque sea un subset inicial.

9C.15 FX: Tweening, Hanabi, Aseprite (panorámica)

Para cerrar la demo nos faltan dos detalles: animar el cubo cuando se clicka (un pequeño tween de escala) y partículas para el feedback. Aquí entra el ecosistema otra vez.

bevy_tweening 0.16

Antes llamado "bevy_tweening", sigue siendo el crate estándar para interpolaciones. Ojo al cambio de API en 0.16: ya no se usa Animator<T>; ahora es AnimatedEntityCommands + AnimTarget.

[dependencies]
bevy_tweening = "0.16"
use bevy_tweening::{AnimatedEntityCommands, AnimTarget, Lens, EaseFunction, EaseMethod, Tween, TweeningPlugin};

// Un lens: cómo modificar el componente objetivo
struct ScaleLens { start: Vec3, end: Vec3 }
impl Lens<Transform> for ScaleLens {
    fn lerp(&mut self, target: &mut Transform, ratio: f32) {
        target.scale = self.start.lerp(self.end, ratio);
    }
}

// M7a — tween de "pop" al clickar
fn tween_pop(entity: Entity, mut commands: Commands) {
    let tween = Tween::new(
        EaseMethod::from(EaseFunction::BackOut),
        std::time::Duration::from_millis(250),
        ScaleLens { start: Vec3::ONE, end: Vec3::splat(1.4) },
    )
    .with_repeat_strategy(bevy_tweening::RepeatStrategy::MirroredRepeat)
    .with_repeat_count(1);

    commands.entity(entity)
        .insert(AnimTarget)               // ← marca la entidad como animable
        .animate(tween);                  // ← encola el tween (AnimatedEntityCommands)

Renombres de 0.16. Antes se usaba Animator<T> como componente con commands.entity(e).insert(Animator::new(tween)). En 0.16 es AnimatedEntityCommands (un trait que añade .animate(tween) sobre EntityCommands) + AnimTarget como componente marcador. Si venías de 0.15, esto se rompe al migrar.

bevy_hanabi 0.19

Para partículas en GPU (miles sin caída de framerate), bevy_hanabi es la referencia. La versión 0.19 (que coincide con la de Bevy) introdujo renombres: SpawnerEffectSpawner, y la API se organiza en torno a Module + SpawnerSettings.

[dependencies]
bevy_hanabi = "0.19"
use bevy_hanabi::prelude::*;

fn setup_particulas(mut effects: ResMut<Assets<EffectAsset>>, mut commands: Commands) {
    let mut module = Module::default();
    let writer = ExprWriter::new(&mut module);

    let lifetime = writer.lit(0.5).expr();
    let init_pos = SetPositionCircleModifier {
        center: writer.lit(Vec3::ZERO).expr(),
        axis: writer.lit(Vec3::Z).expr(),
        radius: writer.lit(10.).expr(),
        dimension: ShapeDimension::Surface,
    };

    let asset = EffectAsset::new(
        vec![32768],                          // capacidad
        SpawnerSettings::rate(500.0.into()),   // partículas por segundo
        writer.finish(),                       // módulo cerrado
    )
    .init(init_pos);

    let handle = effects.add(asset);

    commands.spawn((
        ParticleEffect::new(handle),
        Transform::from_xyz(0., 0., 2.),
    ));
}

Renombres. En Hanabi 0.19 es SpawnerSettings y EffectSpawner (antes Spawner solo). Si venías de versiones anteriores, los nombres cambiaron. La API Module + ExprWriter es la nueva forma de construir efectos de manera composable.

Hanabi compila shaders WGSL en runtime. En web (WASM) puede haber limitaciones; revisa el README del crate si tu juego va a navegador.

bevy_aseprite_ultra 0.9 (mención breve)

Para animaciones hechas en Aseprite, este crate te deja importar el .ase o .aseprite directamente (no exportar a sprite sheet) con hot-reload "inquebrantable" (palabra textual de su README). Define componentes AseSpriteAnimation y AseUiAnimation (con S mayúscula en Ase). Lo dejamos apuntado en el mapa del ecosistema; no lo usamos en Clicker Squared.

M7 completo: tween + partículas al click

El observer de click del M3 ahora se amplía para, además de cambiar color, lanzar el tween de pop y un burst de partículas:

fn on_click_cubo(
    trigger: Trigger<Pointer<Click>>,
    mut commands: Commands,
    mut query: Query<(Entity, &mut Sprite), With<Cubo>>,
    effects: Res<Assets<EffectAsset>>, // handler a un burst preconfigurado
) {
    let entity = trigger.target();
    if let Ok((e, mut sprite)) = query.get_mut(entity) {
        sprite.color = color_aleatorio();
        // tween de pop
        commands.entity(e)
            .insert(AnimTarget)
            .animate(tween_pop());
        // partículas: spawneamos un ParticleEffect efímero en la posición
        commands.spawn((
            ParticleEffect::new(burst_handle(&effects)),
            Transform::from_translation(sprite_anchor(e)),
        ));
    }
}

Ya tenemos ocho subsistemas en marcha: render (Sprite), jerarquía (ChildOf), texto (Text2d), cámara (Camera2d), input/picking (Pointer), estados (GameState), audio (AudioPlayer), FX (Tweening + Hanabi). Y apenas 200 líneas.

9C.16 Dev tooling que ya viene

Bevy no es solo runtime: también trae un kit de herramientas para desarrollar más rápido. Vamos a repasarlo.

Logging: LogPlugin y la familia info!

use bevy::log::{info, warn, error, debug, trace};

info!("Cubo spawneado en {:?}", transform.translation);
warn!("FPS cayó a {}", fps);
error!("No se pudo cargar el asset: {}", path);

// Variantes "once" (solo loguea la primera vez):
info_once!("Esto solo sale una vez por ejecución");

// Variantes "every N segundos":
info_span!("mi_sistema"); // tracing span para profiling

El nivel se controla con LogPlugin:

.add_plugins(DefaultPlugins.set(LogPlugin {
    level: bevy::log::Level::INFO,
    filter: "wgpu=Error,bevy_render=Info,mygame=Debug".into(),
    ..default()
}))

FrameCountPlugin y DiagnosticsStore

Para métricas en runtime, Bevy trae el FrameCountPlugin y el DiagnosticsStore. Lo más útil es el helper print_diagnostics_system que escupe FPS, frame time y entity count cada N frames.

bevy_inspector_egui (ecosistema)

Para inspección visual del World en runtime, el estándar de facto es bevy_inspector_egui 0.37. La API canónica para un inspector rápido:

[dependencies]
bevy = "0.19"
bevy_inspector_egui = "0.37"
use bevy_inspector_egui::quick::WorldInspectorPlugin;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(WorldInspectorPlugin::new())  // ← path correcto
        .run();
}

Aparece un panel egui donde navegar por entidades, componentes y recursos. Imprescindible en prototipado.

bevy_mod_debugdump (CLI)

Para generar un grafo del schedule en Graphviz, usa bevy_mod_debugdump 0.16. No es un plugin; se invoca desde un binario aparte:

// En un ejemplo separado:
use bevy_mod_debugdump::schedule_graph_dot;

fn main() {
    let mut app = App::new();
    app.add_plugins(DefaultPlugins);
    // ... tus sistemas
    let dot = schedule_graph_dot::get_schedule_graph(&mut app, Update);
    std::fs::write("schedule.dot", dot).unwrap();
}
// Luego: dot -Tsvg schedule.dot > schedule.svg

Tracy profiling

Para profiling con Tracy (la herramienta de Bartosz Taudul, alias wolfpld), basta activar la feature:

[dependencies]
bevy = { version = "0.19", features = ["trace_tracy"] }

Luego conecta el cliente de Tracy y verás cada sistema, cada span, cada alocation. Cambia tu vida para optimizar.

Tracy es obra de Bartosz Taudul (wolfpld), no de Wolfgang Engel (que es de otra cosa). Atribuciones correctas importan; el libro en general tenía este dato mal en otros capítulos.

9C.17 Mapa del ecosistema 2D (tabla canónica)

Esta es la tabla canónica de "para X, usa Y (versión Z)". Está verificada a julio de 2026 contra crates.io.

NecesidadPlugin nativo ⭐Plugin del ecosistemaVersión (julio 2026)
Input crudoInputPlugin
Input por accionesleafwing-input-manager0.21
Input observer-basedbevy_enhanced_input0.26
Click / hover / dragDefaultPickingPlugins
Sprites 2DSpritePlugin
Mesh 2D custombevy::sprite_render (MeshMaterial2d) ⭐
Tilemapsbevy_ecs_tilemap0.19
Tilemaps + Tiled (.tmx)bevy_ecs_tiled0.13
Importar LDtkbevy_ecs_ldtk0.15
CámaraCameraPlugin
JerarquíaTransformPlugin + HierarchyPlugin
EstadosStatesPlugin
TiempoTimePlugin
Audio básicoAudioPlugin
Audio avanzado (buses, DSP)bevy_kira_audio0.26
Audio next-genbevy_seedling / Firewheel (experimental)0.7 / 0.12
Texto en mundo 2Dbevy::sprite::Text2d
Texto UIbevy::ui::Text
Físicaavian2d o bevy_rapier2d0.7 / 0.34
Iluminación 2D(path vacío en 0.19)bevy_light_2d (atascado en 0.18)0.9
Post-procesadoVignette, ChromaticAberration, LensDistortion
Partículas GPUbevy_hanabi0.19
Tweeningbevy_tweening o bevy_tween0.16 / 0.13
Asepritebevy_aseprite_ultra0.9
Networkingbevy_replicon (+renet/quinnet)0.41
Save / serializebevy_world_serialization ⭐ (renombrado de bevy_scene)— (bevy_save atascado en 0.16)
Dev inspectorbevy_inspector_egui0.37
Schedule graphbevy_mod_debugdump (CLI)0.16
Profiling CPUfeature trace_tracy
Escenas declarativasbsn! + spawn_scene
Rollback / netcode deterministabevy_ggrs0.22
Pathfinding (navmesh)vleue_navigator (renombrado de bevy_pathmesh)0.15

⭐ = viene con DefaultPlugins, cero configuración.

Cositas que el libro tenía mal y este mapa corrige.

9C.18 Errores comunes que este capítulo corrige de una vez por todas

Esta sección es la que servirá de referencia para corregir los demás capítulos del libro. Cópiala, imprímela, ponla en la nevera con un imán.

Error típicoAPI correcta en 0.19Sección
use bevy::text::Text2duse bevy::sprite::Text2d§9C.4
entity.observe(: On<Pointer<Click>>)entity.observe(: Trigger<Pointer<Click>>)§9C.8
OnAdd, OnInsert, OnRemoveAdd, Insert, Discard, Remove, Despawn (5 structs, sin prefijo On)§9C.10
on_replace = … (hook)on_discard = … (renombrado en 0.19, PR #22789)§9C.10
3 lifecycle events (Add/Insert/Remove)5: Add, Insert, Discard, Remove, Despawn§9C.10
app.add_state::<S>()app.init_state::<S>()§9C.9
StateTransition / StateChangeEvent con from/toStateTransitionEvent<S> con exited/entered§9C.9
time.delta_seconds()time.delta_secs()§9C.7
OrthographicProjection::scale / with_scaling(f32)ScalingMode (enum) pasado a with_scaling(ScalingMode::…); el campo scale no existe§9C.5
Animator<T> en bevy_tweeningAnimatedEntityCommands (trait) + AnimTarget (componente) en 0.16§9C.15
KinematicCharacterController en AvianMoveAndSlide (SystemParam en avian2d::character_controller::move_and_slide); KinematicCharacterController es de bevy_rapier§9C.13
ExternalForce / ExternalTorque en AvianConstantForce / ConstantTorque (renames en Avian 0.7)§9C.13
CollisionStarted / CollisionEndedCollisionStart / CollisionEnd (singular; campos entity1/entity2)§9C.13
Spawner en HanabiEffectSpawner + SpawnerSettings en 0.19§9C.15
bevy_scene (serialización)bevy_world_serialization (renombrado; bevy_scene ahora aloja BSN)§9C.14, §9C.17
TextureAtlas como componente sueltoEs un campo de Sprite desde 0.15§9C.2
SpriteBundle / Camera2dBundleDeprecados; spawnea Sprite o Camera2d directo (required components hace el resto)§9C.2, §9C.5
app.add_event::<T>() + EventWriter::sendSigue funcionando para el patrón buffered, pero la API moderna distingue Event (observers, Trigger<E>) de Message (buffered, MessageWriter/MessageReader). Decide cuál te sirve.§9C.1
MeshMaterial2d<T> "eliminado"Sigue existiendo en bevy::sprite_render en 0.19. La afirmación de que fue reemplazado por un SpriteMesh es incorrecta; SpriteMesh no es un tipo público equivalente.§9C.17
#[derive(EntityEvent)]No existe. Usar #[derive(Event)].§9C.9
Editor oficial de Bevybevy_editor_prototypes está archivado. No existe release oficial usable todavía. BSN (§9C.14) es la base sobre la que se construirá.§9C.14

Mini-glosario de cierre.

  • Hook: función que Bevy corre automáticamente en un momento del ciclo de vida de un componente. 4 derivables en 0.19: on_add, on_insert, on_discard, on_remove.
  • Lifecycle event: evento observable con Trigger<E>. 5 en 0.19: Add, Insert, Discard, Remove, Despawn.
  • Observer: sistema que reacciona a un evento. Su parámetro es Trigger<E> (no On<E>).
  • Required component: componente que se inserta solo porque otro lo declara con #[require].
  • Scene (BSN): valor devuelto por bsn!{...}; implementa el trait Scene y se puede spawnear con spawn o spawn_scene.
  • Event vs Message: Event → observers síncronos; Message → cola buffered leída en frames siguientes.
  • SystemParam: tipo que puedes pedir como argumento de un sistema (Query, Res, MoveAndSlide, SpatialQuery…).

Trivia para brillar en reuniones.

  • "Bevy" significa bandada de aves en inglés (a bevy of birds). Carter Anderson, alias cart, lo eligió por eso. (No es un apellido propio "Bevy", ni una nave de Homeworld.)
  • Tracy, el profiler que solemos recomendar, es obra de Bartosz Taudul (wolfpld), no de Wolfgang Engel.
  • El Unofficial Bevy Cheat Book (IyesGames / Ida Iyes) está descontinuado desde ~0.14. No lo cites como referencia actual.
  • Bevy no tiene modelo LTS (long-term support): es train release, sale cada ~3 meses y la rama vieja no recibe fixes. Migra o quédate con bugs.
  • El BSN del capítulo es la base sobre la que se construirá el Bevy Editor oficial (todavía no lanzado). Aprenderlo ahora te adelanta trabajo.

🎯 Patrón del capítulo: No reinventes la rueda, encuentra el pedal

"No reinventes la rueda. Bevy ya te regaló un coche entero; lo único que tendrías que hacer es aprender dónde están los pedales."

El patrón de este capítulo no es una estructura de código concreta (como "Invariantes auto-cumplidas" del cap. 9 o "Relation con linked_spawn" del 9B). Es una actitud:

Antes de escribir un sistema, pregunta si ya existe. ¿Estás escribiendo un raycast manual para detectar clicks? Existe: DefaultPickingPlugins + Pointer<Click>. ¿Estás propagando transforms a mano por la jerarquía? Ya lo hace TransformSystems::Propagate. ¿Estás montando un Vec<Entity> para tener "hijos"? Ya existe ChildOf/Children. ¿Estás escribiendo un Timer custom para medir intervalos? Existe bevy::time::Timer. ¿Estás serializando el World a mano para guardar partida? Existe bevy_world_serialization. ¿Estás cargando un archivo de configuración YAML a mano? Existe el nuevo SettingsPlugin (app settings) en 0.19.

La regla mnemotécnica, "los tres pedales":

  1. Antes de escribir, busca en DefaultPlugins. ¿Es cosa de render, input, tiempo, assets, audio, picking, jerarquía? Ya está.
  2. Si no está en nativo, busca en el mapa del ecosistema (§9C.17). ¿Física? Avian. ¿Partículas? Hanabi. ¿Tweening? bevy_tweening. ¿Tilemaps? bevy_ecs_tilemap.
  3. Solo si no está en ninguno, escríbelo tú. Y entonces probablemente valga la pena publicarlo como crate.

Cuándo sí reinventar.

El anti-patrón simétrico, igual de peligroso, es "usar el plugin porque sí": meter Avian para hacer mover un cursor de menú, o Hanabi para una sola explosión estática. Cada plugin que añades es cargo build más lento, más superficie de bugs, y otra dependencia que migrar cuando Bevy suba de versión. Sé minimalista cuando puedas, perezoso cuando debas.

El Clicker Squared que construiste es la prueba viviente: ~200 líneas, ocho subsistemas, cero código "custom" que ya existiera. Esa es la promesa de Bevy cuando lo usas bien.

Lo que vimos

En el siguiente

Llegó el momento. Has visto todas las primitivas nativas y ya sabes qué te regala Bevy y qué tienes que ir a buscar al ecosistema. El siguiente capítulo (10) cierra la Parte II de ECS con el patrón más poderoso y a la vez más sutil de todos: los Observers a fondo. Allí veremos Trigger<E> en todas sus variantes, el commands.trigger vs world.trigger, cómo encadenar observers sin crear un espagueti de reacciones, el patrón "N observers para una sola explosión", y por qué push gana a pull cuando tienes 10.000 entidades pero pocos eventos por frame. También veremos propagación (bubbling): cómo hacer que un click en un hijo llegue al padre, y cómo cortar la propagación a mitad. Y ataremos cabos con la sección de lifecycle events que aquí solo hemos_presentado: el evento Despawn, los observers de Insert<T> tipados, y la combinación con hooks para construir invariantes que reaccionan. Programa café: la cosa se pone intensa.