This is the third article in our series explaining the core features of the QuizU sample for UI Toolkit. QuizU is a small UI-based game project that showcases common techniques in game architecture and can help developers transition to UI Toolkit. You can download the project from the QuizU repository. Make sure to install Unity 6.1 to follow along with the project in the Editor.
Here are the other posts in this series:
Post 1: The QuizU UI Toolkit sample: Now updated for Unity 6
Post 2: QuizU: Managing menu screens in UI Toolkit
Post 4: QuizU: The Model View Presenter pattern
Post 5: QuizU: Event handling in UI Toolkit
Post 6: QuizU: Vector API
Post 7: QuizU: UI Toolkit performance tips
In this post we’ll look at the use of the state programming design pattern to set up game flow in the QuizU project.
The state pattern: A programming design pattern
The state pattern is a software design pattern that can represent complex behaviors as a simpler set of internal states. Each state encapsulates the behavior and allowable actions within that state.
As an example, consider a 3D animated character in Unity. Its character rig might run, jump, attack, and idle, using a hierarchy of animated transforms. Though it’s possible, working directly with the many different individual parts of a character is difficult. The sheer number of objects is unwieldy and can be hard to manage.
Instead, often you’ll use an AnimatorController. This allows you to wrangle those hundreds of moving parts into clear and concise states: Jumping, running, attacking, idling, etc.
The AnimatorController is functioning as a state machine. It tracks the character’s current state and manages transitions to other states. It does this based on predefined rules and triggers. You only need to manage the logic of transitions between states, and then the state machine takes care of the rest.
You can visually represent a state machine as a graph, much like an AnimatorController:
The AnimationController is an example of a state machine.
This is the essence of the state pattern: It distills a complex system into distinct, self-contained states, each encapsulating specific behavior states.
The principle of state machines can be applied to numerous other aspects of your application. UI screens, AI behavior, level or scene management, etc. are potential candidates for using them.
One common use for the state pattern is to describe the main game loop and game states. Let’s walk through how to set up a state machine for the general game flow of the QuizU application.
Check out the e-book Level up your code with game programming patterns and SOLID for an introduction to the state pattern.
Components of the state machine
You can think of a state machine as a map of possibilities. Like our AnimatorController, it can be represented visually as a graph, in which each state corresponds to a single node.
Only one state, the current one, can be active at a time. This current state switches between nodes in this graph, based on specific conditions or events. Imagine a choose-your-own-adventure book, where the current page (the state) changes depending on the choices (the conditions or events) you make.
Visualize a state machine as a graph
The state machine is a collection of these states. Each state, in turn, can connect to one or more other states. These “links” evaluate conditions or events to determine how the transition happens. The arrows also illustrate how the pattern allows you to define varying rules for what state can transition to other states.
The IState interface
Rather than use a series of if-else conditions to change states, the original Gang of Four state pattern suggests that you define each state independently. When adding new states, you don’t want to affect the existing ones.
So, keep each state in its own object. Think of it as one “mode” of behavior.
The pattern itself doesn’t care about what that behavior is. A state could handle UI changes, animation playback, or anything else – it’s just a matter of how you want to apply it.
The only requirement is that each state needs some means of transitioning to another state when the time is right. What each state does is up the state itself. This approach makes everything more flexible.
In keeping with the idea of composition over inheritance, we’ll define an interface, IState (\Quiz\Scripts\StateMachine\Interfaces\IState.cs), that represents each state’s barebones functionality. Interfaces allow you to define clear “contracts” between the states and the context (or state machine) that uses them. It ensures every state has the necessary methods that the context might call.
Each state encapsulates its behavior in its own script, making the code modular and easier to understand and maintain.
Each state in the state pattern
The IState interface requires the following methods:
-
Enter: This method is called when a GameObject first enters a particular state. This is used for setup, initializing variables, etc.
-
Execute: This coroutine defines the main actions or behavior the GameObject should perform every frame in this state and is started just after
Enter()
, similar to Unity’sUpdate()
method. You could think of it as a state-specific replacement forUpdate()
that aligns with the State design pattern. -
Exit: This method is called when the object leaves this state. It’s useful for cleanup, resetting variables, etc.
-
ValidateLinks: The owner state-machine calls this method to examine all the links of the state and determines if it should transit to the next state. It does so by determining if any conditions to transition to another state have been met. If not, the state machine stays in the current state.
-
AddLink and RemoveLink: These methods will define how to set up transitions from this state to another one.
-
EnableLinks and DisableLinks: These methods make all the links from this state to others active or inactive.
To follow along in the project go to the folder \Quiz\Scripts\StateMachine \IState.cs.
The ILink interface
To check on the conditions for exiting the state, we define another interface, the ILink.
public interface ILink
{
bool Validate(out IState nextState);
void Enable(){}
void Disable(){}
}
This contains a Validate method to determine whether conditions are right to transition to another state. If the exit check passes, Validate returns true and provides the next state we should transition to. Otherwise, it returns false.
ILink also has methods to Enable and Disable its links.
Remember that the concrete states (below) and their links will actually contain the implementation logic. The interfaces are simply functional blueprints.
The StateMachine class
The StateMachine (\Quiz\Scripts\StateMachine.cs) class is the “brain” that processes how the states and their links work together.
It contains a CurrentState property to track its one active IState.
public IState CurrentState { get; private set; }
This is the current behavior of the object. The logic within the CurrentState’s ILinks determines when to shift to the next state.
In essence, the StateMachine is just a playback controller. It only knows that it contains a series of states and understands they are connected using links. It doesn’t understand the details of how each state works or how their transitions happen. Everything executes depending on the current state’s internal logic.
This pattern allows you to create as many states as you need. Define a state, add transitions, and the state machine is expanded.
The StateMachine includes these methods:
- SetCurrentState, which changes the current state, CurrentState, to a new one. It also stops any previously running state.
public virtual void SetCurrentState(IState state)
{
if (state == null)
throw new ArgumentNullException(nameof(state));
if (CurrentState != null && m_CurrentPlayCoroutine != null)
{
//interrupt currently executing state
Skip();
}
CurrentState = state;
Coroutines.StartCoroutine(Play());
}
- Run, which turns on the main loop of the StateMachine. It starts the state, lets it run, and then waits until it’s complete.
public virtual void Run()
{
if (m_LoopCoroutine != null) //already running
return;
m_LoopCoroutine = Coroutines.StartCoroutine(Loop());
}
- Stop, which stops the state machine from processing and clears the current state.
public void Stop()
{
if (m_LoopCoroutine == null) //already stopped
return;
if (CurrentState != null && m_CurrentPlayCoroutine != null)
{
//interrupt currently executing state
Skip();
}
Coroutines.StopCoroutine(ref m_LoopCoroutine);
CurrentState = null;
}
- Play, which is an update loop that runs continuously. For every frame, the state machine checks the status of the current state. If the conditions are right, it finishes anything left to do in the current state and transitions to the next active state.
IEnumerator Play()
{
if (!m_PlayLock)
{
m_PlayLock = true;
CurrentState.Enter();
//keep a ref to execute coroutine of the current state
//to support stopping it later.
m_CurrentPlayCoroutine = Coroutines.StartCoroutine(CurrentState.Execute());
yield return m_CurrentPlayCoroutine;
m_CurrentPlayCoroutine = null;
}
- Skip, a function that can immediately stop a state that’s currently running.
void Skip()
{
if (CurrentState == null)
throw new Exception($"{nameof(CurrentState)} is null!");
if (m_CurrentPlayCoroutine != null)
{
Coroutines.StopCoroutine(ref m_CurrentPlayCoroutine);
//finalize current state
CurrentState.Exit();
m_CurrentPlayCoroutine = null;
m_PlayLock = false;
}
}
Concrete States
Of course, interfaces are just empty blueprints. To bring our state machine to life, we need concrete states that do something.
First, we define an AbstractState (\Quiz\Scripts\StateMachine\States\ AbstractState.cs) class to serve as a base class; this ensures all of the states share consistent implementation (e.g., the links all work the same way). The rest of our concrete classes can then derive from AbstractClass.
QuizU includes two example states (both available in Quiz\Script\StateMachine\States\ ):
-
The State class executes a specified Action just once when entering the state. This can be useful for anything, from setting a variable to playing back a sound or triggering some action.
-
The DelayState class pauses the state machine for a given duration before it executes. A specified Action can then run every frame. This works for tasks that loop continuously, such as animating a loading bar on the Splash Screen. An optional Action can also run on exit.
The DelayState updates the Splash Screen loading bar.
Transition links
The concrete classes will also require some corresponding ILinks (both available in Quiz\Script\StateMachine\Links):
- The Link class, the simplest implementation of the ILink interface, transitions to another predefined state once the current state finishes execution.
public class Link : ILink
{
readonly IState m_NextState;
/// <param name="nextState">the next state</param>
public Link(IState nextState)
{
m_NextState = nextState;
}
public bool Validate(out IState nextState)
{
nextState = m_NextState;
return true;
}
}
- The EventLink class, on the other hand, waits for an event. The state machine remains in its current state until that event is raised. This is useful, for example, if the transition depends on user input.
The transition links can now connect concrete states together into a basic graph. This is everything that we need to build game flow. This diagram shows an example of how a few states might connect with each other.
The Sequence Manager
Once we define some basic concrete states and links, we can create an instance of the state machine anywhere we need it.
One place we can deploy a state machine is inside the Sequence Manager (\Quiz\Scripts\Managers\SequenceManager.cs). In QuizU, this is a MonoBehaviour that tracks the overall game flow. We can formalize the main game loop as a series of IState objects.
Game states
Remember that the pattern’s goal is to reduce something complex down to a few management states.
Implementing the state pattern early on helps to maintain a clean and organized project. As your game evolves from a small demo and increases in complexity, you can manage that new complexity with additional states.
Each state is its own object. Our sample project breaks the application’s game flow into the following:
SplashScreenState: This is the initial startup state which loads necessary assets (e.g. textures, 3D models, sounds, shaders, etc.) and displays a loading progress bar.
StartScreenState: This is a placeholder state after the Splash Screen commonly used in games. In QuizU, we show the game logo until the user presses the start button.
MainMenuState: This state shows the main menu, where users can select different options, such as starting a game, choosing a level, or changing settings.
LevelSelectionState: This state shows a UI to select a game level. QuizU has a number of different quizzes, but you could expand this to include difficulty settings, load game options, etc.
MenuSettingsState: This state lets users change or customize application/game settings.
GamePlayState: This is the core state where the quiz gameplay takes place. This state is active when the user is playing the game.
GameSettingsState: Similar to MenuSettingsState, this one is accessible during gameplay. This screen allows players to adjust game settings without leaving the game. In QuizU the options are limited to audio volume settings, but this could be expanded to your needs.
PauseState: This state pauses the game during gameplay. It shows a Pause Screen where the user can continue gameplay or return to the menus.
GameWinState: This state is shown when the player wins the game. It displays a “Win” screen with relevant information and options for replaying the game or returning to the main menu.
GameLoseState: Similar to the GameWinState, it shows a “Lose” screen with relevant options.
Though we define these in code, a visual representation of the states might look like this:
Visualizing the game states as a graph
Each state represents one behavior of the game application at runtime. The SequenceManager transitions from one state to another, based on certain events or conditions.
State machine setup
In the SequenceManager (QuizU\Assets\Quiz\Scripts\Managers\SequenceManager.cs), fields are defined for the state machine and each IState.
- Set m_StateMachine to a new empty instance.
- Create a new field for each unique state with type IState.
This shows part of the SequenceManager in the QuizU project:
public class SequenceManager : MonoBehaviour
{
…
StateMachine m_StateMachine = new StateMachine();
IState m_SplashScreenState;
IState m_StartScreenState;
IState m_MainMenuState;
…
private void SetStates()
{
m_SplashScreenState = new DelayState(m_LoadScreenTime, SceneEvents.LoadProgressUpdated,
SceneEvents.PreloadCompleted, "LoadScreenState");
m_StartScreenState = new State(null, "StartScreenState");
m_MainMenuState = new State(null, "MainMenuState" );
}
}
Then in the SetStates method, use the constructor to create a new State or DelayState for each. In the above example:
m_SplashScreenState is a DelayState. It raises the SceneEvents.LoadProgressUpdated event every frame for a delay of m_LoadScreenTime seconds.
Once the delay is over, the state machine triggers the SceneEvents.PreloadCompleted event.
m_StartScreenState and m_MainMenuState both implement the State concrete class. Essentially, these are placeholder states. We pass in null as the default action, which means the states don’t do anything specifically, even when they are active.
In Start, we initialize the SequenceManager and call the SetStates.
// Define the state machine's states
private void SetStates()
{
// Executes GameEvents.LoadProgressUpdated every frame and GameEvents.PreloadCompleted on exit
m_SplashScreenState = new DelayState(m_LoadScreenTime, SceneEvents.LoadProgressUpdated,
SceneEvents.PreloadCompleted, "LoadScreenState");
m_StartScreenState = new State(null, "StartScreenState");
m_MainMenuState = new State(null, "MainMenuState");
…
}
Note that even if a state doesn’t perform any additional actions, it still has utility. For example, imagine if a UI element or visual effect needs to work differently during the StartScreen state or the MainMenu state. It can read the CurrentState of the SequenceManager to know when to change behaviors.
Adding link transitions
Each state includes an AddLink method to set what specific conditions or events trigger the state change.
Here’s a snippet of the SequenceManager’s AddLinks method where we set up those individual calls to AddLink:
public class SequenceManager : MonoBehaviour
{
…
private void AddLinks()
{
m_SplashScreenState.AddLink(new Link(m_StartScreenState));
m_StartScreenState.AddLink(new EventLink(UIEvents.MainMenuShown, m_MainMenuState));
…
}
}
These lines set up a transition from the SplashScreen State to the StartScreen State automatically and a transition to the MainMenu State once the UIEvents.MainMenuShown event is raised.
As you add more states and more link transitions, define them here.
Benefits of a state machine
Using a state machine has several advantages:
-
Scalability: As a game grows more complex, the number of states can increase without impacting the existing code. This adheres to the SOLID Open/Closed Principle (OCP) which states that a class should be open for extension but closed for modification. Managing these states using a state machine in separate scripts can be easier than having one large monolithic script full of if-then or switch statements. Remember that in a state machine, each state is a standalone entity. Changing one state does not affect another.
-
Maintainability: Each state handles its own logic with an explicit structure. This makes the code cleaner and more readable. Multiple team members can work on different states simultaneously without stepping on each other.
-
Reduced Complexity: A state describes its own conditions for transitioning to the next state. The logic for each state is self-contained and doesn’t spill out to other states. This keeps the classes short and easy to understand.
Contrast this to modifying if-then statements where a change in one condition can have cascading effects down the chain.
In a small application like QuizU, the benefits of using a state machine may not be apparent. The purpose of this demo, however, is to show how to use such programming design patterns in game UI in a simple, pared-back project so the methods of implementation are really clear to you. This is something to keep in mind as you explort the project: It’s ultimately meant to be instructional and educational.
As an application grows, however, imagine how you can:
-
Poll for the current state: The SequenceManager keeps the CurrentState as a public property. Anything that needs to listen for state changes can reference this and then respond.
-
Debug better: Having the current state can help pinpoint issues or troubleshoot unexpected behavior.
-
Manage transitions: Use state changes to notify other objects when moving between different scenes or levels in a game.
-
Pause the game: Imagine creating a global pause state that doesn’t need to set the TimeScale to 0. By referencing the SequenceManager’s state machine, anything “pausable” can listen to the current state of the game.
-
Handle events: The AbstractState includes an Enter, Exit, and Execute method. Use these to run a single event when starting or ending the state, or execute some logic every frame.
Events can do anything that you need them to do, e.g. play a sound or a visual effect. You’re only limited by your creativity.
Further reading
The specific implementation of QuizU’s state machine is adapted from the Runner Template project. This is a feature-complete state machine ready to be used in your own project. See the original Runner Template (available in the Unity Hub) for more example states and link transitions.
If you’re interested in more software design patterns, see the free e-book Level up your code with game programming patterns. Or, Check out these articles on design patterns:
- Object pooling
- The state pattern
- The factory pattern
- The observer pattern
- The MVP/MVC pattern
- The command pattern
You can also find more advanced best practices guides in the documentation.
Thanks for reading! Our next post in this series explains how to manage menu screens in UI Toolkit.