How to Build a State Machine for NPC AI in Roblox

Learn to create organized, maintainable NPC behavior using state machines—a powerful pattern that makes complex AI logic easy to manage and debug.

If you’ve ever tried building an NPC that does more than just stand still, you’ve probably run into a familiar problem: your code becomes a tangled mess of if-statements and flags. One moment your guard is chasing a player, the next they should patrol, but wait—they were in the middle of an attack animation! Sound familiar?

This is where state machines come in. They’re one of the most important patterns in game AI, and once you understand them, you’ll wonder how you ever lived without them. Let’s dive in and build one together!

What Is a State Machine?

A state machine is a way of organizing behavior where your NPC can only be in one state at a time. Think about it like this: a guard NPC might have these states:

  • Idle – Standing around, looking for trouble
  • Patrol – Walking along a set path
  • Chase – Running after a player they’ve spotted
  • Attack – Fighting the player
  • Return – Walking back to their post after losing sight of the player

The beauty of this approach is that each state has its own clear logic, and you define exactly which states can transition to which other states. No more spaghetti code!

Why Use a State Machine?

Before we start coding, let’s talk about why this pattern is so powerful:

  • Organization: Each state’s code is separate and focused. When your attack behavior has a bug, you know exactly where to look.
  • Predictability: You control exactly when and how states change. No more “how did my NPC get into this weird situation?” moments.
  • Scalability: Adding new behaviors means adding new states, not untangling existing code.
  • Debugging: You can print the current state and instantly understand what your NPC thinks it’s doing.

Building Our State Machine Class

Let’s start by creating a reusable StateMachine class. We’ll keep it simple but powerful:

local StateMachine = {}
StateMachine.__index = StateMachine

function StateMachine.new(npc)
    local self = setmetatable({}, StateMachine)
    
    self.npc = npc
    self.states = {}
    self.currentState = nil
    self.currentStateName = nil
    
    return self
end

function StateMachine:AddState(name, stateTable)
    self.states[name] = stateTable
end

function StateMachine:ChangeState(newStateName)
    -- Exit the current state
    if self.currentState and self.currentState.OnExit then
        self.currentState.OnExit(self.npc)
    end
    
    -- Change to new state
    self.currentStateName = newStateName
    self.currentState = self.states[newStateName]
    
    -- Enter the new state
    if self.currentState and self.currentState.OnEnter then
        self.currentState.OnEnter(self.npc)
    end
end

function StateMachine:Update(deltaTime)
    if self.currentState and self.currentState.OnUpdate then
        self.currentState.OnUpdate(self.npc, deltaTime, self)
    end
end

return StateMachine

This class does three important things:

  1. OnEnter – Runs once when entering a state (perfect for playing animations or setting initial values)
  2. OnUpdate – Runs every frame while in that state (this is where the state’s main logic lives)
  3. OnExit – Runs once when leaving a state (great for cleanup)

Creating States for a Guard NPC

Now let’s build actual states for a guard NPC. We’ll create each state as a table with those three functions:

The Idle State

local IdleState = {}

function IdleState.OnEnter(npc)
    local humanoid = npc:FindFirstChild("Humanoid")
    if humanoid then
        humanoid.WalkSpeed = 0
    end
    print(npc.Name .. " is now idle")
end

function IdleState.OnUpdate(npc, deltaTime, stateMachine)
    -- Look for nearby players
    local nearestPlayer = findNearestPlayer(npc, 30)
    
    if nearestPlayer then
        stateMachine:ChangeState("Chase")
    else
        -- After 3 seconds of idle, start patrolling
        wait(3)
        stateMachine:ChangeState("Patrol")
    end
end

function IdleState.OnExit(npc)
    -- Nothing special needed when leaving idle
end

The Chase State

local ChaseState = {}

function ChaseState.OnEnter(npc)
    local humanoid = npc:FindFirstChild("Humanoid")
    if humanoid then
        humanoid.WalkSpeed = 24  -- Run fast!
    end
    print(npc.Name .. " is chasing!")
end

function ChaseState.OnUpdate(npc, deltaTime, stateMachine)
    local humanoid = npc:FindFirstChild("Humanoid")
    local nearestPlayer = findNearestPlayer(npc, 50)
    
    if not nearestPlayer then
        -- Lost sight of player
        stateMachine:ChangeState("Return")
        return
    end
    
    local distance = (npc.HumanoidRootPart.Position - nearestPlayer.Character.HumanoidRootPart.Position).Magnitude
    
    if distance < 5 then
        -- Close enough to attack
        stateMachine:ChangeState("Attack")
    else
        -- Keep chasing
        humanoid:MoveTo(nearestPlayer.Character.HumanoidRootPart.Position)
    end
end

function ChaseState.OnExit(npc)
    local humanoid = npc:FindFirstChild("Humanoid")
    if humanoid then
        humanoid.WalkSpeed = 16  -- Return to normal speed
    end
end

The Attack State

local AttackState = {}

function AttackState.OnEnter(npc)
    npc.HumanoidRootPart.Anchored = true
    print(npc.Name .. " is attacking!")
    -- Play attack animation here
end

function AttackState.OnUpdate(npc, deltaTime, stateMachine)
    local nearestPlayer = findNearestPlayer(npc, 10)
    
    if not nearestPlayer then
        stateMachine:ChangeState("Return")
        return
    end
    
    local distance = (npc.HumanoidRootPart.Position - nearestPlayer.Character.HumanoidRootPart.Position).Magnitude
    
    if distance > 7 then
        -- Player moved away, chase again
        stateMachine:ChangeState("Chase")
    else
        -- Deal damage (simplified)
        wait(1)  -- Attack cooldown
        local playerHumanoid = nearestPlayer.Character:FindFirstChild("Humanoid")
        if playerHumanoid then
            playerHumanoid:TakeDamage(10)
        end
    end
end

function AttackState.OnExit(npc)
    npc.HumanoidRootPart.Anchored = false
end

Helper Functions

Our states need some helper functions. Here's a simple one to find nearby players:

local function findNearestPlayer(npc, maxDistance)
    local nearestPlayer = nil
    local shortestDistance = maxDistance
    
    for _, player in pairs(game.Players:GetPlayers()) do
        if player.Character and player.Character:FindFirstChild("HumanoidRootPart") then
            local distance = (npc.HumanoidRootPart.Position - player.Character.HumanoidRootPart.Position).Magnitude
            
            if distance < shortestDistance then
                shortestDistance = distance
                nearestPlayer = player
            end
        end
    end
    
    return nearestPlayer
end

Putting It All Together

Now let's create an NPC and give it our state machine:

local StateMachine = require(script.StateMachine)
local npc = workspace.GuardNPC  -- Your NPC model

-- Create the state machine
local ai = StateMachine.new(npc)

-- Add all our states
ai:AddState("Idle", IdleState)
ai:AddState("Chase", ChaseState)
ai:AddState("Attack", AttackState)
-- Add Patrol and Return states here too

-- Start in the Idle state
ai:ChangeState("Idle")

-- Main update loop
local RunService = game:GetService("RunService")
RunService.Heartbeat:Connect(function(deltaTime)
    ai:Update(deltaTime)
end)

Tips and Best Practices

Here are some things I've learned building state machines:

  • Keep states focused: Each state should do one thing well. If a state is getting complicated, consider splitting it into multiple states.
  • Use OnEnter and OnExit: These are perfect for setting up and cleaning up. Don't scatter initialization code throughout OnUpdate.
  • Debug with prints: Add print statements to your OnEnter functions. You'll immediately see if your NPC is switching states too quickly or getting stuck.
  • Consider using enums: Instead of strings like "Idle", create a table of state names to avoid typos.
  • Don't overuse wait(): In production code, use tick() or os.clock() to track time instead of yielding with wait().

Recap and Next Steps

Congratulations! You've just built a flexible, maintainable state machine for NPC AI. You now understand how to organize complex behaviors into clear, separate states, and how to control transitions between them.

Your state machine handles entering states, updating them each frame, and exiting them cleanly. This pattern will serve you well in countless game development scenarios—not just NPCs, but also game modes, UI systems, and more.

Ready to level up? Try these next steps:

  • Add more states: Implement Patrol and Return states to complete the guard behavior
  • Create state transitions: Build a system that validates whether a state transition is allowed
  • Learn behavior trees: These are the next evolution of AI systems, perfect for even more complex NPCs
  • Explore path finding: Combine your state machine with Roblox's PathfindingService for smarter navigation

Happy scripting, and may your NPCs always know what state they're in!

Author

Leave a Reply

Your email address will not be published. Required fields are marked *