Smarter AI in Unity: A Behavior Tree Tutorial
Forget the spaghetti code! It’s time to ditch the haphazard AI implementations that plague so many games. Behavior Trees offer a clean, modular, and remarkably powerful solution for controlling your Non-Player Characters (NPCs) in Unity. This isn’t just about making enemies move; it’s about orchestrating believable, dynamic behaviors that react intelligently to the game world. We’re diving deep into creating a patrol-chase-attack AI, a fundamental pattern, using Behavior Trees. Let’s build something robust, understandable, and genuinely impressive.
What are Behavior Trees (and Why You Should Care)?
Behavior Trees are a graphical representation of decision-making logic. Think of them as flowcharts on steroids, specifically designed for AI. Unlike traditional state machines, which can become unwieldy with complex behaviors, Behavior Trees offer scalability and reusability.
Imagine trying to add a “retreat” behavior to a state machine enemy. It’s likely you’ll have to refactor existing states and transitions. With Behavior Trees, you simply insert a new branch! This is crucial for projects that evolve and require increasingly complex AI. The biggest advantage? Readability. Anyone can look at a Behavior Tree and understand the AI’s decision process.
Setting Up Your Unity Project and Environment
First, create a new Unity project (or use an existing one). Create a new folder called “Scripts” and add a C# script named BehaviorTree
. This will be our base class. Here’s the foundational code:
using UnityEngine;
using System.Collections.Generic;
public abstract class BehaviorTree : MonoBehaviour
{
private Node _rootNode;
protected abstract Node SetupTree();
private void Start()
{
_rootNode = SetupTree();
}
private void Update()
{
if (_rootNode != null)
{
_rootNode.Evaluate();
}
}
}
public abstract class Node
{
protected NodeState _state;
public NodeState State => _state;
protected List<Node> _children = new List<Node>();
public Node() { }
public Node(List<Node> children)
{
_children = children;
}
public abstract NodeState Evaluate();
}
public enum NodeState
{
Running,
Success,
Failure
}
Common Pitfall: Forgetting the abstract
keyword on the SetupTree()
method. This forces you to implement the tree structure in your derived class.
Now, create other empty scripts to hold the basic composite and task nodes: Composite.cs
, Selector.cs
, Sequence.cs
, and Task.cs
. Also, create a script for your enemy AI to be attached to the enemy EnemyAI.cs
.
Building the Patrol-Chase-Attack Behavior Tree
Our goal is to have the enemy patrol between waypoints, chase the player if detected, and attack when in range. This translates beautifully into a Behavior Tree structure.
The Root: The entry point of our tree.
Selector: This composite node will try its children in order. If one succeeds, the Selector succeeds. If all fail, the Selector fails. In our case, it will choose between attacking, chasing, or patrolling.
Sequence (Attack): This composite node executes its children in order. If one fails, the Sequence fails immediately. All must succeed for the Sequence to succeed. Here, we’ll have tasks to check if the player is in attack range and then perform the attack.
Sequence (Chase): Similar to Attack, but checks if the player is in chase range and then moves towards the player.
Sequence (Patrol): Contains tasks to move to the next waypoint.
Here’s how the EnemyAI.cs
will look.
using UnityEngine;
using System.Collections.Generic;
public class EnemyAI : BehaviorTree
{
public Transform[] waypoints;
public float patrolSpeed = 2f;
public float chaseSpeed = 5f;
public float attackRange = 1f;
public float chaseRange = 5f;
public GameObject target;
protected override Node SetupTree()
{
Node root = new Selector(new List<Node>
{
new Sequence(new List<Node>
{
new CheckEnemyInAttackRange(transform, target, attackRange),
new TaskAttack(transform)
}),
new Sequence(new List<Node>
{
new CheckEnemyInChaseRange(transform, target, chaseRange),
new TaskGoToTarget(transform, target, chaseSpeed)
}),
new TaskPatrol(transform, waypoints, patrolSpeed)
});
return root;
}
}
Value: This is far superior to having the movement and target selection in a single monolithic script. This allows for rapid modifications of the AI.
Implementing the Tasks
Now, let’s create the individual tasks: CheckEnemyInAttackRange.cs
, CheckEnemyInChaseRange.cs
, TaskAttack.cs
, TaskGoToTarget.cs
, and TaskPatrol.cs
.
CheckEnemyInAttackRange.cs:
using UnityEngine;
public class CheckEnemyInAttackRange : Node
{
private Transform _transform;
private GameObject _target;
private float _attackRange;
public CheckEnemyInAttackRange(Transform transform, GameObject target, float attackRange)
{
_transform = transform;
_target = target;
_attackRange = attackRange;
}
public override NodeState Evaluate()
{
float distance = Vector3.Distance(_transform.position, _target.transform.position);
if (distance <= _attackRange)
{
_state = NodeState.Success;
return _state;
}
_state = NodeState.Failure;
return _state;
}
}
TaskAttack.cs:
using UnityEngine;
public class TaskAttack : Node
{
private Transform _transform;
public TaskAttack(Transform transform)
{
_transform = transform;
}
public override NodeState Evaluate()
{
// Replace with actual attack logic (e.g., play animation, deal damage)
Debug.Log("Enemy Attacking!");
_state = NodeState.Success; // Assuming attack always succeeds for simplicity
return _state;
}
}
CheckEnemyInChaseRange.cs:
using UnityEngine;
public class CheckEnemyInChaseRange : Node
{
private Transform _transform;
private GameObject _target;
private float _chaseRange;
public CheckEnemyInChaseRange(Transform transform, GameObject target, float chaseRange)
{
_transform = transform;
_target = target;
_chaseRange = chaseRange;
}
public override NodeState Evaluate()
{
float distance = Vector3.Distance(_transform.position, _target.transform.position);
if (distance <= _chaseRange)
{
_state = NodeState.Success;
return _state;
}
_state = NodeState.Failure;
return _state;
}
}
TaskGoToTarget.cs:
using UnityEngine;
using UnityEngine.AI;
public class TaskGoToTarget : Node
{
private Transform _transform;
private GameObject _target;
private float _speed;
private NavMeshAgent _navMeshAgent;
public TaskGoToTarget(Transform transform, GameObject target, float speed)
{
_transform = transform;
_target = target;
_speed = speed;
_navMeshAgent = _transform.GetComponent<NavMeshAgent>();
}
public override NodeState Evaluate()
{
_navMeshAgent.speed = _speed;
_navMeshAgent.SetDestination(_target.transform.position);
_state = NodeState.Running;
// Assuming it eventually reaches the target, for simplicity
if (_navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance)
{
_state = NodeState.Success;
}
return _state;
}
}
TaskPatrol.cs:
using UnityEngine;
using UnityEngine.AI;
public class TaskPatrol : Node
{
private Transform _transform;
private Transform[] _waypoints;
private float _speed;
private int _currentWaypointIndex = 0;
private float _waitTime = 1f; // Time to wait at each waypoint
private float _waitCounter;
private bool _waiting;
private NavMeshAgent _navMeshAgent;
public TaskPatrol(Transform transform, Transform[] waypoints, float speed)
{
_transform = transform;
_waypoints = waypoints;
_speed = speed;
_navMeshAgent = _transform.GetComponent<NavMeshAgent>();
}
public override NodeState Evaluate()
{
if (_waiting)
{
_waitCounter += Time.deltaTime;
if (_waitCounter >= _waitTime)
{
_waiting = false;
_navMeshAgent.SetDestination(_waypoints[_currentWaypointIndex].position);
}
_state = NodeState.Running;
return _state;
}
_navMeshAgent.speed = _speed;
_navMeshAgent.SetDestination(_waypoints[_currentWaypointIndex].position);
if (_navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance)
{
_waiting = true;
_waitCounter = 0f;
_currentWaypointIndex = (_currentWaypointIndex + 1) % _waypoints.Length;
_state = NodeState.Success;
}
_state = NodeState.Running;
return _state;
}
}
Challenge: NavMeshAgents need to be set up properly. If your agent isn’t moving, double-check that you’ve baked your NavMesh and that the agent has a valid destination.
Putting it All Together
- Create an enemy GameObject in your scene.
- Attach the
EnemyAI
script. - Create several waypoint GameObjects and assign them to the
waypoints
array in the Inspector. - Assign a target GameObject to the enemy. The enemy will try to go to and attack this target.
- Add a
NavMeshAgent
component to your enemy. Bake the scene’s NavMesh by going to Window > AI > Navigation.
Enhancements and Further Exploration
This is just the foundation. To take this further:
- Add more complex attack logic: Different attack types, cooldowns, etc.
- Implement a health system: Track enemy and player health.
- Add a “dead” state: Transition to a different behavior tree when the enemy dies.
- Incorporate animation: Trigger animations based on the current task.
- Use a visual editor: There are several Behavior Tree editor assets available on the Unity Asset Store. These can greatly simplify the creation and management of complex trees.
By embracing Behavior Trees, you empower yourself to create truly compelling and intelligent AI. So, ditch the spaghetti code and start building smarter, more believable game worlds!