Capítulo 20. Máquinas de estado finitas: del fantasma de Pac-Man al boss final

CAP 20 · Bevy 0.18/0.19
"Cualquier sistema que se mueve por fases tiene una FSM, lo sepa o no. La diferencia entre el amateur y el profesional es que el segundo la dibuja antes de escribir el código."

— Notas de clase, Game AI Pro, 2013.

20.1 Cuatro fantasmas y un FSM

En 1980, Toru Iwatani programó Pac-Man en menos de un año con un equipo de nueve personas. El juego tenía cuatro fantasmas, cada uno con un comportamiento distinto: Blinky persigue directamente al jugador, Pinky apunta a una casilla cuatro pasos por delante, Inky hace una emboscada basada en Blinky, y Clyde alterna entre perseguir y dispersarse. Si abres un emulador y reproduces una partida, los fantasmas parecen impredecibles, casi inteligentes. Pero no: cada uno es una FSM (Finite State Machine, máquina de estados finitos) con sólo dos o tres estados.

El patrón FSM es el más viejo de la informática (se estudia en cualquier curso de autómatas desde los años 60) y el más usado en juegos (se aplica a IA, UI, animación, networking, gameplay loop). Dominarlo bien es prerrequisito para los capítulos siguientes: BT, utility AI y HFSM son evoluciones de la misma idea.

Una analogía útil: una FSM es como un guion de teatro. El personaje está en una escena (estado). El director dice "corta" (transición) y empieza la siguiente. Las escenas se reciclan (un mismo estado puede aparecer varias veces), pero en cada momento hay exactamente una escena en curso. Si en algún momento el actor no sabe en qué escena está, tienes un bug.

// cap-20 — sección 20.1: la FSM de Blinky, minimalista.
enum BlinkyState {
    Scatter,        // volver a la esquina superior derecha.
    Chase,          // perseguir directamente a Pac-Man.
    Frightened,     // huir y volverse azul cuando Pac-Man come un power pellet.
}

impl BlinkyState {
    fn next(&self, dots_remaining: u32, power_active: bool) -> Self {
        if power_active { return Self::Frightened; }
        match dots_remaining {
            0..=30 => Self::Chase,
            _      => Self::Scatter,
        }
    }
}

Esta versión "manual" funciona, pero escala mal cuando aparecen más transiciones, condiciones compuestas y efectos colaterales. Bevy te da varias herramientas para no tener que reinventarla.

20.2 States de Bevy: la FSM a nivel de aplicación

Bevy expone un trait States que convierte un enum en estados globales del schedule:

// cap-20 — sección 20.2: definir el enum de estados.
use bevy::prelude::*;

#[derive(States, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppState {
    #[default]
    Boot,
    Menu,
    Gameplay,
    Pause,
    GameOver,
}

#[derive(States)] requiere Default, y Bevy exige que uno de los variantes sea #[default] para que App::new() sepa en qué estado arrancar. Esto es un detalle que a veces sorprende a quienes vienen de engines sin sistema de estados.

Una vez tienes el enum, App se configura así:

// cap-20 — sección 20.2: registrar estados y transiciones.
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_state::<AppState>()
        .add_systems(OnEnter(AppState::Menu), setup_menu)
        .add_systems(OnExit(AppState::Gameplay), teardown_world)
        .add_systems(Update, handle_pause.run_if(in_state(AppState::Gameplay)))
        .add_systems(Update, (move_player, spawn_enemies).run_if(in_state(AppState::Gameplay)))
        .run();
}

Los OnEnter(state) y OnExit(state) son schedules paralelos a Update que sólo corren cuando se entra o se sale del estado correspondiente. Son la pieza que faltaba para que una FSM global sea realmente una FSM: puedes tener un sistema "cargar nivel" que corra exactamente al pasar a Gameplay, y otro "guardar score final" que corra exactamente al salir.

Las transiciones se hacen con ResMut<NextState<S>>:

// cap-20 — sección 20.2: transición manual.
fn start_game(
    keys: Res<ButtonInput<KeyCode>>,
    mut next: ResMut<NextState<AppState>>,
) {
    if keys.just_pressed(KeyCode::Enter) {
        next.set(AppState::Gameplay);
    }
}

NextState::set tiene la firma fn set(&mut self, state: S) — un solo parámetro, el nuevo estado. La transición es deliberadamente diferida: set sólo registra el próximo valor; el cambio real lo aplica Bevy en el schedule StateTransition al final del frame, evitando inconsistencias de "medio frame en un estado, medio frame en otro". No existe una variante "instantánea" de NextState::set con parámetros adicionales; si necesitas ejecutar lógica síncrona en el instante exacto del cambio, registra un observer sobre StateTransitionEvent<S> (ver sección 20.3) o usa los schedules OnEnter/OnExit. En la mayoría de los casos, el comportamiento por defecto es exactamente lo que quieres.

20.2.1 Transitions vs Update-time checks

Un error típico es escribir:

// cap-20 — sección 20.2.1: antipatrón — comprobar estado en Update.
fn mover_player(state: Res<State<AppState>>, mut q: Query<&mut Transform, With<Player>>) {
    if state.get() == &AppState::Gameplay {
        // mover...
    }
}

Funciona, pero todos los sistemas van a tener ese if. Mejor:

// cap-20 — sección 20.2.1: usar run_if en el schedule.
.add_systems(Update, mover_player.run_if(in_state(AppState::Gameplay)));

Resultado: si no estás en Gameplay, el sistema ni siquiera se compila dentro del grafo. Más limpio y más rápido.

20.3 Transiciones con eventos: la versión push

Cada vez que Bevy aplica un cambio de estado (al final del frame, en el schedule StateTransition) emite un evento estándar de tipo StateTransitionEvent<S>, con dos campos opcionales: exited: Option<S> (estado del que sales) y entered: Option<S> (estado al que entras). Cualquiera de los dos puede ser None en los bordes (arranque inicial, último estado antes de apagar la app). Lo consumes con un EventReader, como cualquier otro evento:

// cap-20 — sección 20.3: leer transiciones con EventReader.
use bevy::state::state::StateTransitionEvent;

fn watch_transitions(mut reader: EventReader<StateTransitionEvent<AppState>>) {
    for ev in reader.read() {
        info!("transición: {:?} -> {:?}", ev.exited, ev.entered);
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_state::<AppState>()
        .add_systems(Update, watch_transitions)
        .run();
}

Ojo: el tipo StateTransition (sin Event) es un struct vacío usado como marcador de schedule, no un evento. No lo uses como Trigger<StateTransition<...>>: no compila y, aunque compilara, el cambio de estado no se dispara como un observer de ciclo de vida. La API correcta es el EventReader<StateTransitionEvent<S>> mostrado arriba.

StateTransitionEvent es perfecto para sistemas "laterales" que necesitan reaccionar a cambios pero no son parte del flujo principal: analytics, transiciones de música, pop-ups de "pausa", guardado automático. Antes de 0.15 se hacía con eventos manuales en OnEnter/OnExit; ahora es nativo y consistente con el resto del modelo de eventos de Bevy.

20.4 SubStates: FSMs jerárquicas

Una pantalla de juego típica tiene sub-estados dentro de Gameplay: el jugador puede estar en Cutscene, Dialog, FreeRoam, Inventory. Modelar todos como variantes de AppState funciona, pero pierde la relación "estos cuatro están dentro de Gameplay". SubStates resuelve esto:

// cap-20 — sección 20.4: sub-estados.
#[derive(SubStates, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[source(AppState = AppState::Gameplay)]   // sólo existe si estamos en Gameplay.
pub enum GameplayState {
    #[default]
    FreeRoam,
    Dialog,
    Inventory,
    Cutscene,
}

Los SubStates tienen su propio OnEnter/OnExit y sus propias reglas de transición. Combinados con SubStates anidados (un sub-estado dentro de otro sub-estado) se construyen HFSMs (Hierarchical FSM, FSM jerárquica) de varios niveles.

// cap-20 — sección 20.4: HFSM de un boss.
#[derive(SubStates, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[source(EnemyState = EnemyState::Active)]
pub enum BossPhase {
    #[default]
    Phase1,
    Phase2,
    Enrage,
}

Un boss con tres fases se modela como un EnemyState::Active que contiene un sub-estado BossPhase. La transición Active → Dying ocurre cuando la vida llega a 0; las transiciones Phase1 → Phase2 → Enrage ocurren a umbrales de vida. Sin SubStates, esto serían seis variantes en el mismo enum; con SubStates, son dos jerarquías limpias.

20.5 FSM con entidades: el componente StateMachine

Para personajes individuales (NPCs, enemigos, proyectiles, ítems) suele convenir una FSM propia de la entidad, no global. La forma idiomática en ECS es un componente:

// cap-20 — sección 20.5: FSM como componente de entidad.
#[derive(Component)]
pub struct StateMachine<S: Component> {
    pub current: S,
    pub previous: Option<S>,
    pub time_in_state: f32,
}

impl<S: Component> StateMachine<S> {
    pub fn enter(&mut self, new: S) {
        self.previous = Some(std::mem::replace(&mut self.current, new));
        self.time_in_state = 0.0;
    }
}

Cada entidad lleva su propia máquina. El sistema que la actualiza corre sobre todas:

// cap-20 — sección 20.5: sistema que avanza la FSM por entidad.
fn tick_state_machine(time: Res<Time>, mut q: Query<&mut StateMachine<EnemyState>>) {
    for mut sm in &mut q {
        sm.time_in_state += time.delta_secs();
    }
}

La transición es local: la entidad decide cuándo cambia de estado mirando sus propios componentes:

// cap-20 — sección 20.5: transiciones por entidad.
fn enemy_ai(
    mut q: Query<(&Transform, &Health, &mut StateMachine<EnemyState>), With<Enemy>>,
    player: Query<&Transform, With<Player>>,
) {
    let player_pos = player.single().translation;
    for (t, hp, mut sm) in &mut q {
        match sm.current {
            EnemyState::Idle if distance(t.translation, player_pos) < 200.0 => {
                sm.enter(EnemyState::Chase);
            }
            EnemyState::Chase if hp.0 < 30.0 => {
                sm.enter(EnemyState::Flee);
            }
            EnemyState::Chase if distance(t.translation, player_pos) > 400.0 => {
                sm.enter(EnemyState::Idle);
            }
            _ => {}
        }
    }
}

Este patrón tiene tres ventajas enormes:

  1. Cero contención: cada entidad tiene su propio estado, no hay locks ni resources globales.
  2. Trivially parallelizable: el sistema es &mut Query pero cada elemento es independiente, así que el borrow checker lo acepta sin esfuerzo.
  3. Fácil de testear: creas una entidad en un estado y compruebas la transición sin montar todo el App.

20.5.1 Por qué evitar HashMap<String, Box<dyn Behavior>>

Un antipatrón clásico es modelar FSMs con un HashMap de "estados" como punteros a función o closures. Funciona, pero mata el análisis estático: el compilador no puede decirte que Chase recibe argumentos incorrectos, y la tabla dinámica hace imposible serializar el estado en un save sin gymnastics. Un enum + match es siempre preferible hasta que demuestres que necesitas otra cosa.

20.6 Transiciones con observers sobre componentes

Observers y FSMs componen elegantemente. Imagina que un enemigo entra en estado Stunned y quieres que cualquier componente que sepa del stun reaccione:

// cap-20 — sección 20.6: trigger al cambiar de estado.
#[derive(Component)]
pub struct Stunned;

#[derive(EntityEvent)]
pub struct EnteredStunned {
    #[event_target]
    pub who: Entity,
}

fn on_enter_stunned(
    trigger: Trigger<OnInsert, Stunned>,
    mut commands: Commands,
) {
    commands.trigger(EnteredStunned { who: trigger.target() });
}

Cualquier sistema que quiera reaccionar a "este enemigo se ha stunned" registra un observer sobre EnteredStunned y recibe el evento sin tener que saber de FSMs. La FSM sigue siendo el componente StateMachine, pero los efectos colaterales se enchufan vía observers, exactamente como vimos en el cap 10.

20.7 Trivia: el scheduler de Pac-Man

Iwatani describió en varias entrevistas (recogidas en el libro Pac-Man: Birth of an Icon, 2018) que la "IA" de los fantasmas era una tabla de estados con un scheduler global: cada 7 segundos (al principio del nivel) Blinky salía de Scatter para pasar a Chase, y los tiempos variaban según el nivel. Los diseñadores ajustaban los timings manualmente hasta que el juego se sentía justo. Esa calibración humana es la razón por la que los modos Scatter/Chase de Pac-Man siguen siendo divertidos hoy: una IA perfecta destruiría la diversión. Lección gratuita: una buena FSM no es la que toma la decisión óptima, sino la que el jugador puede anticipar.

20.8 FSM en ECS: cuándo conviene qué

CasoSolución recomendada
Estados de UI global (menú, juego, pausa)States global.
Sub-estados dentro de un estadoSubStates anidados.
IA de un enemigo con 3-7 estadosStateMachine<S> componente.
Boss con fases reutilizablesSubStates o componente con jerarquía.
Estados efímeros (timer de un power-up)Componente con Timer, no FSM.
Decisiones complejas con prioridadesBT (cap 21), no FSM.

20.9 Glosario del capítulo

20.10 Metedura de pata: estados sin transición explícita

El error más común con FSMs en Bevy es olvidarse de NextState y tratar de mutar el estado directamente:

// cap-20 — sección 20.10: antipatrón.
fn mover_player(mut state: ResMut<State<AppState>>) {
    *state = State::new(AppState::Menu);   // NO compila, State no es mutable así.
}

State<S> no es mutable por una razón: cambiar el estado en mitad del frame produciría sistemas ejecutándose con un estado incoherente. La API correcta es siempre NextState<S>.

Otro clásico: olvidar #[default] en uno de los variantes del enum derivado States. El compilador te avisa, pero el error es críptico ("not all enum variants are non-default"). Solución: marca exactamente uno con #[default].

20.11 Debugging de FSMs: la columna vertebral del inspector

Las FSMs fallan en producción de maneras sutiles: el enemigo entra en un estado y nunca sale, o transiciones que deberían ser exclusivas se disparan a la vez. Para diagnosticar esto, hay tres trucos que pagan su costo mil veces:

  1. Log en transiciones. Cada vez que se llama enter(), loguear el estado anterior y el nuevo con info!. En producción se quita, pero durante desarrollo es oro puro.
  1. Contador de transiciones por estado. Un HashMap<EnemyState, u32> resource que se incrementa en cada enter(). Si Chase tiene 10 000 entradas y Attack 0, sabes que tu IA nunca llega a atacar.
  1. Visualización con gizmos. Un sistema que dibuja el nombre del estado actual encima de cada entidad con un Text2d o un Gizmos label. Especialmente útil para FSMs complejas de boss.
// cap-20 — sección 20.11: log de transición global.
// `StateTransitionEvent<S>` es el evento real; tiene `exited`/`entered`, NO `entity`/`from`/`to`.
fn debug_state_change<S: States>(
    mut events: EventReader<StateTransitionEvent<S>>,
) {
    for ev in events.read() {
        info!("{:?} -> {:?}", ev.exited, ev.entered);
    }
}

Bevy también tiene el Bevy Remote Protocol (BRP), que permite consultar el estado de cualquier entidad desde un cliente externo (un web inspector). Combinar BRP con un componente DebugLabel(String) te da un debugger de FSMs sin escribir UI propia.

20.12 FSMs con timers: el caso del 80 %

El 80 % de las FSMs en juegos 2D son variantes de este esqueleto:

El patrón canónico en ECS:

// cap-20 — sección 20.12: FSM con Timer.
#[derive(Component)]
pub struct Lifetime {
    pub state: LifetimeState,
    pub timer: Timer,
}

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum LifetimeState {
    Spawning,
    Active,
    Dying,
}

fn tick_lifetimes(time: Res<Time>, mut q: Query<&mut Lifetime>) {
    for mut lt in &mut q {
        lt.timer.tick(time.delta());
        match lt.state {
            LifetimeState::Spawning if lt.timer.finished() => {
                lt.state = LifetimeState::Active;
                lt.timer.reset();
            }
            LifetimeState::Dying if lt.timer.finished() => {
                // despawn se hace en otro sistema que lea esta condición.
            }
            _ => {}
        }
    }
}

La tentación aquí es meter todo dentro del componente. No caigas: las transiciones son lógica (van en un sistema); los datos son datos (van en componentes). Mantener la separación es lo que hace que una FSM escale a docenas de tipos de entidad sin reescribirse cada vez.

20.13 Sub-estados en la práctica: el caso del jugador

Un jugador típico tiene una HFSM de tres niveles:

- PlayerMode::Normal / PlayerMode::Cutscene / PlayerMode::Dialog (sub-estado 1)

- LocomotionState::Idle / Run / Jump / Fall / Dash (sub-estado 2)

Si aplanamos todo, terminamos con un PlayerState de 15 variantes, cada match con 15 ramas, y combinaciones inválidas como Dialog && Jump && Dash que el compilador no puede impedir. Con jerarquía, cada nivel sólo se preocupa de su parte:

// cap-20 — sección 20.13: jugador con HFSM.
fn player_input(
    action: Res<ActionState<PlayerAction>>,
    mode: Res<State<PlayerMode>>,
    mut loco: Query<&mut LocomotionState, With<Player>>,
) {
    let mut loco = loco.single_mut();
    if mode.get() != &PlayerMode::Normal { return; }   // fuera de modo normal, no hay locomotion.
    if action.just_pressed(&PlayerAction::Jump) {
        *loco = LocomotionState::Jump;
    }
}

La regla es simple: el sistema de locomotion corre sólo cuando mode == Normal. Si estás en Dialog, el sistema ni se ejecuta. La belleza de la jerarquía es que los sistemas de un nivel no necesitan conocer los otros niveles.

20.14 HFSM para bosses: ejemplo Hades

Hades (Supergiant, 2020) tiene bosses con tres fases bien definidas: una fase de "aprender el patrón", una fase de "más agresiva", y una fase de "todo o nada" con música enrutada. Modelado con la HFSM de Bevy:

// cap-20 — sección 20.14: HFSM de Hades-style boss.
#[derive(SubStates, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[source(BossActive = BossActive::Active)]
pub enum BossPhase {
    #[default]
    Opening,
    Aggressive,
    Enrage,
}

#[derive(States, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BossActive {
    #[default]
    Inactive,
    Active,
    Defeated,
}

Las transiciones se disparan con umbrales de vida y tienen efectos colaterales cableados a OnEnter/OnExit:

// cap-20 — sección 20.14: transiciones del boss.
fn boss_transitions(
    mut next_phase: ResMut<NextState<BossPhase>>,
    mut next_active: ResMut<NextState<BossActive>>,
    q: Query<&Health, With<Boss>>,
) {
    let hp = q.single().0;
    if hp <= 0.0 {
        next_active.set(BossActive::Defeated);
        return;
    }
    match hp {
        x if x > 66.0 => {},    // Opening, no transición.
        x if x > 33.0 => next_phase.set(BossPhase::Aggressive),
        _ => next_phase.set(BossPhase::Enrage),
    }
}

fn on_enter_enrage(mut commands: Commands) {
    commands.spawn(MusicTrackChange::new("boss_phase_3.ogg"));
}

MusicTrackChange es un evento de tu juego que tu sistema de audio (cap 18) escucha. La fase del boss es independiente del cambio musical: si más adelante decides añadir un efecto visual al Enrage, sólo añades un nuevo OnEnter(BossPhase::Enrage). Esa es la potencia del modelo "schedule por estado".

20.15 Patrón del capítulo: "state = decisión medible"

Nombre: state = decisión medible.

Resumen: modela cada estado como una variante de un enum Rust, no como un string ni como un bool disperso. La transición debe ser una función pura del estado actual y unas pocas condiciones medibles (vida, distancia, timer).

Aplicar cuando: cualquier sistema con fases distinguibles (IA, UI, animación, gameplay loop).

No aplicar cuando: sistemas con datos continuos sin fases claras (cámara que sigue al jugador, partículas); usar interpolación, no FSM.

Costo: disciplina para resistir la tentación de añadir "un estado más" en cada reunión.

Beneficio: transiciones explícitas, debug trivial (un println! de la variante te dice en qué fase está todo), serialización directa.

Una FSM bien hecha se lee como un diagrama. Una FSM mal hecha se lee como espagueti. La diferencia es siempre la misma: los estados son un enum, las transiciones son match arms, y cada match tiene todas sus variantes cubiertas.

Lo que vimos

En el siguiente

En el cap 21 cambiamos a la IA enemiga y los Behavior Trees (BT): cómo modelar un árbol de decisión para un enemigo que tiene 4-8 acciones (perseguir, atacar, cubrirse, recargar, pedir ayuda), cuándo conviene BT sobre utility AI, y los bugs clásicos (BT recursivos infinitos, debug del árbol, mezclar BT con FSM). El cap 22 serán los algoritmos de pathfinding (A*, flow fields, navmesh) que los BT consumen: "ya decidí QUÉ quiero hacer, ahora CÓMO llego". Y el cap 23 entra en UI: cómo hacer menús, HUD, y los fundamentos de UI en Bevy 0.18+ (que cambió bastante respecto a 0.14). Si el cap 20 era "estoy en una fase", el cap 21 es "elijo qué hacer". Otro cambio de chip, a por él.