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.
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:
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:
- Reload if the turret is out of ammo.
- 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.