Reactive Pattern

Implement reactive behavior that responds to condition changes and gracefully invalidates state.

Overview

A reactive node monitors for condition changes and gracefully resets its children when conditions invalidate. This is useful for behaviors that should respond immediately to state changes.

Making a Node Reactive

Use the Reactive keyword with the * operator to make a node reactive:

void MyNode() => Reactive * Sequence(() => 
{
    // We add a "_ =", a discard operation, otherwise we'll get an
    // Only assignment, call, increment, decrement, await, and new object expressions can be used as a statement
    _ = Reactive * Selector(() => 
    {
        // ...
    })
});
void MyNode() => Reactive * Sequence(() => 
{
    // We add a "_ =", a discard operation, otherwise we'll get an
    // Only assignment, call, increment, decrement, await, and new object expressions can be used as a statement
    _ = Reactive * Selector(() => 
    {
        // ...
    })
});

In the case of a reactive sequence, we check previous nodes if any have invalidated. Here's a complete example:

using UnityEngine;
using static ClosureBT.BT;

public class Example : MonoBehaviour
{
    public Node AI;
    
    public bool foo;
    public bool bar;
    public bool baz;

    private void Update() => AI.Tick();
    private void OnDestroy() => AI.ResetImmediately();

    private void ExampleReactiveSequence() => Reactive * Sequence("Example", () =>
    {
        Condition(() => foo);
        Condition(() => bar);
        Condition(() => baz);

        Wait(5, () => 
        {
            OnEnter(() => Debug.Log("We reached the wait node!"));
            OnExit(() => Debug.Log("We exitted out of the wait node!"));
            OnSuccess(() => Debug.Log("Wait node success!"));
        });
    });
}
using UnityEngine;
using static ClosureBT.BT;

public class Example : MonoBehaviour
{
    public Node AI;
    
    public bool foo;
    public bool bar;
    public bool baz;

    private void Update() => AI.Tick();
    private void OnDestroy() => AI.ResetImmediately();

    private void ExampleReactiveSequence() => Reactive * Sequence("Example", () =>
    {
        Condition(() => foo);
        Condition(() => bar);
        Condition(() => baz);

        Wait(5, () => 
        {
            OnEnter(() => Debug.Log("We reached the wait node!"));
            OnExit(() => Debug.Log("We exitted out of the wait node!"));
            OnSuccess(() => Debug.Log("Wait node success!"));
        });
    });
}

Now toggle foo, bar, or baz on and off. You'll see that if we're at the Wait node and any of the 3 conditions become false, the Wait node will exit!

Invalidation Check

Let's look at how Condition implements invalidation:

Node Condition(string name, Func<bool> condition, Action lifecycle = null) => Leaf("Condition", () =>
{
    var _previous = Variable(false);

    SetNodeName(name);
    OnInvalidCheck(() => condition() != _previous.Value);

    OnBaseTick(() =>
    {
        _previous.Value = condition();
        return _previous.Value ? Status.Success : Status.Failure;
    });

    lifecycle?.Invoke();
});
Node Condition(string name, Func<bool> condition, Action lifecycle = null) => Leaf("Condition", () =>
{
    var _previous = Variable(false);

    SetNodeName(name);
    OnInvalidCheck(() => condition() != _previous.Value);

    OnBaseTick(() =>
    {
        _previous.Value = condition();
        return _previous.Value ? Status.Success : Status.Failure;
    });

    lifecycle?.Invoke();
});

In the previous example, when we reach a Wait node, the Sequence nodes marked with the Reactive flag will check their previously ran children using the provided OnInvalidCheck. For conditions, it's simple: has the value changed from when it attempted to run in OnBaseTick?

Let's write a Follow node:

Node Follow(Action lifecycle = null) => Reactive * Selector("Follow", () =>
{
    var stoppingDistance = 2f;
    var chaseAgainDistance = 3f;

    OnInvalidCheck(() => // We return true if it is invalid
    {
        var distance = Vector3.Distance(transform.position, target.transform.position);
        return distance >= chaseAgainDistance;
    });

    OnBaseTick(() =>
    {
        var distance = Vector3.Distance(transform.position, target.transform.position);

        if (distance >= stoppingDistance)
        {
            MoveTowards(target.transform.position);
            return Status.Running;
        }
        else
            return Status.Success;
    });

    lifecycle?.Invoke();
});
Node Follow(Action lifecycle = null) => Reactive * Selector("Follow", () =>
{
    var stoppingDistance = 2f;
    var chaseAgainDistance = 3f;

    OnInvalidCheck(() => // We return true if it is invalid
    {
        var distance = Vector3.Distance(transform.position, target.transform.position);
        return distance >= chaseAgainDistance;
    });

    OnBaseTick(() =>
    {
        var distance = Vector3.Distance(transform.position, target.transform.position);

        if (distance >= stoppingDistance)
        {
            MoveTowards(target.transform.position);
            return Status.Running;
        }
        else
            return Status.Success;
    });

    lifecycle?.Invoke();
});

Here, we have a Follow node that will chase a target until within a stopping distance. It will be "Done" until the target moves beyond the chaseAgainDistance, at which point it will invalidate and re-run the logic.

The Action lifecycle = null parameter is nice to have since you can now attach lifecycle methods to your custom node:

Follow(() =>
{
    OnTick(() => Debug.Log("Following the target..."));
    OnSuccess(() => Debug.Log("We reached the target!"));
    OnFailure(() => Debug.Log("We gave up following the target!"));
    // etc lifecycle methods
});
Follow(() =>
{
    OnTick(() => Debug.Log("Following the target..."));
    OnSuccess(() => Debug.Log("We reached the target!"));
    OnFailure(() => Debug.Log("We gave up following the target!"));
    // etc lifecycle methods
});

Important Notes

Reactive nodes are powerful but have performance implications:

  • Invalidation checks run every tick
  • Use for frequently-changing conditions
  • Consider caching expensive checks

Next: Lifecycle Hooks

Discover lifecycle hooks that run at different execution phases.

Read about Lifecycle Hooks →