— Anónimo, foro de Bevy, 2024.
Imagina un RPG. Tienes Enemy y, dentro de Enemy, tres tipos: Goblin, Orc y Troll. Cada uno tiene vida, ataque, velocidad y un comportamiento especial. En Java o C++ escribirías:
class Enemy { int hp; int atk; }
class Goblin extends Enemy { void flee() {...} }
class Orc extends Enemy { void rage() {...} }
class Troll extends Enemy { void regen() {...} }
Y todo el mundo contento. Pero estamos en ECS, donde no hay clases: hay Entity (un u32) y Component (datos puros, sin métodos). Cuando alguien nuevo llega a Bevy desde Unity u OOP, lo primero que pregunta es: "¿cómo hago que Goblin herede de Enemy?".
La respuesta corta: no lo hagas. La respuesta larga: lo que realmente quieres no es herencia, son tres cosas distintas que ECS resuelve mejor con composición. Vamos a verlas una por una.
Sentido 1 (subtipo): "un Goblin ES-UN Enemy" → componente marcador + query filtrada.
Sentido 2 (mixin): "Goblin TIENE-UNA-IA-fleer" → componente de comportamiento.
Sentido 3 (override): "Goblin hace X de forma distinta" → sistema que mira el componente.
Sentido 4 (jerarquía): "la espada está en la mano del goblin" → Parent / Children.
Sentido 5 (relación): "el goblin odia al orco" → Relationship components (0.17+).
Estos cinco sentidos son los que cubrimos en este capítulo. Spoiler: ninguno requiere herencia de verdad. Y la buena noticia es que cada uno te queda con código que se testea, se inspecciona con bevy_inspector_egui y se modifica en runtime sin tocar una línea de Rust.
Lo que queremos: una Entity que es un enemigo, y dentro de los enemigos, que es un goblin, un orco o un troll. La forma idiomática en Bevy:
//! cap-9B — sección 9B.2: jerarquía de tipos con componentes marcadores.
use bevy::prelude::*;
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Goblin;
#[derive(Component)]
struct Orc;
#[derive(Component)]
struct Troll;
Ahora puedes filtrar así:
//! cap-9B — sección 9B.2: tres queries distintas.
fn system_enemy_ai(mut q: Query<&mut Transform, With<Enemy>>) { /* todos los enemigos */ }
fn system_goblin_ai(mut q: Query<&mut Transform, (With<Enemy>, With<Goblin>)>) { /* solo goblins */ }
fn system_troll_regen(mut q: Query<&mut Health, (With<Enemy>, With<Troll>)>) { /* solo trolls */ }
Parece tonto a primera vista: tres structs vacíos. Pero en ECS esto es más potente que la herencia clásica. ¿Por qué?
Orc + HeavyArmor sin reescribir nada. En OOP tendrías que crear HeavyOrc extends Orc extends Enemy. En ECS, agregás un componente. Eso es composición al 100%.(With<Enemy>, With<Goblin>) y otro que mira (With<Enemy>, With<Orc>) no se solapan.Analogía: un componente marcador es como una etiqueta adhesiva que pegás en una entidad. Una entidad puede tener varias etiquetas. Una clase, en OOP, es un molde del que sale un objeto: si querés cambiar la forma, cambiás el molde y re-instanciás. En ECS, cambiás las etiquetas sin tocar la entidad.
Los goblins huyen cuando están a menos del 30% de vida. Los orcos entran en modo "rage" cuando están a menos del 50%. Los trolls regeneran vida cada segundo. Si metes todo eso en un solo sistema, tenés un match infernal:
//! cap-9B — sección 9B.3: el anti-patrón de un único sistema-spaghetti.
fn enemy_ai_system(
mut q: Query<(&Enemy, &mut Health, &Transform)>,
) {
for (enemy, mut hp, xform) in q.iter_mut() {
if enemy.is_goblin && hp.0 < hp.max * 0.3 {
// flee logic...
}
if enemy.is_orc && hp.0 < hp.max * 0.5 {
// rage logic...
}
// ... 200 líneas más.
}
}
Funciona, pero es inmantenible. La forma ECS es un componente por comportamiento y un sistema por comportamiento:
//! cap-9B — sección 9B.3: comportamiento como componente.
#[derive(Component)]
struct FleeWhenLow { threshold: f32 }
#[derive(Component)]
struct RageWhenLow { threshold: f32 }
#[derive(Component)]
struct Regenerate { per_second: f32 }
// Cada sistema mira solo las entidades que le importan.
fn flee_when_low(
mut q: Query<(&Transform, &Health, &FleeWhenLow)>,
player: Query<&Transform, With<Player>>,
) { /* ... */ }
fn rage_when_low(
mut q: Query<(&mut EnemyState, &Health, &RageWhenLow)>,
) { /* ... */ }
fn regenerate(
mut q: Query<(&mut Health, &Regenerate)>,
time: Res<Time>,
) {
for (mut hp, regen) in q.iter_mut() {
hp.0 = (hp.0 + regen.per_second * time.delta_secs()).min(hp.max);
}
}
Ahora un orco con regeneración se spawne así:
//! cap-9B — sección 9B.3: composición pura.
commands.spawn((
Enemy, Orc,
Health { current: 100.0, max: 100.0 },
Transform::from_xyz(0.0, 0.0, 0.0),
RageWhenLow { threshold: 0.5 },
Regenerate { per_second: 1.0 }, // mixin: trolls y orcos lo comparten.
));
Tres líneas extras y, de repente, un orco que entra en rage Y regenera. No hay class OrcWithRegen extends Orc. No hay explosión combinatoria. Y si más adelante querés un orco con armadura y sin rage, borrás una línea y listo.
Truco: para reutilizar spawns, usá
Bundles. UnGoblinBundlepuede contenerEnemy, Goblin, Health::default_goblin(), FleeWhenLow::default_goblin(). Una pieza de ECS data, componible, inspeccionable.
¿Qué pasa cuando el comportamiento de un goblin es casi el de un orco, pero con un detalle? El patrón Bevy es: el componente tiene los parámetros, y un solo sistema mira el componente. Si dos subclases necesitan un detalle distinto, el componente lleva un discriminador.
//! cap-9B — sección 9B.4: parámetros, no subclases.
#[derive(Component)]
enum EnemyKind { Goblin, Orc, Troll }
#[derive(Component)]
struct CombatStats {
base_damage: f32,
attack_range: f32,
attack_cooldown: Timer,
}
fn enemy_attack(
mut q: Query<(&EnemyKind, &Transform, &mut CombatStats)>,
player: Query<&Transform, With<Player>>,
time: Res<Time>,
) {
for (kind, xform, mut stats) in q.iter_mut() {
stats.attack_cooldown.tick(time.delta());
if !stats.attack_cooldown.finished() { continue; }
// ... lógica común ...
let damage = match kind {
EnemyKind::Goblin => stats.base_damage * 0.8, // -20% por ser Goblin.
EnemyKind::Orc => stats.base_damage * 1.2, // +20% por rage.
EnemyKind::Troll => stats.base_damage * 1.0,
};
// ... aplicar daño ...
}
}
El match está en un solo sistema, no disperso por 200 archivos. Si mañana agregás EnemyKind::Dragon, solo tocás este match. En OOP tendrías que haber predicho Dragon extends Enemy antes de que existiera.
Regla práctica: si tu "override" se puede modelar con un campo (un enum, un f32, un flag), ponelo en el componente. Si requiere lógica de verdad distinta (un dragón vuela, un goblin no), entonces es un componente nuevo + un sistema nuevo (sentido 2).
Esta es la única forma de "herencia" que Bevy trae de fábrica, y la deberías usar para lo que es: jerarquías de transformación, no de comportamiento. Un enemigo que tiene una espada en la mano:
//! cap-9B — sección 9B.5: jerarquía de transform.
commands.spawn((
Enemy, Goblin,
Transform::from_xyz(0.0, 0.0, 0.0),
)).with_children(|parent| {
parent.spawn((
Sword,
Transform::from_xyz(20.0, 5.0, 0.0), // relativa al goblin.
));
});
El sistema propagate_transforms se encarga de que la espada se mueva con el goblin. Si el goblin muere, despawneás al goblin y Bevy arrastra a sus hijos automáticamente:
//! cap-9B — sección 9B.5: despawn con arrastre de hijos.
// Desde 0.16, `despawn()` despawnea también a los descendientes por defecto.
// Las viejas `despawn_recursive()` / `despawn_descendants()` se consolidaron.
fn despawn_goblin(mut commands: Commands, goblin: Entity) {
commands.entity(goblin).despawn();
}
Esto es lo que la gente llama "jerarquía" en Unity o Unreal. Y es correcto. Lo que NO debes hacer es usar Parent/Children para modelar "este enemigo ES-UN goblin": para eso ya tienes los componentes marcadores del sentido 1.
Tres trampas con Parent/Children que verás mil veces:
GlobalTransform del hijo. No lo hagas: propagate_transforms lo recalcula.despawn() arrastra a los hijos (desde 0.16 lo hace por defecto). Si querés despawnear solo al padre y reparentar a los hijos, tenés que sacarlos del árbol primero con remove_child; si no, desaparecen con él.Bevy 0.17 introdujo el sistema de Relationship Components (la PR #17398). Es el intento formal de Bevy de tener relaciones "flecs-like": una entidad puede apuntar a otra como objetivo, dueño, aliado, etc., con un grafo que Bevy mantiene en dual (cada relación va con su target coleccionador que se actualiza solo).
La sintaxis real en 0.19 (verificada contra docs.rs/bevy/…/relationship) son dos atributos separados, uno sobre el componente de relación y otro sobre el componente target:
//! cap-9B — sección 9B.6: relación custom "Targeting/TargetedBy".
use bevy::prelude::*;
// 1) El componente de relación: lleva el `Entity` al que apunta.
// `#[relationship(relationship_target = ...)]` lo vincula a su target.
#[derive(Component)]
#[relationship(relationship_target = TargetedBy)]
pub struct Targeting(pub Entity);
// 2) El componente target: colecciona todas las entidades que apuntan aquí.
// Bevy lo mantiene sincronizado. `linked_spawn` es OPT-IN (por defecto off).
#[derive(Component)]
#[relationship_target(relationship = Targeting)]
pub struct TargetedBy(pub Vec<Entity>);
fn goblin_targets_orco(
mut commands: Commands,
goblin: Entity,
orco: Entity,
) {
// Al insertar `Targeting(orco)` en goblin, Bevy añade goblin al
// `TargetedBy` de orco automáticamente. Sin bookkeeping manual.
commands.entity(goblin).insert(Targeting(orco));
}
Nombres propios, no los nativos. Bevy ya trae la relación jerárquica
ChildOf/Childrengestionada por el motor (es la base de la jerarquía de transform del sentido 4). No reutilices el nombreChildrenniChildOfpara tus relaciones custom: defines las tuyas con nombres semánticos (Targeting/TargetedBy,Owns/OwnedBy,AllyOf/Allies, etc.). Mezclar tus targets con elChildrennativo rompe el grafo interno del motor.
linked_spawnes opt-in. Por defecto está desactivado. Si lo añades al target (#[relationship_target(relationship = Targeting, linked_spawn)]), Bevy propagará el spawn/despawn/clone a lo largo de la relación (igual que haceChildrencondespawn_recursive). Sin él, la relación es "apuntar y ya": despawnear al target no arrastra a quien lo apunta. Úsalo solo cuando la relación sea realmente jerárquica.
Ejemplo de lectura del grafo dual: para saber "quién me apunta", lees el target; para saber "a quién apunto", lees la relación.
//! cap-9B — sección 9B.6: leer los dos lados del grafo.
fn aplicar_dano_a_objetivos(
atacantes: Query<&Targeting>,
mut damage_events: EventWriter<DamageEvent>,
) {
// Lado "apunto a": itero Targeting.
for targeting in &atacantes {
damage_events.write(DamageEvent { target: targeting.0, amount: 1.0 });
}
}
fn loggear_quien_me_ataca(
objetivos: Query<(Entity, &TargetedBy)>,
) {
// Lado "soy apuntado por": itero TargetedBy (mantenido por Bevy).
for (e, targeted_by) in &objetivos {
info!("{:?} es objetivo de {} atacantes", e, targeted_by.0.len());
}
}
El caso de uso: grafos de relaciones (NPCs con opiniones entre sí, facciones, dependencias entre quests). Antes de 0.17, había que simular esto a mano con un componente Entity y un sistema que mantenía la lista inversa. Ahora el motor lo hace por ti, con detección de cambios correcta en ambos lados.
Analogía: ChildOf/Children (la relación jerárquica nativa de Bevy) es para "el goblin tiene una espada" (jerarquía geométrica). Tus relaciones custom como Targeting son para "el goblin ataca al orco" (grafo semántico). Mezclarlos es la receta de bugs sutiles.
Caveat: en 0.19, Bevy rechaza por defecto las relaciones que apuntan a la propia entidad (auto-referencia), eliminando el componente y emitiendo un warning. La API de relaciones es funcional pero en evolución: revisa
docs.rs/bevy/0.19.0/bevy/ecs/relationship/antes de construir arquitectura gruesa encima. Para profundizar, mira el ejemplo oficialexamples/ecs/relationships.rsy la discusión #6493.
| Concepto OOP | Bevy ECS | flecs | Unity DOTS |
|---|---|---|---|
| "Es-un" | Componente marcador + query | IsA (built-in) | ISystem + tags |
| "Tiene-un" | Componente de comportamiento | Componente normal | Componente normal |
| "Override" | enum o flag dentro del componente | IsA + override | IComponentData polimórfico |
| Jerarquía geométrica | Parent/Children | ChildOf | Transform hierarchy |
| Relación semántica | Relationship components (0.17+) | Relaciones built-in | IBufferElementData |
| Herencia múltiple | Composición de componentes (sumar) | Composición + IsA | Composición |
Lo que Bevy NO tiene (a 0.19): un sistema de "subtipo" automático como IsA de flecs. ¿Es un problema? En la práctica, no. La composición cubre el 95% de los casos. Para el 5% restante, un enum EnemyKind + un match en un sistema es menos magia y más legible.
Trivia útil: flecs (el ECS de Sander Mertens, anterior a Bevy) implementó
IsApara herencia de entidades. Es elegante, pero la comunidad de Bevy descubrió que, en juegos 2D, el 90% de los "ES-UN" se modelan mejor con composición + queries. La doc oficial de Bevy desaconseja la herencia de entidades.
Volvamos al ejemplo original: 5 tipos de enemigo. Sin una sola class, sin una sola herencia:
//! cap-9B — sección 9B.8: cinco tipos de enemigo, composición pura.
use bevy::prelude::*;
// --- Componentes marcadores (sentido 1: "ES-UN") ---
#[derive(Component)] struct Enemy;
#[derive(Component)] struct Goblin;
#[derive(Component)] struct Orc;
#[derive(Component)] struct Troll;
#[derive(Component)] struct Dragon;
// --- Componentes de comportamiento (sentido 2: "TIENE-UNA-IA") ---
#[derive(Component)] struct FleeWhenLow { threshold: f32 }
#[derive(Component)] struct RageWhenLow { threshold: f32 }
#[derive(Component)] struct Regenerate { per_second: f32 }
#[derive(Component)] struct Flying { speed: f32 }
#[derive(Component)] struct Breathes { element: Element }
#[derive(Component)] enum Element { Fire, Ice, Poison }
// --- Datos comunes (sentido 3: "OVERRIDE" con parámetros) ---
#[derive(Component)] struct Health { current: f32, max: f32 }
#[derive(Component)] struct CombatStats { damage: f32, range: f32 }
// --- Spawn factory: composición según tipo ---
fn spawn_enemy(commands: &mut Commands, kind: EnemyKind, pos: Vec2) {
match kind {
EnemyKind::Goblin => commands.spawn((
Enemy, Goblin,
Transform::from_xyz(pos.x, pos.y, 0.0),
Health { current: 30.0, max: 30.0 },
CombatStats { damage: 5.0, range: 30.0 },
FleeWhenLow { threshold: 0.3 },
)),
EnemyKind::Orc => commands.spawn((
Enemy, Orc,
Transform::from_xyz(pos.x, pos.y, 0.0),
Health { current: 80.0, max: 80.0 },
CombatStats { damage: 12.0, range: 40.0 },
RageWhenLow { threshold: 0.5 },
)),
EnemyKind::Troll => commands.spawn((
Enemy, Troll,
Transform::from_xyz(pos.x, pos.y, 0.0),
Health { current: 120.0, max: 120.0 },
CombatStats { damage: 15.0, range: 50.0 },
Regenerate { per_second: 2.0 },
)),
EnemyKind::Dragon => commands.spawn((
Enemy, Dragon,
Transform::from_xyz(pos.x, pos.y, 0.0),
Health { current: 500.0, max: 500.0 },
CombatStats { damage: 40.0, range: 200.0 },
Flying { speed: 80.0 },
Breathes { element: Element::Fire },
)),
};
}
Cero herencia. Cada entidad es un mosaico de componentes. Para agregar Poison a los goblins, agregás Breathes { element: Element::Poison } a su bundle. Listo. No hay GoblinWithPoison extends Goblin. No hay explosión combinatoria.
Subtipo (sentido 1): "ES-UN". Marcado por un componente vacío + query filtrada.
Mixin (sentido 2): "TIENE-UN-comportamiento". Un componente por comportamiento, un sistema por comportamiento.
Override (sentido 3): "HACE-X-de-forma-distinta". Un `enum` o flag dentro del componente; un solo sistema hace `match`.
Jerarquía (sentido 4): relación geométrica padre-hijo via `Parent`/`Children`. Para transformaciones, no para comportamiento.
Relación (sentido 5): "X odia a Y", "X es-amigo-de Y". Relationship components (Bevy 0.17+).
IsA (flecs): sistema de herencia de entidades de flecs. Bevy no lo tiene built-in; lo suple con composición + queries.
Component Bundle: conjunto de componentes que se spawnean juntos. Útil para "tipos" de entidad.
Marca / Tag component: componente sin datos, solo presente o ausente. Sirve para filtrar queries.
Composición (patrón): construir entidades agregando componentes, no extendiendo clases.
Thief (Looking Glass Studios, 1998) — uno de los primeros juegos en usar un sistema de "stealth" como componente: el sonido se modelaba como un valor escalar, y cada entidad (jugador, NPC, monedero) tenía su propio nivel de sonido, independiente de su tipo "lógico". Esto es ECS puro, hecho a mano en C.
Dungeon Siege (Gas Powered Games, 2002) — motor "Gemstone" con un sistema de "ability" como componentes. Podías meter una habilidad a cualquier entidad: el rey podía aprender a tirar bolas de fuego si le dabamos la componente. La filosofía es idéntica a la composición Bevy.
flecs (Sander Mertens, 2018) — primera librería de ECS en hacer explícito el concepto de "relación" con `IsA`, `ChildOf`, etc. Bevy tomó nota y creó su propio sistema de Relationship en 0.17.
Bevy PR #17398 (2024) — la que introdujo Relationships en Bevy. Es la respuesta formal a "Bevy no tiene IsA". Spoiler: la respuesta fue "no lo necesitábamos, pero igual lo agregamos".
Inside (Playdead, 2016) — la IA de los NPCs no tiene "tipos" de enemigo. Cada NPC tiene un set de comportamientos (`correr`, `agarrar`, `morir`) y se activan por triggers. ECS puro, hecho en MonoGame.
❌ Hacer un struct `Enemy` con un `enum EnemyType` adentro, y muchos `match enemy.enemy_type` por todos lados.
✅ Un componente marcador `Goblin`, otro `Orc`, etc. Filtrá por `With<Goblin>`.
💡 Por qué: el `match` centralizado se desordena rápido. Los componentes marcadores son declarativos, observables y combinables.
❌ `commands.spawn((Enemy, Orc, Goblin))` (un orco que también es goblin).
✅ Un solo componente marcador por "taxonomía" (un enemigo es un goblin O un orco, no ambos).
💡 Por qué: tener dos marcadores de subclase contradictorios genera queries ambiguas. Si necesitás un "mini-orco-goblin", creá `Enemy, MiniOrcGoblin` y listo.
❌ `Parent/Children` para modelar "este goblin es hijo del clan Orco".
✅ `Relationship` (sentido 5) o un componente propio `MemberOf { clan: Entity }`.
💡 Por qué: `Parent/Children` es para transformaciones, no para taxonomías. Mezclar ambos lleva a "el goblin se teletransporta cuando muere el líder del clan".
Problema: tu juego crece. Empiezas con un Enemy genérico. Pronto tienes 5 tipos, 12 comportamientos, 3 facciones, 4 elementos. En OOP, terminas con OrcWithRegenAndFireBreath extends Orc y la explosión combinatoria. En ECS naive, terminas con un sistema-gordo que tiene 600 líneas de match.
Solución: tratá cada entidad como un mosaico de componentes. Cada componente es una pregunta: "¿esta entidad vuela? ¿regenera? ¿está en el clan Norte?". Cada sistema es un ojo: "¿a quién le importa esto?".
┌────────────────────────────────────────────┐
│ Entity(u32 = 42) │
│ Enemy, Orc │
│ Health { current: 50, max: 100 } │
│ CombatStats { damage: 12, range: 40 } │
│ RageWhenLow { threshold: 0.5 } │
│ Regenerate { per_second: 1.0 } │
│ MemberOf { clan: clan_norte } │
│ Breathes { element: Fire } │
└────────────────────────────────────────────┘
Cuándo sí:
Cuándo no:
enum interno para override: parámetros en lugar de subclases.Parent/Children solo para transformaciones, no para taxonomía.IsA) y Unity DOTS.Ya tenemos jerarquías "duras" resueltas. Capítulo 10: cómo hacer que el motor reaccione a eventos sin acoplar sistemas. Spoiler: Observers y Entity Events son la versión push de "oye, esto pasó". Si te suena el patrón listener de Java, te va a sonar esto — pero con esteroides Bevy.