FSM y HFSM: máquinas de estados sin volverte loco
Máquina de estados y su jerarquía. Aprende a saber usarla y cómo migrar sin reescribir todo.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Una Finite State Machine (FSM) es la IA más antigua que sigue vigente. Pac-Man (1980) tenía una; los enemigos de Doom (1993) también; y prácticamente cualquier RPG de los 90 está hecho a base de FSMs encadenadas. Más allá de la nostalgia, la razón por la que sigue viva es práctica: para un agente con 5 comportamientos claros y transiciones bien definidas, sigue siendo la solución correcta. Otros sistemas (BT, GOAP, Utility AI) brillan en escenarios más complejos, pero pagan un costo de complejidad que no siempre tiene sentido.
¿Cuándo una FSM es la elección correcta?
- El agente tiene un número fijo y conocido de comportamientos (idle, patrol, chase, attack, flee).
- En cada momento solo hace una cosa a la vez.
- Las transiciones se pueden expresar como condiciones simples (
hp < 20,playerInRange()).
¿Cuándo NO?
- El agente debe hacer varias cosas en paralelo (“disparar Y avanzar Y hablar”). Mirar Behavior Trees.
- Quieres que el agente planifique secuencias de acciones según objetivos. Eso es GOAP.
- Las “decisiones” dependen de muchas variables ponderadas y quieres balance dinámico. Eso es Utility AI.
¿Y HFSM?
El problema empieza a los 10 estados. Las transiciones crecen rápido (peor caso ~n²) y mantener el grafo se vuelve frágil. Ahí entra HFSM (Hierarchical FSM): estados que contienen sub-estados. Reduce el grafo visualmente y permite compartir transiciones padre entre los hijos.
En esta página vas a ver:
- La anatomía mínima de una FSM, con una viz de los hooks
enter/update/exit. - El problema de la explosión de aristas visualizado con un slider.
- Cómo HFSM colapsa ese problema en una sola transición global.
- El snippet Unity con un enemigo patrullero realista.
1. Demo
2. Concepto
Una FSM se define por tres piezas:
- Un conjunto finito de estados.
- Un conjunto de transiciones (estado origen, condición, estado destino).
- Un estado actual.
Cada frame, el estado actual ejecuta su update(). Antes (o después, según tu implementación), se evalúan las transiciones salientes: si una condición se cumple, cambias de estado.
2.1 Estado, transición, condición
Imagina dos estados: Idle (esperando) y Run (avanzando). Una transición simple sería:
Idle → Runcuando el jugador presiona una tecla (onInput).Run → Idlecuando el agente llega a destino (stopped).
Cada transición tiene siempre tres datos: estado origen, condición, estado destino.
- Idle.enter()
Pulsa los botones para disparar transiciones. Mira cómo exit() del estado anterior y enter() del siguiente se ejecutan una sola vez; update(dt) corre en cada tick mientras el estado esté activo.
Pulsa los botones y mira el log: cada transición ejecuta exactamente dos hooks únicos (exit() del anterior, enter() del siguiente) y luego update() empieza a correr en el nuevo estado.
2.2 Los tres hooks por estado
enter()— se ejecuta una sola vez al entrar al estado. Ideal para configurar animaciones, resetear contadores, suscribirse a eventos.update(dt)— se ejecuta cada frame mientras el estado está activo.exit()— se ejecuta una sola vez al salir. Ideal para desuscribirse de eventos, cerrarAudioSources, limpiar variables compartidas.
Olvidar exit() es el error más común. Si el estado Shooting arrancó un loop de audio en enter(), el exit() es quien lo detiene. Si te lo saltas, el agente sale del estado y el sonido sigue para siempre.
Paso a paso de un cambio de estado
-
Detectar transición válida.
if currentState == from and condition(): change_to(to) -
Ejecutar
exit()del estado actual.current.exit() -
Reasignar el estado actual.
current = next -
Ejecutar
enter()del nuevo estado.current.enter() -
En el siguiente frame,
update(dt)ya corre dentro del nuevo estado.
2.3 Cuándo duele: la explosión de aristas
Imagina el enemigo de la demo principal: 5 estados y unas 8 transiciones. Se lee en 30 segundos.
Ahora imagina 15 estados con condiciones compartidas: “cualquier estado puede transicionar a Flee si HP < 20”. Vas a escribir esa transición 15 veces. Cambiar la condición (“HP < 20” → “HP < 25 OR cantidad de aliados < 2”) implica tocar 15 líneas. Eso es explosión de aristas, y es la señal clara de que necesitas jerarquía.
Cada estado debe poder transicionar a Flee cuando HP < 20. Activa el toggle para ver el peor caso: cada estado conectado con cada otro estado. Mira el contador.
Sube el slider hasta 14 estados. Cada estado necesita su propia arista a Flee — ya son 14 transiciones repetidas. Activa el toggle de interconexiones para ver el peor caso teórico (cualquier estado a cualquier otro): crecimiento cuadrático.
2.4 HFSM: colapsando el grafo con jerarquía
Un estado puede ser, en realidad, otra FSM entera. Cuando entras al estado padre, entras a su sub-estado inicial. Las transiciones del padre se heredan por los hijos.
Paso a paso para migrar a HFSM
-
Identificar el grupo lógico. En el ejemplo:
Chase,Attack,CircleStrafeson todos “estar en combate”. -
Crear el padre.
Combates ahora un estado que contiene a esos tres como sub-estados. -
Mover las transiciones internas (las que conectan estados del grupo entre sí) adentro del padre.
-
Promover las transiciones globales al nivel del padre. La regla “si HP < 20 → Flee” se define una vez sobre
Combat, y aplica a los tres hijos automáticamente. -
Definir el estado inicial del padre. Cuando se entra a
Combat, ¿se entra porChaseo porAttack? Eso es elinitialdel subgrafo.
Combat (FSM padre — initial: Chase)
├─ Chase
├─ Attack
└─ CircleStrafe
Flee
Patrol
Idle
Transición global: Combat → Flee cuando HP < 20
El mismo grafo del slider anterior, pero colapsado bajo un padre Combat. Una sola arista Combat → Flee aplica a todos sus sub-estados gracias a la herencia jerárquica.
Es exactamente el ejemplo del slider anterior, pero la transición a Flee se dibuja una sola vez y vale para los tres sub-estados.
2.5 FSM, HFSM, BT — ¿cuál elegir?
| Necesidad | FSM plana | HFSM | Behavior Tree |
|---|---|---|---|
| 3-7 estados, transiciones claras | Ideal | Overkill | Overkill |
| 8-15 estados con grupos lógicos | Doloroso | Ideal | Aceptable |
| 20+ estados, mucha lógica condicional | No escala | Aceptable | Ideal |
| Acciones que tardan varios frames (animaciones largas, paths) | Manual con flags | Manual con flags | Nativo (estado running) |
| Múltiples comportamientos en paralelo | No | No | Sí (con nodos paralelos) |
| Editor visual maduro en Unity | Animator funciona | Animator funciona | Asset (Behavior Designer, etc.) |
| Coste de aprendizaje | Mínimo | Bajo | Medio |
| Reuso entre agentes | Bajo (copy-paste) | Medio (subgrafos) | Alto (subárboles) |
Regla práctica: arranca FSM plana. Si te encuentras escribiendo la misma transición 4 veces, migra a HFSM. Si te encuentras necesitando paralelismo o reuso fuerte de subárboles, migra a Behavior Trees.
3. Pseudocódigo
class State
enter()
update(dt)
exit()
class FSM
current: State
transitions: List<(from: State, cond: Func -> Bool, to: State)>
function change_to(next: State)
current.exit()
current = next
current.enter()
function tick(dt)
for each (from, cond, to) in transitions
if from == current and cond()
change_to(to)
return
current.update(dt)
Una variante importante: algunos motores ponen update antes de evaluar transiciones (para que el update pueda “completar” su trabajo primero). Elige uno y sé consistente.
3.1 HFSM — la sub-FSM como un estado
class CompoundState extends State
sub_fsm: FSM
initial: State
function enter()
sub_fsm.current = initial
sub_fsm.current.enter()
function update(dt)
sub_fsm.tick(dt)
function exit()
sub_fsm.current.exit()
Cuando se entra al CompoundState, el enter arranca la sub-FSM en su estado inicial. Cuando se sale, el exit cierra el sub-estado activo. Las transiciones del padre se evalúan antes que las del hijo (eso garantiza la “interrupción global”).
4. Implementación en Unity / C#
using System;
using System.Collections.Generic;
using UnityEngine;
public abstract class State {
public virtual void Enter() {}
public virtual void Update(float dt) {}
public virtual void Exit() {}
}
public class FSM {
State current;
readonly List<(State from, Func<bool> cond, State to)> transitions = new();
public FSM(State initial) {
current = initial;
current.Enter();
}
public void AddTransition(State from, Func<bool> cond, State to) {
transitions.Add((from, cond, to));
}
public void Tick(float dt) {
foreach (var (from, cond, to) in transitions) {
if (from == current && cond()) {
current.Exit();
current = to;
current.Enter();
return;
}
}
current.Update(dt);
}
}
// Uso:
public class Enemy : MonoBehaviour {
FSM fsm;
public Transform player;
public float visionRadius = 8f;
public float attackRadius = 1.5f;
public float hp = 100;
void Start() {
var patrol = new PatrolState(this);
var chase = new ChaseState(this);
var attack = new AttackState(this);
var flee = new FleeState(this);
fsm = new FSM(patrol);
fsm.AddTransition(patrol, () => CanSeePlayer(), chase);
fsm.AddTransition(chase, () => Distance() < attackRadius, attack);
fsm.AddTransition(chase, () => !CanSeePlayer(), patrol);
fsm.AddTransition(attack, () => Distance() > attackRadius*1.2f, chase);
// Global flee — repetido para cada estado origen (sin HFSM)
fsm.AddTransition(patrol, () => hp < 20, flee);
fsm.AddTransition(chase, () => hp < 20, flee);
fsm.AddTransition(attack, () => hp < 20, flee);
}
void Update() { fsm.Tick(Time.deltaTime); }
bool CanSeePlayer() => Distance() < visionRadius;
float Distance() => Vector3.Distance(transform.position, player.position);
}
Esas tres líneas del flee repetidas te muestran exactamente por qué HFSM existe.
5. En otros engines
- Godot:
AnimationTreecon State Machine hace FSM para animaciones de fábrica; si necesitas lógica más allá de eso, un script propio como el de arriba es directo. - Unreal:
State Tree(experimental pero ya en producción en muchos juegos) cubre el hueco entre FSM y BT. Para proyectos Unreal, es el camino recomendado. - JavaScript: la librería
xstatees overkill para un juego pequeño pero te da visualizador y transiciones globales gratis. Para un prototipo itch.io, 30 líneas como el pseudocódigo alcanzan.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Si olvidas implementar `exit()` en un estado 'Shooting' que arranca un loop de audio, ¿qué pasa?
Tu FSM tiene 12 estados y una transición 'a Flee si HP < 20' desde 8 de ellos. ¿Qué ganas migrando a HFSM?
¿Qué se ejecuta una sola vez por visita al estado?
En HFSM, ¿qué pasa si una transición del padre y una del hijo se cumplen en el mismo frame?
Tu IA debe disparar y caminar al mismo tiempo. ¿Sigue siendo FSM la herramienta correcta?