A GameObject Task is the default route for project-specific Task logic. Move a Task to the Entity route only when profiling a build shows that its execution is a bottleneck at the agent counts the project needs — see Performance.

The lifecycle mirrors the MonoBehaviour callbacks:

/// <summary>
/// Callback when the behavior tree is initialized.
/// </summary>
public virtual void OnAwake()

/// <summary>
/// Callback when the behavior tree is enabled.
/// </summary>
public virtual void OnEnable()

/// <summary>
/// Callback when the task is started.
/// </summary>
public virtual void OnStart()

/// <summary>
/// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
/// </summary>
/// <returns>The status of the task.</returns>
public virtual TaskStatus OnUpdate()

/// <summary>
/// Callback when the task stops.
/// </summary>
public virtual void OnEnd()

/// <summary>
/// Callback when the behavior tree is disabled.
/// </summary>
public virtual void OnDisable()

/// <summary>
/// Callback when the behavior tree is destroyed.
/// </summary>
public virtual void OnDestroy()

A Conditional Task can also implement OnReevaluateUpdate to take a different path when a conditional abort is reevaluating it:

/// <summary>
/// Reevaluates the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
/// </summary>
/// <returns>The status of the task during the reevaluation phase.</returns>
public virtual TaskStatus OnReevaluateUpdate()

To receive a physics callback, override the corresponding receive property:

ReceiveCollisionEnterCallback
ReceiveCollisionExitCallback
ReceiveCollisionEnter2DCallback
ReceiveCollisionExit2DCallback
ReceiveTriggerEnterCallback
ReceiveTriggerExitCallback
ReceiveTriggerEnter2DCallback
ReceiveTriggerExit2DCallback
ReceiveControllerColliderHitCallback

A Task receives a physics callback only after opting in through the matching property, which keeps the cost off Tasks that do not need it. The Conditional Task example at the end of this page returns Success once a specific object enters a trigger.

Each Task can also participate in the save/load system and declare what should persist. See Save/Load for the full workflow.

/// <summary>
/// Specifies the type of reflection that should be used to save the task.
/// </summary>
/// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
public virtual MemberVisibility GetSaveReflectionType(int index)

/// <summary>
/// Returns the current task state.
/// </summary>
/// <param name="world">The DOTS world.</param>
/// <param name="entity">The DOTS entity.</param>
/// <returns>The current task state.</returns>
public virtual object Save(World world, Entity entity)

/// <summary>
/// Loads the previous task state.
/// </summary>
/// <param name="saveData">The previous task state.</param>
/// <param name="world">The DOTS world.</param>
/// <param name="entity">The DOTS entity.</param>
public virtual void Load(object saveData, World world, Entity entity)

Create an action task

An Action Task derives from either Action or ActionNode. Action Tasks are stacked with other Action Tasks; ActionNode Tasks are not. Derive from Action unless the Task must stay separate from everything else, as the Subtree Reference Task does.

The example below moves the agent to a random point within a radius of a center point, at a configurable speed. It uses Shared Variables so the values can be shared with other Tasks, and implements Save and Load so the chosen point survives a save.

Create a new script in the project. Behavior Designer uses assembly definitions, so reference the Opsive.BehaviorDesigner.Runtime assembly. Start with OnStart, which chooses the destination:

using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable<Vector3> m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable<float> m_Radius = 10;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }
}

This block chooses a new random destination when the Task starts, using a Vector3 Shared Variable for the center point and a float Shared Variable for the radius. Opsive.GraphDesigner.Runtime.Variables contains the Shared Variable system, and Opsive.BehaviorDesigner.Runtime.Tasks.Actions contains the parent Action class. With a destination selected, add the movement:

using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;

public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable<Vector3> m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable<float> m_Radius = 10;
    [Tooltip("The speed that the agent should move towards the destination.")]
    public SharedVariable<float> m_MoveSpeed = 5;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }

    /// <summary>
    /// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task.</returns>
    public override TaskStatus OnUpdate()
    {
        // The agent has arrived when they get close to the destination.
        if (Vector3.Distance(transform.position, m_Destination) < 0.5f) {
            return TaskStatus.Success;
        }

        // The agent hasn't arrived yet. Keep moving towards the destination and return a running status.
        transform.position = Vector3.MoveTowards(transform.position, m_Destination, m_MoveSpeed.Value * Time.deltaTime);
        return TaskStatus.Running;
    }
}

This revision adds a namespace, the m_MoveSpeed variable, and OnUpdate. OnUpdate is called on each tick and its return value tells the tree how to proceed: TaskStatus.Success once the agent reaches the destination, and TaskStatus.Running while it is still moving. The Task is now functionally complete.

Two optional attributes improve how the Task presents in the editor. Selecting a node shows its description in the lower right, which comes from Opsive.Shared.Utility.Description, and NodeIcon supplies a node icon:

using Opsive.GraphDesigner.Runtime;

[NodeIcon("Assets/MyIcon.png")]
[Opsive.Shared.Utility.Description("Moves the agent towards a random position within the specified radius.")]
public class MoveTowardsRandomPoint : Action

NodeIcon accepts an asset path or an asset GUID, plus an optional second value used as the light-theme icon.

The Task is complete at this point if the tree does not need saving. To support Save/Load, implement the following methods as well:

    /// <summary>
    /// Specifies the type of reflection that should be used to save the task.
    /// </summary>
    /// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
    public override MemberVisibility GetSaveReflectionType(int index)
    {
        // Do not use reflection to save. This task will implement the Save and Load methods.
        return MemberVisibility.None;
    }

    /// <summary>
    /// Returns the current task state.
    /// </summary>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    /// <returns>The current task state.</returns>
    public override object Save(World world, Entity entity)
    {
        // Only save the destination.
        return m_Destination;
    }

    /// <summary>
    /// Loads the previous task state.
    /// </summary>
    /// <param name="saveData">The previous task state.</param>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    public override void Load(object saveData, World world, Entity entity)
    {
        // The saveData will only contain the objects specified by the Save method.
        m_Destination = (Vector3)saveData;
    }

The Unity.Entities namespace must be added in order for this code to compile. GetSaveReflectionType specifies how the variables should be saved using reflection:

  • MemberVisibility.All: Public and private variables will be saved with reflection.
  • MemberVisiblity.Public: Only public and serialized private variables will be saved with reflection.
  • MemberVisiblity.None: No variables will be saved with reflection. If this value is specified then the Save and Load methods need to be implemented.

Since MemberVisiblity.None was specified we need to implement the Save and Load methods. The Save method simply returns the value that we want to save (in this case the random destination), and the Load method will restore that value. The task is now complete. The entire task looks like:

using Opsive.Shared.Utility;
using Opsive.GraphDesigner.Runtime;
using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
using Unity.Entities;

[NodeIcon("Assets/MyIcon.png")]
[Description("Moves the agent towards a random position within the specified radius.")]
public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable<Vector3> m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable<float> m_Radius = 10;
    [Tooltip("The speed that the agent should move towards the destination.")]
    public SharedVariable<float> m_MoveSpeed = 5;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }

    /// <summary>
    /// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task.</returns>
    public override TaskStatus OnUpdate()
    {
        // The agent has arrived when they get close to the destination.
        if (Vector3.Distance(transform.position, m_Destination) < 0.5f) {
            return TaskStatus.Success;
        }

        // The agent hasn't arrived yet. Keep moving towards the destination and return a running status.
        transform.position = Vector3.MoveTowards(transform.position, m_Destination, m_MoveSpeed.Value * Time.deltaTime);
        return TaskStatus.Running;
    }

    /// <summary>
    /// Specifies the type of reflection that should be used to save the task.
    /// </summary>
    /// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
    public override MemberVisibility GetSaveReflectionType(int index)
    {
        // Do not use reflection to save. This task will implement the Save and Load methods.
        return MemberVisibility.None;
    }

    /// <summary>
    /// Returns the current task state.
    /// </summary>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    /// <returns>The current task state.</returns>
    public override object Save(World world, Entity entity)
    {
        // Only save the destination.
        return m_Destination;
    }

    /// <summary>
    /// Loads the previous task state.
    /// </summary>
    /// <param name="saveData">The previous task state.</param>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    public override void Load(object saveData, World world, Entity entity)
    {
        // The saveData will only contain the objects specified by the Save method.
        m_Destination = (Vector3)saveData;
    }
}

Create a conditional task

A Conditional Task uses the same API as an Action Task, plus OnReevaluateUpdate for conditional aborts. It derives from Conditional, which is stacked with other Conditionals, or ConditionalNode, which is not. The example below returns Success once a matching object enters the agent’s trigger.

public class HasEnteredTrigger : Conditional
{
    [Tooltip("The tag of the GameObject that the trigger should be checked against.")]
    [SerializeField] protected SharedVariable<string> m_Tag;

    protected override bool ReceiveTriggerEnterCallback => true;

    private bool m_EnteredTrigger;

    /// <summary>
    /// Returns true when the agent has entered a trigger.
    /// </summary>
    /// <returns>True when the agent has entered a trigger.</returns>
    public override TaskStatus OnUpdate()
    {
        return m_EnteredTrigger ? TaskStatus.Success : TaskStatus.Failure;
    }

    /// <summary>
    /// The agent has entered a trigger.
    /// </summary>
    /// <param name="other">The trigger that the agent entered.</param>
    protected override void OnTriggerEnter(Collider other)
    {
        if (!string.IsNullOrEmpty(m_Tag.Value) && !other.gameObject.CompareTag(m_Tag.Value)) {
            return;
        }
        m_EnteredTrigger = true;
    }
}

A lot of the same concepts from the action task applies to conditional tasks. Compared to the action task from above this conditional task:

  • Implements the Conditional base class.
  • Overrides the ReceivedTriggerEnterCallback property.
  • Implements OnTriggerEnter. If ReceivedTriggerEnterCallback was not overridden with a true status then this method would not be called.

Conditional tasks are special in that they can be reevaluated with conditional aborts. By default conditional aborts will call the OnUpdate method, but you can also implement a separate callback that has logic specific to the reevaluation:

    /// <summary>
    /// Reevaluates the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task during the reevaluation phase.</returns>
    public override TaskStatus OnReevaluateUpdate()
    {
        return (m_EnteredTrigger && string.Equals(m_Tag.Value, "BlueTeam")) ? TaskStatus.Success : TaskStatus.Failure;
    }

This is a contrived example but it illustrates the point well that the reevaluation update can be different from the regular update. In this example in order for the conditional abort to trigger the agent must enter the trigger and the tag must match the "BlueTeam" tag. In most cases you will not need to implement a separate OnReevaluateUpdate callback.

Composite & Decorator Tasks

The composite and decorator tasks are very similar. Composite tasks should implement the CompositeNode base class, and decorator tasks should implement DecoratorNode. Composite and decorator tasks cannot be stacked. Composite tasks can implement two extra properties:

/// <summary>
/// The maximum number of child tasks that can be parented to the current task.
/// </summary>
public virtual int MaxChildCount { get => int.MaxValue; }

/// <summary>
/// Returns the index of the next active task index.
/// </summary>
public virtual ushort NextChildIndex { get => (ushort)(Index + 1); }

The MaxChildCount property will be checked during edit time when tasks are being added to the tree. NextChildIndex is called at runtime in order to determine the next task that should start. This property is only called if the task has a status of running. If the task is not running then the child is not running. Decorators implement these two properties but their values are restricted because decorators can only have a single child. Therefore you do not need to implement these methods for a decorator task.