How to Make an AI of a Deer in Unity
In game development, adding Artificial Intelligence means writing code that will control the game entity without any external input.
Animal AI in games is a branch of AI that aims to translate animal behavior into the game's digital environment to create a realistic experience.
In this tutorial, I will be showing how to make a simple animal (Deer) AI in Unity that will have two states, idle and flee.
Step 1: Prepare the Scene and the Deer Model
We will need a level and a deer model.
For the level, I will use a simple Terrain with some grass and trees:
For the Deer model I simply combined some Cubes (but you can use this deer model):
Now let's move to the coding part.
Step 2: Set up Player Controller
We begin by setting up a Player Controller so we can walk around and test the AI:
- Create a new script, name it SC_CharacterController and paste the code below inside it:
SC_CharacterController.cs
using UnityEngine;
[RequireComponent(typeof(CharacterController))]
public class SC_CharacterController : MonoBehaviour
{
public float speed = 7.5f;
public float jumpSpeed = 8.0f;
public float gravity = 20.0f;
public Camera playerCamera;
public float lookSpeed = 2.0f;
public float lookXLimit = 45.0f;
CharacterController characterController;
Vector3 moveDirection = Vector3.zero;
Vector2 rotation = Vector2.zero;
[HideInInspector]
public bool canMove = true;
void Start()
{
characterController = GetComponent<CharacterController>();
rotation.y = transform.eulerAngles.y;
}
void Update()
{
if (characterController.isGrounded)
{
// We are grounded, so recalculate move direction based on axes
Vector3 forward = transform.TransformDirection(Vector3.forward);
Vector3 right = transform.TransformDirection(Vector3.right);
float curSpeedX = speed * Input.GetAxis("Vertical");
float curSpeedY = speed * Input.GetAxis("Horizontal");
moveDirection = (forward * curSpeedX) + (right * curSpeedY);
if (Input.GetButton("Jump"))
{
moveDirection.y = jumpSpeed;
}
}
// Apply gravity. Gravity is multiplied by deltaTime twice (once here, and once below
// when the moveDirection is multiplied by deltaTime). This is because gravity should be applied
// as an acceleration (ms^-2)
moveDirection.y -= gravity * Time.deltaTime;
// Move the controller
characterController.Move(moveDirection * Time.deltaTime);
// Player and Camera rotation
if (canMove)
{
rotation.y += Input.GetAxis("Mouse X") * lookSpeed;
rotation.x += -Input.GetAxis("Mouse Y") * lookSpeed;
rotation.x = Mathf.Clamp(rotation.x, -lookXLimit, lookXLimit);
playerCamera.transform.localRotation = Quaternion.Euler(rotation.x, 0, 0);
transform.eulerAngles = new Vector2(0, rotation.y);
}
}
}
- Create a new GameObject and name it "Player" and change its tag to "Player"
- Create a new Capsule (GameObject -> 3D Object -> Capsule), then make it a child object of the "Player" Object, change its position to (0, 1, 0), and remove its CapsuleCollider component.
- Move the Main Camera inside the "Player" Object and change its position to (0, 1.64, 0)
- Attach SC_CharacterController script to a "Player" Object (You will notice it will also add another component called Character Controller. Set its center value to (0, 1, 0))
- Assign the Main Camera to the "Player Camera" variable at SC_CharacterController then Save the Scene
The Player Controller is now ready.
Step 3: Program Deer AI
Now let's move to the part where we program a Deer AI:
- Create a new script and name it SC_DeerAI (this script will control the AI movement):
Open SC_DeerAI and continue the steps below:
At the start of the script, we make sure that all the necessary classes are included (specifically UnityEngine.AI):
using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
public class SC_DeerAI : MonoBehaviour
{
Now let's add all the variables:
public enum AIState { Idle, Walking, Eating, Running }
public AIState currentState = AIState.Idle;
public int awarenessArea = 15; //How far the deer should detect the enemy
public float walkingSpeed = 3.5f;
public float runningSpeed = 7f;
public Animator animator;
//Trigger collider that represents the awareness area
SphereCollider c;
//NavMesh Agent
NavMeshAgent agent;
bool switchAction = false;
float actionTimer = 0; //Timer duration till the next action
Transform enemy;
float range = 20; //How far the Deer have to run to resume the usual activities
float multiplier = 1;
bool reverseFlee = false; //In case the AI is stuck, send it to one of the original Idle points
//Detect NavMesh edges to detect whether the AI is stuck
Vector3 closestEdge;
float distanceToEdge;
float distance; //Squared distance to the enemy
//How long the AI has been near the edge of NavMesh, if too long, send it to one of the random previousIdlePoints
float timeStuck = 0;
//Store previous idle points for reference
List<Vector3> previousIdlePoints = new List<Vector3>();
Then we initialize everything in the void Start():
// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
agent.stoppingDistance = 0;
agent.autoBraking = true;
c = gameObject.AddComponent<SphereCollider>();
c.isTrigger = true;
c.radius = awarenessArea;
//Initialize the AI state
currentState = AIState.Idle;
actionTimer = Random.Range(0.1f, 2.0f);
SwitchAnimationState(currentState);
}
(As you can see we add a Sphere Collider which is marked as Trigger. This collider will act as an awareness area when the enemy enters it).
The actual AI logic is done in the void Update() with some helper functions:
// Update is called once per frame
void Update()
{
//Wait for the next course of action
if (actionTimer > 0)
{
actionTimer -= Time.deltaTime;
}
else
{
switchAction = true;
}
if (currentState == AIState.Idle)
{
if(switchAction)
{
if (enemy)
{
//Run away
agent.SetDestination(RandomNavSphere(transform.position, Random.Range(1, 2.4f)));
currentState = AIState.Running;
SwitchAnimationState(currentState);
}
else
{
//No enemies nearby, start eating
actionTimer = Random.Range(14, 22);
currentState = AIState.Eating;
SwitchAnimationState(currentState);
//Keep last 5 Idle positions for future reference
previousIdlePoints.Add(transform.position);
if (previousIdlePoints.Count > 5)
{
previousIdlePoints.RemoveAt(0);
}
}
}
}
else if (currentState == AIState.Walking)
{
//Set NavMesh Agent Speed
agent.speed = walkingSpeed;
// Check if we've reached the destination
if (DoneReachingDestination())
{
currentState = AIState.Idle;
}
}
else if (currentState == AIState.Eating)
{
if (switchAction)
{
//Wait for current animation to finish playing
if(!animator || animator.GetCurrentAnimatorStateInfo(0).normalizedTime - Mathf.Floor(animator.GetCurrentAnimatorStateInfo(0).normalizedTime) > 0.99f)
{
//Walk to another random destination
agent.destination = RandomNavSphere(transform.position, Random.Range(3, 7));
currentState = AIState.Walking;
SwitchAnimationState(currentState);
}
}
}
else if (currentState == AIState.Running)
{
//Set NavMesh Agent Speed
agent.speed = runningSpeed;
//Run away
if (enemy)
{
if (reverseFlee)
{
if (DoneReachingDestination() && timeStuck < 0)
{
reverseFlee = false;
}
else
{
timeStuck -= Time.deltaTime;
}
}
else
{
Vector3 runTo = transform.position + ((transform.position - enemy.position) * multiplier);
distance = (transform.position - enemy.position).sqrMagnitude;
//Find the closest NavMesh edge
NavMeshHit hit;
if (NavMesh.FindClosestEdge(transform.position, out hit, NavMesh.AllAreas))
{
closestEdge = hit.position;
distanceToEdge = hit.distance;
//Debug.DrawLine(transform.position, closestEdge, Color.red);
}
if (distanceToEdge < 1f)
{
if(timeStuck > 1.5f)
{
if(previousIdlePoints.Count > 0)
{
runTo = previousIdlePoints[Random.Range(0, previousIdlePoints.Count - 1)];
reverseFlee = true;
}
}
else
{
timeStuck += Time.deltaTime;
}
}
if (distance < range * range)
{
agent.SetDestination(runTo);
}
else
{
enemy = null;
}
}
//Temporarily switch to Idle if the Agent stopped
if(agent.velocity.sqrMagnitude < 0.1f * 0.1f)
{
SwitchAnimationState(AIState.Idle);
}
else
{
SwitchAnimationState(AIState.Running);
}
}
else
{
//Check if we've reached the destination then stop running
if (DoneReachingDestination())
{
actionTimer = Random.Range(1.4f, 3.4f);
currentState = AIState.Eating;
SwitchAnimationState(AIState.Idle);
}
}
}
switchAction = false;
}
bool DoneReachingDestination()
{
if (!agent.pathPending)
{
if (agent.remainingDistance <= agent.stoppingDistance)
{
if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)
{
//Done reaching the Destination
return true;
}
}
}
return false;
}
void SwitchAnimationState(AIState state)
{
//Animation control
if (animator)
{
animator.SetBool("isEating", state == AIState.Eating);
animator.SetBool("isRunning", state == AIState.Running);
animator.SetBool("isWalking", state == AIState.Walking);
}
}
Vector3 RandomNavSphere(Vector3 origin, float distance)
{
Vector3 randomDirection = Random.insideUnitSphere * distance;
randomDirection += origin;
NavMeshHit navHit;
NavMesh.SamplePosition(randomDirection, out navHit, distance, NavMesh.AllAreas);
return navHit.position;
}
(Each State initializes the values and the NavMesh Agent target for the next state. For example, the Idle state has 2 possible outcomes, it either initializes the Running state if the enemy is present or the Eating state if no Enemy crossed the awareness area.
A walking state is used in between the Eating states to move to the new destination.
Running state calculates the direction relative to the enemy position, to run directly from it.
If stuck in the corner, the AI retracts to one of the previously saved Idle positions. The enemy is lost after the AI is far enough from the enemy).
And lastly, we add an OnTriggerEnter event which will monitor the Sphere Collider (a.k.a. Awareness Area) and will initialize the Running state once the enemy gets too close:
void OnTriggerEnter(Collider other)
{
//Make sure the Player instance has a tag "Player"
if (!other.CompareTag("Player"))
return;
enemy = other.transform;
actionTimer = Random.Range(0.24f, 0.8f);
currentState = AIState.Idle;
SwitchAnimationState(currentState);
}
As soon as the player enters the trigger, the enemy variable is assigned and the Idle state is initialized, after that, the Running state is initialized.
Below is the final SC_DeerAI.cs script:
//You are free to use this script in Free or Commercial projects
//sharpcoderblog.com @2019
using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
public class SC_DeerAI : MonoBehaviour
{
public enum AIState { Idle, Walking, Eating, Running }
public AIState currentState = AIState.Idle;
public int awarenessArea = 15; //How far the deer should detect the enemy
public float walkingSpeed = 3.5f;
public float runningSpeed = 7f;
public Animator animator;
//Trigger collider that represents the awareness area
SphereCollider c;
//NavMesh Agent
NavMeshAgent agent;
bool switchAction = false;
float actionTimer = 0; //Timer duration till the next action
Transform enemy;
float range = 20; //How far the Deer have to run to resume the usual activities
float multiplier = 1;
bool reverseFlee = false; //In case the AI is stuck, send it to one of the original Idle points
//Detect NavMesh edges to detect whether the AI is stuck
Vector3 closestEdge;
float distanceToEdge;
float distance; //Squared distance to the enemy
//How long the AI has been near the edge of NavMesh, if too long, send it to one of the random previousIdlePoints
float timeStuck = 0;
//Store previous idle points for reference
List<Vector3> previousIdlePoints = new List<Vector3>();
// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
agent.stoppingDistance = 0;
agent.autoBraking = true;
c = gameObject.AddComponent<SphereCollider>();
c.isTrigger = true;
c.radius = awarenessArea;
//Initialize the AI state
currentState = AIState.Idle;
actionTimer = Random.Range(0.1f, 2.0f);
SwitchAnimationState(currentState);
}
// Update is called once per frame
void Update()
{
//Wait for the next course of action
if (actionTimer > 0)
{
actionTimer -= Time.deltaTime;
}
else
{
switchAction = true;
}
if (currentState == AIState.Idle)
{
if(switchAction)
{
if (enemy)
{
//Run away
agent.SetDestination(RandomNavSphere(transform.position, Random.Range(1, 2.4f)));
currentState = AIState.Running;
SwitchAnimationState(currentState);
}
else
{
//No enemies nearby, start eating
actionTimer = Random.Range(14, 22);
currentState = AIState.Eating;
SwitchAnimationState(currentState);
//Keep last 5 Idle positions for future reference
previousIdlePoints.Add(transform.position);
if (previousIdlePoints.Count > 5)
{
previousIdlePoints.RemoveAt(0);
}
}
}
}
else if (currentState == AIState.Walking)
{
//Set NavMesh Agent Speed
agent.speed = walkingSpeed;
// Check if we've reached the destination
if (DoneReachingDestination())
{
currentState = AIState.Idle;
}
}
else if (currentState == AIState.Eating)
{
if (switchAction)
{
//Wait for current animation to finish playing
if(!animator || animator.GetCurrentAnimatorStateInfo(0).normalizedTime - Mathf.Floor(animator.GetCurrentAnimatorStateInfo(0).normalizedTime) > 0.99f)
{
//Walk to another random destination
agent.destination = RandomNavSphere(transform.position, Random.Range(3, 7));
currentState = AIState.Walking;
SwitchAnimationState(currentState);
}
}
}
else if (currentState == AIState.Running)
{
//Set NavMesh Agent Speed
agent.speed = runningSpeed;
//Run away
if (enemy)
{
if (reverseFlee)
{
if (DoneReachingDestination() && timeStuck < 0)
{
reverseFlee = false;
}
else
{
timeStuck -= Time.deltaTime;
}
}
else
{
Vector3 runTo = transform.position + ((transform.position - enemy.position) * multiplier);
distance = (transform.position - enemy.position).sqrMagnitude;
//Find the closest NavMesh edge
NavMeshHit hit;
if (NavMesh.FindClosestEdge(transform.position, out hit, NavMesh.AllAreas))
{
closestEdge = hit.position;
distanceToEdge = hit.distance;
//Debug.DrawLine(transform.position, closestEdge, Color.red);
}
if (distanceToEdge < 1f)
{
if(timeStuck > 1.5f)
{
if(previousIdlePoints.Count > 0)
{
runTo = previousIdlePoints[Random.Range(0, previousIdlePoints.Count - 1)];
reverseFlee = true;
}
}
else
{
timeStuck += Time.deltaTime;
}
}
if (distance < range * range)
{
agent.SetDestination(runTo);
}
else
{
enemy = null;
}
}
//Temporarily switch to Idle if the Agent stopped
if(agent.velocity.sqrMagnitude < 0.1f * 0.1f)
{
SwitchAnimationState(AIState.Idle);
}
else
{
SwitchAnimationState(AIState.Running);
}
}
else
{
//Check if we've reached the destination then stop running
if (DoneReachingDestination())
{
actionTimer = Random.Range(1.4f, 3.4f);
currentState = AIState.Eating;
SwitchAnimationState(AIState.Idle);
}
}
}
switchAction = false;
}
bool DoneReachingDestination()
{
if (!agent.pathPending)
{
if (agent.remainingDistance <= agent.stoppingDistance)
{
if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)
{
//Done reaching the Destination
return true;
}
}
}
return false;
}
void SwitchAnimationState(AIState state)
{
//Animation control
if (animator)
{
animator.SetBool("isEating", state == AIState.Eating);
animator.SetBool("isRunning", state == AIState.Running);
animator.SetBool("isWalking", state == AIState.Walking);
}
}
Vector3 RandomNavSphere(Vector3 origin, float distance)
{
Vector3 randomDirection = Random.insideUnitSphere * distance;
randomDirection += origin;
NavMeshHit navHit;
NavMesh.SamplePosition(randomDirection, out navHit, distance, NavMesh.AllAreas);
return navHit.position;
}
void OnTriggerEnter(Collider other)
{
//Make sure the Player instance has a tag "Player"
if (!other.CompareTag("Player"))
return;
enemy = other.transform;
actionTimer = Random.Range(0.24f, 0.8f);
currentState = AIState.Idle;
SwitchAnimationState(currentState);
}
}
- Place the Deer model in the Scene and attach a NavMesh Agent, SC_DeerAI script, and Animator component to it:
SC_DeerAI only has one variable that needs to be assigned which is "Animator".
The animator component requires a Controller with 4 animations: Idle Animation, Walking Animation, Eating Animation, and Running Animation, and 3 bool parameters: isEating, isRunning, and isWalking:
You can learn how to setup a simple Animator Controller by clicking here
After everything is assigned there is one last thing left to do, which is to bake a NavMesh.
- Select all the Scene Objects that will be static (Ex. Terrain, Trees, etc.) and Mark them as "Navigation Static":
- Go to Navigation Window (Window -> AI -> Navigation) and click the "Bake" tab then click the "Bake" button. After the NavMesh is baked it should look something like this:
After the NavMesh was baked, we can test the AI:
Everything works as expected. The Deer runs away when the enemy is near and resumes its usual activities once the enemy is far enough.