"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.
DefaultPlugins (lo pones a cero configuración).add_plugins.Add, Insert, Discard, Remove, Despawn. Se observan con Trigger<E>.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).
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:
| Plugin | Te regala… |
|---|---|
WindowPlugin | Una ventana con evento loop, input crudo, multitouch. |
InputPlugin | ButtonInput<KeyCode>, ButtonInput<MouseButton>, Axis<GamepadAxis>, Gamepads. |
TimePlugin | El recurso Time con delta, instant, reloj virtual pausable y fixed-step. |
TransformPlugin + HierarchyPlugin | Transform, GlobalTransform, propagación automática padre→hijo. |
SpritePlugin | Render de sprites, TextureAtlas, Text2d, Anchor. |
CameraPlugin | Cámara 2D/3D, RenderLayers, Projection. |
TextPlugin | Tipografía con Parley (nuevo en 0.19, antes era Cosmic Text), fuentes, TextFont, TextLayout. |
UiPlugin + InputDispatchPlugin | bevy_ui moderno (en 0.19 ahora viene por defecto; antes había que pedirlo). |
AudioPlugin | AudioPlayer, PlaybackSettings, AudioSink, GlobalVolume. Rodio por debajo. |
AssetPlugin | AssetServer, 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. |
StatesPlugin | State<S>, NextState, OnEnter/OnExit, DespawnOnEnter/DespawnOnExit. |
ScenePlugin | El nuevo sistema BSN (bsn!{...} y world.spawn_scene). |
ImagePlugin, MeshPlugin | Carga y registro de Image y Mesh como assets. |
LogPlugin, FrameCountPlugin, TaskPoolPlugin, DiagnosticsPlugin | Logging, 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.
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.
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)] |
| Registro | se observa con .add_observer | app.add_message::<T>() |
| Escritura | commands.trigger(E) | MessageWriter::write |
| Lectura | observer Trigger<E> | MessageReader en el siguiente frame |
| Timing | síncrono, en el punto del trigger | buffered, 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.
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:
Transform (la transformación local: traslación, rotación, escala).GlobalTransform (la matriz global, propagada desde el padre).Visibility y InheritedVisibility + ViewVisibility (estado visible/oculto).Anchor (dónde se ancla el sprite dentro de su célula; por defecto centrado).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.
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:
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.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.
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:
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.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.
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:
| Componente | Vive en | Sirve para |
|---|---|---|
Text2d | bevy::sprite::Text2d | Texto en el mundo 2D: se mueve con la cámara, se propaga con el Transform, puede estar detrás o delante de un sprite. |
Text | bevy::ui::Text | Texto 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).
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:
Text2d es el "contenido" (el string en sí). En 0.19 lleva ya dentro la lógica de secciones (rich text), pero en su forma simple acepta un String.TextFont es el "cómo": fuente (un Handle<Font>), tamaño, font_smoothing. En 0.19 el campo se llama font_size (la migración 0.18→0.19 renombró un par de cosas aquí).TextLayout es el "cómo se rompe": LineBreak (WordBoundary, AnyCharacter, NoWrap), JustifyText (Left, Center, Right), y opcionalmente un max_width.TextColor es el color. Si pones Text2d con varias secciones, cada sección puede llevar su propio color; este componente es el fallback global.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),
));
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.
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.
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.
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
| Para… | Usa… | Porque… |
|---|---|---|
| UI, sprites, texto, almacenar colores | Srgba | Es lo que entienden los formatos (PNG, CSS) y tu monitor. |
| Shaders WGSL, iluminación, blending | LinearRgba | Las matemáticas de luz solo son correctas en espacio lineal. |
| Generar paletas en runtime | Oklcha o Lcha | Perceptualmente uniformes: cambiar el hue rota el color sin cambiar el brillo percibido. |
| Decir "rojo + brillante" | Hsla o Hsva | Intuitivo 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.
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.
| Reloj | Tipo | Qué mide | Cómo se accede |
|---|---|---|---|
| Real | Time<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>> |
| Fixed | Time<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>>.
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().
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
}
}
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).
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.
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.
Cuando tu juego crece, quieres definir "acciones" (Jump, Move, Interact) y mapearlas a cualquier tecla, botón o eje. Aquí hay dos contendientes:
| Crate | Versión (julio 2026) | Estilo |
|---|---|---|
leafwing-input-manager | 0.21 | Resource-based. Estable, documentado. Define Actionlike enum y un InputMap. |
bevy_enhanced_input | 0.26 | Observer-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.
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).
El enum PointerAction (o directamente las variantes del genérico Pointer<E>) te da:
Pointer<Over> / Pointer<Out> — el cursor entró/salió del área de la entidad.Pointer<Enter> / Pointer<Leave> — equivalente a over/out, pero con semántica de jerarquía (bubbling).Pointer<Down> / Pointer<Up> — botón presionado/suelto.Pointer<Click> — down + up sobre el mismo target (lo más usado).Pointer<Move> — movimiento del cursor mientras está sobre la entidad.Pointer<Drag> / Pointer<DragDrop> — arrastrar y soltar entre entidades.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.
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.
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.
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.
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.
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.
Esta sección es la que más errores del libro corrige de una vez por todas. Presta atención.
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:
| Hook | Cuándo se dispara |
|---|---|
on_add | El componente se añade por primera vez a una entidad (no lo tenía). |
on_insert | El componente se inserta (incluyendo sobreescrituras; sea o no la primera vez). |
on_discard | El valor antiguo se va a descartar (sobrescrito por uno nuevo, o removido). Nuevo nombre en 0.19, antes on_replace. |
on_remove | El 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".
Aparte de los 4 hooks, hay 5 lifecycle events observables que puedes capturar con observers. Módulo bevy::ecs::lifecycle:
| Evento | Struct | Cuándo |
|---|---|---|
Add | struct Add | Componente añadido por primera vez. Corre antes de Insert. |
Insert | struct Insert | Componente insertado, haya o no estado previo. Corre después de Add. |
Discard | struct Discard | Valor antiguo se descarta (sobrescribe o se remueve). |
Remove | struct Remove | Componente se está removiendo. Corre antes de la remoción. |
Despawn | struct Despawn | Entidad 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.
| Criterio | Hook | Observer |
|---|---|---|
| ¿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.
Llegamos al subsistema que sostiene todo lo demás. Cuando escribes asset_server.load("player.png"), pasa lo siguiente:
AssetServer encola una petición de carga.IoTaskPool lee el archivo y lo parsea al tipo correspondiente (Image, Mesh, Font, AudioSource, etc.).Assets<T> y se notifica vía AssetEvent.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.),
));
}
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.
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.
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
));
}
Tres presets y métodos para combinarlos:
PlaybackSettings::NONE — suena una vez, la entidad sigue viva.PlaybackSettings::LOOP — loop infinito.PlaybackSettings::DESPAWN — al terminar, despawnea la entidad..with_volume(v), .with_speed(s), .pause(), .loop_start(secs).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();
}
}
}
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.
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.
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"
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.
| Componente | Para qué |
|---|---|
RigidBody::Dynamic | Cuerpo que reacciona a fuerzas y colisiones (caen, chocan). |
RigidBody::Static | Cuerpo inmóvil (suelo, paredes). |
RigidBody::Kinematic | Cuerpo controlado manualmente; no reacciona a fuerzas pero sí empuja. |
Collider::rectangle(w, h) | Hitbox rectangular. También circle(r), capsule, convex_hull(points). |
Sensor | Trigger: 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. ExternalForce → ConstantForce. ExternalTorque → ConstantTorque. Si venías de Avian 0.5/0.6, cámbialos.
// 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.
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.
| Escenario | Recomendación |
|---|---|
| Plataformer 2D, top-down, arcade con colisiones simples | Avian 0.7 |
Necesitas KinematicCharacterController o feature muy específica de Rapier | bevy_rapier2d 0.34 |
| Asteroids-style sin gravedad, pocas entidades | Física custom (un sistema con velocity + AABB propio) |
| Miles de cuerpos, alta performance crítica | Rapier (mejor performance en benchmarks pesados) |
En el capítulo 17.8 profundizamos en física 2D; aquí solo te dejamos el mapa.
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.
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} }
};
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.))
}
}
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 }
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 — 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.
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.
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.
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: Spawner → EffectSpawner, 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.
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.
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.
Bevy no es solo runtime: también trae un kit de herramientas para desarrollar más rápido. Vamos a repasarlo.
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()
}))
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.
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.
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
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.
Esta es la tabla canónica de "para X, usa Y (versión Z)". Está verificada a julio de 2026 contra crates.io.
| Necesidad | Plugin nativo ⭐ | Plugin del ecosistema | Versión (julio 2026) |
|---|---|---|---|
| Input crudo | InputPlugin ⭐ | — | — |
| Input por acciones | — | leafwing-input-manager | 0.21 |
| Input observer-based | — | bevy_enhanced_input | 0.26 |
| Click / hover / drag | DefaultPickingPlugins ⭐ | — | — |
| Sprites 2D | SpritePlugin ⭐ | — | — |
| Mesh 2D custom | bevy::sprite_render (MeshMaterial2d) ⭐ | — | — |
| Tilemaps | — | bevy_ecs_tilemap | 0.19 |
| Tilemaps + Tiled (.tmx) | — | bevy_ecs_tiled | 0.13 |
| Importar LDtk | — | bevy_ecs_ldtk | 0.15 |
| Cámara | CameraPlugin ⭐ | — | — |
| Jerarquía | TransformPlugin + HierarchyPlugin ⭐ | — | — |
| Estados | StatesPlugin ⭐ | — | — |
| Tiempo | TimePlugin ⭐ | — | — |
| Audio básico | AudioPlugin ⭐ | — | — |
| Audio avanzado (buses, DSP) | — | bevy_kira_audio | 0.26 |
| Audio next-gen | — | bevy_seedling / Firewheel (experimental) | 0.7 / 0.12 |
| Texto en mundo 2D | bevy::sprite::Text2d ⭐ | — | — |
| Texto UI | bevy::ui::Text ⭐ | — | — |
| Física | — | avian2d o bevy_rapier2d | 0.7 / 0.34 |
| Iluminación 2D | (path vacío en 0.19) | bevy_light_2d (atascado en 0.18) | 0.9 |
| Post-procesado | Vignette, ChromaticAberration, LensDistortion ⭐ | — | — |
| Partículas GPU | — | bevy_hanabi | 0.19 |
| Tweening | — | bevy_tweening o bevy_tween | 0.16 / 0.13 |
| Aseprite | — | bevy_aseprite_ultra | 0.9 |
| Networking | — | bevy_replicon (+renet/quinnet) | 0.41 |
| Save / serialize | bevy_world_serialization ⭐ (renombrado de bevy_scene) | — (bevy_save atascado en 0.16) | — |
| Dev inspector | — | bevy_inspector_egui | 0.37 |
| Schedule graph | — | bevy_mod_debugdump (CLI) | 0.16 |
| Profiling CPU | feature trace_tracy ⭐ | — | — |
| Escenas declarativas | bsn! + spawn_scene ⭐ | — | — |
| Rollback / netcode determinista | — | bevy_ggrs | 0.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.
bevy_light_2d sigue siendo externo: en 0.19 los PointLight2d / AmbientLight2d son nativos (módulo bevy::render::light_2d), pero la versión "completa" del crate externo (bevy_light_2d) está atascada en 0.18. No los confundas.bevy_save está atascado en 0.16. Si necesitas serialización en 0.19, el camino actual es bevy_world_serialization nativo.bevy_navigation. Para pathfinding, vleue_navigator (no bevy_pathmesh, que es el nombre antiguo).bevy_render_diagnostic. Para diagnósticos de rendering, usa DiagnosticsStore nativo o profiling con Tracy.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ípico | API correcta en 0.19 | Sección |
|---|---|---|
use bevy::text::Text2d | use bevy::sprite::Text2d | §9C.4 |
entity.observe(…: On<Pointer<Click>>) | entity.observe(…: Trigger<Pointer<Click>>) | §9C.8 |
OnAdd, OnInsert, OnRemove | Add, 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/to | StateTransitionEvent<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_tweening | AnimatedEntityCommands (trait) + AnimTarget (componente) en 0.16 | §9C.15 |
KinematicCharacterController en Avian | MoveAndSlide (SystemParam en avian2d::character_controller::move_and_slide); KinematicCharacterController es de bevy_rapier | §9C.13 |
ExternalForce / ExternalTorque en Avian | ConstantForce / ConstantTorque (renames en Avian 0.7) | §9C.13 |
CollisionStarted / CollisionEnded | CollisionStart / CollisionEnd (singular; campos entity1/entity2) | §9C.13 |
Spawner en Hanabi | EffectSpawner + 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 suelto | Es un campo de Sprite desde 0.15 | §9C.2 |
SpriteBundle / Camera2dBundle | Deprecados; spawnea Sprite o Camera2d directo (required components hace el resto) | §9C.2, §9C.5 |
app.add_event::<T>() + EventWriter::send | Sigue 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 Bevy | bevy_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.
on_add, on_insert, on_discard, on_remove.Trigger<E>. 5 en 0.19: Add, Insert, Discard, Remove, Despawn.Trigger<E> (no On<E>).#[require].bsn!{...}; implementa el trait Scene y se puede spawnear con spawn o spawn_scene.Event → observers síncronos; Message → cola buffered leída en frames siguientes.Query, Res, MoveAndSlide, SpatialQuery…).Trivia para brillar en reuniones.
cart, lo eligió por eso. (No es un apellido propio "Bevy", ni una nave de Homeworld.)wolfpld), no de Wolfgang Engel."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":
DefaultPlugins. ¿Es cosa de render, input, tiempo, assets, audio, picking, jerarquía? Ya está.Cuándo sí reinventar.
sickle_ui, atascado en 0.14) y migrarlo es inviable.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.
First, PreUpdate (con StateTransition), RunFixedMainLoop (con FixedUpdate), Update, SpawnScene, PostUpdate (con TransformSystems::Propagate), Last.Trigger<E>) y Message (cola buffered, MessageWriter/MessageReader). El viejo add_event/EventWriter sigue para compat, pero la documentación te anima a elegir el correcto según el caso.commands.spawn(Sprite::from_color(c, size)). Los required components insertan Transform, GlobalTransform, Visibility y Anchor automáticamente.ChildOf/Children con linked_spawn; la propagación de GlobalTransform corre en PostUpdate y tú no la tocas.bevy::sprite::Text2d (no bevy::text::Text2d, que no existe). Va acompañado de TextFont, TextLayout y opcionalmente TextColor. En 0.19 se migró de Cosmic Text a Parley; ahora existe EditableText con método .value().commands.spawn(Camera2d::default()). La proyección trae OrthographicProjection::default_2d() y se controla con ScalingMode (enum), no con un f32. RenderLayers para aislar capas entre cámaras.Color tiene 10 variantes (Srgba, LinearRgba, Hsla, Oklcha, …). Almacena en Srgba, opera en shaders con LinearRgba, genera paletas con Oklcha.Time<Real>, Time<Virtual>, Time<Fixed>, …). El Time a secas es el Virtual. Método para delta: time.delta_secs() (no delta_seconds).ButtonInput<KeyCode>), por acciones (leafwing-input-manager / bevy_enhanced_input), y picking (Pointer<Click> + observer Trigger<E>).init_state::<S>() (no add_state), NextState::set, OnEnter/OnExit, evento StateTransitionEvent<S> con exited/entered, DespawnOnExit para limpiar entidades con el estado.on_add, on_insert, on_discard, on_remove) + 5 eventos observables (Add, Insert, Discard, Remove, Despawn). on_replace se renombró a on_discard (PR #22789).AssetServer::load devuelve Handle<T>; el contenido vive en Assets<T>; reaccionas con AssetEvent; hot-reload con feature file_watcher.(AudioPlayer::new(handle), PlaybackSettings::LOOP.with_volume(0.4)); control runtime con AudioSink; volumen global con GlobalVolume; formatos como features opt-in (OGG por defecto).PhysicsPlugins (plural), RigidBody::Dynamic + Collider, ConstantForce (renamed from ExternalForce), CollisionStart/CollisionEnd (singular), MoveAndSlide (SystemParam, no es KinematicCharacterController).bsn!{...} que devuelve un impl Scene; composición por patching, relaciones first-class, scene functions. Spawn con commands.spawn, world.spawn_scene o commands.queue_spawn_scene.AnimatedEntityCommands + AnimTarget (no Animator<T>); bevy_hanabi 0.19 con Module + SpawnerSettings + EffectSpawner; bevy_aseprite_ultra 0.9 para animaciones de Aseprite con hot-reload.info! / warn! / error!, inspector con bevy_inspector_egui::quick::WorldInspectorPlugin, grafo del schedule con bevy_mod_debugdump::schedule_graph_dot (CLI, no plugin), profiling con feature trace_tracy.bevy_save, sickle_ui, bevy_light_2d) y a los que no existen (bevy_navigation, bevy_render_diagnostic).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.