Hello! Let's continue our journey into Finite State Machines.
In the last lesson, we gave our states behavior by implementing OnEnter, OnExit, and OnUpdate callbacks. Our handleEvent function grew to accommodate this, calling the OnExit and OnEnter functions at the right time. While functional, this mixed the logic of finding a transition with the logic of executing it.
Today, we'll clean that up. The learning outcome for this lesson is to create a transition function that executes state changes by calling exit and enter callbacks. We will encapsulate the mechanics of a state change into a dedicated function, making our FSM code more organized and reusable.
1. The Single Responsibility Principle in FSMs
In programming, a good practice is the Single Responsibility Principle, which states that a function or module should have one, and only one, reason to change. In our current code, the handleEvent function has two responsibilities:
- It determines if an event should cause a state change by looking up the event in the current state's transition table.
- It executes the state change by calling
OnExit, updating the current state, and callingOnEnter.
This isn't ideal. A better design separates these concerns. We'll refactor our code so that handleEvent is only responsible for the first part (the decision), and a new, dedicated function will handle the second part (the execution).
This new function, which we'll call transitionTo, will be the engine of our state changes.
2. Anatomy of a Transition Function
A transition function needs to perform a precise sequence of actions to move the FSM from its current state to a new one. The sequence is critical for ensuring clean hand-offs between states.
Here's the step-by-step process:
- Get the current state's data (so we can access its
OnExitcallback). - Call the
OnExitfunction of the current state, if it exists. This is for cleanup. - Update the FSM's
currentStateNamevariable to the new state's name. - Get the new state's data (so we can access its
OnEntercallback). - Call the
OnEnterfunction of the new state, if it exists. This is for setup.
This visual diagram, which we looked at briefly before, shows this exact flow of enter/exit events around a state change.

Let's look at a concrete Luau example of this pattern.
Is this a correct way to implement a basic FSM in Lua for my FSM diagram
The following Stack Overflow post shows a well-structured StateMachine module in Luau. We'll focus on its transition function, which serves as a perfect model for what we're about to build.
In the 'StateMachine Module' code block, find the StateMachine:SetState(state) function. Notice the clear sequence: it calls Exit() on the currentState, reassigns self.currentState to the new state, and then calls Init() and Enter() on the new state. This exit-then-enter sequence is the key takeaway.
3. Hands-On: Creating the transitionTo Function
Now, let's refactor our turnstile code from the previous lesson to use this new, cleaner structure.
Create a new Script in ServerScriptService and start with the code from our last lesson. We'll then create our new transitionTo function and refactor handleEvent to use it.
Here is the complete, refactored code. The new and modified parts are highlighted with comments.
local RunService = game:GetService("RunService")
local states = {
Locked = {
transitions = {
Coin = "Unlocked"
},
OnEnter = function()
print("▶️ ENTER: Locked. The turnstile is now locked.")
end,
OnExit = function()
print("◀️ EXIT: Locked. Leaving the locked state.")
end,
OnUpdate = function(deltaTime)
-- Runs every frame while in the 'Locked' state.
end
},
Unlocked = {
transitions = {
Push = "Locked"
},
OnEnter = function()
print("▶️ ENTER: Unlocked. You may now push the turnstile.")
end,
OnExit = function()
print("◀️ EXIT: Unlocked. Leaving the unlocked state.")
end,
OnUpdate = function(deltaTime)
-- Runs every frame while in the 'Unlocked' state.
end
}
}
local currentStateName = "Locked"
-- NEW: The dedicated transition function
function transitionTo(newStateName)
print("TRANSITIONING: " .. currentStateName .. " -> " .. newStateName)
-- 1. Call OnExit for the state we are LEAVING
local currentStateData = states[currentStateName]
if currentStateData and currentStateData.OnExit then
currentStateData.OnExit()
end
-- 2. Update the state name
currentStateName = newStateName
-- 3. Call OnEnter for the state we are ENTERING
local newStateData = states[currentStateName]
if newStateData and newStateData.OnEnter then
newStateData.OnEnter()
end
end
-- MODIFIED: handleEvent is now simpler
function handleEvent(event)
print("-------------------------")
print("EVENT received: " .. event)
local currentStateData = states[currentStateName]
local nextStateName = currentStateData.transitions[event]
if nextStateName then
-- The only job is now to call the transition function
transitionTo(nextStateName)
else
print("No transition found for event '" .. event .. "' in state '" .. currentStateName .. "'.")
end
end
-- This part remains the same
RunService.Heartbeat:Connect(function(deltaTime)
local currentStateData = states[currentStateName]
if currentStateData and currentStateData.OnUpdate then
currentStateData.OnUpdate(deltaTime)
end
end)
-- Initializing the FSM
print("FSM Initializing... ")
-- We don't use transitionTo here because there is no 'previous' state to exit from.
-- We just need to enter the first state.
local initialStateData = states[currentStateName]
if initialStateData.OnEnter then
initialStateData.OnEnter()
end
-- Simulate events to test the new structure
wait(2)
handleEvent("Push") -- Should do nothing
wait(2)
handleEvent("Coin") -- Should call transitionTo("Unlocked")
wait(2)
handleEvent("Coin") -- Should do nothing
wait(2)
handleEvent("Push") -- Should call transitionTo("Locked")
Analysis of the Refactor
transitionTo(newStateName): This new function now contains all the logic for performing a state change. It neatly follows the exit-then-enter sequence we discussed.handleEvent(event): This function is now much cleaner. Its single responsibility is to check if an event is valid for the current state and, if so, delegate the actual state change totransitionTo.- Initialization: Notice that when we start the FSM, we still call the first state's
OnEnterfunction directly. We don't usetransitionTobecause there is no previous state toOnExitfrom. The transition function is for moving between states.
Run the script. The output should be functionally identical to the previous lesson's, but our code is now better organized, which is crucial as our FSMs become more complex.
4. The Importance of Order
Why is the Exit -> Change -> Enter order so important? Let's consider a practical example from your goal of creating NPC AI.
Imagine an NPC transitioning from a Patrol state to a Chase state.
- In the
Patrolstate'sOnExitfunction, you might stop the NPC's "walk" animation and clear its pathfinding waypoints. - In the
Chasestate'sOnEnterfunction, you would start the "run" animation and calculate a new path to the player.
If you called OnEnter before OnExit, you would be telling the NPC to start running and calculate a new path before you've told it to stop walking. This could lead to conflicting animations or pathfinding errors. By ensuring the old state is fully cleaned up (OnExit) before the new state is set up (OnEnter), you guarantee a clean and predictable transition.
The following article describes this process very clearly in the context of a player character.
An introduction to state machines and the state pattern
The article 'An introduction to state machines and the state pattern' refactors a player controller to use a change_state function. This provides another excellent example of our transition logic in action.
Read the section titled 'Initialization and destruction'. The author introduces enter() and exit() methods and creates a change_state function. Note how it explicitly calls active_state.exit(), changes the state, and then calls active_state.enter(). The example of using enter() to play a jump animation is a perfect parallel to managing NPC or UI behavior.
Conclusion
In this lesson, you successfully isolated the core state change logic into a dedicated transitionTo function. This is a significant step towards creating a truly modular and reusable FSM system.
Key Takeaways:
- Separating the decision to transition from the execution of the transition leads to cleaner, more maintainable code, adhering to the Single Responsibility Principle.
- A transition function must follow a strict order: call
OnExiton the old state, update the state variable, and then callOnEnteron the new state. - This exit-then-enter sequence is critical for preventing conflicts and ensuring a clean hand-off between state behaviors.
Our transitionTo function is a great improvement, but it has a weakness: it implicitly trusts that any call to it is valid. In the next lesson, we will make our FSM more robust by adding validation to the transition function to ensure a requested transition is actually permissible from the current state.