Decisiones Principiante 12 min de lectura

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

Demo — FSM con 5 estados Mueve el cursor y baja el HP del agente. Vas a ver cómo cambian las transiciones en el grafo en tiempo real.
Mueve el cursor (jugador). Baja el HP y mira cómo el agente huye. El estado activo se resalta en el grafo.

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 → Run cuando el jugador presiona una tecla (onInput).
  • Run → Idle cuando el agente llega a destino (stopped).

Cada transición tiene siempre tres datos: estado origen, condición, estado destino.

Idleesperando
Runavanzando
Log de hooks
  • 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, cerrar AudioSources, 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

  1. Detectar transición válida.

    if currentState == from and condition(): change_to(to)

  2. Ejecutar exit() del estado actual.

    current.exit()

  3. Reasignar el estado actual.

    current = next

  4. Ejecutar enter() del nuevo estado.

    current.enter()

  5. 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

  1. Identificar el grupo lógico. En el ejemplo: Chase, Attack, CircleStrafe son todos “estar en combate”.

  2. Crear el padre. Combat es ahora un estado que contiene a esos tres como sub-estados.

  3. Mover las transiciones internas (las que conectan estados del grupo entre sí) adentro del padre.

  4. 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.

  5. Definir el estado inicial del padre. Cuando se entra a Combat, ¿se entra por Chase o por Attack? Eso es el initial del 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?

NecesidadFSM planaHFSMBehavior Tree
3-7 estados, transiciones clarasIdealOverkillOverkill
8-15 estados con grupos lógicosDolorosoIdealAceptable
20+ estados, mucha lógica condicionalNo escalaAceptableIdeal
Acciones que tardan varios frames (animaciones largas, paths)Manual con flagsManual con flagsNativo (estado running)
Múltiples comportamientos en paraleloNoNoSí (con nodos paralelos)
Editor visual maduro en UnityAnimator funcionaAnimator funcionaAsset (Behavior Designer, etc.)
Coste de aprendizajeMínimoBajoMedio
Reuso entre agentesBajo (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: AnimationTree con 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 xstate es 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.

  1. Si olvidas implementar `exit()` en un estado 'Shooting' que arranca un loop de audio, ¿qué pasa?

  2. Tu FSM tiene 12 estados y una transición 'a Flee si HP < 20' desde 8 de ellos. ¿Qué ganas migrando a HFSM?

  3. ¿Qué se ejecuta una sola vez por visita al estado?

  4. En HFSM, ¿qué pasa si una transición del padre y una del hijo se cumplen en el mismo frame?

  5. Tu IA debe disparar y caminar al mismo tiempo. ¿Sigue siendo FSM la herramienta correcta?