Overview
UI layout and rendering has always fascinated me, and one of the patterns I seem to always return to while making my games is the good-old Stack-based Menu system.
I’ve used this in several of my games, including Furlastic Duo, Axis Shift, and most recently, JellyFish, as well as several personal projects.
What’s a Stack?
A Stack is a linear data structure that uses a Last In First Out (LIFO) methodology. That is, the last element in is the first that’s removed. A stack of plates is a commonly cited analogy. The last element is also called the top of the stack.
Adding and Removing elements are called Pushing and Popping, respectively. If represented as an array, a Push
would (assuming there’s space) add an element to the end of the array, after the current last element. Similarly, a Pop
operation (assuming it’s not empty) would remove this last element, returning it.
There’s a third common operation, Peek
, which as the name implies, allows you to check the last element sneakily (not really) without removing it from memory. This ends up being extremely useful.
Why a Stack?
A Stack ends up representing most common UI navigation patterns extremely well. Navigating to another page, for example, is as simple as Push
-ing a new page on the stack. Similarly, returning to a previous one is a simple Pop
.
As long as a single menu is interactable at a given time (the menu on top of the stack), this makes for a simple but fairly versatile system.
For similar reasons, stacks are also often used to track Undo/Redo states, page navigation at both the browser and sometimes website level, and windowing systems.
Why make a Menu System to begin with?
Well, there’s a few reasons:
- I like having a central system for most of my menus (HUD is usually separate) as it makes tracking things extremely easy.
- Separate menus that share a common interface promote less rigid patterns with dozens of references all over the place.
- I want each Menu to handle a single, presentation-related task, rather than core logic directly.
- Debugging Menu state is as easy as logging current Menu stack.
Implementation Overview
Over time, I’ve developed a rough framework for how I like to set up my menu systems. For the purposes of this article, I’ll focus on a concrete Unity implementation, but it’s pretty similar in whatever.
To ensure brevity, I’ll work within the following constraints:
- UI Toolkit: exclusively target Unity’s ‘modern’ (albeit half-baked) UI solution
- Singleton-based MenuManager: while Singleton hate is often warranted, I know that this will truly be a single instance deal
- Base Menu Class: common functionality is shared/abstracted
Menu
The base Menu
class is fairly straightfoward, with public Show()
, Hide()
, and Refresh()
functions, as well as an abstract BuildUI()
that inheritors implement.
A skeleton Menu
might look something like this:
public abstract class Menu : MonoBehaviour
{
private void Initialize() { } // Lazy Initialization
protected abstract void BuildUI();
public void Refresh() { } // Calls Initialize() and BuildUI()
public virtual void Show() { } // Calls Refresh()
public virtual void Hide() { }
}
Initialization
I like to have the option of lazily initializing the Menu, if required. This consists of setting up a reference to the UI Document, and usually, stylesheet setup.
[RequireComponent(typeof(UIDocument))]
public abstract class Menu : MonoBehaviour
{
[SerializeField] protected StyleSheet style;
protected UIDocument Document { get; private set; }
protected VisualElement Root => Document.rootVisualElement;
private bool _isInitialized;
private void Initialize()
{
if (_isInitialized) return;
Document = GetComponent<UIDocument>();
if (style is not null && !Root.styleSheets.Contains(style))
{
Root.styleSheets.Add(style);
}
_isInitialized = true;
}
...
}
The RequireComponent
makes Editor setup easier, and prevents misconfiguration. I tend to use the RequireComponent
+ GetComponent
pattern fairly frequently in my work.
Refresh
To ensure up-to-date information is displayed, Menus have a public Refresh()
method that rebuilds UI after initializing the Menu, if required.
public void Refresh()
{
Initialize();
BuildUI();
}
Showing and Hiding Menus
Showing and hiding Menus is similarly straightforward, although I like having Unity Events (that I can configure in-editor) for when I show or hide a Menu.
public virtual void Show()
{
var wasActive = gameObject.activeSelf;
gameObject.SetActive(true);
Refresh();
if (wasActive) return;
onMenuShow?.Invoke();
}
public virtual void Hide()
{
if (!gameObject.activeSelf) return;
gameObject.SetActive(false);
onMenuHide?.Invoke();
}
MenuManager
At it’s core, the MenuManager
is basically just:
public class MenuManager : PersistentSingleton<MenuManager>
{
private const int MAX_MENU_STACK_DEPTH = 32;
private readonly Stack<Menu> _menuStack = new(MAX_MENU_STACK_DEPTH);
public void Push(Menu menu) { }
public bool Pop() { }
}
PersistentSingleton?
When I do use Singletons, I like having a single (or a few, in this case) templated Singleton implementations for consistent access.
This is what my PersistentSingleton
looks like in Axis Shift:
public abstract class PersistentSingleton<T> : Singleton<T> where T : Component
{
protected override void Awake()
{
if (_instance is null)
{
_instance = this as T;
DontDestroyOnLoad(gameObject);
}
else if (_instance != this)
{
Debug.LogWarning($"Duplicate instance of {typeof(T)} destroyed!", this);
Destroy(gameObject);
}
}
}
Similarly, Singleton
:
public abstract class Singleton<T> : MonoBehaviour where T : Component
{
protected static T _instance;
public static T Instance
{
get
{
_instance ??= FindAnyObjectByType<T>();
if (_instance is not null) return _instance;
var go = new GameObject(typeof(T).Name);
_instance = go.AddComponent<T>();
return _instance;
}
}
public static bool HasInstance() => _instance is not null;
protected virtual void Awake()
{
if (_instance is null)
{
_instance = this as T;
}
else if (_instance != this)
{
Debug.LogWarning($"Duplicate instance of {typeof(T)} destroyed!", this);
Destroy(gameObject);
}
}
protected void OnDestroy()
{
if (_instance == this) _instance = null;
}
}
Showing a new Menu
Pushing a Menu simply adds it on top of the Stack, if there’s space, and shows it, hiding the menu underneath:
public void PushMenu(Menu menu)
{
if (_menuStack.Count >= MAX_MENU_STACK_DEPTH)
{
Debug.LogWarning($"Menu stack limit reached! Cannot push new menu {menu}");
return;
}
menu.Show();
if (_menuStack.TryPeek(out var currentMenu))
{
currentMenu.Hide();
}
_menuStack.Push(menu);
}
Notably, I Show()
the new Menu before calling Hide()
on the previous one to prevent popping. An intermediary screen (I’ve used a full-screen Canvas in the past) or a transition could also go here.
Returning to a Previous Menu
Similarly, Popping the newest Menu off the Stack returns us to the previous state.
public bool Pop()
{
if (!_menuStack.TryPop(out var oldMenu)) return false;
if (_menuStack.TryPeek(out var currentMenu)) currentMenu.Show();
oldMenu.Hide();
return true;
}
It also returns a boolean, indicating whether the operation was successful.
Creating a Menu
Here’s a simplified Main Menu:
public class MainMenu : Menu
{
protected override void BuildUI()
{
var buttons = Root.Q("Buttons");
buttons.Clear();
var newGameButton = CreateMainMenuButton(buttons, "START GAME", "Start a New Game");
newGameButton.Focus();
newGameButton.ApplyClickCallbacks(_ =>
{
GameManager.Instance.ChangeState(new PlayState());
newGameButton.SetEnabled(false);
});
var optionsButton = CreateMainMenuButton(buttons, "SETTINGS", "Modify Game Settings");
optionsButton.SetEnabled(false);
var creditsButton = CreateMainMenuButton(buttons, "CREDITS", "Game Credits");
creditsButton.SetEnabled(false);
var quitButton = CreateMainMenuButton(buttons, "EXIT TO DESKTOP", "Quit Game");
quitButton.ApplyClickCallbacks(_ => Application.Quit());
}
private Button CreateMainMenuButton(VisualElement buttonContainer, string label, string tooltip = null)
{
var buttonHelp = Root.Q<Label>("ButtonHelp");
var button = buttonContainer.Create<Button>("main-menu_option");
button.text = label;
button.RegisterCallback<FocusEvent>(_ => buttonHelp.text = tooltip);
return button;
}
}
The callbacks do need to be tracked and discarded when required to prevent a memory leak, but this makes for a pretty flexible system overall.
Is that it?
Well, not quite. It varies by game, and I do slightly different things each time, like Build Version Text for Axis Shift and Adaptive Controller-aware Sprites for Furlastic Duo. I also usually allow optional async (via UniTask).
Adding async capabilities (and making it thread-safe) are left as exercises to the reader.