Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Implementing an Attack State

Recommended resources

Hello and welcome to the next lesson in our journey with Finite State Machines!

In the last lesson, you brought your NPC to life by implementing a Chase state, using PathfindingService to make it intelligently pursue the player. Now, we're going to give your NPC a purpose for that chase.

This lesson addresses the learning outcome: Add an 'Attack' state, triggered when the NPC is within attack range during the 'Chase' state. We will complete the fundamental "Patrol -> Chase -> Attack" behavior loop, which is a cornerstone of AI in many games.

NPC Behavior Finite State Machine Diagram
A simple FSM diagram showing the relationship between wandering (like our Patrol/Idle), attacking, and other potential states. We'll be creating the transition from 'Chase' to 'Attack'.

From Chasing to Attacking

Right now, your NPC will chase the player indefinitely until they get too far away. The logical next step is to make the NPC act when it finally catches its target. This means that within the Chase state's logic, we need to constantly check the distance to the player.

We already do this to see if the player is too far away (> CHASE_MAX_DISTANCE). Now, we'll add a second check to see if the player is close enough to attack.


1. The Chase to Attack Transition

Let's modify the chaseState.Update function you built in the last lesson. We need to define an attack range and then check against it.

First, add a new configuration variable at the top of your script. A value between 8 and 15 studs is usually a good starting point for a ranged attack.

-- Add this near your other configuration variables
local ATTACK_RANGE = 10

Now, find your chaseState.Update function. Inside the main logic block, right after you calculate the distance to the target, add a new if statement.

-- Inside chaseState.Update function...

-- ... (code to check if target is lost or too far away) ...

-- Check if target is close enough to attack
if distance <= ATTACK_RANGE then
    print("Target in attack range. Transitioning to Attack.")
    fsm:transitionTo("Attack", chaseState.currentTarget) -- Pass the target to the Attack state
    return -- Exit the Chase state's update
end

-- == CORE CHASE LOGIC ==
-- ... (rest of your pathfinding logic) ...

This logic is simple but powerful. If the player is within ATTACK_RANGE, we command the FSM to transition to a new state, "Attack," and we pass along the target so the Attack state knows who to attack.


2. Building the Attack State

Of course, transitioning to "Attack" won't work until we define what that state does. Let's create it. The attack behavior will have three parts:

  1. Enter: Stop moving and face the target.
  2. Update: Continuously fire at the target as long as it's alive and in range. Transition back to Chase if the target runs away.
  3. Exit: Clean up when the attack is over.

The following video demonstrates a very robust way to handle an attack, using a separate thread (task.spawn) to manage the firing loop. This prevents the attack action from freezing the rest of our FSM.

NPC Attack Mode using Finite State Machines

This video from SimTek Game Development provides an excellent, in-depth look at implementing an attack state. We will adapt its core concepts for our FSM.

Watch from 34:07 to 48:33. You don't need to copy the code exactly, as it's part of a larger class structure, but focus on these key patterns: (34:07) Checks to ensure the target is valid before attacking. (37:35) Turning to face the target using CFrame.new(position, lookAt). (45:00) The use of task.spawn to create a non-blocking while loop that handles continuous firing. This is the most important concept to grasp. (47:50) The check inside the loop that causes a transition to a Cooldown state when the fight is over.

Inspired by that video, let's build our attackState.

Exercise: Implement the Attack and Cooldown States

Add the following code to your NPC script, alongside your other state definitions.

1. Add New Configuration

First, let's add a variable for how fast the NPC should attack.

-- Add near other config variables
local ATTACK_RATE = 0.5 -- Time in seconds between each shot

2. Create the attackState

This is the most complex part. Read the comments carefully to understand what each part does.

local attackState = {
    currentTarget = nil,
    isAttacking = false, -- A flag to prevent the attack loop from starting multiple times

    Enter = function(target)
        print("NPC entered Attack state. Target: " .. target.Name)
        attackState.currentTarget = target
        attackState.isAttacking = false

        -- Stop any movement from the Chase state
        humanoid:MoveTo(npc.PrimaryPart.Position)

        -- Turn to face the target
        local targetRoot = attackState.currentTarget:FindFirstChild("HumanoidRootPart")
        if targetRoot then
            local newCFrame = CFrame.new(npc.PrimaryPart.Position, targetRoot.Position)
            npc.PrimaryPart.CFrame = newCFrame
        end
    end,

    Update = function(dt)
        local target = attackState.currentTarget
        local targetRoot = target and target:FindFirstChild("HumanoidRootPart")
        local targetHumanoid = target and target:FindFirstChild("Humanoid")

        -- == Guard Clauses: Conditions to EXIT the state ==
        if not targetRoot or not targetHumanoid or targetHumanoid.Health <= 0 then
            print("Target is invalid or defeated. Transitioning to Cooldown.")
            fsm:transitionTo("Cooldown")
            return
        end

        local distance = (npc.PrimaryPart.Position - targetRoot.Position).Magnitude
        if distance > ATTACK_RANGE then
            print("Target moved out of attack range. Transitioning back to Chase.")
            fsm:transitionTo("Chase", target) -- Pass the target back to the Chase state
            return
        end

        -- Keep facing the target
        npc.PrimaryPart.CFrame = CFrame.new(npc.PrimaryPart.Position, targetRoot.Position)
        
        -- == Core Attack Loop ==
        -- Start the attack loop only if it's not already running
        if not attackState.isAttacking then
            attackState.isAttacking = true
            
            -- Use task.spawn to run this in parallel, so it doesn't block Update
            task.spawn(function()
                while attackState.isAttacking and fsm:getCurrentState() == "Attack" do
                    -- Re-check target health before firing
                    if targetHumanoid.Health > 0 then
                        print("NPC is attacking " .. target.Name)
                        -- Here you would play an animation, sound, and deal damage
                        targetHumanoid:TakeDamage(10)
                    else
                        -- If target is defeated, break the loop
                        break
                    end
                    task.wait(ATTACK_RATE)
                end
            end)
        end
    end,

    Exit = function()
        print("NPC is leaving Attack state.")
        attackState.currentTarget = nil
        attackState.isAttacking = false -- This will stop the while loop in the coroutine
    end,
}

This Stack Overflow post also shows a clear example of the logic for transitioning between Chase and Attack based on distance, which is what we've just implemented.

Is this a correct way to implement a basic FSM in Lua for my FSM diagram

This Stack Overflow post shows a straightforward implementation of the logic we're building. It's a good reference for the core transition conditions.

Read the code snippets for newZombie.chaseState.Update and newZombie.attackState.Update. Notice how the chaseState transitions to attackState when distanceToTarget is less than MAX_ATTACK_RADIUS, and the attackState transitions back to chaseState when the distance becomes greater.

3. Create the cooldownState

As seen in the SimTek video, it's good practice to have a Cooldown state after a fight. This creates a natural pause before the NPC returns to its normal duties.

local cooldownState = {
    Enter = function()
        print("NPC entered Cooldown state.")
        
        -- The coroutine will start a timer that transitions after a delay
        coroutine.wrap(function()
            task.wait(3) -- Wait for 3 seconds
            
            -- Ensure we are still in the Cooldown state before transitioning
            if fsm:getCurrentState() == "Cooldown" then
                print("Cooldown finished. Transitioning to Patrol.")
                fsm:transitionTo("Patrol")
            end
        end)()
    end,

    Update = function(dt)
        -- This state is purely time-based, so the update function is empty.
        -- All logic is handled in the Enter function's coroutine.
    end,

    Exit = function()
        print("NPC is leaving Cooldown state.")
    end,
}

4. Update Your FSM Definition

Finally, add your new states to the table where you define all the states for your FSM, so the transitionTo function can find them.

-- Find your fsm states table and add the new states
local states = {
    Idle = idleState,
    Patrol = patrolState,
    Chase = chaseState,
    Attack = attackState, -- Add this
    Cooldown = cooldownState, -- And this
}

Testing Your Full Combat Loop

It's time to see it all in action!

  1. Start your game. The NPC should begin its Idle/Patrol routine.
  2. Walk into the NPC's detection range. It should transition to Chase and start following you.
  3. Let the NPC get close to you (within 10 studs). The console should print "Target in attack range" and the NPC should transition to Attack.
  4. The NPC should stop moving, turn to face you, and start printing "NPC is attacking..." every 0.5 seconds. Your character should start taking damage.
  5. Now, run away from the NPC. Once you are more than 10 studs away, it should transition back to Chase and start pursuing you again.
  6. Let the NPC defeat your character. Once your health is zero, the NPC should transition to Cooldown, wait for 3 seconds, and then return to its Patrol state.

Conclusion

Excellent work! You have now implemented the core combat loop for an AI agent. Your NPC can now patrol, detect and chase a target, engage in combat, and return to its routine after the fight is over.

Key Takeaways:

  • State transitions are driven by conditions. The Chase state transitions to Attack based on a distance check in its Update function.
  • The Attack state often requires a self-contained loop. Using task.spawn or coroutine.wrap is the standard way to run this loop without blocking the entire FSM.
  • States can be simple. The Cooldown state shows that a state can be as simple as a timed delay to create more believable pacing in your AI's behavior.

Next Steps

Your NPC is becoming quite formidable! It can patrol, chase, and attack. But what if the player gets the upper hand? In our next lesson, we will implement a 'Flee' state triggered by a low health condition, adding a self-preservation instinct to your AI's repertoire.