Skip to main content

Welcome to a Grasp lesson

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

Lesson 7

Implementing State Transitions with Exit and Enter Callbacks

Recommended resources

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:

  1. It determines if an event should cause a state change by looking up the event in the current state's transition table.
  2. It executes the state change by calling OnExit, updating the current state, and calling OnEnter.

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:

  1. Get the current state's data (so we can access its OnExit callback).
  2. Call the OnExit function of the current state, if it exists. This is for cleanup.
  3. Update the FSM's currentStateName variable to the new state's name.
  4. Get the new state's data (so we can access its OnEnter callback).
  5. Call the OnEnter function 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.

Wizard Represented As FSM Flow
This FSM diagram for a UI wizard explicitly labels transition events like `onExit(N,O)` (exiting state N to go to O) and `onEnter(M,N)` (entering state N from M). Our `transitionTo` function will implement this exact logic.

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

  1. 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.
  2. 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 to transitionTo.
  3. Initialization: Notice that when we start the FSM, we still call the first state's OnEnter function directly. We don't use transitionTo because there is no previous state to OnExit from. 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 Patrol state's OnExit function, you might stop the NPC's "walk" animation and clear its pathfinding waypoints.
  • In the Chase state's OnEnter function, 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 OnExit on the old state, update the state variable, and then call OnEnter on 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.