FSM Character Controller

2025-01-2720-09-36-ezgif optimize

Showcases a character controller navigating a test scene, demonstrating movement mechanics controller by a finite state machine.
Overview

Finite state machines (FSMs) are an important tool in game development, offering a structured way to manage complex behaviors. In my 3D game project, FSMs are central to managing character movement, the core mechanic of the game, making the system easier to manage and extend.

As explained in the article by Georges Lteif 1, an FSM is a mathematical model used to describe the dynamic behavior of systems through a finite number of states, transitions between these states, and actions associated with these transitions. As discussed in the article What Is The Difference Between DFA And NFA? 2, FSMs can be categorized into two main types: non-deterministic finite automata (NFA) and deterministic finite automata (DFA).

A NFA allows for the machine to exist in multiple states simultaneously, allowing for multiple possible transitions from a given input.

  • The machine can be in multiple states at the same time.
  • It’s not always clear what the next state is, as multiple valid transitions may exist.

A DFA, on the other hand, requires that there is exactly one possible state transition for a given state and input. This ensures:

  • The machine is always in a single, well-defined state.
  • There is no ambiguity in determining the next state.

The Role of FSMs in Game Development

As outlined in the video How to Program in Unity: State Machines Explained 4, an FSM offers an important solution to manage a game and its components as they become more complex, being a structured and efficent way to manage the behavior and interactions of dynamic elements.

A state can be thought of as the current condition of an object or system. For example, when designing a character controller, you might break down the character’s actions into individual states such as:

  • Idle
  • Running
  • Walking
  • Jumping
  • Falling
  • Climbing
  • Sliding
  • Swimming

Similarly, we can break down a weapon’s states:

  • Attacking
  • Ready
  • Cooldown
  • Reloading
  • Disabled

Each state represents a specific behavior of an object or system. For instance, if a player is in the Running state, you might apply a speed multiplier to their movement. Whereas, if the player is in the Falling state, you would instead apply gravity.

  • Managing Complex Behaviors: The state pattern allows for a clear separation of behaviors within code. By implementing a state pattern, we can eliminate unnecessary dependencies between states, allowing us to modify the behavior of an object or system for a state without affecting other states, which makes the system easier to manage, extend, and debug.

  • Efficiency and Performance: Without FSMs, managing states would require numerous conditionals checked repeatedly even when irrelevant. FSMs improve this by focusing only on the current state and its specific logic, which reduces unnecessary checks. For example, if we wanted to determine whether the player should walk, we no longer need to manually check whether the player is not falling or in any other state that prevents walking.

In the context of game design, it’s important to organize states in a way that creates a minimal DFA, where we ensure that each state transition is clearly defined and does not lead to ambiguity. Structuring the states in this way helps to reduce unnecessary complexity. To give a practical example of minimizing a DFA, I originally came up with three states when implementing a slide mechanic for my player. At this time, I was also considering how to make sure that the player’s movement worked for slopes, and I wanted to implement some custom logic for when the player would slide down a slope.

  • Sliding: handle basic sliding logic
  • SlidingOnSlope: handle sliding logic when the player is on a slope
  • SlidingOnSlopeGravityAccel: extend the player’s slide so long as they are oriented down the slope.

While these states made sense at first, this approach quickly became unmanageable as following the same logic, I would need equivalent states for other movement types, such as running, walking, and jumping:

  • RunningOnSlope
  • WalkingOnSlope
  • JumpingOnSlope

These additional states would introduce redundancy as they would share essentially the same transitions and only slightly differ in their behavior (calculating the same movement just for a slope). This demonstrates how quickly the number of states can grow to represent every possible scenario.

Instead of defining individual states for each situation, I refactored the design by abstracting the “OnSlope” behavior into a separate system. Thereby, separating the logic for slopes from the movement states and treating it as its own concern. This resulted in a secondary FSM for processing and applying the player’s movement. For my current implementation, I need two states to represent this FSM: Normal and OnSlope.

Implementation

To implement an FSM to control my character’s movement actions, the main class FSMPlayer is passed around to each state. This is part of the “Context” that is described in the aforementioned video 3. To represent each state in a way that enhances scalability, I also created an abstract class as a framework for different states. To implement automatic handling of state transitions, I created a StateTransition class that contains all of the data needed for evaluating transition conditions, and a PlayerStateCondition which serves as a framework for defining and updating dynamic data which the transition depends on (e.g., grounded check).

In the PlayerState class’s OnUpdate function, I call all dependency conditions (e.g., the grounded check) to ensure they contain the most up-to-date information before the OnFixedUpdate function is called. Then, I run any logic in the overrideable Execute function. The OnFixedUpdate function loops through each transition condition to determine whether a state change should occur. If no transition occurs, the overridable FixedExecute function is called.

I found that transition conditions must be evaluated in FixedUpdate to ensure that physics-based calculations in FixedExecute, such as jumping and falling mechanics, remain frame-rate independent.

namespace PlayerStates
{
    [System.Serializable]
    public abstract class PlayerStateCondition
    {
        private int lastCheckedFrame = -1;

        public void OnUpdate(FSMPlayer context)
        {
            if (Time.frameCount != lastCheckedFrame)  // Only update if we're on a new frame
            {
                lastCheckedFrame = Time.frameCount;
                CheckCondition(context);  // Re-evaluate the condition
            }
        }
        public abstract void CheckCondition(FSMPlayer context);
        public virtual void OnDrawDebugGizmos(FSMPlayer context) {}
    }


    public class StateTransition
    {
        public Func<bool> Condition;
        public PlayerState NextState;
        public Action<FSMPlayer> OnExit { get; }
        public Action<FSMPlayer, PlayerState> OverrideSwitchState { get; } // Custom state-switching function
        public List<PlayerStateCondition> DependencyConditions { get; }  // List of conditions that must be met for this transition to occur
        

        public StateTransition(Func<bool> condition, 
                               PlayerState nextState, 
                               Action<FSMPlayer> onExit = null, 
                               Action<FSMPlayer, PlayerState> overrideSwitchState = null, 
                               List<PlayerStateCondition> dependencyConditions = null)
        {
            Condition = condition;
            NextState = nextState;
            OnExit = onExit;
            OverrideSwitchState = overrideSwitchState;
            DependencyConditions = dependencyConditions ?? new List<PlayerStateCondition>();
        }
    }
    
    [System.Serializable]
    public abstract class PlayerState 
    {
        public Action<FSMPlayer> onExitAnyCallback;
        protected List<StateTransition> transitions = new List<StateTransition>();
        

        public PlayerState() {}

        public void AddTransition(Func<bool> condition, 
                                  PlayerState nextState, 
                                  Action<FSMPlayer> onExit = null, 
                                  Action<FSMPlayer, PlayerState> overrideSwitchState = null, 
                                  List<PlayerStateCondition> dependencyConditions = null)
        {
            transitions.Add(new StateTransition(condition, nextState, onExit, overrideSwitchState, dependencyConditions));
        }

        public void OnUpdate(FSMPlayer context)
        {
            // Update condition values in Update to ensure they are fresh
            foreach (var transition in transitions)
            {
                foreach (var condition in transition.DependencyConditions)
                {
                    condition.OnUpdate(context);
                }
            }

            Execute(context);
        }

        public void OnFixedUpdate(FSMPlayer context)
        {
            bool transitionOccurred = false;

            foreach (var transition in transitions)
            {
                if (transition.Condition.Invoke()) 
                {
                    transition.OnExit?.Invoke(context);  // Exit action for current state
                    onExitAnyCallback?.Invoke(context);  // Exit action for all states

                    if (transition.OverrideSwitchState != null)
                    {
                        transition.OverrideSwitchState.Invoke(context, transition.NextState);
                    }
                    else
                    {
                        context.SwitchState(transition.NextState);
                    }

                    transitionOccurred = true;
                    break;  // Exit loop on first valid transition
                }
            }

            if (!transitionOccurred)
            {
                FixedExecute(context);  // Continue executing state behavior
            }
        }

        public virtual void Execute(FSMPlayer context) {}
        public virtual void FixedExecute(FSMPlayer context) {}


        public virtual void ConfigureTransitions(FSMPlayer context) {}
        public virtual void OnEnter(FSMPlayer context) {}
    }
}

Here is an example implementation of the PlayerState class. I set the onExitAnyCallback to reset the player’s vertical velocity to 0, ensuring this happens regardless of which state the player transitions to.

In the ConfigureTransitions function, I define all transition conditions by specifying the data each transition relies on, a boolean function for the transition condition, and an optional onExit callback.

In the Execute function, I update the appropriate animator variable for falling and manage the player’s rotation. Then, in the FixedExecute function, I update the player’s vertical and horizontal velocity.

namespace PlayerStates
{
    public class FallingState : PlayerState
    {
      
        public FallingState()
        {
            onExitAnyCallback = (context) => 
            { 
                context.verticalVelocity = 0.0f;
            };
        }
        
        public override void ConfigureTransitions(FSMPlayer context)
        {
            var player = context.playerData;

            // Define Dependency Conditions
            List<PlayerStateCondition> isIdleConditions = new List<PlayerStateCondition>
            {
                context.groundCheck
            };

            List<PlayerStateCondition> isEdgeHoldConditions = new List<PlayerStateCondition>
            {
               context.edgeHoldCheck
            };


            bool IsIdle() =>
                context.groundCheck.playerGrounded;

            bool IsEdgeHold() =>
                context.edgeHoldCheck.edgeHoldState;


            void OnExitIdle(FSMPlayer ctx)
            {
                var animationController = context.playerAnimationController;

                foreach(VisualEffectStruct vfx in context.onLandVFX)
                {
                    VFXEventController.Instance.SpawnSimpleVFXGeneral(vfx, context.playerRoot.transform);
                }

                if (animationController._hasAnimator) animationController.animator.SetBool(animationController.GetAnimatorIDFreeFall(), false);
            }


            AddTransition(IsIdle, context.idleState, OnExitIdle, dependencyConditions: isIdleConditions);
            AddTransition(IsEdgeHold, context.edgeHoldState, dependencyConditions: isEdgeHoldConditions);
        }
       
        public override void Execute(FSMPlayer context)
        {
            var jumpAndFallData = context.jumpAndFallingData;
            var animationController = context.playerAnimationController;

            // Update animator 
            if (animationController._hasAnimator) animationController.animator.SetBool(animationController.GetAnimatorIDFreeFall(), true);

            // Push the rotation to the stack
            context.playerRotationManager.scheduledRotations.Push(new RotationData(jumpAndFallData.rotationSmoothTime));
        }

        public override void FixedExecute(FSMPlayer context)
        {
            PlayerMovementHelpers.UpdateVerticalVelocity(context);
            PlayerMovementHelpers.UpdateHorizontalVelocityInAir(context);  
        }

        public override void OnEnter(FSMPlayer context)
        {
            context.playerDataManager.EdgeJumpModifier = 0.0f;
            context.playerDataManager.BaseJumpModifier = 0.0f;
        }
    }
}

The following class defines the main controller for the FSM. It declares each player state and player state condition, then calls the current state’s OnUpdate and OnFixedUpdate within the update loop. After updating the player’s current movement state FSM, the class applies the same update and fixed update process to the player movement processing FSM, which handles moving the character controller.

namespace PlayerStates
{
    public class FSMPlayer : MonoBehaviour
    {
        [Header("References")]
        public PlayerDataManager playerDataManager;
        public PlayerRotationManager playerRotationManager;
        public PlayerAnimationController playerAnimationController;
        public PlayerResources playerResources;
        public PlayerWeaponContainer playerWeaponContainer; 
        public GameObject playerRoot;
        public LayerMask groundLayers;
        public CharacterController characterController;
        

        [Header("Debug Options")]
        [SerializeField] private bool logStateTransitions = false; 
        [SerializeField] private bool logJumpSpeedChange = false;

        
        [Header("VFX")]
        public List<VisualEffectStruct> onLandVFX = new List<VisualEffectStruct>();
        public List<VisualEffectStruct> onPivotVFX = new List<VisualEffectStruct>();
        public List<VisualEffectStruct> onSlideVFX = new List<VisualEffectStruct>();
        public List<VisualEffectStruct> onWalkVFX = new List<VisualEffectStruct>();
        public List<VisualEffectStruct> onJumpVFX = new List<VisualEffectStruct>();
        
        
        [Header("State Data")]
        public JumpAndFallingData jumpAndFallingData;
        public SlideActionData slideActionData; 
        public WalkActionData walkActionData; 
        public IdleActionData idleActionData;
        public DirectionalPivotData directionalPivotData;


        [Header("State Checks")]
        [SerializeReference] public GroundCheck groundCheck = new GroundCheck();
        [SerializeReference] public CeilingCheck ceilingCheck = new CeilingCheck();
        [SerializeReference] public SlopeCheck slopeCheck = new SlopeCheck();
        [SerializeReference] public EdgeHoldCheck edgeHoldCheck = new EdgeHoldCheck();
        [SerializeReference] public CoyoteTimeCheck coyoteTimeCheck = new CoyoteTimeCheck();
        
        // Pivot
        [SerializeReference] public PivotCheck pivotCheck = new PivotCheck();
        public ExtendPivotCheck extendPivotCheck = new ExtendPivotCheck();
        
        // Jumping
        public JumpCooldown jumpCooldown = new JumpCooldown();
        public ExtendJumpCheck extendJumpCheck = new ExtendJumpCheck();

        // Sliding
        public SlideCooldown slideCooldown = new SlideCooldown();
        public ExtendSlideCheck extendSlideCheck = new ExtendSlideCheck();
        public LockInputSlideCheck lockInputSlideCheck = new LockInputSlideCheck();


        // Movement States
        public IdleState idleState = new IdleState();
        public WalkState walkState = new WalkState();
        public JumpingState jumpingState = new JumpingState();
        public FallingState fallingState = new FallingState();
        public SlideState slidingState = new SlideState();
        public EdgeHoldState edgeHoldState = new EdgeHoldState(); 
        public DirectionalPivotState directionalPivotState = new DirectionalPivotState(); 

        // Movment Processing States
        public NormalMovementState normalMovementState = new NormalMovementState(); 
        public SlopeAdjustmentState slopeAdjustmentState = new SlopeAdjustmentState();




        [Header("Dynamic")]
        public Vector3 targetPosition;
        public Vector3 previousPosition;
        public Vector3 playerVelocity;
        public float verticalVelocity;
        public float horizontalSpeed;
        public PlayerState currentPlayerState;
        public PlayerState currentMovementProcessingState;
       

        void Awake()
        {
            idleState.ConfigureTransitions(this);
            walkState.ConfigureTransitions(this);
            jumpingState.ConfigureTransitions(this);
            fallingState.ConfigureTransitions(this);
            slidingState.ConfigureTransitions(this);
            edgeHoldState.ConfigureTransitions(this);
            directionalPivotState.ConfigureTransitions(this);
            normalMovementState.ConfigureTransitions(this);
            slopeAdjustmentState.ConfigureTransitions(this);
        }
        

        void Start()
        {
            currentPlayerState = idleState;
            currentMovementProcessingState = normalMovementState;
            
            currentPlayerState.OnEnter(this);
            currentMovementProcessingState.OnEnter(this);
        }

        public void SwitchState(PlayerState state)
        {
            if(logStateTransitions) Debug.Log("Player Movement State:" + currentPlayerState + " -> " + state);
            currentPlayerState = state;
            state.OnEnter(this);
        }

        public void SwitchMovementProcessingState(PlayerState state)
        {
            if(logStateTransitions) Debug.Log("Player Movement Processing State:" + currentMovementProcessingState + " -> " + state);
            currentMovementProcessingState = state;
            state.OnEnter(this);
        } 

        void Update()
        {
            if(playerData == null)
            {
                Debug.LogError("Player Data Management not found!");
            }

            currentPlayerState.OnUpdate(this);
            currentMovementProcessingState.OnUpdate(this);
        }

        void FixedUpdate()
        {
            currentPlayerState.OnFixedUpdate(this);
            currentMovementProcessingState.OnFixedUpdate(this);

            // Consume Input
            playerData.ConsumeInput();
        }

        void OnDrawGizmosSelected()
        {
            groundCheck.OnDrawDebugGizmos(this);
            slopeCheck.OnDrawDebugGizmos(this);
            edgeHoldCheck.OnDrawDebugGizmos(this);
            ceilingCheck.OnDrawDebugGizmos(this);
        }
    }
}
LINKS
  1. Finite State Machines: An Introduction to FSMs and their Role in Computer Science
  2. What Is The Difference Between DFA And NFA?
  3. How to Program in Unity: State Machines Explained