State Machines

Learn how to use state machines to decide WHAT your AI should do.

Introduction

State machines and behavior trees serve different purposes in AI design. Understanding this distinction is crucial for building clean, maintainable AI systems.

The WHAT vs HOW Principle

Layer Pattern Responsibility Example
WHAT State Machine Decides which action to perform "Should I attack, flee, or patrol?"
HOW Behavior Tree Implements the details of each action "Move to target, wait for cooldown, deal damage"

Key Insight

State machines excel at high-level decision making and mode switching. Behavior trees excel at executing sequences and handling reactive conditions. The best AI systems use both together.

Basic State Machine

In ClosureBT, state machines are created using YieldSimple. This node returns a different child node each tick based on a state variable.

private void Awake() => AI = YieldSimple("Simple AI", () =>
{
    // State variable: 0 = Idle, 1 = Patrol, 2 = Attack
    var state = Variable(() => 0);

    // Cache nodes to avoid recreating them
    Node idleNode = null;
    Node patrolNode = null;
    Node attackNode = null;

    // Return different node based on state
    return () => state.Value switch
    {
        0 => idleNode ??= JustRunning("Idle", () =>
        {
            OnTick(() =>
            {
                // Check for enemies every tick
                if (CanSeeEnemy())
                    state.Value = 2;  // Switch to attack state

                // Check if time to patrol
                if (ShouldPatrol())
                    state.Value = 1;  // Switch to patrol state
            });
        }),

        1 => patrolNode ??= Sequence("Patrol", () =>
        {
            // Patrol behavior
            MoveTo(patrolPoints[currentPatrolPoint]);
            Wait(2f);

            // Return to idle when done
            OnExit(() =>
            {
                currentPatrolPoint = (currentPatrolPoint + 1) % patrolPoints.Length;
                state.Value = 0;
            });
        }),

        2 => attackNode ??= Reactive * Sequence("Attack", () =>
        {
            // Attack behavior
            Condition(() => EnemyVisible());
            ChaseEnemy();
            Attack();

            // Return to idle when done
            OnExit(() => state.Value = 0);
        }),

        _ => null
    });
});
private void Awake() => AI = YieldSimple("Simple AI", () =>
{
    // State variable: 0 = Idle, 1 = Patrol, 2 = Attack
    var state = Variable(() => 0);

    // Cache nodes to avoid recreating them
    Node idleNode = null;
    Node patrolNode = null;
    Node attackNode = null;

    // Return different node based on state
    return () => state.Value switch
    {
        0 => idleNode ??= JustRunning("Idle", () =>
        {
            OnTick(() =>
            {
                // Check for enemies every tick
                if (CanSeeEnemy())
                    state.Value = 2;  // Switch to attack state

                // Check if time to patrol
                if (ShouldPatrol())
                    state.Value = 1;  // Switch to patrol state
            });
        }),

        1 => patrolNode ??= Sequence("Patrol", () =>
        {
            // Patrol behavior
            MoveTo(patrolPoints[currentPatrolPoint]);
            Wait(2f);

            // Return to idle when done
            OnExit(() =>
            {
                currentPatrolPoint = (currentPatrolPoint + 1) % patrolPoints.Length;
                state.Value = 0;
            });
        }),

        2 => attackNode ??= Reactive * Sequence("Attack", () =>
        {
            // Attack behavior
            Condition(() => EnemyVisible());
            ChaseEnemy();
            Attack();

            // Return to idle when done
            OnExit(() => state.Value = 0);
        }),

        _ => null
    });
});

Breaking it down:

  • var state = Variable(() => 0) - Creates a state variable initialized to 0 (Idle)
  • return () => state.Value switch - Returns a function that switches based on current state
  • ??= - Null-coalescing assignment ensures each node is created only once
  • OnTick(() => state.Value = X) - Transition based on continuous condition checks
  • OnExit(() => state.Value = X) - Transition when behavior completes

State Transitions

State transitions happen when you change the state variable's value. The most common way to do this is through lifecycle callbacks.

Transition Patterns

1. Condition-Based Transitions (OnTick)

Use OnTick when you need to check conditions every frame:

0 => idleNode ??= JustRunning("Idle", () =>
{
    OnTick(() =>
    {
        // Check continuously for transition conditions
        if (EnemyVisible()) state.Value = 2;    // Attack
        if (ShouldPatrol()) state.Value = 1;    // Patrol
        if (Health < 20) state.Value = 3;       // Flee
    });
});
0 => idleNode ??= JustRunning("Idle", () =>
{
    OnTick(() =>
    {
        // Check continuously for transition conditions
        if (EnemyVisible()) state.Value = 2;    // Attack
        if (ShouldPatrol()) state.Value = 1;    // Patrol
        if (Health < 20) state.Value = 3;       // Flee
    });
});

2. Completion-Based Transitions (OnExit)

Use OnExit when you want to transition after a behavior finishes:

1 => patrolNode ??= Sequence("Patrol", () =>
{
    MoveTo(patrolPoint);
    Wait(2f);

    // Automatically return to idle when patrol completes
    OnExit(() => state.Value = 0);
});
1 => patrolNode ??= Sequence("Patrol", () =>
{
    MoveTo(patrolPoint);
    Wait(2f);

    // Automatically return to idle when patrol completes
    OnExit(() => state.Value = 0);
});

3. Outcome-Based Transitions (OnSuccess/OnFailure)

Use OnSuccess or OnFailure for outcome-specific transitions:

2 => attackNode ??= Sequence("Attack", () =>
{
    ChaseEnemy();
    Attack();

    // Different transitions based on outcome
    OnSuccess(() => state.Value = 0);    // Victory -> Idle
    OnFailure(() => state.Value = 3);    // Failed -> Flee
});
2 => attackNode ??= Sequence("Attack", () =>
{
    ChaseEnemy();
    Attack();

    // Different transitions based on outcome
    OnSuccess(() => state.Value = 0);    // Victory -> Idle
    OnFailure(() => state.Value = 3);    // Failed -> Flee
});

Important

State variables are local to the YieldSimple node. If you have nested state machines (hierarchical), each level has its own state variables that don't interfere with each other.

Summary

State machines provide the high-level decision making for your AI. They answer "What should I be doing right now?" by switching between mutually exclusive modes.

State Machines (WHAT):

  • Decide between modes (Idle, Combat, Patrol, Flee)
  • Handle state transitions via lifecycle callbacks
  • Implemented with YieldSimple
  • Can be hierarchical (nested state machines)

Behavior Trees (HOW):

  • Execute steps within a state
  • Handle reactive conditions
  • Manage sequences and parallel tasks
  • Implemented with Sequence, Selector, etc.

The best practice is to use YieldSimple at the top level with behavior trees as states. This gives you clear separation of concerns and easy-to-understand AI logic.

Next: Reactive Pattern

Build reactive behavior trees that respond to changes.

Read about Reactive Pattern →