Capítulo 12. bsn! y escenas next-gen: definir mundos en una macro

CAP 12 · Bevy 0.18/0.19
"El código debería escribirse como se habla: con intención, contexto y estructura. Las escenas de Bevy merecían una sintaxis que respetara eso."

Carter Anderson, en el blog oficial de Bevy (2024), anunciando la macro bsn!.

Si has llegado hasta aquí desde el capítulo 1, has visto cómo Bevy pasó de un motor experimental a un sistema con ECS, scenes, observers, relations y hooks. Pero faltaba una pieza que todo el mundo pedía a gritos: una forma declarativa y legible de definir escenas. Hasta 0.18, escribir una escena grande era como rellenar el papeleo del médico a mano: muchos commands.spawn(...), muchos with_children, muchos insert. La macro bsn! (Bevy Scene Notation, pronunciado "beeson") cambia eso para siempre.

Piensa en este capítulo como el día que le dieron a Bevy un diccionario ilustrado en vez de un manual de instrucciones. Misma información, otra accesibilidad.


12.1 — El problema: las escenas actuales escalan fatal

Para apreciar bsn!, primero hay que sufrir con lo que había. Supongamos que quieres una escena de "casa" con:

Con commands.spawn a la antigua:

use bevy::prelude::*;

#[derive(Component)] struct House;
#[derive(Component)] struct Mailbox;
#[derive(Component)] struct Door { locked: bool }
#[derive(Component)] struct Lock;

fn spawn_house(mut commands: Commands) {
    commands
        .spawn((
            Name::new("House"),
            House,
            Transform::from_xyz(0.0, 0.0, 0.0),
        ))
        .with_children(|parent| {
            parent.spawn((Name::new("Mailbox"), Mailbox, Transform::from_xyz(2.0, 0.0, 0.0)));
            parent
                .spawn((
                    Name::new("Door"),
                    Door { locked: true },
                    Transform::from_xyz(0.0, 0.0, 1.0),
                ))
                .with_children(|door| {
                    door.spawn((Name::new("Lock"), Lock));
                });
        });
}

Cuenta las líneas. Cuenta los with_children anidados. Cuenta los Transform::from_xyz que vas a tener que escribir para 50 objetos. Es repetitivo y propenso a errores. Olvidas un paréntesis y media casa flota en el éter.

DynamicScene + .scn (archivo binario) es más compacto en disco pero requiere un loader y un round-trip a través de serialización. No es rápido para prototipar.

bsn! aparece como respuesta: una macro que te deja escribir la estructura como un literal estructurado, directamente en código Rust.

**bsn! (Bevy Scene Notation)** — macro procedural de Bevy 0.19 que produce un `DynamicScene` (o un bundle para `spawn`) a partir de una sintaxis declarativa inspirada en JSON. Soporta hierarchies, required components, observers inline y componentes reflejados (Reflect).

12.2 — Hello, bsn!

La forma más simple:

use bevy::prelude::*;
use bevy::scene::bsn;

let scene = bsn! {
    Name("Casa") : {
        House,
        Transform::from_xyz(0.0, 0.0, 0.0),
        Mailbox : {
            Name: "Buzón",
            Transform::from_xyz(2.0, 0.0, 0.0),
        },
        Door(locked: true) : {
            Transform::from_xyz(0.0, 0.0, 1.0),
            Lock,
        },
    }
};

Compilando esto obtienes un valor que se puede pasar a DynamicSceneBuilder o usar como bundle. La sintaxis es casi autoexplicativa: Component(value) para un componente con datos, Component solo para tag, y : seguido de { } para abrir children.

**Trivia** — El nombre `bsn` es un guiño a JSON (JavaScript Object Notation), pero con la B de Bevy. Carter Anderson lo bautizó así en una reunión del equipo core el 14 de junio de 2024, según el commit que introdujo la macro. Lo pronuncian como "beeson" en Discord.

12.3 — Anatomía de la macro

Una expresión bsn! tiene tres bloques principales:

  1. Componentes y tags: cualquier struct que implemente Reflect o Component.
  2. Required components: si el componente declarado requiere otros (vía #[require]), bsn! los añade automáticamente a menos que los listes tú.
  3. Children: el bloque { ... } después de : define las entidades hijas, propagando el Parent.
use bevy::prelude::*;

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

#[derive(Component, Reflect)]
#[reflect(Component)]
#[require(MailboxFlag)]   // <-- required component
struct Mailbox;

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

Al usar bsn! con Mailbox, el MailboxFlag se inserta solo. Puedes sobreescribirlo explícitamente si quieres.

Tipos soportados

FormaSignificado
NameTag component sin datos
Name("texto")Componente con un solo campo (azucarado)
Name(value1, value2)Tupla de campos en orden
Transform::from_xyz(...)Llamada a constructor
Color::srgb(0.5, 0.5, 0.5)Cualquier expresión evaluada en scope
42_i32Literal (interpretado como Reflect-friendly)
Component : { ... }Abre children de esta entidad
**Metedura de pata** — Asumir que `bsn!` te deja llamar funciones externas con lógica compleja. Solo acepta expresiones `const` o que el compilador pueda resolver. Para computación pesada, construye un `DynamicScene` manualmente o usa `commands.spawn`.

12.4 — Un pueblo entero en 30 líneas

Vamos a por el ejemplo jugoso: un pueblo con tres casas, un pozo, un árbol y un cartel. Antes y después.

Antes (sin bsn!): ~80 líneas de commands.spawn.

Después (con bsn!):

use bevy::prelude::*;
use bevy::scene::bsn;

#[derive(Component, Reflect, Default)] #[reflect(Component)] struct House { owner: &'static str }
#[derive(Component, Reflect, Default)] #[reflect(Component)] struct Well;
#[derive(Component, Reflect, Default)] #[reflect(Component)] struct Tree;
#[derive(Component, Reflect, Default)] #[reflect(Component)] struct Sign { text: &'static str }
#[derive(Component, Reflect, Default)] #[reflect(Component)] struct Roof;

let pueblo = bsn! {
    Name: "Pueblo de Sprout"
    : {
        House(owner: "Aldara") : {
            Transform::from_xyz(-4.0, 0.0, 0.0),
            Roof : { Transform::from_xyz(0.0, 2.0, 0.0) },
        },
        House(owner: "Bram") : {
            Transform::from_xyz(0.0, 0.0, 0.0),
            Roof : { Transform::from_xyz(0.0, 2.0, 0.0) },
        },
        House(owner: "Cyra") : {
            Transform::from_xyz(4.0, 0.0, 0.0),
            Roof : { Transform::from_xyz(0.0, 2.0, 0.0) },
        },
        Well : { Transform::from_xyz(2.0, 0.0, -3.0) },
        Tree : { Transform::from_xyz(-3.0, 0.0, -4.0) },
        Sign(text: "Bienvenidos a Sprout") : {
            Transform::from_xyz(0.0, 0.0, 5.0),
        },
    }
};

Compacto, jerárquico, legible. Y lo mejor: cuando lo compiles y mires el AST, Bevy detectará errores tipográficos en componentes que no existen, gracias a #[reflect(Component)].

**Reflect + Component** — el atributo `#[reflect(Component)]` registra el tipo en el `TypeRegistry`, permitiendo que `bsn!` lo reconozca y valide. Sin él, `bsn!` no sabe qué componentes existen y los rechaza en tiempo de compilación (o runtime, según el modo).

12.5 — Observers inline

0.19 permite definir observers dentro de bsn! para que se adjunten automáticamente a la entidad:

use bevy::prelude::*;
use bevy::scene::bsn;

#[derive(Component, Reflect, Default)] #[reflect(Component)] struct Door;
#[derive(Event, Reflect, Default)] #[reflect(Event)] struct DoorOpened;

let casa_con_sensor = bsn! {
    House : {
        Door : {
            // observer inline: cuando se dispare DoorOpened, loguea
            observe(|trigger: Trigger<DoorOpened>, names: Query<&Name>| {
                info!("Puerta {:?} abierta", trigger.entity());
            }),
        },
    }
};

Esa sintaxis exacta puede variar entre 0.19.0 y 0.19.x porque la macro todavía está en estabilización. Lo importante es la intención: el observer vive al lado de la entidad que escucha, no en un sistema distante.

**Trivia** — La sintaxis `observe(|...| { ... })` se inspiró en las **"trait aliases"** que el equipo de Rust experimentó en 2018 y descartó. El RFC 1733 (sinmerged) proponía bloques similares. La idea resucitó en Bevy porque los observers son closures con estado implícito.

12.6 — bsn! vs DynamicScene vs SceneBundle

Las tres vías de definir escenas:

MétodoProsContrasCuándo usarlo
commands.spawnTipado fuerte, runtimeVerboso, mucha boilerplateLógica generada dinámicamente
DynamicScene + .scnBinario compacto, asset-loadableReflection en runtime, sin tiposDatos de diseñadores, save files
bsn!Declarativo, checked, jerárquicoSolo en código, no asset todavíaPrototipado rápido, prefabs en código
SceneBundle (loader)Carga .scn desde discoAsset pipelineProducción con assets

bsn! no reemplaza a DynamicScene: lo envuelve. Internamente, expande a una estructura que DynamicSceneBuilder puede serializar. La diferencia es la ergonomía en el código fuente.

**Metedura de pata** — Creer que `bsn!` genera un asset `.scn` que puedes cargar con `AssetServer`. **No lo hace en 0.19.** Todavía no hay loader oficial para sintaxis `.bsn` en disco. Es code-driven only. Si necesitas un asset, exporta el `DynamicScene` resultante con `DynamicSceneBuilder`.

12.7 — El caveat: en 0.19 todavía no hay asset loader

Repetimos esto porque la confusión es frecuente:

use bevy::prelude::*;
use bevy::scene::{bsn, DynamicSceneBuilder};

fn save_bsn_as_asset(world: &mut World, path: &str) {
    let scene = bsn! {
        House : { Roof }
    };
    let mut builder = DynamicSceneBuilder::from_world(world);
    builder.extract_entity(scene.entity()).unwrap();
    let dynamic = builder.build();
    let serialized = dynamic.serialize_ron(world.resource::<AppTypeRegistry>()).unwrap();
    std::fs::write(path, serialized).unwrap();
}
**DynamicScene** — estructura intermedia de Bevy que describe una escena como una lista de entities + componentes, sin instanciar en el `World`. Permite serializar, transferir entre worlds y aplicar selectivamente.

12.8 — Errores comunes y cómo leerlos

Cuando algo en bsn! falla, el compilador te lanza mensajes a veces crípticos. Algunos patrones típicos:

**Trivia** — Bevy usa `bevy_reflect` (desarrollado originalmente por **Justin Moll** y **Nikita Pekin** desde 2021) para hacer posible todo esto. Sin reflection, ninguna macro podría validar tipos en tiempo de compilación. Es la pieza invisible que hace que `bsn!` funcione.

12.9 — Composición con bsn_list![]

Hasta ahora cada bsn! producía una sola entidad (con sus children). Pero muchas veces quieres spawnear varias entidades sueltas de una vez, sin que sean hijas unas de otras. Para eso existe bsn_list![]:

//! cap-12 — sección 12.9: bsn_list![].
use bevy::prelude::*;
use bevy::scene::bsn;

let pickups_del_nivel = bsn_list![
    Moneda : { Transform::from_xyz(2.0, 0.0, 0.0) },
    Moneda : { Transform::from_xyz(4.0, 0.0, 0.0) },
    Moneda : { Transform::from_xyz(6.0, 0.0, 0.0) },
    Pocion : { Transform::from_xyz(10.0, 0.0, 1.0) },
];

Cada elemento de la lista es una entidad independiente que se spawnea al ejecutar el código. El resultado es un array o Vec de entidades; las puedes iterar o pasar a un sistema.

Caso de uso típico: oleadas de enemigos, pickups, decoraciones, efectos. En vez de un bsn! con 50 hijos (que te obliga a tener un padre artificial), una bsn_list![] te da 50 entidades sueltas con sintaxis mínima.

Combinando bsn! y bsn_list![]:

//! cap-12 — sección 12.9: bsn anidado en bsn_list.
let nivel_completo = bsn! {
    Name: "Nivel 1"
    : {
        SpawnPoint : { Transform::from_xyz(0.0, 0.0, 0.0) },
        Meta       : { Transform::from_xyz(50.0, 0.0, 0.0) },
    }
};

// Luego, en otro sistema, spawnea la lista de pickups:
let pickups = bsn_list![
    Moneda : { Transform::from_xyz(2.0, 0.0, 0.0) },
    Moneda : { Transform::from_xyz(4.0, 0.0, 0.0) },
];

bsn_list![] es especialmente útil para datos generados proceduralmente: el contenido exacto de la lista puede venir de un loop (for i in 0..50 { bsn_list!(...) }) o de un fichero de configuración. La macro solo se queja de la sintaxis, no del origen de los datos.

**Trivia** — `bsn_list![]` se inspiró en las **"array comprehensions"** que muchos lenguajes funcionales (Haskell, F#, Elixir) tienen desde siempre. Rust no las tiene, pero la macro de Bevy las simula con sintaxis familiar. El RFC 4574 sobre array comprehensions (2024) podría traerlas nativas a Rust 2026.

12.10 — SceneComponent para tus tipos custom

El cap 12.3 te mostró que tus componentes necesitan #[reflect(Component)] para aparecer en bsn!. Pero hay un paso más: derivar SceneComponent te garantiza que si tu componente está presente, la escena completa que lo define también lo está. Sin esto, nada impide que alguien spawnee Player::default() sin su bsn! escena asociada.

//! cap-12 — sección 12.10: SceneComponent derive.
use bevy::prelude::*;
use bevy::scene::SceneComponent;

#[derive(Component, Reflect, Default, SceneComponent)]
#[reflect(Component)]
struct Player {
    hp: u32,
    max_hp: u32,
    speed: f32,
}

Ahora, si en tu juego alguien hace world.spawn(Player::default()), Bevy loguea un error en lugar de spawear la entidad vacía. Para spawnear un Player correctamente, hay que pasar por la escena:

//! cap-12 — sección 12.10: spawn correcto via bsn!.
let p = world.spawn_scene(bsn! {
    Player(hp: 100, max_hp: 100, speed: 120.0) : {
        Transform::default(),
        Sprite::default(),
    }
});

El método spawn_scene valida que el SceneComponent derive esté presente y aplica la escena completa (Player + Transform + Sprite + sus required components). Si lo intentas spawnear "a mano" con world.spawn(Player::default()), Bevy te grita.

Cuándo usarlo:

Cuándo NO usarlo:

Comparación rápida:

Forma de spawnearGarantíasCuándo
world.spawn(MyComp::default())Solo el componente. Required pueden faltar.Tags, datos puros
world.spawn(MyComp + bundle)Tú recuerdas el bundle.Componentes con datos, no críticos
world.spawn_scene(bsn! { MyComp : {...} })Componente + escena + required + observers.Componentes con lógica de inicialización

El SceneComponent derive es la red de seguridad para "no se te olvide la inicialización". Úsalo en componentes que tienen un setup complejo, y deja los tags y datos puros sin él.

**Trivia** — La idea de `SceneComponent` viene de un patrón llamado **"Always-Initialized Entity"** que los autores de Bevy vieron en los motores de Naughty Dog (presentaciones de GDC 2014-2018). Un personaje en Uncharted nunca se spawnea como "el esqueleto vacío"; se spawnea como "la entidad con todo su setup". `SceneComponent` lo hace explícito en tipos.

🎯 12.11 — Patrón del capítulo

Patrón: bsn! para prototipar mundos en minutos

>

Cuando necesites una escena con jerarquía clara y muchos componentes, escribe un bloque bsn! antes de tocar commands.spawn. Te dará:

1. Validación en compilación (vía Reflect).

2. Estructura visual que cualquier colaborador entiende sin leer Rust.

3. Refactorización segura: renombrar un componente rompe el build, no en runtime.

4. Migración futura trivial a un asset loader cuando 0.20+ lo introduzca.

>

Combínalo con DynamicSceneBuilder::extract_entity si necesitas persistir. Para datos generados en runtime (procedural, save files), sigue con commands.spawn.

Anti-patrones

  1. ❌ Usar bsn! para una sola entidad. El overhead sintáctico no compensa.
  2. ❌ Asumir que los observers inline persisten al deserializar: solo viven en código.
  3. ❌ Intentar cargar .bsn desde disco: todavía no es un formato asset.
  4. ❌ Olvidar #[reflect(Component)]: la macro fallará silenciosamente con errores raros.
  5. ❌ Computar posiciones con rand() dentro de bsn!: no es const y revienta.

Lo que vimos

Con bsn! puedes definir un nivel entero en una sola función Rust. Pero todavía hay una asimetría en el lenguaje: a veces quieres que un tipo sea Recurso y a la vez Componente. La respuesta elegante a eso es lo que veremos a continuación.

En el siguiente

En el capítulo 13 — Resources-as-components (0.19) veremos el cambio conceptual más grande de 0.19: Resource ahora es un subtrait de Component. Esto unifica los modelos y elimina duplicación, pero trae trade-offs importantes (un tipo no puede ser ambos a la vez). Aprenderemos a migrar código antiguo, cuándo usar uno u otro, y por qué este cambio estaba cantado desde 0.10. Patrón de migración incluido.