Capítulo 14B. Organización del código: la anatomía de un proyecto Bevy 2D

CAP 14B · Bevy 0.18/0.19
"Organización es todo. El código desorganizado es como un cajón de calcetines donde nunca encuentras el par que te gusta."

— Frase atribuida a Linus Torvalds, mientras se ponía calcetines desparejados un martes cualquiera (probablemente alrededor de 2010, Fundación Linux).

Imagina esto: llevas dos meses con tu juego. Tienes main.rs abierto y ya ocupa 1.800 líneas. Hay un Player definido en la línea 34, otro Player (no, no es el mismo, es otro) en la línea 1.205, y estás seguro de que existe un tercero en algún sitio que ya no recuerdas. Para encontrar la función que gestiona la vida del personaje haces Ctrl+F y rezas. Cuando compila, no sabes por qué. Cuando no compila, tampoco.

Tu juego es un IKEA. Si no divides las piezas en cajones, no montas nada.

Un mueble de IKEA viene con miles de tornillitos, tablas y paneles sueltos. Si los echas todos en una caja grande, acabas con un mueble cojo, te sobran 47 tornillos, y tu pareja te mira con esa cara. Bevy funciona igual: te da un millón de piezas (sistemas, componentes, recursos, eventos) y tú decides si las metes en cajones etiquetados o en un vertedero común llamado main.rs.

En este capítulo vamos a convertir ese vertedero en una cocina montada, con cajones, etiquetas y bisagras que no chirrían.


📦 Glosario exprés

- lib.rs: el "anuncio en la portada" de tu proyecto. Es el archivo que dice "oye, mundo, mi crate tiene estos módulos públicos". En proyectos Bevy casi siempre existe, aunque solo sea para evitar que `main.rs` parezca una novela de Tolstói.
- plugin: en Bevy, un `trait Plugin` es un "cajón con etiqueta" que agrupa sistemas, componentes, recursos y eventos. Se enchufa a la `App` con `app.add_plugins(MiPlugin)`. Piensa en él como un mueble modular: lo montas una vez, lo metes donde quieras y, si te aburre, lo desenchufas.
- module: en Rust, una unidad de organización de código marcada por `mod nombre;` o por una carpeta con `mod.rs` (o, en edición 2021+, con un archivo `nombre.rs` al lado). En nuestro caso, cada "cajón" del IKEA es un módulo.
- visibility: el portero de discoteca del código. Decide quién puede ver qué. `pub` deja pasar a todo el mundo; `pub(crate)` solo a la familia; sin `pub` es el bouncer de la esquina que no deja entrar a nadie.
- re-export: cuando un módulo dice "yo no tengo esto, pero mi amigo sí, toma, te lo paso". Se hace con `pub use otro_modulo::Cosa;`. Es como un relaciones públicas: "el señor Componente vive ahí al fondo, pero yo le gestiono las visitas".

🗂️ La estructura de directorios que no te va a explotar en la cara

Después de leer los proyectos de medio mundo (y de pelearme con los míos propios hasta las tantas), esta es la organización que mejor envejece. Funciona para juegos pequeños, medianos y, con cariño, para juegos grandes:

src/
  main.rs                ← punto de entrada (¡adiós, 1800 líneas!)
  lib.rs                 ← re-exports + plugins públicos
  plugins/               ← un plugin por subsistema
    player.rs
    enemy.rs
    camera.rs
    ui.rs
    audio.rs
  components/            ← structs derive(Component)
    player.rs
    physics.rs
  resources/             ← structs derive(Resource)
    score.rs
    game_clock.rs
  events/                ← structs derive(Event)
    collision.rs
    damage.rs
  systems/               ← funciones fn system(...)
    movement.rs
    spawn.rs
  states/                ← enums derive(States)
    mod.rs               ← enum GameState { Loading, Playing, Paused, GameOver }
  assets/                ← loaders, asset handlers
    mod.rs
  config/                ← App Settings (0.19)
    mod.rs
assets/                  ← assets del juego (fuera de src)
  sprites/
  audio/
  fonts/

La regla de oro: cada cajón contiene cosas del mismo tipo. Los componentes con los componentes, los recursos con los recursos, los plugins con los plugins. Si tienes una struct Player que es a la vez componente, recurso y evento, es que Player está pidiendo vacaciones.

Y muy importante: assets/ vive FUERA de src/. Si la metes dentro, tu binario acaba con cada PNG, OGG y TTF incrustado, y un día descubres que tu cargo build --release produce un ejecutable de 1,2 GB y te mudas a vivir a una cabaña.


🧩 El lib.rs: la vitrina del museo

Tu lib.rs debe ser aburrido y corto. Si emociona a alguien, mal vamos. Este es un buen ejemplo:

// src/lib.rs
//! Crate pública del juego. Aquí solo vive lo que otros plugins (¡o crates!)
//! pueden necesitar.

pub mod plugins;
pub mod components;
pub mod resources;
pub mod events;
pub mod states;

// Re-exports convenientes: un solo import para el consumidor.
pub use plugins::SharedApiPlugin;
pub use states::GameState;
pub use components::{Player, Health};
// src/main.rs
use bevy::prelude::*;
use mi_juego::{SharedApiPlugin, GameState};

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

¿Ves? main.rs cabe en una servilleta. La complejidad se ha ido a lib.rs y a los plugins, donde tiene su sitio.


🪛 Features en Cargo.toml: el juego modular

¿Quieres que tu juego tenga un modo "speedrun" que quite toda la UI y los menús? ¿O un build "kiosko" para ferias donde se desactivan los créditos y el guardado? Features al rescate.

# Cargo.toml
[package]
name = "mi_juego"
version = "0.1.0"
edition = "2021"

[features]
default = ["gameplay", "ui", "audio"]
gameplay = []
ui = ["bevy/bevy_ui"]
audio = ["bevy/bevy_audio"]
dev_tools = []
multiplayer = []

[dependencies]
bevy = { version = "0.18", default-features = false }

Y luego en lib.rs:

// src/lib.rs
#[cfg(feature = "ui")]
pub mod ui;

Con --features dev_tools activas el plugin de debug, sin él el binario ni lo compila. Es como tener un menú secreto en el juego, pero en serio.


🔌 Plugins que hablan entre sí (sin gritar)

Vale, ya tienes cajones. ¿Cómo se hablan? Tres canales oficiales y un pasillo secreto:

1. Vía Events (mensajito puntual)

// events/damage.rs
use bevy::prelude::*;

#[derive(Event)]
pub struct DamageEvent {
    pub target: Entity,
    pub amount: f32,
}

El plugin de combate lo emite (event_writer.write(DamageEvent { ... })); el plugin de UI o el de audio lo escucha. Nadie conoce a nadie. Limpio, escalable, divino.

2. Vía Resources (tablón de anuncios)

// resources/score.rs
use bevy::prelude::*;

#[derive(Resource, Default)]
pub struct Score {
    pub current: u32,
    pub high: u32,
}

Cualquier plugin la lee, cualquiera la escribe (con cuidado). Ideal para estado global.

3. Vía Relationship (Bevy 0.18+) (el nudo limpio)

use bevy::prelude::*;

#[derive(Component)]
#[relationship(relationship_target = Children)]
pub struct ParentOf(Entity);

#[derive(Component)]
#[derive(RelationshipTarget)]
pub struct Children(Vec<Entity>);

Útil cuando un componente "apunta" a otro y Bevy debe mantener la coherencia. Perfecto para "este enemigo pertenece a esta oleada" sin liarte con punteros crudos.


🧠 El patrón "Shared API Plugin"

A veces necesitas un plugin que no hace nada por sí mismo, pero expone los tipos públicos que el resto de plugins usará. Es el "vocabulario común" del juego.

// src/plugins/shared_api.rs
use bevy::prelude::*;

pub struct SharedApiPlugin;

impl Plugin for SharedApiPlugin {
    fn build(&self, app: &mut App) {
        app
            .register_type::<Player>()
            .register_type::<Enemy>()
            .add_sub_state::<GameState>();
    }
}

Otros plugins (player, enemy, ui...) lo añaden antes que ellos mismos, y ya pueden usar Player, Enemy, GameState sin acoplarse al archivo donde están definidos. Es como la "carta de presentación" de tu juego.


🏗️ Patrón "core / optional"

#[cfg(feature = "ui")]
app.add_plugins(ui::UiPlugin);

Así un día puedes compilar el binario "Headless Server" sin UI, o el binario "Demo Replay" sin lógica de spawn. Mismo código base, distintos productos.


💀 Anti-patrón: el "god-plugin de 3000 líneas"

Lo he visto. Lo he escrito. Lo he llorado.

Un archivo gameplay.rs con 3.000 líneas, 14 structs, 23 sistemas, 5 estados, eventos, recursos, TODO's y un println!("debug") perdido en la línea 2.847. Compilaba en 47 segundos y nadie se atrevía a tocarlo.

Regla: si un plugin no cabe en una pantalla, se parte. Punto. Sin debate. Sin "ya lo refactorizo la semana que viene". La semana que viene es ahora.


🧪 Trivia del motor

- Bevy adoptó el sistema de "plugins" como unidad básica de extensibilidad desde su primera versión pública (2020, Cartesia Computing / el canal de YouTube de un tal Cart — luego Formal-Yottagram, luego ya todo el mundo lo conoce como Bevy Engine). La idea no era nueva: Amethyst (2016, Team Amethyst) ya lo intentaba, y engines como Unity (2005, Unity Technologies) y Godot (2014, Juan Linietsky + Ariel Manzur, Argentina) tenían sus propios "módulos" o "subsistemas". Lo que Bevy popularizó fue el "plugin = trait + cero magia" y la comunidad lo abrazó porque, por una vez, podías **leer** el código de un engine sin llorar.

🤦 Metedura de pata clásica: dependencias cíclicas entre plugins

Síntomas:

Cómo detectarlo pronto:

  1. Dibuja en una pizarra: Plugin A → Plugin B (con flechas claras).
  2. Si hay una flecha que vuelve al inicio, tienes un ciclo.
  3. La regla: las dependencias entre plugins deben ser un DAG (grafo acíclico dirigido). Si no lo es, refactoriza.

Truco práctico: pon los "contratos" (eventos, componentes públicos, recursos) en SharedApiPlugin y deja que los plugins consumidores importen solo de ahí, nunca entre ellos directamente. Como cuando en una oficina todo pasa por Recursos Humanos y nadie le escribe directamente al CEO para pedir vacaciones.


🧬 Patrón del capítulo: un plugin por concern, no por feature visible

"Un plugin no es 'lo que el jugador ve' (una pantalla, un menú). Un plugin es un concern: una preocupación técnica o de diseño que vive junta y muere junta."

Ejemplo real compilable (Bevy 0.18/0.19, API actual con Event como derive macro puro y Message para mensajes de app):

// src/plugins/input.rs
use bevy::prelude::*;

/// Concern: mapear input crudo del jugador a intenciones de juego.
pub struct InputPlugin;

#[derive(Event)]
pub struct JumpIntent(pub Entity);

#[derive(Event)]
pub struct MoveIntent {
    pub entity: Entity,
    pub direction: Vec2,
}

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_event::<JumpIntent>()
            .add_event::<MoveIntent>()
            .add_systems(Update, (read_keyboard, read_gamepad).chain());
    }
}

fn read_keyboard(
    keys: Res<ButtonInput<KeyCode>>,
    q_player: Query<Entity, With<Player>>,
    mut jump: EventWriter<JumpIntent>,
    mut move_w: EventWriter<MoveIntent>,
) {
    let Some(player) = q_player.iter().next() else { return };

    let mut dir = Vec2::ZERO;
    if keys.pressed(KeyCode::KeyA) { dir.x -= 1.0; }
    if keys.pressed(KeyCode::KeyD) { dir.x += 1.0; }
    if keys.pressed(KeyCode::KeyW) { dir.y += 1.0; }
    if keys.pressed(KeyCode::KeyS) { dir.y -= 1.0; }

    if dir != Vec2::ZERO {
        move_w.write(MoveIntent { entity: player, direction: dir.normalize() });
    }
    if keys.just_pressed(KeyCode::Space) {
        jump.write(JumpIntent(player));
    }
}

fn read_gamepad(/* ... */) { /* análogo */ }
// src/plugins/physics.rs
use bevy::prelude::*;
use crate::events::input::MoveIntent;

/// Concern: aplicar velocity y position a quien lo pida.
pub struct PhysicsPlugin;

impl Plugin for PhysicsPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Update, apply_movement);
    }
}

fn apply_movement(
    mut q: Query<(&mut Transform, &MoveIntentAttached)>,
    mut move_events: EventReader<MoveIntent>,
    time: Res<Time>,
) {
    for ev in move_events.read() {
        // En la vida real filtrarías por ev.entity, no por el primero.
        if let Some((mut t, _)) = q.iter_mut().next() {
            t.translation += ev.direction.extend(0.0) * 200.0 * time.delta_secs();
        }
    }
}

#[derive(Component)]
pub struct MoveIntentAttached;
// src/main.rs
use bevy::prelude::*;
use mi_juego::{
    plugins::{InputPlugin, PhysicsPlugin},
    components::Player,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((InputPlugin, PhysicsPlugin))
        .add_systems(Startup, spawn_player)
        .run();
}

fn spawn_player(mut commands: Commands) {
    commands.spawn((
        Player,
        Transform::default(),
        MoveIntentAttached,
    ));
}

Observa la magia: InputPlugin emite intenciones; PhysicsPlugin las consume. Ninguno sabe que el otro existe. Si mañana sustituyes el input por voz, o la física por un RigidBody de bevy_rapier, los dos plugins siguen sin enterarse. Eso es un concern bien separado.


✅ Lo que vimos

👉 En el siguiente capítulo

Testing. Esa cosa que todo el mundo dice que hace y pocos hacen. En el capítulo 14G veremos cómo testear todo esto: tests unitarios de sistemas, tests de integración con MinimalPlugins y ScheduleRunnerPlugin, mocks de tiempo, fixtures de assets, snapshots con insta, y cómo hacer tests visuales con bevy_ecs puro (sin ventana, sin GPU, sin sustos en CI). Spoiler: App::new() sin DefaultPlugins es tu mejor amigo. — Esta promesa del v2 ahora se cumple en el 14G; el v1 la dejaba en el aire, y esa fue la crítica más dura de la primera auditoría editorial.