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 →