Changing States

There are 3 different methods for changing the CurrentState of a StateMachine: two Try... methods which respect the CanEnterState and CanExitState rules defined by the states and a Force... method which ignores them.

Method Description
TrySetState

Tries to enter a new state.

If the target state is already the CurrentState, this method returns true without doing anything. Otherwise it calls TryResetState.
TryResetState

Tries to enter a new state or re-enter the current state.

  1. If the current state's CanExitState property returns false, this method returns false without doing anything.
  2. Same for the next state's CanEnterState property.
  3. If they both returned true, call ForceSetState and return true.
ForceSetState

Enters a state without checking CanExitState or CanEnterState.

  1. Call the current state's OnExitState method.
  2. Set the next state as the current state.
  3. Call the next state's OnEnterState method.

Details

While a StateMachine is changing its state (throughout all of the Can... and On... methods in the IState interface), references to the PreviousState and NextState can be accessed in several ways:

  • The static StateChange<TState>.PreviousState and NextState properties can be accessed from anywhere.
  • If you have a reference to the StateMachine you can use its properties of the same name (i.e. stateMachine.PreviousState) to avoid needing to specify the TState generic argument.
  • Inside a class that implements IState you can use this.GetPreviousState() and this.GetNextState() which are Extension Methods defined in the StateExtensions class.
    • Using those Extension Methods without specifying the generic TState parameter relies on the compiler to infer the parameter which can sometimes lead to it accessing the wrong type and therefore not working properly.
    • For example, if you have a StateMachine<CharacterState> then you obviously need to check StateChange<CharacterState>, but if an IdleState calls this.GetPreviousState() the compiler will infer the generic type as IdleState and try to access StateChange<IdleState> which will be empty and cause it to throw an InvalidOperationException: Attempted to access StateChange<...> but no StateMachine of that type is currently changing its State.
    • This can be avoided by specifying the generic parameter yourself: this.GetPreviousState<CharacterState>().

All of those properties will throw an exception if no state change is currently occurring (which can be checked using StateChange<TState>.IsActive).

Examples

  • You cannot Jump if you are not on the ground so Jump.CanEnterState checks whether the character is on the ground (or asks whatever system is responsible for checking that).
  • You cannot perform other actions during an Attack so Attack.CanExitState returns false.
  • Attacks can be interrupted if you get hit or killed though, so instead of always returning false, Attack.CanExitState returns true if the StateChange<TState>.NextState is Flinch or Die. This is often implemented by using an enum to indicate the priority of each state or type of each state so you might have multiple different Attack scripts (perhaps for melee vs. ranged or to implement attack combos for some characters but not others) yet they all use the same CharacterAction.Attack as their type. The Interrupt Management example goes into more detail about this.
  • Once the Attack animation finishes, it needs to be able to return to Idle. This could be done by having Attack.CanExitState check the current animation time as well, but since the Attack state already knows it is ending and Idle is unlikely to impose any constraints on when it can be entered you could simply use ForceSetState to skip the check.

The State Machines examples go into more detail and demonstrate these ideas in action.

Initialisation

A StateMachine can be created and have its state changed at any time so the system is very flexible, but when multiple scripts are all interacting with the same StateMachine it can be easy to cause bugs if you don't pay close attention to their execution order. The following initialisation pattern is often useful to avoid those problems:

[SerializeField]
private State _Idle;

public readonly StateMachine<State>
    StateMachine = new StateMachine<State>();

private void Awake()
{
    if (StateMachine.CurrentState == null)
        StateMachine.ForceSetState(_Idle);
}
  • The StateMachine is public so other scripts can access it but readonly so they can't assign a different StateMachine or set it to null.
  • The StateMachine is created using a Field Initializer so that it is always accessible, even if another script executes before this one.
  • The Awake method makes sure there is no CurrentState before entering the first state in case another script executed before this one and already set the state.
    • For example, an Introduction state could set itself as the active state on startup and it wouldn't matter whether that happens before or after this Awake method is called. That way the rest of the system wouldn't have to know or care whether there is an Introduction state or not. If there is an Introduction script attached to the character it will enter itself, but otherwise the character will start in the Idle state.

Here are some of the other approaches that could be used and a summary of their problems:

Create in Awake

[SerializeField]
private State _Idle;

public StateMachine<State> StateMachine { get; private set; }

private void Awake()
{
    StateMachine = new StateMachine<State>(_Idle);
}

If another script executes before this Awake method gets called, the StateMachine will still be null.

Initialize in Field Initializer

[SerializeField]
private State _Idle;

public readonly StateMachine<State>
    StateMachine = new StateMachine<State>(_Idle);

This gives a compiler error because Field Initializers can't access instance fields (_Idle is an instance field).

Initialize in Constructor

[SerializeField]
private State _Idle;

public readonly StateMachine<State> StateMachine;

public MyClass()
{
    StateMachine = new StateMachine<State>(_Idle);
}

This will compile and run fine, but Constructors are executed immediately when the object is created which is before Unity's Serialization system can deserialize its fields, meaning that the _Idle field will still be null.

Default States

A regular StateMachine<TState> doesn't keep track of any states other than the CurrentState, but it is often useful to have a particular state that it can return to by default if nothing else is active so the StateMachine<TState>.WithDefault class allows you to do that:

  • It inherits from the base StateMachine<TState>, meaning it has the usual CurrentState property and methods for Changing States.
  • It has a DefaultState property. If there is no CurrentState when you set the default, the state machine will immediately enter that state.
  • It also has a ForceSetDefaultState callback which is useful for End Events if you want to return to the default state when an animation ends.

The recommended initialisation pattern is basically the same as the regular one explained above, but instead of manually checking the CurrentState and calling ForceSetState, you can just set its DefaultState:

public sealed class Character : MonoBehaviour
{
    [SerializeField]
    private AnimancerComponent _Animancer;
    public AnimancerComponent Animancer => _Animancer;
	
    [SerializeField]
    private CharacterState _Idle;
    
    public readonly StateMachine<CharacterState>.WithDefault
        StateMachine = new StateMachine<CharacterState>.WithDefault();
    
    private void Awake()
    {
        StateMachine.DefaultState = _Idle;
    }
}

Then other states can use its ForceSetDefaultState callback for their End Events:

public abstract class CharacterState : StateBehaviour
{
    [SerializeField]
    private Character _Character;
    public Character Character => _Character;
}

public sealed class AttackState : CharacterState
{
    [SerializeField]
    private AnimationClip _Animation;
    
    private void OnEnable()
    {
	    var animancerState = Character.Animancer.Play(_Animation);
		animancerState.Events.OnEnd = Character.StateMachine.ForceSetDefaultState;
    }
}

Design Rationale

The state change system might seem a bit complicated compared to simply passing the change details into the IState methods as parameters, but there are several main advantages to this design in terms of minimising Boilerplate Code and maximising the flexibility of the system:

  • It allows the IState interface to avoid needing the state type as a generic parameter, which simplifies its usage throughout the system.
  • It avoids the need to declare the parameters in every state because most of the time state changes are dependent on other factors so the parameters would be unused. In particular, while CanEnterState will sometimes base its decision on the previous state, it's much less likely to be relevant in OnEnterState but the parameter would still need to be included in every implementation of that method just in case it's needed.
  • It allows states in Keyed State Machines to access the key change as well as the state change without requiring them to be based on an entirely different IState interface (which would need to pass both the key and state into every method, meaning even more useless code when they aren't used).

Here's a comparison of what various implementations would look like with either approach:

This System Using Parameters
public interface IState
{
    bool CanEnterState { get; }
    bool CanExitState { get; }
    void OnEnterState();
    void OnExitState();
}
public interface IState<TState>
{
    bool CanEnterState(TState previousState);
    bool CanExitState(TState nextState);
    void OnEnterState(TState previousState);
    void OnExitState(TState nextState);
}
public abstract class StateBehaviour : MonoBehaviour, IState
{
    public virtual bool CanEnterState ...
    public virtual bool CanExitState ...
    public virtual void OnEnterState() ...
    public virtual void OnExitState() ...
}
public abstract class StateBehaviour<TState> : MonoBehaviour, IState<TState>
{
    public virtual bool CanEnterState(TState previousState) ...
    public virtual bool CanExitState(TState nextState) ...
    public virtual void OnEnterState(TState previousState) ...
    public virtual void OnExitState(TState nextState) ...
}
public abstract class CharacterState : StateBehaviour ...
public abstract class CharacterState : StateBehaviour<CharacterState> ...
public class JumpState : CharacterState
{
    public override bool CanEnterState => Character.Body.IsGrounded;
    
    public override void OnEnterState()
    {
        Character.Rigidbody.AddForce(...);
        Character.Animancer.Play(...);
    }
}
public class JumpState : CharacterState
{
    public override bool CanEnterState(TState previousState) => Character.Body.IsGrounded;
    
    public override void OnEnterState(TState previousState)
    {
        Character.Rigidbody.AddForce(...);
        Character.Animancer.Play(...);
    }
}