A Bodeville guide to Unity game architecture, part 2: The State Tree

Bo Boghosian
5 min readOct 31, 2023

--

[This is a crosspost from our game development blog at Bodeville. Check out more of our devlogs there!]

I began my software career as a server engineer. In backend servers, the fundamental interaction is a network request. A structured request comes in, and a structured response goes out. It’s simple. All serving frameworks (think java servlets, nginx) are written for this model. As long as your code is fast enough to repeat that simple operation hundreds or thousands of times, you’re good.

Game code has no such unifying abstraction. The first issue is this thing called a “player” that can give whatever input they’d like. They can follow your tutorials as intended, but they also can run away and explore the world in whatever order they’d like. On top of that, a game may have dozens of NPCs, a massive scene with hundreds of interactable objects, GUIs and menus galore, physics, etc, etc.

I think this is the fundamental reason why game code is so much harder to structure. There isn’t always a simple way to model a game’s lifecycle. It’s sheer chaos.

That said, I still like to structure games as rigidly as possible using states. I do this to make the game less buggy, but that’s an indirect effect. The main benefit of using a rigid state system is the understandability of the code. If your code is understandable, you’ll write fewer bugs, you’ll write faster, and you’ll write more confidently.

When I say “understandable,” I’m not referring to line-by-line readability — that’s a different story. I’m talking about a broader, more abstract understanding of all the high level systems and components and how they work with each other.

The state tree

A state tree is a common pattern in games. It’s a way to keep a game structured by using, well, a tree. The more I’ve leaned on state trees in my career, the better results I’ve seen.

Anyway, here’s my state tree. I actually wrote this originally for Chief Emoji Officer, and ported it to subsequent projects.

public abstract class State {

protected State substate; // null if this state is currently the leaf
protected Singletons singletons;

protected State(Singletons singletons) {
this.singletons = singletons;
}

public abstract void EnterState();
public abstract void ExitState();

public void SetSubstate(State substate) {
ExitAllSubstates();
this.substate = substate;
Debug.Log("Entering substate " + substate + "@" + GetHashCode());
substate.EnterState();
}

// Recursively exits all substates of this, making it so this state is
// the current leaf node.
public void ExitAllSubstates() {
if (substate != null) {
substate.ExitAllSubstates();
substate.ExitState();
Debug.Log("Exiting substate " + substate + "@" + GetHashCode());
substate = null;
}
}
}

That’s it! That’s the key to my entire game architecture. It’s not a huge complicated system. It’s a single class and about 35 lines of code.

But the benefit doesn’t derive from the single class per se; it’s a result of organizing and structuring your game code in a consistent, understandable, and rational way.

Let’s dig in a little bit with an example. Here are the states in Grift, as currently prototyped. I even keep the state tree hierarchical on the file system to make my life a little easier.

There’s a RootState with two sub-states, a MainMenuState and a GameplayState. Even if your game just has these two states, you might find it’s a real improvement. In fact, Chief Emoji Officer only had four State implementations — RootState, IntroState (for menus), GameplayState, and OptionsState.

In Grift we make heavier use of the state system. GUI-heavy games tend to work nicely with this state system because they have a rigid series of state transitions. Open-world games which are more decentralized are likely to have fewer states, but this model can also be adapted to give states to individual characters themselves.

Here’s an example of a state from Grift:

public class ViewBotProfileState : State {

private Bot bot;
private Action onBack;

public ViewBotProfileState(Singletons singletons, Bot bot, Action onBack) :
base(singletons) {
this.bot = bot;
this.onBack = onBack;
}

public override void EnterState() {
var accessoryGmts = singletons.GmtLoader.LoadAll<AccessoryGmt>(
new AccessoryTypeDefinition());

singletons.grift.MainWindow.Profile.gameObject.SetActive(true);
singletons.grift.MainWindow.Profile.ShowBotProfile(bot, accessoryGmts);

singletons.grift.BottomBar.SetOnlyBackButton();
singletons.grift.BottomBar.Back.Button.onClick.AddListener(OnBackButton);
}

private void OnBackButton() {
onBack();
}

public override void ExitState() {
singletons.grift.BottomBar.Back.Button.onClick.RemoveListener(OnBackButton);
singletons.grift.BottomBar.SetMainButtonsMode();
singletons.grift.MainWindow.Profile.gameObject.SetActive(false);
}
}

This is a state the player enters when they’re looking at a bot’s profile. The EnterState() method does all the legwork. Then, ExitState() essentially undoes that work when the state is finished.

It’s highly recommended to clean up in ExitState() whatever you did in EnterState(). This way, states won’t have any side effects. For instance, you’ll see we call AddListener(OnBackButton) upon entering the state and RemoveListener(OnBackButton) when exiting the state. When your entire state system is side effect free, you can be quite confident in the current state of the world by looking at the current hierarchy of states.

For the ViewBotProfileState above, the state tree is

RootState
-> GameplayState
-> PeopleTabState
-> ViewProfileState

RootState doesn’t do much — it just loads the single save file and opens gameplay state. In the future, I’ll probably add a MainMenuState in between RootState and GameplayState without needing to change a single line of code related to the gameplay itself.

GameplayState resets the GUI to its initial state, initializes the game based on the save data, and begins running the main simulation coroutine.

In PeopleTabState, the people tab is open and the game shows a list of potential scam targets.

In ViewProfileState, you’ve clicked into one of these people’s profiles and it’s showing the profile.

The really beautiful part of state trees is how easy they tear down. Say you wanted to go back to the main menu. All you need is a single call on the root state!

RootState.SetSubstate(new MainMenuState(singletons));

SetSubstate will recursively exit all substates and clean up everything they did, like removing click listeners, and stopping the game simulation. Then, it will open MainMenuState to do its thing.

Passing data between game states

The way I solved this isn’t necessarily the best or most beautiful solution, but it’s simple enough. I pass any data needed for the state in the constructor.

 public ViewBotProfileState(Singletons singletons, Bot bot, Action onBack) : base(singletons) {
this.bot = bot;
this.onBack = onBack;
}

Singletons and Bot are the initialization data for the state. That’s all that’s necessary for EnterState() to do its work. I’ll have more on Singletons a little later on.

The callback onBack gets called when the state clicks the back button from this state. This is a simple state with just one exit mechanism. But more complicated states can have multiple callbacks, including callbacks that pass data.

Each state instance is only used one time. They’re regular C# classes (not Monobehaviours) and they are cheap, so I have no performance concerns with throwing them away after one use. This is how I get away with using constructors and instance variables in states.

The callback approach delegates out to the parent state for handling when important things happen in the substate. In the callback, the parent class would typically call ExitAllSubstates along with any other game or UI logic that has to happen now that the former parent state is the new leaf state.

That’s it for game states. Like I said, you might find them more relevant in some types of games than others, but when used properly they can make a huge impact in structuring your code and improving its understandability. Practically, that means faster development time and fewer bugs.

--

--

Bo Boghosian

Co-founder, Bodeville. Chief Emoji Officer available for steam wishlist! http://tinyurl.com/chiefemoji