Skip to main content

Welcome to a Grasp lesson

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

Lesson 12

Defining FSM Transitions

Recommended resources

Hello! Welcome back to our course on building Finite State Machines in Roblox.

In our last lesson, you equipped our FSM module with the ability to define states and their enter, update, and exit callbacks. This was a crucial step, but our FSM still lacks a fundamental component: rules. Right now, there's nothing stopping a state from changing to any other state, which could lead to chaotic and illogical behavior in a game.

Today, we will address that by implementing the logic that governs the FSM's flow. The learning outcome for this lesson is to implement a method on the FSM module to define permissible transitions between states. By the end, you will have a system that knows, for example, that an NPC can go from "Patrol" to "Chase", but not directly from "Sleeping" to "Attacking".

1. Why Define Transitions?

A Finite State Machine is defined not just by its states, but by the paths between them. Enforcing which transitions are allowed is the core of an FSM's logic. Without these rules, you might accidentally code a character to start running while they are in their "Sleeping" state, bypassing the "WakingUp" state entirely.

Look at this state diagram for a simple enemy AI.

NPC AI State Diagram
A state diagram showing an enemy AI with IDLE, CHASE, and ATTACK states. The arrows represent the only valid transitions.

The arrows explicitly define the rules:

  • You can go from IDLE to CHASE only when a target is found.
  • You can go from CHASE to ATTACK only when the target is in range.
  • You cannot go directly from IDLE to ATTACK.

Our goal is to build a mechanism into our FSM module to store and later enforce these very rules.


2. Structuring Transition Rules

To enforce rules, we first need a way to store them. A common and efficient way is to create a data structure that maps each state to a list of states it can be transitioned from.

For example, to represent the ATTACK state in the diagram above, the rule would be: "You can only transition to the ATTACK state from the CHASE state."

We can store this in a Luau table. We'll add a transitions table to our FSM instance. Inside this table, each key will be a "to" state, and its value will be another table acting as a set of permissible "from" states.

It will look like this:

-- Conceptual structure of our transition rules table
FSM.transitions = {
    ["Chase"] = {
        ["Idle"] = true -- Can transition to "Chase" from "Idle"
    },
    ["Attack"] = {
        ["Chase"] = true -- Can transition to "Attack" from "Chase"
    },
    ["Idle"] = {
        ["Chase"] = true -- Can transition back to "Idle" from "Chase"
    }
}

Using a dictionary-like table ({ fromState = true }) gives us a very fast way to check if a transition is valid, which will be important in our next lesson.

This declarative approach—defining rules as data—is a powerful pattern. The following resource shows a popular open-source Lua FSM library that uses a very similar idea.

A finite state machine lua micro framework - GitHub

The resource 'A finite state machine lua micro framework' demonstrates a clear, data-driven way to define state transitions using an events table. This is a great example of the pattern we are about to implement.

Read the 'Usage' and 'Multiple 'from' and 'to' states for a single event' sections. Pay close attention to how each event has a from and a to field. Notice how you can specify a single state or a table of states for the from field. We will use this exact concept.


3. Implementing FSM:defineTransitions()

Inspired by the resources, let's create a method to define our transitions. We'll call it defineTransitions. To make it flexible, it will accept a table of rule definitions. Each definition will specify a to state and one or more from states.

First, let's update our constructor in the FSM ModuleScript to initialize the transitions table.

-- In your FSM ModuleScript

function FSM.new(initialStateName)
    local newFsm = {}
    newFsm.currentStateName = initialStateName
    newFsm.states = {}
    newFsm.transitions = {} -- Add this line
    setmetatable(newFsm, FSM)
    return newFsm
end

Now, add the new defineTransitions method below your constructor. This method will loop through a list of transition rules and populate our self.transitions table.

-- In your FSM ModuleScript

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

        -- Create the 'to' state entry if it doesn't exist
        if not self.transitions[to] then
            self.transitions[to] = {}
        end

        -- If 'from' is a list of states, add each one
        if type(from) == "table" then
            for _, fromState in ipairs(from) do
                self.transitions[to][fromState] = true
            end
        -- If 'from' is a single state, just add that one
        elseif type(from) == "string" then
            self.transitions[to][from] = true
        end
    end
end

This function handles both single from states (as a string) and multiple from states (as a table), making our API quite versatile.


4. Defining Transitions in Practice

Let's return to our Script in ServerScriptService and apply this. We'll use our simple turnstile example. A turnstile can go from Locked to Unlocked and from Unlocked back to Locked.

Update your main Script to define these rules.

-- In a regular 'Script' in ServerScriptService

-- 1. Get the FSM "class" from our ModuleScript
local FSM = require(script.Parent.FSM)

-- 2. Create a new FSM instance with its initial state
local myTurnstile = FSM.new("Locked")
print("FSM instance created. Initial state:", myTurnstile.currentStateName)

-- 3. Define the states and their callbacks
myTurnstile:defineState("Locked", {
    enter = function() print("ENTERED Locked state.") end,
    exit = function() print("EXITING Locked state.") end
})

myTurnstile:defineState("Unlocked", {
    enter = function() print("ENTERED Unlocked state.") end,
    exit = function() print("EXITING Unlocked state.") end
})

-- 4. Define the permissible transitions
myTurnstile:defineTransitions({
    { from = "Locked",   to = "Unlocked" },
    { from = "Unlocked", to = "Locked" }
})

-- 5. Verify that the transitions were added correctly
wait(1)
print("\nVerifying transition rules:")
for toState, fromStates in pairs(myTurnstile.transitions) do
    for fromState, _ in pairs(fromStates) do
        print("- Rule added: Can transition from '" .. fromState .. "' to '" .. toState .. "'")
    end
end

When you run this code, your output should confirm that the two rules have been correctly registered in the FSM's transitions table.

This approach of passing a table of definitions is very powerful. The article "Finite State Machines in Game Development" shows a similar implementation for a more complex "War Bot," where some states can be entered from multiple other states.

Finite State Machines in Game Development

This article provides another excellent example of defining transitions as data within a state's definition.

In the article, find the section 'Example 2: Hierarchical State Machine'. Look at the code block where botFSM:addState(...) is called repeatedly. Notice the from parameter, such as from={"defend", "assault"}. This is the same principle we've just implemented: defining a set of valid origin states for a transition.

The key insight here is that by defining our rules as data, our code becomes more readable and easier to manage than if we were to write a series of if/elseif statements for every possible transition.


Conclusion

Excellent work today! You have successfully implemented a clean, data-driven system for defining the rules of your FSM. Our module can now not only hold states and their behaviors but also understands the legal pathways between them.

Key Takeaways:

  • Defining permissible transitions is essential for creating robust and predictable FSMs.
  • A data structure where each "to" state maps to a set of valid "from" states (e.g., transitions[to] = {from1=true, from2=true}) is efficient and clean.
  • A dedicated method like defineTransitions that takes a table of rules makes the FSM setup declarative and easy to read.
  • This approach prevents "spaghetti code" and makes it easy to visualize the FSM's flow just by reading the transition definitions.

We now have all the static pieces in place: states, callbacks, and transition rules. In the next lesson, we will finally bring it all together by implementing the transitionTo() method. This function will be the engine of our FSM, responsible for checking if a transition is allowed and, if so, executing it by calling the correct exit and enter functions.