AI NPC Behavior in Unity and Godot
You will build one guard NPC that patrols, notices the player, gives chase, and gives up, in both Unity and Godot, then learn where a language model fits for dialogue and how to keep the game playable when it is missing.
When people say “AI NPC” today, they often mean a chatbot in a character costume. Most of what makes an NPC feel alive is older and simpler. It is a small set of states, clear rules for moving between them, and good timing.
That kind of game AI is something you can build, test, and ship today. This guide builds it first. A short section at the end covers adding a language model for dialogue, with honest notes on what you can and cannot check.
Pick a pattern: state machine or behavior tree
A finite state machine puts the NPC in exactly one state at a time, such as Patrol or Chase. Each state has rules for when to switch. It is easy to read and easy to debug.
A behavior tree checks a list of priorities every tick, top to bottom, and runs the first one that applies. It scales better when an NPC has many behaviors.
For a guard with four behaviors, use a state machine. You can move to a behavior tree later if the list grows. The same four rules work in both.
Design the guard on paper
Write the states and transitions before you code:
- Patrol: walk between waypoints. If the player is seen, go to Chase.
- Notice: a short pause when the player is first seen. Then go to Chase. This gives the player a fair moment to react.
- Chase: move toward the player. If sight is lost, start a timer.
- Give up: if the timer runs out, walk to the last known position, look around, then return to Patrol.
“Seen” needs a definition. Use three checks, cheapest first:
- The player is within a set distance.
- The player is inside a view angle in front of the guard.
- A raycast from the guard’s eyes to the player hits the player, not a wall.
Pick your own distance, angle, and timer values by playtesting. There is no correct number.
Build it in Unity
Bake a navigation mesh for your level. Navigation tooling has moved between Unity versions and packages, so check the current Unity AI Navigation docs for how to bake in your version. Add a NavMeshAgent component to the guard.
using UnityEngine;
using UnityEngine.AI;
public class Guard : MonoBehaviour {
enum State { Patrol, Notice, Chase, GiveUp }
State state = State.Patrol;
public Transform player;
public Transform[] waypoints;
public float sightRange = 10f, sightAngle = 60f;
public float noticeTime = 0.5f, giveUpTime = 3f;
NavMeshAgent agent;
int wp;
float timer;
Vector3 lastSeen;
void Start() { agent = GetComponent<NavMeshAgent>(); GoTo(waypoints[0].position); }
void Update() {
bool sees = CanSee();
if (sees) lastSeen = player.position;
switch (state) {
case State.Patrol:
if (sees) { Enter(State.Notice); agent.ResetPath(); break; }
if (!agent.pathPending && agent.remainingDistance < 0.5f) {
wp = (wp + 1) % waypoints.Length;
GoTo(waypoints[wp].position);
}
break;
case State.Notice:
if ((timer -= Time.deltaTime) <= 0) Enter(State.Chase);
break;
case State.Chase:
GoTo(lastSeen);
if (sees) timer = giveUpTime;
else if ((timer -= Time.deltaTime) <= 0) { Enter(State.GiveUp); GoTo(lastSeen); }
break;
case State.GiveUp:
if (sees) { Enter(State.Chase); break; }
if (!agent.pathPending && agent.remainingDistance < 0.5f) {
Enter(State.Patrol); GoTo(waypoints[wp].position);
}
break;
}
}
void Enter(State s) { state = s; timer = s == State.Notice ? noticeTime : giveUpTime; }
void GoTo(Vector3 p) { agent.SetDestination(p); }
bool CanSee() {
Vector3 eye = transform.position + Vector3.up * 1.5f;
Vector3 to = player.position - eye;
if (to.magnitude > sightRange) return false;
if (Vector3.Angle(transform.forward, to) > sightAngle * 0.5f) return false;
return Physics.Raycast(eye, to.normalized, out RaycastHit hit, sightRange)
&& hit.transform == player;
}
}
Assign the player and waypoints in the Inspector. Make sure the player has a collider, so the raycast can hit it.
Build it in Godot
Bake a navigation region for your level, and give the guard a CharacterBody3D with a NavigationAgent3D child. Node and method names have shifted between Godot versions, so confirm them against the Godot docs for your release.
extends CharacterBody3D
enum State { PATROL, NOTICE, CHASE, GIVE_UP }
var state := State.PATROL
@export var player: Node3D
@export var waypoints: Array[Node3D]
@export var speed := 3.0
@export var sight_range := 10.0
@export var sight_angle := 60.0
@export var notice_time := 0.5
@export var give_up_time := 3.0
@onready var agent: NavigationAgent3D = $NavigationAgent3D
var wp := 0
var timer := 0.0
var last_seen := Vector3.ZERO
func _ready():
agent.target_position = waypoints[0].global_position
func _physics_process(delta):
var sees := can_see()
if sees:
last_seen = player.global_position
match state:
State.PATROL:
if sees:
enter(State.NOTICE)
elif agent.is_navigation_finished():
wp = (wp + 1) % waypoints.size()
agent.target_position = waypoints[wp].global_position
State.NOTICE:
timer -= delta
if timer <= 0:
enter(State.CHASE)
State.CHASE:
agent.target_position = last_seen
if sees:
timer = give_up_time
else:
timer -= delta
if timer <= 0:
enter(State.GIVE_UP)
State.GIVE_UP:
if sees:
enter(State.CHASE)
elif agent.is_navigation_finished():
enter(State.PATROL)
agent.target_position = waypoints[wp].global_position
if state == State.NOTICE:
velocity = Vector3.ZERO
else:
var next := agent.get_next_path_position()
velocity = (next - global_position).normalized() * speed
move_and_slide()
func enter(s):
state = s
timer = notice_time if s == State.NOTICE else give_up_time
func can_see() -> bool:
var eye := global_position + Vector3.UP * 1.5
var to := player.global_position - eye
if to.length() > sight_range:
return false
if rad_to_deg((-global_transform.basis.z).angle_to(to)) > sight_angle * 0.5:
return false
var query := PhysicsRayQueryParameters3D.create(eye, player.global_position)
query.exclude = [get_rid()]
var hit := get_world_3d().direct_space_state.intersect_ray(query)
return not hit.is_empty() and hit.collider == player
Godot treats negative Z as forward, so model your guard facing that way. Point the player export at the player’s physics body, so the ray hit matches it.
Tune it so it feels fair
- Show the state. Play a sound or raise an alert icon over the guard when Notice starts.
- Slow the chase slightly below the player’s top speed, so escape is possible.
- Let the guard turn toward the last known position before walking there.
- Log state changes to the console while you test.
- Test with the player hiding behind thin cover, standing at the edge of the view angle, and sprinting past. Each case should feel readable, not random.
Adding a language model for dialogue
Once the guard behaves well, you may want it to say something new each time. A language model can do that. Keep it on top of the state machine, never in charge of it.
What you can verify:
- A language model returns text when you send it a prompt.
- You must send that prompt somewhere. That is either a model running on the player’s machine or a vendor’s server.
- You can wrap the call so the game keeps running when it fails.
What you cannot verify from a tutorial:
- How good the lines will be for your game.
- What it will cost to run.
- How long a reply will take on a player’s hardware or network.
- Whether any specific model will still be offered when you ship.
Build the dialogue layer like this:
- Write a fallback line for every state, such as “Who’s there?” for Notice. Ship these no matter what.
- When the state changes, send a short prompt with the guard’s name, mood, and current state. Ask for one line.
- Start the request without blocking the game. If no reply arrives within your time limit, or the model is not available, use the fallback line.
- Filter replies before display. Cap the length and block words you do not want.
- Never let model output change the state. The state machine decides what the guard does. The model only decides what it says.
Before you ship any line a model wrote, read that model’s card and license. Check what it allows for commercial use, what data it was trained on if that is disclosed, and whether the terms require attribution.
Next steps
Add a second guard and have them share the player’s last known position. That one change makes a level feel coordinated, and it still runs without any model at all.