— Alex Champandard, AI Game Programming Wisdom, 2006.
En el capítulo anterior vimos cómo una FSM modela muy bien comportamientos con fases distinguibles: Scatter/Chase en Pac-Man, fases de un boss, estados de UI. Pero hay una clase de comportamientos donde la FSM empieza a romperse: las decisiones compuestas. Imagina un enemigo que tiene que decidir entre 5 acciones posibles (atacar, cubrirse, recargar, pedir ayuda, huir) basándose en 8 condiciones (vida baja, munición baja, aliados cerca, jugador visible, jugador atacando, etc.). Una FSM plana acaba con docenas de transiciones y un match imposible de mantener.
Para estos casos hay dos patrones clásicos:
Este capítulo enseña los dos, cuándo usar cada uno, y cómo implementarlos como componentes en Bevy.
Una analogía culinaria: una FSM es una receta con pasos numerados; un BT es un diagrama de decisiones tipo "¿está el aceite caliente? Sí → añadir ajo, no → esperar"; utility AI es un panel de perillas donde cada plato tiene su dial de "qué tan buena idea es ahora mismo".
Un BT se construye con cuatro tipos de nodos básicos:
Los retornos típicos son tres:
Success: el nodo hizo su trabajo.Failure: el nodo no pudo hacer su trabajo.Running: el nodo está en proceso (típico de acciones que duran varios frames).// cap-21 — sección 21.2: trait fundamental de un BT.
pub enum BtStatus {
Success,
Failure,
Running,
}
pub trait BtNode: Send + Sync {
// La firma del trait FIJA un único parámetro: el contexto.
// Todo lo que un nodo necesite del mundo (tiempo, vida, posiciones)
// debe vivir dentro de `BtContext`, no como argumento extra.
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus;
}
BtContext es el "mundo" que el árbol necesita: entidades cercanas, vida del jugador, posiciones, etc. La forma idiomática de pasarlo en ECS es con un SystemParam propio o directamente con acceso a World. Para que nodos como Cooldown o Repeat puedan avanzar sus temporizadores, conviene que el contexto incluya el delta_secs del frame (lo vuelca el sistema que tica el BT desde Res<Time>).
// cap-21 — sección 21.3: Sequence.
pub struct Sequence { pub children: Vec<Box<dyn BtNode>> }
impl BtNode for Sequence {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
for child in &mut self.children {
match child.tick(ctx) {
BtStatus::Failure => return BtStatus::Failure,
BtStatus::Running => return BtStatus::Running,
BtStatus::Success => continue,
}
}
BtStatus::Success
}
}
// cap-21 — sección 21.3: Selector.
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
}
}
Box<dyn BtNode> te da polimorfismo dinámico. Para juegos grandes, conviene usar una representación arena-based (arena: pool de nodos con índices en vez de punteros) para evitar fragmentación y mejorar cache locality, pero la versión boxed es perfectamente válida hasta cientos de nodos activos.
Los leaves son los nodos "de trabajo":
// cap-21 — sección 21.3.1: leaf como struct con closure.
pub struct Action<F: FnMut(&mut BtContext) -> BtStatus>(pub F);
impl<F: FnMut(&mut BtContext) -> BtStatus> BtNode for Action<F> {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
(self.0)(ctx)
}
}
pub struct Condition<F: Fn(&BtContext) -> bool>(pub F);
impl<F: Fn(&BtContext) -> bool> BtNode for Condition<F> {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
if (self.0)(ctx) { BtStatus::Success } else { BtStatus::Failure }
}
}
Con esto puedes construir un BT al vuelo:
// cap-21 — sección 21.3.1: construir un BT en código.
let tree: Box<dyn BtNode> = Box::new(Sequence {
children: vec![
Box::new(Condition(|ctx| ctx.player_visible)),
Box::new(Condition(|ctx| ctx.ammo > 0)),
Box::new(Action(|ctx| { ctx.shoot(); BtStatus::Success })),
],
});
En ECS, el árbol vive como componente:
// cap-21 — sección 21.4: BT como componente.
#[derive(Component)]
pub struct BehaviorTree {
pub root: Box<dyn BtNode>,
pub blackboard: Blackboard,
}
#[derive(Default)]
pub struct Blackboard {
pub target: Option<Entity>,
pub last_seen: Option<Vec2>,
pub ammo: u32,
pub hp: u32,
pub alert_timer: f32,
}
La Blackboard (pizarra) es la memoria compartida del árbol: datos que cualquier nodo puede leer y escribir. Es la versión BT de un "estado interno", pero a diferencia de una FSM vive en el árbol, no en la entidad.
El sistema que tica el árbol:
// cap-21 — sección 21.4: tick del BT por entidad.
// `BtContext` incluye `delta_secs` (volcado desde `Time`) para que nodos como
// `Cooldown` o `Repeat` puedan avanzar sus temporizadores con la misma firma del trait.
fn tick_bt(
time: Res<Time>,
mut q: Query<(Entity, &mut BehaviorTree, &Transform, &mut Blackboard)>,
player: Query<&Transform, With<Player>>,
) {
let player_pos = player.single().translation.truncate();
for (entity, mut bt, t, mut bb) in &mut q {
let mut ctx = BtContext {
entity,
transform: *t,
blackboard: &mut *bb,
player_pos,
delta_secs: time.delta_secs(),
};
let _ = bt.root.tick(&mut ctx); // descartamos el status, sólo queremos el efecto.
}
}
El truco de pasar el blackboard por &mut dentro de BtContext es lo que permite que las acciones modifiquen el estado del árbol sin tener que devolverlo.
Los decoradores envuelven un hijo y modifican su resultado:
// cap-21 — sección 21.5: decoradores comunes.
pub struct Inverter<T: BtNode>(pub T);
impl<T: BtNode> BtNode for Inverter<T> {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
match self.0.tick(ctx) {
BtStatus::Success => BtStatus::Failure,
BtStatus::Failure => BtStatus::Success,
other => other,
}
}
}
pub struct Repeat<T: BtNode> { pub inner: T, pub times: u32, pub done: u32 }
impl<T: BtNode> BtNode for Repeat<T> {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
if self.done >= self.times { return BtStatus::Success; }
match self.0.tick(ctx) {
BtStatus::Failure => { self.done = self.times; BtStatus::Failure }
BtStatus::Success => { self.done += 1; BtStatus::Running }
other => other,
}
}
}
pub struct Cooldown<T: BtNode> { pub inner: T, pub remaining: f32, pub cooldown: f32 }
impl<T: BtNode> BtNode for Cooldown<T> {
// Misma firma que el trait: el `time` se saca del `ctx.delta_secs`,
// no como parámetro extra. Si lo añadieras como parámetro, el trait
// `BtNode` ya no se podría implementar y el código no compilaría.
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
self.remaining -= ctx.delta_secs;
if self.remaining > 0.0 { return BtStatus::Failure; }
match self.inner.tick(ctx) {
BtStatus::Success => { self.remaining = self.cooldown; BtStatus::Success }
other => other,
}
}
}
Cooldown es uno de los decoradores más útiles en juegos: evita que un enemigo spame su ataque más rápido de lo razonable. En vez de poner timers en cada acción, decoras.
Un servicio es un nodo que corre algo además del hijo, no en lugar. Se usa típicamente para "mientras esté ejecutando este branch, mantén actualizado el blackboard":
// cap-21 — sección 21.6: Service decorator.
pub struct Service<S: FnMut(&mut BtContext), T: BtNode> {
pub service: S,
pub inner: T,
}
impl<S: FnMut(&mut BtContext), T: BtNode> BtNode for Service<S, T> {
fn tick(&mut self, ctx: &mut BtContext) -> BtStatus {
(self.service)(ctx); // corre el side-effect.
self.inner.tick(ctx) // corre el hijo normal.
}
}
Ejemplo: "mientras el enemigo está en alerta, actualiza su ruta cada 0.5 s". El servicio es lo que refresca la ruta; el inner es el leaf que se mueve.
El BT se tica una vez por frame por entidad. Esto introduce un detalle: si un enemy tiene 200 ms en Running y ticas a 60 FPS, su acción todavía no ha acabado. La forma estándar de manejar esto:
// cap-21 — sección 21.7: estado de running.
#[derive(Component)]
pub struct BtState {
pub last_status: BtStatus,
pub running_since: f32,
pub interrupted: bool,
}
Si una acción está en Running por más de X segundos, marca interrupted = true y el siguiente tick re-evalúa desde la raíz. Esto evita árboles zombies (acción que se atasca y bloquea todo lo demás).
La blacklist es el otro mecanismo clave: cuando una acción falla, la marcamos para que el selector no la intente de inmediato. Sin blacklist, un BT con un "fallar para abrir puerta" en bucle infinito gasta CPU probando lo mismo cada frame.
Utility AI es un patrón completamente distinto: en vez de un árbol de decisiones, tienes una lista de acciones con un score numérico, y en cada tick ejecutas la de mayor score. La fórmula canónica de Dave Mark (Behavioral Mathematics for Game AI, 2009):
score(input) = Σ weight_i × response_curve_i(input_i) × consideration_i(input_i)
Tres conceptos:
response curve multiplicada por un weight y multiplicada por un modifier (boolean opcional que la apaga a 0).// cap-21 — sección 21.8: utility AI con curva sigmoide.
use std::f32::consts::E;
pub fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + E.powf(-x))
}
pub fn consideration_attack(ctx: &BtContext) -> f32 {
let dist = ctx.distance_to_player; // 0..500.
let c = 1.0 - (dist / 500.0).clamp(0.0, 1.0); // cuanto más cerca, mejor.
sigmoid(8.0 * (c - 0.5)) * 1.0 // curva con steepness 8.
}
Cada acción suma sus consideraciones:
// cap-21 — sección 21.8: evaluar todas las acciones y elegir la mejor.
pub fn pick_action(ctx: &BtContext) -> ActionKind {
let mut best_score = f32::NEG_INFINITY;
let mut best = ActionKind::Idle;
let candidates: Vec<(&str, f32)> = vec![
("attack", consideration_attack(ctx) + consideration_hp_ok(ctx)),
("flee", consideration_low_hp(ctx) + consideration_close_to_enemy(ctx)),
("reload", consideration_low_ammo(ctx) + consideration_safe_distance(ctx)),
("idle", 0.1),
];
for (name, score) in candidates {
if score > best_score { best_score = score; best = name.parse().unwrap(); }
}
best
}
La gracia de utility AI es que no requiere un árbol: si dos acciones tienen score 0, la IA hace idle; si una sube un poco, se activa automáticamente; si tres compiten, gana la de mejor combinación de factores. Es robusto, modulable y permite añadir nuevas acciones sin tocar las viejas.
La respuesta corta:
Halo (Bungie, 2001) popularizó el uso de FSM + scripts. F.E.A.R. (Monolith, 2005) usó GOAP (Goal-Oriented Action Planning, planificación automática). Más recientes como Alien: Isolation (Creative Assembly, 2014) usan utility AI con curvas muy elaboradas. Hades (Supergiant, 2020) usa una mezcla de BT (para el patrón de ataque) y utility (para decidir qué boon ofrecer al jugador).
Una regla práctica: si puedes dibujar el comportamiento como un organigrama, es un BT. Si puedes escribirlo como "cuanto más X, más probable Y", es utility. Si es una secuencia fija de fases, es una FSM.
El primer boss de Hades, Megaera, tiene tres fases. En cada fase, ataca con una secuencia de tres "movimientos" (golpe de vara, golpe de cola, onda). Modelado con BT:
Selector
├── Sequence (fase = F1)
│ ├── Condition: hp < 100%
│ ├── Action: Enrage (cambia música, cambia color)
│ └── Repeat(3, Pattern-A)
├── Sequence (fase = F2)
│ ├── Action: AnimateTransition
│ └── Repeat(2, Pattern-B)
└── Sequence (fase = F3)
├── Action: TakeCover (un dash lateral)
└── Repeat(5, Pattern-C)
Este árbol se lee casi como prosa. La diferencia entre "dibujar el árbol" y "leer el código" es una de las grandes ventajas del patrón.
El error más común es construir un BT que se llama a sí mismo (un Repeat sin condición de salida, o un Selector que tiene al mismo hijo dos veces). El síntoma: el juego se congela porque el árbol nunca termina de ticar. Solución: loguear el árbol una vez al inicio, validar que cada Sequence tenga al menos un hijo, y poner un contador global de ticks que se reinicia cada frame.
Otra trampa: usar BT para lógica que debería ser FSM. Si tu BT tiene un solo nivel (Selector con 5 actions sin ninguna secuencia), es un utility AI disfrazado. La forma del árbol debe contar la historia; si no la cuenta, refactoriza.
Nombre: decisión compuesta.
Resumen: cuando un personaje debe elegir entre varias acciones basado en varias condiciones, modela la decisión como BT (si las condiciones son jerárquicas) o utility AI (si las condiciones son independientes y combinables). Reserva FSM para fases globales, no para decisiones tácticas.
Aplicar cuando: enemigo con 4+ acciones tácticas, IA que combina factores, comportamientos que cambian con la dificultad.
No aplicar cuando: comportamiento fijo (un NPC que sólo habla), 2-3 estados claros (FSM basta).
Costo: una capa más de indirección y el coste de tick (cada entidad paga su árbol por frame).
Beneficio: decisiones robustas, código legible como organigrama, modularidad para añadir/quitar acciones.
Un buen test para saber si necesitas un BT: intenta escribir el comportamiento como una lista de "if-else" en una sola función. Si la lista tiene más de 5 ramas o cualquier rama tiene más de 2 niveles de anidación, te conviene un BT. Si lo que crece no son las ramas sino los factores que ponderan cada rama, te conviene utility AI. Y si lo que tienes son fases claras con un número fijo de acciones dentro, quédate con la FSM del cap 20.
Sequence (AND), Selector (OR), Action (hoja que hace algo), Condition (hoja que evalúa sin actuar).Success, Failure o Running. Running es lo que permite acciones multi-frame (cargar un ataque 2s antes de golpear) sin bloquear el árbol.tick como sistema, nodos como structs con un método run. ★ **Halo 2 (Bungie, 2004)** — los Grunts y Elites usaban una variante temprana de
utility AI llamada "behaviors with personality". Cada raza tenía factores como
coraje, lealtad y agresividad, y la acción se elegía combinando scores. El
resultado: un Grunt solo es cobarde y huye; un Grunt con un Elite cerca es
valiente y ataca. Mismo código, diferente personalidad.
★ **F.E.A.R. (Monolith, 2005)** — la referencia obligada de BT en la industria.
Los soldados enemigos decidían tácticas (cubrirse, flanquear, lanzar granada)
con un BT clásico, combinado con un planner GOAP para la selección de
objetivos. Los AI de F.E.A.R. siguen siendo de los más creíbles 20 años
después, y todo el código está documentado en el postmortem público.
★ **The Sims (Maxis, 2000)** — Will Wright popularizó un sistema basado en
motives (hambre, sueño, social, higiene) donde cada acción tiene un
contribution a cada motive. El Sim elige la acción con mayor "delta de
felicidad" total. Es utility AI cruda, sin BT, y sostuvo un juego que lleva
25 años publicando expansiones.
En el cap 22 vamos al pathfinding: una vez el BT ha decidido QUÉ quiere hacer un enemigo (perseguir al jugador, ir a cubrirse, huir a una posición), CÓMO llega. Verás A sobre grid, navmesh para mapas no-grid, flow fields para cientos de agentes con el mismo destino, y el patrón "re-path throttleado" para evitar spamear A cada frame. También tocaremos el bug clásico: heurísticas no admisibles que devuelven paths subóptimos y dejan al enemigo atascado en un loop. Si el cap 21 era "elijo qué hacer", el cap 22 es "calculo cómo llegar". Casi un solver de rutas, pero con enemigos hambrientos.