Vamos a abrir el capó de Bevy. Si en el capítulo 3 vimos la app de arriba (ventana, plugins, el gran botón de "play"), ahora vamos a ver el motor. Y el motor de Bevy se sostiene sobre tres pilares que son tan aburridos en abstracto como importantes en la práctica: Entities, Components y Systems. Los tres mosqueteros. O el trío dinámico, como prefieras imaginarlos (yo los imagino como un grupo de K-pop mal avenido, pero eso es otra historia).
Prepárate un café, un monster, o lo que tomes cuando un libro te dice que "todo es dato" y que "los sistemas son funciones puras". Y si no entiendes a la primera, no te preocupes: a la segunda probablemente tampoco. A la tercera empiezas a sonreír como un idiota. A la cuarta ya estás diseñando tu propio juego. Bienvenido.
Voy a explicarte ECS con una analogía que probablemente te arruine la vida útil de cualquier restaurante, pero también te va a aclarar tres años de confusión sobre arquitectura de juegos.
Imagina un restaurante. Un restaurante cutre, de esos donde los camareros son sustituibles y la cocina es una mafia.
AlergiaMariscos, le pones CuentaAcumulada(34.50), le pones CaraDeAsco, le pones MesaNumero(7). Esos papelitos pegados son los componentes. Y son datos tontos. No saben hacer nada. No cocinan. No cobran. Solo son.CuentaAcumulada y Hambriento, te traigo la cuenta". "Tú, mesa 7, que llevas ReciénLlegado y no llevas AsignadoAMesonero, te asigno al Rober". Los mesoneros son los que transforman el estado del mundo (acercarte la cuchara, apuntar tu pedido, cobrarte). Son lógica pura, lista, y — esto es lo bonito — no almacenan estado por sí mismos: lo leen de los papelitos (componentes) de los comensales (entities).Hambriento y ConPedidoTomado y que NO tengan YaServido!". El mesonero pega un grito, el comedor le devuelve una lista de mesas, y él trabaja con esa lista. Nada más.Ahora imagínate lo que pasa cuando un mesonero nuevo llega al turno: no tiene que aprender la historia de cada cliente. Solo ve los papelitos y actúa. Y cuando un comensal se va (la entity se despawna), el restaurante no se cae: el siguiente comensal es una entity nueva, con sus propios papelitos, y los mesoneros siguen su ronda como si nada.
¿Ves la magia? No hay un objeto "Comensal" con un método pedir(). No hay un class Cliente extends Persona. Solo datos (los papelitos) y funciones (los mesoneros) que pasan por encima de los datos y los transforman. Es la misma idea detrás de Pac-Man: cada fantasma era un montoncito de datos (posición, modo, target tile) y un bucle enorme que actualizaba cada uno por separado. La diferencia es que Bevy te da el marco, las herramientas y el #[derive(Component)] para no tener que escribir el bucle a mano.
- **Entity**: un id opaco (un número entero disfrazado). Por sí solo no hace nada. Es "el de la mesa 7". Se crea con `commands.spawn(...)`.
- **Component**: un dato tontito que se "pega" a una entity. En Bevy se define con `#[derive(Component)]`. Ejemplos: `Position { x, y }`, `Velocidad(120.0)`, `Player`.
- **System**: una función que toma datos del mundo (queries, resources, events) y los modifica. Se anota con `fn` normal y se registra con `.add_systems(Update, mi_sistema)`.
- **World**: el gran almacén donde viven entities, components, resources y events. Piensa en él como el restaurante entero: las mesas, los papelitos, la caja registradora, la lista de espera.
- **Query**: un parámetro mágico de un system. Le dice a Bevy "tráeme todas las entities que tengan X componente(s) y que cumplan Y condiciones". Se declara como `Query<&Position, With<Player>>` por ejemplo.
Vamos a escribir el component más simple del mundo, y a sentir la pequeña punzada de ridículo que todo el mundo siente al principio. (Sí, "esto es todo, ¿en serio?". Sí, en serio. Bienvenido.)
use bevy::prelude::*;
#[derive(Component)]
struct Position {
x: f32,
y: f32,
}
Eso es todo. Eso es un component. Una struct con #[derive(Component)] y sus campos. Bevy ya sabe qué hacer con ella: la puede almacenar, copiar, mover, buscar, destruir, y meter en un query. No tiene métodos, no tiene impl, no tiene lógica. Es dato. Dato puro, seco, sin más. Como una pasa de Corinto: pequeña, sin gracia, fundamental.
¿Podemos añadirle un Default? Sí, y es muy buena idea casi siempre:
#[derive(Component, Default)]
struct Position {
x: f32,
y: f32,
}
Y ahora podemos hacer Position::default() para tener una posición en (0, 0) cuando la necesitemos. Bevy no lo exige, pero el trait Default te salva la vida en spawn_bundles y en tests, así que acostúmbrate a ponerlo.
¿Y las unit structs como "marcadores"? Sí, también. Un component vacío (sin campos) sirve para etiquetar entities. Por ejemplo:
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Enemy;
Eso es un "tag component". No almacena datos, pero el mero hecho de estar ahí le dice a Bevy "esta entity es un player" o "esta entity es un enemy", y permite hacer queries como Query<&Transform, With<Player>>. Lo verás mil veces. Es el pan nuestro de cada día en ECS.
¿Y enums? También:
#[derive(Component)]
enum Estado {
Idle,
Caminando,
Saltando,
Muerto,
}
Bevy lo acepta siempre que el componente sea Send + Sync + 'static (que es la condición que pone en la mayoría de derives). Tus tipos favoritos — String, Vec<T>, HashMap<K, V>, structs anidadas — funcionan sin más. La regla es la misma de siempre en Rust: si compila, vale. Si no, ya sabes dónde mirar.
- **1962** — *Spacewar!* (Steve Russell, MIT). Considerado el primer videojuego. Dos naves, datos separados del comportamiento. Los autores se volvieron legendarios no por hacer un "objeto Nave con métodos" sino por tener un bucle principal que actualizaba structs simples. Spoiler: estaban haciendo ECS sin saberlo.
- **1981** — *Pac-Man* (Namco, Japón). Cada fantasma es un set de datos (posición, modo scatter/chase, tile objetivo) más un bucle que actualiza cada uno. Mismo patrón. 14 años antes de que la industria lo bautizara.
- **2002** — *Dungeon Siege* (Gas Powered Games, EE.UU.). El equipo de Steve Taylor acuñó el término "data-oriented design" y empujó a la industria a dejar de pensar en objetos y empezar a pensar en datos. Spoiler 2: estaban hartos de que un `class Monster` con un método `update()` les matara la CPU.
Hay datos que no pertenecen a una entity concreta, sino al mundo en general. La puntuación global. El nivel actual. La configuración de la cámara. El handle del asset del menú. La música que está sonando. La lista de high-scores. El delta-time acumulado.
Esos datos no van como components (porque no van "pegados" a un comensal concreto, sino al restaurante entero). Van como resources. Y se definen exactamente igual que un component, pero con un derive distinto:
use bevy::prelude::*;
#[derive(Resource)]
struct GameConfig {
volumen: f32,
dificultad: f32,
fullscreen: bool,
}
#[derive(Resource)]
struct PuntuacionGlobal(u32);
Se insertan una vez en el World y se accede a ellos desde cualquier system que los pida como parámetro:
fn mostrar_puntuacion(puntuacion: Res<PuntuacionGlobal>) {
println!("Llevas {} punticos.", puntuacion.0);
}
Más adelante (en el capítulo 5) veremos Res vs ResMut, FromWorld, y todos los trucos. Por ahora, quédate con la idea: los components van en entities, los resources van en el world. Es una distinción que parece tonta y que te ahorra tres meses de refactor.
- **Resource**: dato global del world. No pertenece a una entity. Se accede con `Res<T>` (inmutable) o `ResMut<T>` (mutable) en un system.
- **Component**: dato pegado a una entity. Se accede con `Query<&T>` o `Query<&mut T>`.
- **Event**: un mensaje que se envía entre systems. Se accede con `EventReader<T>` o `EventWriter<T>`. Lo vemos en el cap 6.
A veces un system necesita decirle algo a otro system sin llamarlo directamente. "Oye, que un enemigo ha muerto". "Oye, que el jugador ha cogido una moneda". "Oye, que hay que cambiar de nivel".
Eso se hace con events. Piensa en ellos como un buzón: el system que quiere hablar escribe una carta, la mete en el buzón, y cualquier otro system puede leer las cartas que haya. Sin acoplamiento, sin dependencias explícitas, sin que el emisor sepa siquiera quién escucha.
use bevy::prelude::*;
#[derive(Event)]
struct MonedaRecogida {
cantidad: u32,
}
Se envía con un EventWriter<MonedaRecogida> y se recibe con un EventReader<MonedaRecogida>. Lo vemos a fondo en el cap 6. De momento, quédate con la idea de que existe: ya hemos visto el trío Entities + Components + Systems, y los resources y events son "vecinos del barrio" que merecen su propio capítulo (y los tienen).
Aquí viene una de las cosas que más confunden al principio, y que más libertad te da cuando la entiendes.
En Bevy, los systems pueden pedir datos del world de varias formas: Query, Res, EventReader, etc. Y todos esos accesos son al inicio del system, declarados en la firma. Bevy los recoge, calcula el "permiso" (qué components se leen, cuáles se escriben, si hay riesgo de aliasing), y luego ejecuta el cuerpo del system.
Pero modificar el world — añadir entities, quitar components, spawnear cosas, despawnear — no se puede hacer a pelo. ¿Por qué? Porque si pudieras, romperías las invariantes que Bevy promete. Si en mitad de un bucle sobre Query<&mut Transform> tu system decide borrar una entity, el iterador se invalidaría y tendrías un segfault travestido de error de Rust.
La solución es Commands. Es un "buffer de órdenes pendientes": en vez de tocar el world directamente, le dices "cuando termines este system, hazme esto". Bevy recoge todas las órdenes, las aplica al final, y todo queda bonito y consistente.
fn spawnear_enemigo(mut commands: Commands) {
commands.spawn((
Name::new("Enemigo"),
Position { x: 100.0, y: 50.0 },
Enemy,
));
}
Commands se pasa como mut commands: Commands (no &mut Commands — Bevy lo hace por dentro con un sistema de tipado curioso). Y tiene decenas de métodos: spawn, despawn, insert_resource, trigger, run_system, etc. Lo iremos viendo a medida que haga falta.
El truco mental es: Commands no modifica el world ahora. Apunta la orden en un post-it, y el world la ejecuta cuando pueda. Cuando termines de luchar contra esta idea, te habrás ahorrado tres meses de "WTF, ¿por qué mi entity no aparece?".
A veces Commands se queda corto. Por ejemplo, dentro de un system que ya está iterando entities, no puedes spawnear otra entity "ahora mismo" — solo dejarla pendiente. ¿Y si necesitas hacerlo sin post-it? ¿Y si quieres leer componentes de la entity que acabas de spawnear en la misma línea?
Ahí entra EntityWorldMut. Es como un "ticket individual" para una entity concreta: te lo da commands.spawn(...) (que devuelve un EntityWorldMut) o lo obtienes a partir de un Entity con world.entity(id). Con él puedes leer, escribir, añadir components, todo al momento, sin esperar a que termine el system actual.
fn spawnear_con_posicion(mut commands: Commands) {
let mut entity = commands.spawn((
Position { x: 0.0, y: 0.0 },
Player,
));
// Ahora mismo, sin esperar, le añadimos otro component:
entity.insert(Velocity(50.0));
}
EntityWorldMut y Commands se complementan: Commands es para "trabajo en diferido", EntityWorldMut es para "trabajo al instante". Cuando veas que un system se vuelve raro y no sabes por qué, mira si estás intentando hacer cosas síncronas con Commands cuando deberías usar EntityWorldMut. O al revés.
**La metedura de pata del día**: pasar `&mut Commands` en vez de `mut Commands`. Bevy te compila el primero, te ejecuta el system, y... no pasa nada. Tu `commands.spawn(...)` se evalúa, pero como `Commands` no se está "recolectando" para aplicar al final, la entity no aparece nunca. El compilador no chilla. Tu juego no chilla. Solo no hay entity. Tres horas de "¿por qué no se ve mi cubo?".
Basta de teoría. Vamos a hacer el "Hola mundo" del ECS: un cubo que se mueve por la pantalla. Sin renderizar nada (eso viene en el cap 14), solo el dato y la lógica. Y luego vamos a ver qué pasa cuando lo unimos todo en una App.
use bevy::prelude::*;
#[derive(Component)]
struct Posicion(f32, f32);
#[derive(Component)]
struct Velocidad(f32);
fn mover_cubo(
time: Res<Time>,
mut query: Query<(&mut Posicion, &Velocidad)>,
) {
for (mut pos, vel) in &mut query {
pos.0 += vel.0 * time.delta_secs();
// pos.1 += vel.0 * time.delta_secs(); // Y si quisiéramos en Y
}
}
Lee eso despacio. Es oro puro, y vale la pena saborearlo:
time: Res<Time> — pedimos el resource Time (que Bevy inserta automáticamente). Nos da el delta_secs(), o sea, los segundos que han pasado desde el último frame. Es nuestro "cuánto tiempo ha pasado" oficial.mut query: Query<(&mut Posicion, &Velocidad)> — pedimos todas las entities que tengan un &mut Posicion y un &Velocidad (los dos a la vez, no solo uno). Bevy nos las entrega en un iterador.for (mut pos, vel) in &mut query — iteramos. Por cada entity que cumple la condición, sacamos sus dos components. &mut Posicion (escritura) y &Velocidad (lectura).pos.0 += vel.0 time.delta_secs()* — la física más simple del mundo: posición += velocidad tiempo. Si la velocidad es 50 px/s y han pasado 0.016 s, se mueve 0.8 px. Si pasan 0.5 s, se mueve 25 px. Constante, predecible, frame-independent.Y ahora la app que lo une todo:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, mover_cubo)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn((
Posicion(0.0, 0.0),
Velocidad(50.0),
));
commands.spawn((
Posicion(100.0, 0.0),
Velocidad(80.0),
));
}
Tres líneas para entender la coreografía:
Startup: corre UNA vez, al arrancar. Aquí spawneamos entities, inicializamos resources, lo que sea. Es la "puerta de entrada".Update: corre CADA frame, en bucle, hasta que cerremos la ventana. Aquí va la lógica del juego (mover, IA, colisiones, input, lo que sea).mover_cubo: el system que mueve todo lo que tenga Posicion y Velocidad. Como hay dos entities con esos components, las mueve a las dos. Sin saber nada de "qué entity es cuál", sin un if entity == player, sin nada. Solo datos.Si ejecutas esto (y añades un plugin de render que dibuje las posiciones, que veremos en el cap 14), verás dos cubos moviéndose a la derecha. Uno a 50 px/s, otro a 80 px/s. Y el código que los gobierna es un único for de cuatro líneas. Esto es ECS. Esto es lo que cambia tu vida de desarrollador de juegos.
Transform real: cuando dejas de reinventar la ruedaEn la práctica, casi nunca vas a tener un Posicion propio. Bevy ya te da Transform, que es la struct canónica para posición, rotación y escala, en 3D (y se usa también para 2D ignorando el eje Z). Viene con métodos, integración con el sistema de render, jerarquías, interpolación... todo. Y se deriva así:
use bevy::prelude::*;
#[derive(Component)]
struct Velocidad2D {
x: f32,
y: f32,
}
fn mover(mut query: Query<(&mut Transform, &Velocidad2D)>, time: Res<Time>) {
let dt = time.delta_secs();
for (mut transform, vel) in &mut query {
transform.translation.x += vel.x * dt;
transform.translation.y += vel.y * dt;
}
}
Eso es exactamente el mismo patrón, pero usando el Transform que ya entiende el motor. Cuando llegues al cap 14, este mover moverá sprites por pantalla, y no tendrás que cambiar ni una línea.
Patrón del capítulo: "Datos tontos, sistemas listos"
>
Regla: los components almacenan dato y solo dato. Cero lógica. Cero métodos gordos. Cero
impl Bloquecon unupdate()que toca siete campos. Los components son como tarjetas de un fichero: solo dicen "soy tal cosa y mido tanto". Los systems, en cambio, son los únicos que actúan: leen tarjetas, transforman, escriben.
>
Por qué funciona: si los components no tienen lógica, puedes reorganizarlos, copiarlos, moverlos entre entities, mandarlos por red, serializarlos, todo sin preocuparte de "qué pasa si rompo una invariante". Los systems se vuelven funciones pequeñas, testables, paralelizables, intercambiables. Es el Data-Oriented Design en estado puro, y es la razón por la que juegos modernos manejan millones de entities a 60 fps sin despeinarse.
>
Antipatrón a evitar: el
impl MiComponente { fn update(&mut self) { ... } }. Si ves que tu component tiene un método que modifica sus propios campos, para. Sácalo a un system. Te lo agradecerás en seis meses.
Antes de cerrar el cap, una pregunta para que rumies:
Tienes un juego con tres tipos de entities: Player, Enemy, Bullet. El Player se mueve con WASD. Los Enemies patrullan en círculos. Las Bullets se mueven en línea recta y desaparecen al salir de la pantalla. ¿Cómo lo modelarías con components? ¿Y con systems? Piensa: ¿cuántos components necesitarías? ¿Cuántos systems? ¿Bastaría con uno, o necesitas tres?
Pista: probablemente te salen dos o tres components (Player, Enemy, Bullet como tags, más Velocidad, más PatrullaCentro para los enemigos) y un par de systems (uno "mover lineal", uno "mover circular"). Pero la forma exacta depende de ti, y eso es lo bonito. No hay "una respuesta correcta" en ECS: hay una respuesta que escala, y eso es lo que entrena este patrón.
★ **Thief: The Dark Project (Looking Glass Studios, 1998)** — el motor "Dark"
usaba un ECS casero donde cada entidad era un set de componentes
(posición, inventario, IA, script) y los sistemas eran funciones puras
que iteraban por tipo. La llamaron "object-oriented done wrong
intentionally" en broma, pero sentó las bases de los motores modernos.
Valve fichó a tres de sus autores para Half-Life 2.
★ **Scott Pilgrim vs. The World (Ubisoft Montreal, 2010)** — motor
interno basado en ECS para el beat-em-up. 200 entidades en pantalla
simultáneamente con animaciones, colisiones y efectos, todo en una Xbox
360/PS3. Lo que hoy harías con Bevy en 200 líneas, ellos lo hacían en
C++ con un ECS hecho a mano y doscientos cafés.
★ **Overwatch (Blizzard, 2016)** — el "Gameplay Entity Component System"
interno (no público) maneja 22 héroes con abilities únicas, cada uno
con 10-20 componentes. Las presentations de Blizzard en GDC han
inspirado a medio submundo de Bevy-devs.
Commands se aplica de forma diferida; EntityWorldMut te da acceso síncrono a una entity concreta.Posicion + Velocidad + time.delta_secs() moviendo dos entities a la vez.En el cap 5 vamos a profundizar en queries y resources: los filtros (With, Without, Added, Changed), el QuerySet/ParamSet para cuando necesitas dos vistas del mismo dato, y el patrón "resource para configuración, component para estado de instancia" que te va a ahorrar disgustos cuando tu juego crezca. Agárrate, que esto se pone jugoso.