UI Framework

A panel stack for menus, HUD, and dialogs with focus memory, gamepad navigation, and modal pause.

Menus, HUD, and dialogs as a stack of panels · Focus memory, gamepad navigation, modal pause · No-op replacement: NullPanelStack

CGS manages every screen your game shows — menus, HUD, overlays, dialogs — through IPanelStack: a stack of panels. Push a panel with _ui.Push(panel) and it becomes the active input target; pop it with _ui.Pop() and focus returns to the panel below. The stack handles the plumbing that usually turns menu code into spaghetti:

  • Focus memory. A covered panel saves its focus state (which button was highlighted, scroll position) and restores it when revealed again.
  • Input routing. Every push switches the input context, so an open menu never intercepts gameplay buttons — and vice versa.
  • Modal pause. A panel marked modal (one that blocks the game behind it) can freeze game time while UI time keeps flowing. Dialogs animate; the world stops.
  • Gamepad and keyboard navigation. Arrow keys and the D-pad move focus, with key repeat when held.

All panels share one input language: Esc or the gamepad B button goes back, arrows or the D-pad move focus, Enter or the A button confirms.

The implementation ships in the optional CommonGameSystem.UI assembly. It needs Unity's Input System package and the built-in uGUI package. If either package is removed, the assembly excludes itself and the panel stack becomes a safe no-op — the rest of CGS still boots.

Menus not responding to any key or button? The panel stack routes navigation through Unity's Input System, so it inherits the same project-setting requirement as the Input service: Active Input Handling must be Input System Package (New) or Both. On a fresh project it is still Input Manager (Old) and every panel ignores input. The Welcome window (Tools > Common Game System > Welcome) fixes it with one click — see the FAQ.

Getting the service

Resolve in Awake() or Start() on a MonoBehaviour marked [DefaultExecutionOrder(100)]:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class UiController : MonoBehaviour
{
    private IPanelStack _ui;

    private void Awake()
    {
        _ui = ServiceLocator.Resolve<IPanelStack>();
    }
}

Cache the reference and call methods on it. Never resolve inside Update().

API reference

Stack queries

int Depth { get; }        // Current stack depth (0 when empty)
IPanel ActivePanel { get; } // Top panel, or null if the stack is empty

Push, pop, and pop-to-root (main thread only)

void Push(IPanel panel)

Puts the panel on top. The covered panel gets SaveFocus() and then OnSuspended(). The new panel gets OnPushed(), receives initial focus, and gains its own input context. Throws ArgumentNullException if panel is null; logs a warning and does nothing if the panel is already on the stack or the stack is at maximum depth.

bool Pop()

Removes the top panel. It gets OnPopped() and its input context is disposed. The revealed panel gets OnResumed() and then RestoreFocus(). Returns false on an empty stack, true on success. Throws if called again while a push or pop is still in progress.

void PopToRoot()

Pops everything except the bottom panel. Middle panels receive OnPopped() only (no OnSuspended()). The bottom panel receives OnResumed() and then RestoreFocus(). Does nothing if the depth is 1 or less.

Dispatching input

Your input pipeline (typically a handler on an InputAction) forwards UI input to the stack:

void DispatchAction(PanelAction action)

Routes Confirm, Back, AltPrimary, or AltSecondary to the active panel. By default, Back pops the panel automatically. A panel with InterceptsBackAction = true gets HandleBack() instead and can block the pop (an unsaved-changes prompt, for example). Confirm, AltPrimary, and AltSecondary always call the panel's OnAction().

void DispatchFocus(Vector2 dirVec)

Routes D-pad, left-stick, or arrow-key input to the active panel's OnFocusRequested(). Diagonal input snaps to the dominant axis (up-left becomes up); near-zero vectors are ignored.

What a panel implements (IPanel)

Your screens implement IPanel. The stack calls these members — you never call them yourself:

MemberWhen it is called
OnPushed()Right after Push; the panel is now active. Show yourself, register listeners.
OnSuspended()Another panel was pushed on top. Save transient state, hide if appropriate.
OnResumed()The panel above popped; this panel is active again.
OnPopped()This panel was popped off the stack. Unregister listeners, hide yourself.
GetInitialFocus()During Push, after OnPushed(). Return the element to focus first (usually the first button), or null.
OnFocusRequested(FocusDirection dir)On arrow or D-pad input (Up/Down/Left/Right). Wrap-around is your responsibility.
SaveFocus()On the panel being covered, just before OnSuspended(). Return a FocusSnapshot struct capturing the focus state.
RestoreFocus(FocusSnapshot snapshot)On the revealed panel, just after OnResumed().
OnAction(PanelAction action)For Confirm/AltPrimary/AltSecondary input. Back goes through HandleBack() instead.
HandleBack()Only when InterceptsBackAction is true. Return true to block the pop, false to allow it.

And these properties describe the panel:

PropertyMeaning
IsModalIf true (and the autoPauseOnModal option is on), pushing this panel pauses the Gameplay clock. Game time freezes; the UI and Background clocks keep flowing.
InterceptsBackActionIf false (the default), Back pops the panel automatically. If true, HandleBack() runs first.
AccessibleLabelReserved for future screen-reader support; may be null.
TransitionDurationFade duration in seconds for this panel; null uses the stack's default.

Focus targets (IFocusable)

void Focus()             // Move focus to this element (e.g. Selectable.Select())
bool IsFocused { get; }  // Whether this element currently holds focus

Full example

A settings panel implemented with uGUI buttons, plus the controller that opens it:

using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.UI;

// A small adapter that lets the panel stack focus a uGUI Selectable.
public sealed class ButtonFocusable : IFocusable
{
    private readonly Selectable _target;

    public ButtonFocusable(Selectable target)
    {
        _target = target;
    }

    public void Focus() => _target.Select();

    public bool IsFocused =>
        UnityEngine.EventSystems.EventSystem.current != null &&
        UnityEngine.EventSystems.EventSystem.current.currentSelectedGameObject == _target.gameObject;
}

[DefaultExecutionOrder(100)]
public class SettingsPanel : MonoBehaviour, IPanel
{
    [SerializeField] private Button _audioButton;
    [SerializeField] private Button _graphicsButton;
    [SerializeField] private Button _backButton;

    private IPanelStack _ui;
    private UnityEngine.Events.UnityAction _goBack;

    public bool IsModal => false;
    public bool InterceptsBackAction => false;
    public string AccessibleLabel => "Settings";
    public float? TransitionDuration => null;

    public void OnPushed()
    {
        _ui ??= ServiceLocator.Resolve<IPanelStack>();
        _goBack ??= () => _ui.Pop();
        _backButton.onClick.AddListener(_goBack); // register here...
        gameObject.SetActive(true);
    }

    public void OnPopped()
    {
        _backButton.onClick.RemoveListener(_goBack); // ...remove the SAME delegate here
        gameObject.SetActive(false);
    }

    public void OnSuspended() => gameObject.SetActive(false);

    public void OnResumed() => gameObject.SetActive(true);

    public IFocusable GetInitialFocus() => new ButtonFocusable(_audioButton);

    public void OnFocusRequested(FocusDirection dir)
    {
        // Move focus between buttons. Wrap-around is up to you.
        if (dir == FocusDirection.Down) _graphicsButton.Select();
        if (dir == FocusDirection.Up) _audioButton.Select();
    }

    public FocusSnapshot SaveFocus()
    {
        // FocusSnapshot is a plain struct — fill its fields directly.
        // Here we remember the focused Selectable by its instance id.
        var current = UnityEngine.EventSystems.EventSystem.current?.currentSelectedGameObject;
        return new FocusSnapshot
        {
            FocusedElementId = current != null ? current.GetInstanceID() : 0
        };
    }

    public void RestoreFocus(FocusSnapshot snapshot)
    {
        if (snapshot.FocusedElementId == _audioButton.gameObject.GetInstanceID()) _audioButton.Select();
        else _graphicsButton.Select();
    }

    public void OnAction(PanelAction action)
    {
        if (action == PanelAction.Confirm)
        {
            // Confirm pressed — the focused button receives the click.
        }
    }

    public bool HandleBack() => false; // Never called while InterceptsBackAction is false.
}

Opening the panel from anywhere in your game:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class PauseMenuController : MonoBehaviour
{
    [SerializeField] private SettingsPanel _settingsPanel;

    private IPanelStack _ui;

    private void Awake() => _ui = ServiceLocator.Resolve<IPanelStack>();

    public void OpenSettings() => _ui.Push(_settingsPanel);
}

Turning it off

ServiceLocator.Replace<IPanelStack>(new NullPanelStack());

This disables the UI framework entirely. Push and Pop become silent no-ops, Depth always returns 0, and ActivePanel always returns null. No input contexts are pushed, no time is paused, no events are published, nothing is logged. Use this when you ship your own UI system.

Common pitfalls

  • Menus ignore all input. Nine times out of ten this is the Active Input Handling project setting — see the callout at the top of this page and the FAQ.

  • Main thread only. All public methods and properties throw InvalidOperationException when called from a worker thread. Add [DefaultExecutionOrder(100)] to MonoBehaviours that resolve the service, so the automatic bootstrapper finishes registering before your Awake() runs.

  • Register in OnPushed(), deregister in OnPopped(). Add button listeners when the panel is pushed and remove the same delegate instance when it is popped. A forgotten removal means the next push adds a second handler and every click fires twice. This bites hardest in the Editor with Domain Reload disabled.

  • Modal pause is counted. A modal push pauses the Gameplay clock; the matching pop resumes it. The Time service counts pauses — two pauses need two resumes. If you also call Pause()/Resume() yourself, keep your own calls balanced or gameplay stays frozen.

  • Input contexts track the stack one-to-one. Every push adds an input context and every pop removes one. If you turn off the autoPushInputContext option, you must manage input contexts yourself or menu input will not route.

  • Your own saved-state types need link.xml on IL2CPP. FocusSnapshot itself is preserved by the framework, so the example above needs nothing extra. If your panel introduces its own custom value types for saved state, add them to your project's link.xml:

    <assembly fullname="YourGame">
      <type fullname="YourGame.MyCustomFocusState" preserve="all"/>
    </assembly>
    
  • Input — input contexts, action maps, and rebinding
  • Time — the clocks behind modal pause
  • Event Bus — publish/subscribe events
  • FAQ — the Active Input Handling fix, step by step