Skip to main content

Welcome to a Grasp lesson

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

Lesson 13

Triggering State Transitions with Guard Conditions

Recommended resources

Hello! Welcome back to our course on building a reusable Finite State Machine in Roblox.

In the last lesson, we established the "map" of our FSM by creating a defineTransitions method. This gave our machine a static set of rules about which states can be reached from others. However, our FSM is still just a blueprint; it can't actually move from one state to another.

Today, we'll build the engine that drives the FSM. Our goal is to implement a method to trigger a state transition, including support for guard conditions. This function will be the heart of our module, responsible for validating a requested change and executing it by firing the appropriate exit and enter callbacks.

1. The Core Transition Method

First, let's create the central method that will handle all state changes. We'll call it transitionTo. Its basic job is to move the FSM from its current state to a new one, but only if the rules we defined last time allow it.

The logic for a transition from state A to state B follows a precise sequence:

  1. Verify that a transition from A to B is permitted.
  2. If it is, call the exit function for state A.
  3. Update the FSM's current state to be B.
  4. Finally, call the enter function for state B.

Let's add a basic transitionTo method to our FSM ModuleScript. This version will only check the rules from our transitions table.

-- In your FSM ModuleScript

function FSM:transitionTo(newStateName)
    -- First, check if the transition is even possible according to our rules
    local validFromStates = self.transitions[newStateName]
    local isPermitted = validFromStates and validFromStates[self.currentStateName]

    if not isPermitted then
        warn("FSM Error: Transition from '" .. self.currentStateName .. "' to '" .. newStateName .. "' is not permitted.")
        return -- Stop here if the transition is illegal
    end

    -- If we're already in the target state, do nothing.
    if self.currentStateName == newStateName then
        return
    end

    -- Get the state definitions
    local oldState = self.states[self.currentStateName]
    local newState = self.states[newStateName]

    -- Call the exit callback on the old state, if it exists
    if oldState and oldState.exit then
        oldState.exit()
    end

    local oldStateName = self.currentStateName
    self.currentStateName = newStateName

    -- Call the enter callback on the new state, if it exists
    if newState and newState.enter then
        -- Pass the previous state's name to the enter function
        newState.enter(oldStateName)
    end
end

This is a great start. We now have a function that safely changes the state and orchestrates the exit and enter callbacks.


2. Adding Power with Guard Conditions

Our current system can answer the question, "Can I go from Chase to Attack?" But in a real game, you need to answer a more specific question: "Should I go from Chase to Attack right now?"

For example, an NPC should only transition to "Attack" if the player is within range. This is a guard condition: an additional, dynamic check that must pass for a transition to occur.

Take a look at this combat FSM diagram. The labels on the arrows are guard conditions.

Character Combat State Machine Diagram
A state machine for character combat. The text in brackets like '[Timed Guard Is A Parry]' represents guard conditions that must be true for the transition to happen.

A transition from Guarding to Stunned is only triggered if the guard was parried. A transition from Attacking to Idle might only happen if the attack animation has finished. Guards give us this fine-grained, conditional control.


3. Implementing Guards in Luau

To implement guards, we need to do two things:

  1. Update defineTransitions to accept an optional guard function for each rule.
  2. Update transitionTo to execute this guard function before proceeding.

Modifying defineTransitions

Let's modify our transition definitions to include a guard field. Our transitions table inside the FSM will now store the guard function itself (or true if no guard is provided).

-- In your FSM ModuleScript, replace your existing defineTransitions function

function FSM:defineTransitions(definitions)
    for _, definition in ipairs(definitions) do
        local from = definition.from
        local to = definition.to
        local guard = definition.guard -- The new guard function

        if not self.transitions[to] then
            self.transitions[to] = {}
        end

        local rule = guard or true -- Store the function, or 'true' as a placeholder

        if type(from) == "table" then
            for _, fromState in ipairs(from) do
                self.transitions[to][fromState] = rule
            end
        elseif type(from) == "string" then
            self.transitions[to][from] = rule
        end
    end
end

Modifying transitionTo

Now, let's create the final version of transitionTo. This function will now check for and execute the guard. We will also add support for passing arguments through the transition, which can be useful for both the guard and the new state's enter callback.

-- In your FSM ModuleScript, replace your existing transitionTo function

function FSM:transitionTo(newStateName, ...)
    if self.currentStateName == newStateName then
        return
    end

    local fromStates = self.transitions[newStateName]
    local rule = fromStates and fromStates[self.currentStateName]

    if not rule then
        warn("FSM Error: Transition from '" .. self.currentStateName .. "' to '" .. newStateName .. "' is not permitted.")
        return
    end

    -- Check the guard condition if it's a function
    if type(rule) == "function" then
        if not rule(...) then -- If the guard returns false, abort the transition
            return
        end
    end

    local oldState = self.states[self.currentStateName]
    local newState = self.states[self.newStateName]

    if oldState and oldState.exit then
        oldState.exit(newStateName) -- Pass the upcoming state to the exit function
    end

    local oldStateName = self.currentStateName
    self.currentStateName = newStateName

    if newState and newState.enter then
        newState.enter(oldStateName, ...) -- Pass the previous state and any other args
    end
end

Notice how rule(...) is called. If the rule is a function (our guard), it gets executed. If it returns false (or nil), the transition is silently stopped. This is a powerful and clean way to control your FSM's logic.

The following video provides an excellent real-world example. The developer refactors an NPC's AI to use an FSM and implicitly uses guard conditions to decide when to transition, such as when a player's "friend score" changes.

NPC AI - How to Create a Finite State Machine for AI

In this video from SimTek Game Development, pay close attention to how external events and changing conditions are used to trigger state changes. This is the essence of what guard conditions do.

Watch the segment from 16:47 to 19:50. The receiveChat function acts as an external trigger. Notice the if not wasFriend and isFriend check. This is a perfect example of a guard condition: the transition to the 'Greet' state only happens if the NPC's friendship status just changed.


4. Putting It All Together

Let's test our new system with a practical example. Imagine an enemy AI with three states: Patrolling, Chasing, and Attacking.

  • It can go from Patrolling to Chasing only if a target is visible.
  • It can go from Chasing to Attacking only if the target is within a certain range.

Let's update our test Script to model this.

-- In a regular 'Script' in ServerScriptService

local FSM = require(script.Parent.FSM)

-- A dummy 'enemy' object with some properties for our guards to check
local enemy = {
    isTargetVisible = false,
    targetDistance = 100
}

-- Create the FSM
local enemyAI = FSM.new("Patrolling")

-- Define states
enemyAI:defineState("Patrolling", {
    enter = function(previousState) print("Starting to Patrol. Came from: " .. tostring(previousState)) end
})
enemyAI:defineState("Chasing", {
    enter = function(previousState) print("Target spotted! Chasing now. Came from: " .. previousState) end
})
enemyAI:defineState("Attacking", {
    enter = function(previousState) print("In range! Attacking! Came from: " .. previousState) end
})

-- Define transitions with guards
enemyAI:defineTransitions({
    { from = "Patrolling", to = "Chasing", guard = function()
        return enemy.isTargetVisible
    end },
    { from = "Chasing", to = "Patrolling", guard = function()
        return not enemy.isTargetVisible
    end },
    { from = "Chasing", to = "Attacking", guard = function(distance)
        -- This guard uses an argument passed during the transition
        return distance <= 10
    end },
    { from = "Attacking", to = "Chasing", guard = function(distance)
        return distance > 10
    end }
})

-- Let's simulate some scenarios
print("--- SCENARIO 1: Target not visible ---")
print("Current State:", enemyAI.currentStateName)
print("Attempting to transition to Chasing...")
enemyAI:transitionTo("Chasing") -- This will fail because the guard is false
print("State after attempt:", enemyAI.currentStateName, "\n")

wait(2)

print("--- SCENARIO 2: Target becomes visible ---")
enemy.isTargetVisible = true
print("Current State:", enemyAI.currentStateName)
print("Attempting to transition to Chasing...")
enemyAI:transitionTo("Chasing") -- This will succeed
print("State after attempt:", enemyAI.currentStateName, "\n")

wait(2)

print("--- SCENARIO 3: Target is too far to attack ---")
enemy.targetDistance = 20
print("Current State:", enemyAI.currentStateName)
print("Attempting to transition to Attacking, passing distance:", enemy.targetDistance)
enemyAI:transitionTo("Attacking", enemy.targetDistance) -- This will fail
print("State after attempt:", enemyAI.currentStateName, "\n")

wait(2)

print("--- SCENARIO 4: Target moves into range ---")
enemy.targetDistance = 8
print("Current State:", enemyAI.currentStateName)
print("Attempting to transition to Attacking, passing distance:", enemy.targetDistance)
enemyAI:transitionTo("Attacking", enemy.targetDistance) -- This will succeed
print("State after attempt:", enemyAI.currentStateName, "\n")

When you run this script, you'll see how the FSM correctly blocks or allows transitions based on the return values of our guard functions. This pattern is extremely common, and many professional FSM libraries for Roblox, like the popular "Pulse" module, feature a similar concept, often called CanTransition.


Conclusion

Fantastic work! You have now implemented the most critical piece of our reusable FSM module. The transitionTo method is the engine that brings your state machine to life, and the addition of guard conditions gives it the intelligence to make dynamic, context-aware decisions.

Key Takeaways:

  • A transitionTo method is the public-facing function used to request a state change.
  • The transition process follows a strict order: check rules, call exit, change state, call enter.
  • Guard conditions are functions that return true or false, allowing you to add dynamic logic to your transition rules.
  • Guards check conditions that are only known at runtime (e.g., player distance, health, animation status).
  • Passing arguments through transitionTo is a powerful way to provide context to both guard functions and enter callbacks.

We now have all the core pieces of our FSM module: a constructor, a way to define states, a way to define transition rules, and a way to execute those transitions. In our next lesson, we will put it all to the test by writing a dedicated test script to verify all its functionality and ensure our module is robust and ready for use in a real game.