Burricornio Dev

Ingeniería de software sin humo: sistemas, código y aprendizajes en abierto.

Patrones 2D y ECS con Bevy 0.19 (v2.0): lo que el libro te lleva a aprender

¿Qué se necesita para hacer un juego 2D moderno en Rust sin perderse por el camino? ¿Cuánto de lo que aprendes haciendo un plataformas te sirve para diseñar sistemas reactivos, planificadores, motores de UI o pipelines de eventos? Este post anuncia la v2.0 del libro y resume cinco bloques temáticos con los patrones transferibles que el lector se lleva, quiera o no terminar el Mini Metroidvania del capítulo 28.

Si trabajas con Rust, te interesa el patrón ECS, o vienes de otro motor y quieres entender qué aporta Bevy 0.19, este artículo funciona como carta de presentación. Si ya sigues el blog, es la pieza que conecta el escaparate de la librería con el detalle fino de los capítulos.

Anuncio: por qué una v2.0

La primera edición del libro se escribió contra Bevy 0.17. Bevy 0.19 introdujo cambios lo bastante profundos — Resource: Component, time.delta_secs(), ButtonInput<KeyCode>, Mesh2d y MeshMaterial2d como reemplazos de los viejos bundles — como para que una revisión no fuera cosmética. Una migración capítulo a capítulo no bastaba; algunos patrones (modelo unificado de storage, plugins de animación, dev tooling) merecían capítulos nuevos. La v2.0 los añade.

Lo que cambia respecto a la v1.0:

Validación: el libro se compiló contra Bevy 0.19.12 con cargo check tras cada cambio; doce API fixes quedaron validados en commits como 9157308 (round-3 compile-error fixes) y la metadata/errata de la edición 2.0 se sincronizaron en 012c119.

Lo que el libro te enseña, hagas un juego o no

El resto del post es un resumen profundo de cinco bloques. Cada bloque cierra con un “Sigue en el libro →” apuntando al capítulo concreto, no al libro completo: el objetivo es que, si algo te engancha, vayas directo.


Bloque 1 — Fundamentos ECS: datos tontos, sistemas listos

“Datos tontos, sistemas listos.” El valor de un ECS está en invertir la asignación clásica de responsabilidades: los componentes son tarjetas con datos, los sistemas son funciones puras que los leen y transforman.

La metáfora no es retórica. Una struct Position { x: f32, y: f32 } sin métodos es trivialmente Send + Sync, trivialmente serializable, trivialmente paralelizable, trivialmente movible por red y trivialmente testeable. La disciplina opuesta — impl Bloque { fn update(&mut self) {...} } — es lo que el libro llama “data-oriented done wrong intentionally”. En ECS, mover la lógica a un sistema separado no es un capricho estilístico: es lo que permite que el scheduler decida en tiempo de compilación qué sistemas pueden correr en paralelo.

El ejemplo canónico del libro es el “Hola Mundo” del ECS — el cubo que se mueve:

fn move_cube(
    time: Res<Time>,
    mut query: Query<(&mut Position, &Velocity)>,
) {
    for (mut pos, vel) in &mut query {
        pos.0 += vel.0 * time.delta_secs();
    }
}

Lo transferible está en la firma de la función. Query, Res, ResMut, MessageReader<T> no son “parámetros que pides” sino declaraciones de permisos que el framework deduce en compilación. El sistema le dice al scheduler qué necesita; el scheduler decide si dos sistemas que piden lo mismo pueden correr en paralelo o no. Es inyección de dependencias verificada en tiempo de compilación — el mismo principio que en marcos de DI, pero sin runtime reflection.

Tres patrones que el bloque instala:

Sigue en el libro → cap. 4 — Entities, Components y Systems: el trío sagrado y, si necesitas el empujón inicial de Rust, cap. 2 — Rust para Bevy en 30 minutos.


Bloque 2 — Modelado avanzado: invariantes declarativas y modelo unificado

“La diferencia entre un componente y un contrato es que el contrato se cumple solo.” Los hooks (on_add, on_insert, on_discard, on_remove) son funciones que Bevy ejecuta automáticamente en eventos del ciclo de vida de un componente. Mantienen invariantes sin recordatorios en cada sistema.

En lugar de “sanitizar” un valor tras cada mutación posible —la estrategia del desarrollador cansado—, declaras el invariante una vez en on_insert (clamp, derivación, normalización) y olvidas. Es la misma idea que los __post_init__ de las dataclasses de Python, los constructores validados de DDD, o los CHECK constraints de SQL: mover la validación al punto de inserción, no esparcirla por el código.

El ejemplo del libro es un herrero que suelta materiales al despawnear:

#[derive(Component)]
#[component(on_discard = consume_materials)]
struct Smith;

fn consume_materials(
    mut world: DeferredWorld,
    entity: Entity,
    _: ComponentId,
) {
    if let Some(inv) = world.get::<Inventory>(entity) {
        let items = inv.items.clone();
        let pos = world.get::<Position>(entity).copied();
        if let Some(p) = pos {
            info!("soltando {} items al ground en {:?}", items.len(), p);
            // spawneamos entidades-ground... (simplificado)
        }
    }
}

Lo transferible: DeferredWorld es la API reducida para contextos restringidos — los hooks no pueden usar Query/Res directamente porque esas APIs no son seguras dentro del ciclo de mutación. Es la misma técnica que el Context de React, los scopes de Python o el Environment de Node: puertas de acceso limitadas para contextos sensibles.

El segundo capítulo de este bloque (cap. 13) ataca algo más fundamental: Bevy 0.19 funde Resource con Component. Resource pasa a ser un subtrait de Component:

use bevy::prelude::*;

#[derive(Component, Resource, Reflect, Debug)]
#[reflect(Component, Resource)]
struct GameSettings {
    pub difficulty: f32,
    pub music_volume: f32,
}

fn read_settings(res: Res<GameSettings>) {
    info!("Dificultad: {}", res.difficulty);
}

// Y si en algún momento decides adjuntarlo a una entity:
fn attach_settings(mut commands: Commands) {
    commands.spawn(GameSettings { difficulty: 1.0, music_volume: 0.8 });
}

La idea no es una refactorización caprichosa. Tras cinco años manteniendo dos sistemas de almacenamiento casi idénticos, el equipo de Bevy admitió que siempre fueron el mismo, diferenciados solo por un parámetro: la cardinalidad. Storage, hooks, observers y relations ya no se duplican entre resource y component. La regla transferible es: cuando dos primitivas de tu sistema son el 95% idénticas, lo que las diferencia es un parámetro — modela el parámetro, no la primitiva.

Sigue en el libro → cap. 9 — Component Hooks y cap. 13 — Resources-as-components y el nuevo modelo unificado de Bevy 0.19.


Bloque 3 — Render y shaders: WGSL como amplificador

“Un shader es una promesa: ‘con estos píxeles, hazme esto’. La GPU cumple; el resto es pedagogía.” La regla del capítulo: escribe un shader solo para amplificar algo que ya existe, no para sustituirlo entero.

Hay tres patrones de fondo en el bloque de render que merecen salir del contexto de los juegos.

Bind groups como “sobres” con apartados numerados. Rust rellena apartados (#[uniform(0)], #[texture(1)], #[sampler(2)]); WGSL lee apartados (@binding(0), @binding(1), @binding(2)); MATERIAL_BIND_GROUP es el número de sobre que Bevy asigna. El patrón es transversal a FFI, syscall ABIs, descriptores de sockets y protocolos de red: canales numerados entre dos dominios con seguridad de tipos.

Cómputo en el sitio correcto de la pipeline. El ejemplo del capítulo 14E — un vertex shader que ondula un sprite — ilustra el principio:

#[vertex]
fn vertex(
    @builtin(vertex_index) idx: u32,
    @location(0) position: vec2<f32>,
    @location(1) uv: vec2<f32>,
) -> VertexOutput {
    var out: VertexOutput;
    let wave = sin(position.x * material.frequency + material.time) * material.amplitude;
    out.position = vec4<f32>(position.x, position.y + wave, 0.0, 1.0);
    out.uv = uv;
    return out;
}

El sistema de pulso en CPU escribe una vez por frame el color lerp en el uniform, y el shader simplemente lo aplica. La tentación de meter time como uniform y hacer todo el cálculo en WGSL “funciona pero contamina el shader”. La regla transferible: los datos derivados se computan donde está la lógica; el shader solo lee. Es la separación control plane vs. data plane.

Batching y materiales compartidos. 200 sprites con 200 Material2d distintos = 200 draw calls. Reutilizar el mismo Handle<Material> (clonando, no creando) entre sprites preserva el batching. Trade-off: cada material custom rompe el batching automático. La regla de oro: compartir inmutables, duplicar mutables — válida para cualquier pool de recursos costosos, no solo para la GPU.

Sigue en el libro → cap. 14E — Shaders WGSL para 2D, cap. 14F — Post-processing 2D y, si quieres el contexto de sprites, cap. 14 — Renderizado 2D.


Bloque 4 — Sistemas de juego: decidir, planificar, ejecutar

“Una FSM decide qué hacer ahora. Un behavior tree decide qué probar ahora. La diferencia es que el segundo puede elegir no hacer nada.” — Alex Champandard

El bloque de sistemas de juego es donde más fácil resulta extrapolar a otros dominios. Una IA de enemigo, un planificador de tareas en un orquestador, un sistema de retry con backoff o un agente que decide qué API llamar son, en el fondo, el mismo problema: decidir entre varias opciones con información incompleta y coste desigual.

El libro recorre el catálogo y deja clara la regla de selección:

Lo transferible del behavior tree es el patrón de los combinadores:

pub struct Selector { pub children: Vec<Box<dyn BtNode>> }

impl BtNode for Selector {
    fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
        for child in &mut self.children {
            match child.tick(ctx) {
                BtStatus::Success => return BtStatus::Success,
                BtStatus::Running  => return BtStatus::Running,
                BtStatus::Failure  => continue,
            }
        }
        BtStatus::Failure
    }
}

El trait BtNode tiene una sola firma: fn tick(&mut self, ctx: &mut BtContext) -> BtStatus. Todo lo que un nodo necesite (tiempo, salud, posiciones) vive dentro de BtContext, no como parámetro extra. Es el patrón “trait de un solo parámetro + context object” que usan Iterator, Visitor, Reducer, Middleware en otros lenguajes. Garantiza que cualquier struct que implemente el trait sea intercambiable.

El capítulo de pathfinding añade el segundo patrón transferible: separar planificación y ejecución, e invalidar por condiciones explícitas.

fn maybe_repath(
    mut q: Query<(&mut PathCache, &GridPos)>,
    time: Res<Time>,
    player: Query<&GridPos, With<Player>>,
) {
    let player_pos = *player.single();
    let now = time.elapsed_secs();
    for (mut cache, current) in &mut q {
        let stale = now - cache.last_calculated > 0.5;
        let goal_moved = cache.goal_at_calc != player_pos;
        let blocked = cache.path.first().map_or(false, |p| is_blocked(*p));
        if stale || goal_moved || blocked {
            cache.path = a_star(*current, player_pos, neighbours, manhattan)
                .unwrap_or_default();
            cache.last_calculated = now;
            cache.goal_at_calc = player_pos;
        }
    }
}

“El pathfinding no es difícil. Lo difícil es saber cuándo dejar de calcular y empezar a moverse.” — Amit Patel. La regla es universal: el coste de ir, no el de llegar. A* corre una vez por cambio de destino, no por frame; el path se cachea hasta que algo lo invalida. Es la separación entre commit y apply que ves en cualquier sistema con recálculo caro: garbage collector, rebuilder de índices, replanificador de rutas, recompilador de regex, gestor de conexiones a base de datos.

Sigue en el libro → cap. 21 — Behavior trees y utility AI, cap. 22 — Pathfinding A* y navegación y, si te interesa la generación procedural, cap. 21C — PCG.


Bloque 5 — Producción: release como feature

“Hacer un juego es el 30% del trabajo. Lanzarlo es el 70%.”

Este bloque es, en mi opinión, el más transferible del libro entero, incluso si nunca tocas Bevy. Lo presento como cinco hábitos que aplican a cualquier binario Rust que quieras llevar a producción.

Milestones incrementales compilables. Los once milestones del cap. 28 (M1 = ventana + gravedad; M11 = persistencia + audio) demuestran un patrón de proyecto universal: cada paso produce código compilable y ejecutable, no un diseño en el vacío. La integración (M8) es el último milestone, no el primero: solo cuando tienes suficientes piezas sabes qué orden tiene sentido. Es la versión “vertical slice” del MVP.

Arquitectura por plugins como descomposición modular. PlayerPlugin, EnemyPlugin, TilemapPlugin, HudPlugin, StatePlugin, SavePlugin, AudioPlugin, GamePlugin (orquestador). El patrón es “feature modules” o “bounded contexts” de DDD aplicado a un motor de juego. Cada plugin encapsula recursos + componentes + eventos + sistemas. La regla operativa: “cada vez que main.rs pasa de 300 líneas, parte algo en un plugin”.

Migraciones de save con versionado en el header. El patrón SaveV1SaveV2 con un enum SaveAny y un método to_v2 que aplica las migraciones en cadena es exactamente el patrón de migraciones de bases de datos (Rails, Flyway, Alembic) o de esquemas JSON Schema:

#[derive(Serialize, Deserialize)]
struct SaveV1 { player_name: String, hp: i32 }

#[derive(Serialize, Deserialize)]
struct SaveV2 { player_name: String, hp: i32, level: u32 }

impl SaveV1 {
    fn migrate(self) -> SaveV2 {
        SaveV2 {
            player_name: self.player_name,
            hp: self.hp,
            level: 1,   // default para migraciones v1 → v2.
        }
    }
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "version")]
enum SaveAny {
    #[serde(rename = "1")] V1(SaveV1),
    #[serde(rename = "2")] V2(SaveV2),
}

impl SaveAny {
    fn to_v2(self) -> SaveV2 {
        match self {
            SaveAny::V1(v1) => v1.migrate(),
            SaveAny::V2(v2) => v2,
        }
    }
}

La regla operativa: “cada save tiene una versión en su header; al cargar, miras la versión; si es vieja, aplicas las migraciones en cadena”. Pensarlo antes, no después.

Optimización en cuatro capas: profile → Cargo.toml → features → linker. El cap. 28B es un manual de optimización de binarios Rust en producción que va mucho más allá de los juegos:

Telemetría honesta: percentil 95, no promedio. “El promedio puede decir 60 fps mientras que el 5% de los frames son 12 fps (lo que se siente horrible). El percentil 95 ignora el 5% peor. Útil para ‘frame time’ porque el promedio miente.” Transferible a cualquier sistema con latencia: monitorización de API (p99 de response time), colas (p95 de espera), compilación (p95 de tiempo de build).

Sigue en el libro → cap. 28 — Proyecto final: un Mini Metroidvania y, sobre todo, cap. 28B — De prototipo a release.


¿Por dónde empezar?

Tres rutas según de dónde vengas:

Nota: el libro completo está disponible en blog.rubentxu.dev/libros/bevy-2d-ecs/, con índice navegable, lectura lineal y código fuente de cada capítulo. La próxima edición se apoyará en bevy_cli (en alpha) cuando salga de alpha y consolide su modelo de scaffolding y migración entre versiones.

Si este resumen te dejó con ganas de más, el libro está al aire y es de lectura abierta. Si te cerró alguna puerta (Bevy no era para ti, prefieres otro motor, o el bloque de producción te interesa más que el resto), el cap. 28B sigue siendo lectura útil sin saber nada de ECS: es, en el fondo, un manual de cómo llevar un binario Rust a producción.