Introduction
In this post I’m going through steps to implement an asynchronous finite-state machine (FSM) in Unity, using async/await library UniTask. In the end you’ll have a nice modular state machine with all the usual stuff you would expect to find in a FMS. We’ll also take a look at how we can run update loops independently of monobehaviours / gameobjects.
You can follow along or hop directly to my GitHub to explore the repository which contains the full project.
Requirements
- Unity 2020.2+
- UniTask
I recommend installing UniTask via git URL using Unity’s package manager.
States
Alright, let’s start with the states. We create an interface IState
and three abstract classes: State
, State<T>
and Options
. State
and State<T>
are the base classes for all state implementations and Options
can be used as a container for custom properties when transitioning to a new state.
public interface IState
{
}
public abstract class State : State<Options>
{
}
public abstract class State<T> : IState where T : Options
{
}
public abstract class Options
{
}
The IState
interface contains the blueprint of our state. States know which state machine they belong to and they’ll use that reference to request state transitions. The OnEnter
and OnExit
methods will both return UniTask
struct which makes them awaitable.
The SetOptions
method will be called each time we do a state transition. The state machine supports states with and without options.
To keep things simple we add in only one update method: OnUpdate
. Later I’ll introduce you to the true power 😱 of UniTask and show how easily you can hook in to different timings in Unity’s player loop. You can even inject your own player loop timings to the state machine.
using Cysharp.Threading.Tasks;
public interface IState
{
StateMachine StateMachine { get; set; }
UniTask OnEnter();
UniTask OnExit();
void SetOptions(Options options);
void OnUpdate();
}
Next, let’s implement the members of IState
to the State<T>
class. If you’re wondering about await UniTask.Yield()
, it’s the UniTask’s replacement for yield return null
. We’ll make the methods virtual so our derived states can override only the methods they need.
using Cysharp.Threading.Tasks;
public abstract class State : State<Options>
{
}
public abstract class State<T> : IState where T : Options
{
public T Options { get; private set; }
public virtual async UniTask OnEnter()
{
await UniTask.Yield();
}
public virtual async UniTask OnExit()
{
await UniTask.Yield();
}
public void SetOptions(Options options)
{
if (options is T stateOptions)
{
Options = stateOptions;
}
}
public virtual void OnUpdate()
{
}
}
Transitions
For handling state transitions we’ll create two classes: Transition
and Transition<T>
.
public class Transition : Transition<Options>
{
}
public abstract class Transition<T> where T : Options
{
}
A transition needs the state Type
so we know to which state we want to transition in to. We can also provide Options
which can be used for setting up state properties before the state’s OnEnter()
method is called.
using System;
public class Transition : Transition<Options>
{
public Transition(Type type, Options options) : base(type, options)
{
}
}
public abstract class Transition<T> where T : Options
{
public Type Type { get; }
public T Options { get; }
protected Transition(Type type, T options)
{
Type = type;
Options = options;
}
}
Implementing the state machine
Now let’s create the StateMachine
class and add in some members.
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
public class StateMachine
{
private IState _currentState;
private IState _previousState;
private readonly Dictionary<Type, IState> _states = new();
private readonly Queue<Transition> _pendingTransitions = new();
}
We create a dictionary to keep track of registered states. If you dislike using Type
as the dictionary key, you could as well use a string
or an enum
of your choice. We’ll also create a queue for keeping track of the requested state transitions.
Now it’s time to add in some methods. First add in a method for registering new states.
public void RegisterState(IState state)
{
state.StateMachine = this;
_states.Add(state.GetType(), state);
}
We create two methods for requesting transitions. When a transition is requested it will added into a queue. The queue will be processed in our state machine’s update loop.
public void RequestTransition(Type stateType)
{
_pendingTransitions.Enqueue(new Transition(stateType, null));
}
public void RequestTransition<T>(Type stateType, T options) where T : Options
{
_pendingTransitions.Enqueue(new Transition(stateType, options));
}
The ChangeTo
method will be responsible of handling a transition. We’ll again return a UniTask
struct so we can await for it’s completion.
private async UniTask ChangeTo<T>(Type stateType, T options) where T : Options
{
}
We’ll implement a typical FSM-style transition where we first exit the current state (if any) and then enter the next state. Keeping track of the current state as we do this. You’ll probably want to include better validation for certain things. For now let’s just throw an exception if we try to transition to a state which is not registered to our state machine.
private async UniTask ChangeTo<T>(Type stateType, T options) where T : Options
{
if (_currentState != null)
{
_previousState = _currentState;
await _previousState.OnExit();
_currentState = null;
}
if (_states.TryGetValue(stateType, out IState nextState))
{
nextState.SetOptions(options);
_currentState = nextState;
await nextState.OnEnter();
}
else
{
throw new Exception($"State: {stateType.Name} is not registered to state machine.");
}
}
The async update loop
This is where things get somewhat interesting. Async enumerables is a C# 8.0 feature which, using UniTask, allows us to use a new update notation that lets us to inject our code directly into Unity’s PlayerLoop
, breaking us free from the shackles of MonoBehaviour
.
private async void Update()
{
await foreach (var _ in UniTaskAsyncEnumerable.EveryUpdate())
{
}
}
Using UniTaskAsyncEnumerable
we can emulate the Update()
method of the MonoBehaviour
component in a pure C# class. This is very powerful and useful for many other things besides this state machine. You can read more about async enumerables in the context of Unity and UniTask in the UniTask repository.
By default EveryUpdate
uses PlayerLoopTiming.Update
but you can easily change it to something else (for example to PlayerLoopTiming.FixedUpdate
). For this example we’re only going to implement a standard Update
loop.
Everything looks good so far, but before we continue with the update loop let’s discuss how we can actually start and stop the update. Let’s create methods called Run()
and Stop()
.
public void Run()
{
}
public void Stop()
{
}
To stop the state machine from running we need a CancellationToken
. So let’s add in a field for cancellation token source to our state machine class. We provide this token to the UniTaskAsyncEnumerable
in our update loop. The token is used to request the cancellation of the enumerator.
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
public class StateMachine
{
private IState _currentState;
private IState _previousState;
private readonly Dictionary<Type, IState> _states = new();
private readonly Queue<Transition> _pendingTransitions = new();
private CancellationTokenSource _cancellationTokenSource;
...
When we call Run
to start the state machine we create a new CancellationTokenSource
and fire up our async Update()
method. To actually use the token we have to provide it using the WithCancellation
method.
public void Run()
{
_cancellationTokenSource = new();
Update();
}
private async void Update()
{
await foreach (var _ in UniTaskAsyncEnumerable
.EveryUpdate()
.WithCancellation(_cancellationToken.Token))
{
}
}
To stop the state machine from running we call Cancel
on the cancellation token source to request a cancellation of the enumerator. We will also dispose the cancellation token source.
public void Stop()
{
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
}
Now the only things remaining are to actually process the transition queue and calling the OnUpdate()
method of the current state.
private async void Update()
{
await foreach (var _ in UniTaskAsyncEnumerable
.EveryUpdate()
.WithCancellation(_cancellationToken.Token))
{
while (_pendingTransitions.Count > 0)
{
var transition = _pendingTransitions.Dequeue();
await ChangeTo(transition.Type, transition.Options);
}
_currentState?.OnUpdate();
}
}
Examples
Let’s put the state machine to test by implementing a very basic example. We will create a state machine that has two states and we’ll also test out a transition between those states. In this example our state machine is running independently of MonoBehaviour
. You can of course have the state machine be a member of a class that is derived from monobehaviour or use it in any other way you like.
Create the following classes: Example
, ExampleState
, ExampleStateWithOptions
and ExampleStateOptions
.
ExampleState
is a basic state with no options. We’ll request a transition after a 2 second delay. You’ll notice that OnUpdate
never gets called because we request the transition already inside the OnEnter
method.
using Cysharp.Threading.Tasks;
using UnityEngine;
public class ExampleState : State
{
public override async UniTask OnEnter()
{
Debug.Log("Entering ExampleState! Waiting 2 seconds before changing state.");
await UniTask.Delay(2000);
var options = new ExampleStateOptions
{
text = "Hello world!"
};
StateMachine.RequestTransition(typeof(ExampleStateWithOptions), options);
}
public override async UniTask OnExit()
{
Debug.Log("Exiting ExampleState!");
await UniTask.Yield();
}
public override void OnUpdate()
{
// This is never called because we request a transition in OnEnter.
Debug.Log("Calling OnUpdate in ExampleState!");
}
}
ExampleStateWithOptions
uses a custom options container. You’ll notice that the options are already initialized when we enter the OnEnter
method. Options are a great way for some state-specific initialization which you might want to run before the OnUpdate
method is called.
using Cysharp.Threading.Tasks;
using UnityEngine;
public class ExampleStateOptions : Options
{
public string text;
}
public class ExampleStateWithOptions : State<ExampleStateOptions>
{
public override async UniTask OnEnter()
{
Debug.Log($"Entering ExampleStateWithOptions. Here's our options text: {Options.text}");
await UniTask.Yield();
}
public override void OnUpdate()
{
Debug.Log($"realTimeSinceStartup: {Time.realtimeSinceStartup}, frameCount:{Time.frameCount}");
}
}
In Example
class we’ll create a new state machine, create and register 2 states, request a transition to the initial state and call Run
to start the state machine.
using UnityEngine;
public static class Example
{
private static StateMachine _stateMachine;
[RuntimeInitializeOnLoadMethod]
private static void Initialize()
{
_stateMachine = new StateMachine();
_stateMachine.RegisterState(new ExampleState());
_stateMachine.RegisterState(new ExampleStateWithOptions());
_stateMachine.RequestTransition(typeof(ExampleState));
_stateMachine.Run();
}
}
Now, if you hit play in Unity and take a look at the console, you should see the state machine in action!