Using AI Behavior Frameworks For NPCs

Introduction

Hello! I am FROGMIRE (/TEARAPARTJAMES), and I have noticed a lack of tutorials on Roblox about developing AI using different behavioral frameworks.

In this post, I will cover three AI behavior frameworks: unstructured AI, state machines, and utility AI. I will also provide brief examples of how to implement these(state machines and unstructured) frameworks into an NPC agent in Roblox.

Currently, I have implemented both unstructured AI and state machines in Roblox. In the future, I plan to cover Utility AI and GOAP, but since they are more complex, I will save them for later.

My examples may not be visually impressive, but I hope they clearly demonstrate how these frameworks function.

There is no one-size-fits-all solution when it comes to AI, as each system addresses different challenges with varying levels of complexity. However, I personally prefer state machines as my go-to framework.


Explanations & Pseudocode

Unstructured

Unstructured AI refers to AI that does not follow a defined structure and is often programmed using multiple nested conditional statements.

This is the simplest way to implement AI in a game; however, the lack of structure makes it difficult to maintain as the AI becomes more complex.

A pseudocode example is:

while active:
    target = findTarget()
    
    if target:
        if target is within detection range:
            moveTo(target)
            
            if target is close enough to attack:
                attack(target)
    else:
        wander()

This approach works best for simple behaviors but lacks scalability. If more complex decision-making is required, the code can quickly become messy and difficult to manage.


State machine

A state machine organizes AI behavior into individual states, allowing the AI to transition between them based on conditions. This makes it easier to manage complex behavior without relying on excessive nested conditionals.

States in a state machine typically include Start(), Update(), and Exit() functions, which define what happens when entering, updating, and leaving a state.

A pseudocode example is:

state = "WANDER"

while active:
    if state == "WANDER":
        wander()
        target = findTarget()
        if target:
            state = "CHASE"
    
    elif state == "CHASE":
        if target is within detection range:
            moveTo(target)
            if target is close enough to attack:
                state = "ATTACK"
        else:
            state = "WANDER"

    elif state == "ATTACK":
        attack(target)
        if target is no longer close enough:
            state = "CHASE"
        elif target is lost or defeated:
            state = "WANDER"

Using a state machine makes AI logic easier to manage and extend. However, having too many transitions and states can eventually make the system messy.

Behavior trees are often considered the next step to address this issue, but they will not be covered in this post. If you’re interested in learning more about behavior trees, you can check out this topic instead.

I personally think state machines are a good stopping point for AI development in terms of complexity. Just because behavior trees are the next step doesn’t mean you have to leap!


Utility

Utility AI allows for dynamic decision-making by assigning a utility score to possible actions and selecting the one with the highest priority. This approach enables more flexible AI behavior compared to state machines and unstructured methods.

A rough pseudocode example:

while active:
    target = findTarget()
    detectionScore = 0
    attackScore = 0
    wanderScore = 1  # Default low-priority action

    if target:
        detectionScore = getDetectionScore()
        attackScore = getAttackScore()

    if attackScore > detectionScore and attackScore > wanderScore:
        attack(target)
    elif detectionScore > wanderScore:
        moveTo(target)
    else:
        wander()

With Utility AI, the AI can dynamically choose the best action based on the situation. However, each action requires evaluating multiple considerations, which can lead to performance issues. This can be mitigated by incorporating a state machine to manage broader action categories, reducing the number of considerations.




Roblox AI Implementation

For simplicity, we will be using NoobPath for pathfinding. While it has its issues, we’ll make do with it.

This implementation will follow an object-oriented programming (OOP) approach.

Unstructured - Simple zombie

For our implementation, we will create a simple zombie agent. It will search through the CollectionService to find a valid target, move toward it, and attack when close enough. If no target is found, it will wander around.

There are no strict guidelines—just implement it in a way that makes sense for you.

ZombieObject.lua
local DETECTION_RANGE = 45
local ATTACK_RANGE = 4
local DAMAGE: number = 10

local ATTACK_RATE: number = 1/4
local UPDATE_RATE: number = 1/16
local PATROL_RATE: number = 1

local SELF_TAG: string = "Undead"
local TARGET_TAG: string = "Living"

-- Services
local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local NoobPath = require(ReplicatedStorage.NoobPath)

-- Zombie
local Zombie = {}
Zombie.__index = Zombie

-- Creates a new Zombie object
function Zombie.new(model: Model)
	local self = setmetatable({}, Zombie) do
		self._lastUpdate = os.clock() + math.random():: number -- this will hopefully spread out how each AI is updated
		self._lastPatrol = self._lastUpdate
		self._lastAttack = self._lastUpdate
		
		self.Model = model:: Model
		self.Root = self.Model:WaitForChild("HumanoidRootPart"):: BasePart
		self.Humanoid = self.Model:WaitForChild("Humanoid"):: Humanoid
		self.Target = nil:: Humanoid?
		self.PatrolPoint = Vector3.zero:: Vector3
		
		self.Pathfinder = NoobPath.Humanoid(self.Model, {AgentHeight = 5, AgentRadius = 2, AgentCanJump = false})
	end
	
	for _, basePart in self.Model:GetDescendants() do
		if basePart:IsA("BasePart") and basePart:CanSetNetworkOwnership() then
			basePart:SetNetworkOwner(nil) -- give the server ownership
		end
	end
	
	self.Humanoid:AddTag(SELF_TAG)
	
	return self
end

-- Updates the zombie and returns whether the zombie is dead for GC.
function Zombie.Update(self: Zombie): boolean
	if self.Humanoid and self.Humanoid.Health <= 0 then
		return true
	end
	
	local now = os.clock()
	local dt = now - self._lastUpdate
	
	if dt >= UPDATE_RATE then
		self._lastUpdate = now
		
		local target = self:GetClosestTarget()
		if not target and self.Target then
			self.Pathfinder:Stop()
		end
		
		self.Target = target
		
		if self.Target then
			local dist = (self.Target.RootPart.Position - self.Root.Position).Magnitude
			
			if dist < ATTACK_RANGE and self._lastAttack + ATTACK_RATE < now then
				self._lastAttack = now
				self.Target:TakeDamage(DAMAGE)
				self.Pathfinder:Stop()
			else
				self.Pathfinder:Run(self.Target.Parent)
			end
		else
			if self._lastPatrol + PATROL_RATE < now and math.random() > 0.75 then
				self._lastPatrol = now
				self.PatrolPoint = self.Root.Position + Vector3.new(math.random(-10, 10), 0, math.random(-10, 10))
			end
			
			self.Pathfinder:Run(self.PatrolPoint)
		end
		
	end
	
	return false
end

function Zombie.GetClosestTarget(self: Zombie): Humanoid?
	local targets: {Humanoid} = CollectionService:GetTagged(TARGET_TAG)
	
	if #targets > 0 then
		local closest = DETECTION_RANGE
		local target = nil
		
		for _, posTarget in targets do
			if posTarget:IsA("Humanoid") then
				if posTarget.Health > 0 then
					local root = posTarget.RootPart
					if root then
						local dist = (root.Position - self.Root.Position).Magnitude
						
						if dist < closest then
							closest = dist
							target = posTarget
						end
					end
				end
			end
		end
		
		return target
	end
	
	return nil
end

function Zombie.Destroy(self: Zombie)
	self.Pathfinder:Destroy()
	
	for index, value in self do
		self[index] = nil
	end
	
	self = nil
end

export type Zombie = typeof(Zombie.new(table.unpack(...)))

return Zombie

This is the finished unstructured AI, and it does work. However, you may notice that it is somewhat unorganized and messy, making it harder to maintain as complexity increases.

https://youtu.be/WumMXbvbdiA


State machine - Turret

For this implementation, we will use a turret as our example, as it provides a clear use case for multiple states.

I have created a simple diagram to illustrate the states and transitions in our system:
image

I personally prefer a different approach to state machines than the one shown in the pseudocode.

For this implementation, I will be using a simple FSM module to define each state as an individual module, with the state machine managing the entire AI.

We will follow a straightforward structure like this, but avoid defining methods that won’t be used:

Template
-- services
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local State = require(ReplicatedStorage.FSM.State)

-- StateName
local StateName = State.new("StateName")

function StateName:Start(agent)
end

function StateName:Update(dt: number, agent): string?
end

function StateName:Exit(agent)
end

return StateName

The Update method is the only required method, as it controls state transitions by returning the name of the next state.

We will start with the Idle state, which has two possible transitions:

  1. Reload if the turret is out of ammo.
  2. Fire at the nearest target if one is detected.

To handle this, we need to check the ammo count and check for a valid target.

-- Idle
local Idle = State.new("Idle")

function Idle:Start(agent)
	agent.Model.Light.Color = Color3.fromRGB(0, 255, 0)
end

function Idle:Update(dt: number, agent): string?
	if agent.Ammo <= 0 then
		return "Reloading"
	end
	
	agent.Target = agent:GetClosestTarget()
	if agent.Target then
		return "ShootTarget"
	end
end

return Idle

Now, we need to consider the Reloading state. The main focus here is determining when to transition back to Idle after reloading is complete.

At the start of this state, the agent will begin the reloading process. Once the reload is finished, it will transition back to Idle, ready to check for targets again.

-- Reloading
local Reloading = State.new("Reloading")

function Reloading:Start(agent)
	agent.Model.Light.Color = Color3.fromRGB(255, 255, 0)
	agent:Reload()
end

function Reloading:Update(dt: number, agent): string?
	if agent.Ammo > 0 then
		return "Idle"
	end
end

return Reloading

Lastly, we need to implement the ShootTarget state. This state functions similarly to Idle, but with active targeting and firing.

The transitions for this state are:

  • If there are no targets, transition back to Idle.
  • If out of ammo, transition to Reloading.

This ensures the turret continuously engages enemies while managing its ammo effectively.

-- ShootTarget
local ShootTarget = State.new("ShootTarget")

function ShootTarget:Start(agent)
	agent.Model.Light.Color = Color3.fromRGB(255, 0, 0)
end

function ShootTarget:Update(dt: number, agent): string?
	if agent.Ammo <= 0 then
		return "Reloading"
	end
	
	agent.Target = agent:GetClosestTarget()
	if agent.Target then
		agent:FaceTarget()
		agent:Shoot()
	else
		return "Idle"
	end
end

return ShootTarget

Well hey! Now it’s time to integrate these states into the turret’s state machine.

We’ll add Idle, Reloading, and ShootTarget states to the FSM so the turret can transition between them dynamically based on its conditions. This will allow the turret to properly detect targets, fire, and reload as needed.

-- Services
local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local FSM = require(ReplicatedStorage.FSM)

local STATES = {}

for _, state in script.States:GetChildren() do
	table.insert(STATES, require(state))
end

-- Turret
local Turret = {}
Turret.__index = Turret

-- Creates a new Turret object
function Turret.new(model: Model)
	local self = setmetatable({}, Turret) do
	end

	self.FSM = FSM.new(self, "Idle", STATES)
	
	return self
end

-- methods here

return Turret

We now have a fully functional turret, and it’s no longer a tangled mess of nested conditionals!


Videos:

Conclusion

In this post, we explored different AI behavior frameworks: Unstructured AI, State Machines, and Utility AI, and how they affect NPC decision-making in Roblox. Each framework has its own strengths and weaknesses.

If you have any advice on improving this tutorial, please let me know!

Here is the current place file which is uncopylocked:

What's next?

As stated above, I plan to eventually cover Utility AI and may introduce Goal-Oriented Action Planning (GOAP).

These systems are much more advanced compared to state machines and unstructured AI, so I want to properly structure them to account for their complexity.

15 Likes

Thank you for this detailed guide, I found this very useful for me

1 Like

Thanks so much for writing this and also providing an example place for us to mess with. I am guilty of writing too many basic NPC scripts but will certainly be looking at this to improve a few games.