Skip to main content

Finite State Machines in Unity

A practical, code-first guide to implementing FSMs for player and enemy state management — with clean transition logic that doesn't turn into spaghetti.


Why Bother With FSMs?

Early in Sharpshooters, my player controller was a single Update() function with nested if statements:

// This is what "just ship it" looks like six weeks in
void Update()
{
if (isGrounded && !isCrouching && Input.GetKey(roll))
{
if (!isRolling && !isAiming)
{
// start roll
}
}
if (isAiming && isRolling) // which wins?
{
// ...
}
}

After 12 states and 30-something conditions, I couldn't reason about what the player could and couldn't do at any given moment. The fix: explicit states with explicit transitions.


The Pattern

Three pieces: IState, StateMachine, and concrete state classes.

IState

public interface IState
{
void Enter();
void Update();
void Exit();
}

StateMachine

public class StateMachine
{
private IState current;

public void ChangeState(IState newState)
{
current?.Exit();
current = newState;
current.Enter();
}

public void Update() => current?.Update();

public IState Current => current;
}

No magic — the machine holds one state at a time, calls Exit() on the old one, Enter() on the new one, and Update() every frame.

Concrete State Example

public class PlayerAimingState : IState
{
private readonly PlayerController player;
private readonly StateMachine machine;

public PlayerAimingState(PlayerController player, StateMachine machine)
{
this.player = player;
this.machine = machine;
}

public void Enter()
{
player.Camera.SetAimFOV();
player.Animator.SetBool("IsAiming", true);
player.MoveSpeed = player.AimWalkSpeed;
}

public void Update()
{
// Transition out: release aim button
if (!Input.GetButton("Aim"))
{
machine.ChangeState(player.IdleState);
return;
}

// Transition out: got hit while aiming
if (player.IsHit)
{
machine.ChangeState(player.HitState);
return;
}

player.HandleAimMovement();
player.HandleShoot();
}

public void Exit()
{
player.Camera.ResetFOV();
player.Animator.SetBool("IsAiming", false);
player.MoveSpeed = player.DefaultMoveSpeed;
}
}

Transitions live inside Update(). Each state only knows how to exit itself — it doesn't know the internal logic of other states.


Full State Graph for Sharpshooters Player

┌──────────────────────────────────────┐
↓ │
[Idle] ──move──→ [Moving] │
│ │ │
│ sprint │
│ ↓ │
│ [Sprinting] │
│ │ │
aim aim │
↓ ↓ │
[Aiming] ←─────────┘ │
│ │
roll died │
↓ │
[Rolling] ──────────────────────────→ [Dead]

crouch

[Crouching]

Drawing this before coding saved me from discovering mid-implementation that Rolling → Crouching needed a separate animation blend.


Debugging Trick: State Log

Add one line to ChangeState:

public void ChangeState(IState newState)
{
Debug.Log($"[FSM] {current?.GetType().Name}{newState.GetType().Name}");
current?.Exit();
current = newState;
current.Enter();
}

When something breaks, the console tells you exactly which transitions fired. Disable the log in builds.


When FSMs Break Down

FSMs get awkward when states need to remember why they were entered. For example: should the player return to Idle or Moving after finishing a roll? You'd need to pass context into the state, or use a stack-based state machine (push/pop states).

For Sharpshooters I solved it simply: RollingState checks movement input on exit and transitions to Moving or Idle accordingly. For more complex scenarios (nested states, history), look into hierarchical state machines or behavior trees (which I used for enemy AI in Sharpshooters).


Key Takeaways

  • One state owns one concern. If your state Update() is longer than 30 lines, you might be doing two states' work.
  • Transitions are the hard part. Draw the graph. Find the edge cases. Roll → Aiming? What happens? Decide it before you code it.
  • Enter() and Exit() are underused. Camera changes, animator flags, speed modifiers — all of this belongs in Enter/Exit, not scattered across Update.