tutorial • 6 min read

Stack-based Menu Systems

Related Content

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:

  1. I like having a central system for most of my menus (HUD is usually separate) as it makes tracking things extremely easy.
  2. Separate menus that share a common interface promote less rigid patterns with dozens of references all over the place.
  3. I want each Menu to handle a single, presentation-related task, rather than core logic directly.
  4. 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:

  1. UI Toolkit: exclusively target Unity’s ‘modern’ (albeit half-baked) UI solution
  2. Singleton-based MenuManager: while Singleton hate is often warranted, I know that this will truly be a single instance deal
  3. Base Menu Class: common functionality is shared/abstracted

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();
}

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.