12.2 HUD, Menus & Inventory UI

Phase 12 · UI / UX Programming · Study time: 25–45 h

The screens gacha and RPG games live on — HUDs, complex menus, grids and inventory UI that stay fast with hundreds of items.

The previous chapter (UI Systems) covered the plumbing: Canvas, RectTransform, anchors, and wiring a Button's OnClick to a method. That plumbing is not the interesting part of a real game's UI. The interesting part is keeping dozens of moving pieces — a health bar, a stack of menus, an inventory with hundreds of items — in sync with data that changes constantly, without the UI code turning into a mess of manual updates scattered everywhere. This chapter covers three UI systems almost every game needs: a HUD (heads-up display — the always-visible overlay showing health, mana, ammo, and so on), a menu system you can navigate into and back out of, and an inventory screen that can hold anywhere from ten items to several hundred. The same one idea solves all three: UI should be a reflection of data, updated when the data changes, never rebuilt from scratch every frame.

1. The One Idea: UI Reflects Data

Picture the laziest possible way to keep a health bar correct: check the player's health every single frame and redraw the bar.


// WORKS, but is the wrong habit to build.
void Update()
{
    healthBarImage.fillAmount = (float)player.CurrentHealth / player.MaxHealth;
}

For one bar, this genuinely does not matter — modern CPUs do this division and assignment for free 60 times a second. The problem shows up once this habit spreads. A game with a health bar, a mana bar, an ammo counter, a minimap, a quest tracker, and an inventory grid, each polling (repeatedly checking) its own data source every frame, adds up. Worse, polling scales badly the moment the data is not one number: an inventory grid that re-reads and re-draws all 40 slots every frame, just in case one of them changed, wastes work on the 39 that did not.

The fix is not a clever optimization — it is a different habit: the data announces when it changes, and the UI listens. This is the Observer pattern (an earlier chapter's term: a publisher announces something happened, and any number of subscribers react, without the publisher knowing who they are) applied specifically to UI. In this chapter, "the data" is things like a player's current health, an inventory slot's contents, or which menu screen is on top. "The UI" is things like a fill bar, a grid of item icons, or a visible panel. The rule for this entire chapter is:

Tip A UI element should update only when the thing it displays actually changes, triggered by an event — never by checking "did anything change?" every frame just in case.
Polling (what to avoid): Update() -- every frame, forever --> "did health change?" --> redraw Event-driven (what this chapter teaches): TakeDamage() -- only when it happens --> OnHealthChanged event --> redraw The event-driven version does zero work on the 59 frames out of 60 where nothing changed.

The rest of this chapter builds a HUD, a menu system, and an inventory UI, all using this one idea, plus one more idea for when there is a lot of data: do not create a UI object for every piece of data, only for the pieces currently visible. That second idea is called UI virtualization, covered in Section 9.

2. HUD Basics: A Health Bar Driven by Events

Start with the data. A PlayerStats component owns the actual health number and raises an event whenever it changes — it does not know or care whether a bar, a number, or a screen-flash effect is listening.


using UnityEngine;

public class PlayerStats : MonoBehaviour
{
    // (current, max) -- both values travel together so a listener
    // never has to ask "wait, max compared to what?"
    public event System.Action<int, int> OnHealthChanged;

    [SerializeField] private int maxHealth = 100;
    private int currentHealth;

    public int CurrentHealth => currentHealth;
    public int MaxHealth => maxHealth;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth = Mathf.Clamp(currentHealth - amount, 0, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }

    public void Heal(int amount)
    {
        currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }
}

Now the bar itself. Unity's Image component has an Image Type called Filled (in the Inspector): instead of drawing the whole sprite, it draws a fraction of it based on a 0-to-1 number called fillAmount. Set the Image Type to Filled and the Fill Method to Horizontal in the Inspector, then drive that number from code:


using UnityEngine;
using UnityEngine.UI;

public class HealthBarUI : MonoBehaviour
{
    [SerializeField] private PlayerStats stats;
    [SerializeField] private Image fillImage; // Image Type = Filled

    void OnEnable()
    {
        stats.OnHealthChanged += UpdateBar;

        // Sync immediately -- otherwise the bar shows empty (its default
        // Inspector value) until the very first TakeDamage() call.
        UpdateBar(stats.CurrentHealth, stats.MaxHealth);
    }

    void OnDisable()
    {
        stats.OnHealthChanged -= UpdateBar;
    }

    void UpdateBar(int current, int max)
    {
        fillImage.fillAmount = (float)current / max;
    }
}

Expected result: call stats.TakeDamage(30) on a full 100-hp player. Inside TakeDamage, currentHealth becomes 70, and OnHealthChanged?.Invoke(70, 100) fires. HealthBarUI.UpdateBar runs once, sets fillImage.fillAmount = 0.7f, and the bar visually shows 70% full. No other code ran — not 59 wasted checks on the frames where nothing happened, exactly one update on the one frame where something did.

PlayerStats.TakeDamage(30) | v currentHealth = 70 (data changes first) | v OnHealthChanged?.Invoke(70, 100) (data announces the change) | v HealthBarUI.UpdateBar(70, 100) (UI reacts, sets fillAmount = 0.7) PlayerStats never mentions HealthBarUI by name. It would fire the exact same event with zero listeners subscribed, and nothing would break.
Common mistake Subscribing in OnEnable but forgetting the immediate sync call afterward. The bar will show whatever value it had in the editor (often 0 or 1) until the next real health change, which can be seconds into a level — long enough for a player to see a wrong or empty bar on a full-health character.

3. A Reusable Stat Bar Component (Health and Mana)

A mana bar needs the exact same behavior as a health bar: given a current and a max, show a fraction filled. Writing a second, near-identical ManaBarUI script would duplicate that logic. Instead, make the bar's script generic about what it displays, and let something else wire it to the right data source.


using UnityEngine;
using UnityEngine.UI;

// Knows nothing about health or mana specifically -- just
// "show this fraction filled." Works with Unity's Slider too.
public class StatBarUI : MonoBehaviour
{
    [SerializeField] private Slider slider; // has built-in min/max/value

    public void Bind(int current, int max)
    {
        slider.minValue = 0;
        slider.maxValue = max;
        slider.value = current;
    }
}

A small HudController owns the wiring: it knows that this bar shows health and that bar shows mana, but neither bar knows that about itself.


using UnityEngine;

public class HudController : MonoBehaviour
{
    [SerializeField] private PlayerStats stats;
    [SerializeField] private ManaSystem mana;      // same shape as PlayerStats
    [SerializeField] private StatBarUI healthBar;
    [SerializeField] private StatBarUI manaBar;

    void OnEnable()
    {
        stats.OnHealthChanged += healthBar.Bind;
        mana.OnManaChanged    += manaBar.Bind;

        healthBar.Bind(stats.CurrentHealth, stats.MaxHealth);
        manaBar.Bind(mana.CurrentMana, mana.MaxMana);
    }

    void OnDisable()
    {
        stats.OnHealthChanged -= healthBar.Bind;
        mana.OnManaChanged    -= manaBar.Bind;
    }
}

Expected result: adding a third bar (stamina, an ultimate-ability charge meter, a boss's health bar) never means writing a new bar script — it means dropping another StatBarUI on the Canvas and adding two lines to HudController. The presentation (a filled bar) is fully decoupled from the data source (health, mana, or anything else shaped like "a current number out of a max").

When to use it: any time two or more UI elements would otherwise be copy-pasted with only the data source different. When not to: a one-off HUD element with genuinely unique display logic (a compass needle, a damage-number popup) does not benefit from forcing it into a shared "bar" shape.

4. The Menu Stack: Screens as a Push/Pop State Machine

A game's screens rarely form a straight line. From gameplay you can open a pause menu; from the pause menu you can open settings; from settings you can back out to the pause menu, and from there back out to gameplay — and pressing one single "Back" button (or Escape, or a gamepad's B button) should always do the locally correct thing, no matter how deep you are.

The right data structure for this is a stack (an earlier data structures chapter's term: a last-in-first-out list — you can only add to or remove from the top). Opening a new screen pushes it onto the stack; the "Back" action always pops the top of the stack, revealing whatever was underneath. This makes the whole menu system a UI state machine (a system always in exactly one visible configuration, moving between configurations by explicit rules) where "the current state" is simply "whatever is on top of the stack."

Stack (drawn top to bottom, top = active + visible): Push(SettingsMenu) Push(PauseMenu) start -------------------- -------------------- ---------------- [ SettingsMenu ] [ PauseMenu ] [ HUD (base) ] [ PauseMenu ] [ HUD (base) ] [ ] [ HUD (base) ] [ ] [ ] Pop() always removes the top and reveals what's underneath it -- the same Pop() works whether you are 1 screen deep or 5.

The transitions between specific screens can be drawn as a state diagram, the same style used for enemy AI in an earlier chapter:

HUD --(Esc pressed)--------> PauseMenu [push] PauseMenu --(Resume button)------> HUD [pop] PauseMenu --(Settings button)----> SettingsMenu [push] SettingsMenu --(Back button)--------> PauseMenu [pop] PauseMenu --(Quit button)--------> ConfirmQuit [push] ConfirmQuit --(Yes)----------------> (Application.Quit) ConfirmQuit --(No / Esc)-----------> PauseMenu [pop]

Notice that "Back" is not a different action on every screen — it is always the same Pop() call. That is the entire point of modeling menus as a stack instead of as a web of screens that each manually remember "who opened me."

5. Implementing the Menu Stack: Pause Menu and Back Button

Every screen implements a small base class so the stack manager can treat all screens the same way:


using UnityEngine;

public class UIScreen : MonoBehaviour
{
    // Called when this screen becomes the top of the stack.
    public virtual void OnPush() { gameObject.SetActive(true); }

    // Called when this screen is popped off the stack.
    public virtual void OnPop() { gameObject.SetActive(false); }

    // Called when another screen is pushed ON TOP of this one.
    public virtual void OnCovered() { }

    // Called when the screen above this one is popped, making
    // this one the top again.
    public virtual void OnRevealed() { }
}

using System.Collections.Generic;
using UnityEngine;

public class UIStackManager : MonoBehaviour
{
    private readonly Stack<UIScreen> stack = new Stack<UIScreen>();

    public void Push(UIScreen screen)
    {
        if (stack.Count > 0)
        {
            stack.Peek().OnCovered();
        }
        stack.Push(screen);
        screen.OnPush();
    }

    public void Pop()
    {
        if (stack.Count == 0) return;

        UIScreen top = stack.Pop();
        top.OnPop();

        if (stack.Count > 0)
        {
            stack.Peek().OnRevealed();
        }
    }

    void Update()
    {
        // One shared "Back" input for the whole game, instead of
        // every screen writing its own Escape-key handling.
        if (Input.GetKeyDown(KeyCode.Escape) || Input.GetButtonDown("Cancel"))
        {
            Pop();
        }
    }
}

A pause menu screen uses the base class to freeze and unfreeze gameplay:


using UnityEngine;

public class PauseMenu : UIScreen
{
    [SerializeField] private UIStackManager uiStack;
    [SerializeField] private UIScreen settingsScreen;

    public override void OnPush()
    {
        base.OnPush();
        Time.timeScale = 0f; // freeze gameplay while the menu is open
    }

    public override void OnPop()
    {
        base.OnPop();
        Time.timeScale = 1f; // resume gameplay
    }

    // Wired to the Resume button's OnClick in the Inspector.
    public void OnResumeButton()
    {
        uiStack.Pop();
    }

    // Wired to the Settings button's OnClick in the Inspector.
    public void OnSettingsButton()
    {
        uiStack.Push(settingsScreen);
    }
}

Expected result, traced step by step: player presses Esc during gameplay. UIStackManager.Update() calls Pop(), but the stack is empty of menus, so nothing happens (this game should instead call Push(pauseMenu) from a gameplay script on Esc — the stack manager's own Esc handling is for going back, not opening the first menu). Suppose gameplay code already called uiStack.Push(pauseMenu): PauseMenu.OnPush() runs, setting Time.timeScale = 0f and showing the panel. Player clicks Settings: OnSettingsButton() calls Push(settingsScreen), which calls pauseMenu.OnCovered() first (pause menu could dim itself here) then shows settings. Player presses Esc: Pop() removes settings, calls its OnPop(), then calls pauseMenu.OnRevealed() — the pause menu is visible again, exactly where the player left it. Press Esc once more: Pop() removes the pause menu, Time.timeScale returns to 1, gameplay resumes.

Tip Because Pop() is the same call everywhere, a "Back" button's OnClick in the Inspector can point at the exact same UIStackManager.Pop method on every single screen prefab. You do not write a new "go back" method per screen.

6. Inventory Data: The Source of Truth

Before drawing a single inventory slot, build the data it will display. This matters more than it sounds: if the UI script itself stores "what item is where," the data only exists as long as the UI GameObjects exist, and saving/loading or checking "does the player have a health potion?" from gameplay code becomes awkward. Keep the data as plain C# — no MonoBehaviour required for the data itself.


using UnityEngine;

// A ScriptableObject is a data asset that lives in the project,
// not on a scene GameObject -- one asset per item type, shared
// by every stack of that item anywhere in the game.
[CreateAssetMenu(menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    public string itemId;
    public string displayName;
    [TextArea] public string description;
    public Sprite icon;
    public int maxStack = 99;
}

using System;

// One slot: which item (null = empty) and how many.
[Serializable]
public class InventorySlotData
{
    public ItemData item;
    public int count;
}

using System;
using UnityEngine;

// Plain C# class -- not a MonoBehaviour. It does not need a
// GameObject to exist, which makes it far easier to save, load,
// and unit-test independently of any UI.
public class Inventory
{
    public event Action<int> OnSlotChanged; // which slot index changed

    private readonly InventorySlotData[] slots;

    public Inventory(int capacity)
    {
        slots = new InventorySlotData[capacity];
        for (int i = 0; i < capacity; i++)
        {
            slots[i] = new InventorySlotData();
        }
    }

    public int Capacity => slots.Length;
    public InventorySlotData GetSlot(int index) => slots[index];

    public bool AddItem(ItemData item, int amount)
    {
        // 1. Try to stack onto an existing slot of the same item.
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == item && slots[i].count < item.maxStack)
            {
                int room = item.maxStack - slots[i].count;
                int add = Mathf.Min(room, amount);
                slots[i].count += add;
                amount -= add;
                OnSlotChanged?.Invoke(i);
                if (amount == 0) return true;
            }
        }

        // 2. Drop whatever is left into the first empty slot.
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == null)
            {
                slots[i].item = item;
                slots[i].count = amount;
                OnSlotChanged?.Invoke(i);
                return true;
            }
        }

        return false; // bag is full
    }

    public void MoveSlot(int from, int to)
    {
        InventorySlotData temp = slots[from];
        slots[from] = slots[to];
        slots[to] = temp;

        OnSlotChanged?.Invoke(from);
        OnSlotChanged?.Invoke(to);
    }
}

Expected result: calling inventory.AddItem(healthPotion, 5) on an empty inventory skips the stacking loop (no existing stack of health potions yet), finds the first empty slot (index 0), sets slots[0].item = healthPotion, slots[0].count = 5, and fires OnSlotChanged(0). Calling it again with 3 more potions finds the existing stack at index 0, adds 3 to it (now 8), and fires OnSlotChanged(0) again — no new slot used, because they stacked.

Notice the shape: Inventory raises OnSlotChanged(int index) the same way PlayerStats raised OnHealthChanged(int, int) in Section 2. Same idea — data changes, data announces it — just with an index instead of a value, because here many independent "slots" of data exist instead of one health number.

7. The Inventory Grid UI: Binding Slot Views to Data

A slot view is a small script on a UI prefab that knows how to display one InventorySlotData — nothing more. It does not know about the rest of the inventory, and it never reaches out and asks for data; data is handed to it.


using UnityEngine;
using UnityEngine.UI;

public class InventorySlotView : MonoBehaviour
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText; // TMP_Text in a real project

    public void Bind(InventorySlotData data)
    {
        if (data.item == null)
        {
            icon.enabled = false;
            stackCountText.text = "";
            return;
        }

        icon.enabled = true;
        icon.sprite = data.item.icon;
        // Hide the "1" on single items -- only stacks need a count shown.
        stackCountText.text = data.count > 1 ? data.count.ToString() : "";
    }
}

For a small bag (say, 30-40 slots — a typical player backpack, not an endgame stash), the grid can simply build one view per slot, once, and then only re-bind the specific slot that changed:


using UnityEngine;

public class InventoryGridUI : MonoBehaviour
{
    [SerializeField] private Inventory inventory;       // data, Section 6
    [SerializeField] private InventorySlotView slotPrefab;
    [SerializeField] private Transform gridParent;       // has a Grid Layout Group

    private InventorySlotView[] views;

    void Start()
    {
        // Build the view objects ONCE. This runs a handful of times,
        // not every frame and not every time an item is picked up.
        views = new InventorySlotView[inventory.Capacity];
        for (int i = 0; i < inventory.Capacity; i++)
        {
            views[i] = Instantiate(slotPrefab, gridParent);
            views[i].Bind(inventory.GetSlot(i));
        }
    }

    void OnEnable()  { inventory.OnSlotChanged += RefreshSlot; }
    void OnDisable() { inventory.OnSlotChanged -= RefreshSlot; }

    void RefreshSlot(int index)
    {
        views[index].Bind(inventory.GetSlot(index)); // re-bind ONLY this slot
    }
}

Expected result: the player picks up 5 health potions, landing in slot 3. Inventory.AddItem fires OnSlotChanged(3). InventoryGridUI.RefreshSlot(3) runs, calling views[3].Bind(...) — that one icon and count update. The other 29 or 39 slot views do nothing at all: no Bind call, no layout recalculation, because nothing about them changed. This is the Section 1 rule applied to a grid instead of a single bar: 40 independent pieces of UI, each updating exactly when — and only when — its own one piece of data changes.

8. Drag-and-Drop and Hover Tooltips

Drag-and-Drop Between Slots

Unity's UI event system exposes drag interfaces you implement on a component: IBeginDragHandler, IDragHandler, IEndDragHandler, and IDropHandler. A slot view can implement all four to support dragging an item onto another slot to swap them.


using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class InventorySlotView : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText;
    [SerializeField] private Image dragGhost; // one shared Image, top-level "Drag Layer" canvas

    public int SlotIndex { get; set; }
    private Inventory inventory;

    public void Init(Inventory inv, int index)
    {
        inventory = inv;
        SlotIndex = index;
    }

    public void Bind(InventorySlotData data) { /* same as Section 7 */ }

    public void OnBeginDrag(PointerEventData eventData)
    {
        InventorySlotData data = inventory.GetSlot(SlotIndex);
        if (data.item == null) return; // nothing to drag from an empty slot

        dragGhost.sprite = data.item.icon;
        dragGhost.enabled = true;
    }

    public void OnDrag(PointerEventData eventData)
    {
        dragGhost.transform.position = eventData.position; // follows the cursor
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        dragGhost.enabled = false;
    }

    public void OnDrop(PointerEventData eventData)
    {
        InventorySlotView draggedFrom =
            eventData.pointerDrag.GetComponent<InventorySlotView>();

        if (draggedFrom == null || draggedFrom == this) return;

        // Change the DATA. The view updates itself automatically,
        // because InventoryGridUI is already listening to OnSlotChanged.
        inventory.MoveSlot(draggedFrom.SlotIndex, SlotIndex);
    }
}

Expected result, traced: player presses the mouse down on slot 2 (a sword) and drags toward slot 5 (empty). OnBeginDrag fires on slot 2, showing the drag ghost icon. OnDrag repositions the ghost every frame the mouse moves. The mouse releases over slot 5: Unity's event system calls OnDrop on slot 5, passing eventData.pointerDrag (the GameObject that was being dragged, i.e. slot 2). Slot 5 calls inventory.MoveSlot(2, 5). Inventory.MoveSlot swaps the underlying data and fires OnSlotChanged(2) then OnSlotChanged(5). InventoryGridUI.RefreshSlot re-binds both — slot 2 now shows empty, slot 5 now shows the sword. No code in OnDrop touched an Image or a Text directly.

Common mistake Swapping the visuals directly inside OnDrop (copying one slot's sprite onto another) instead of changing the data and letting the existing event pipeline redraw it. That shortcut looks like it works, but now the data (what the game actually saves, and what gameplay code checks) and the screen disagree — the next unrelated OnSlotChanged event, or a save/load, will reveal that the swap never really happened underneath.

Tooltips on Hover

Tooltips use two more interfaces, IPointerEnterHandler and IPointerExitHandler, and — importantly — a single shared tooltip object rather than one tooltip per slot. A player only ever hovers one slot at a time, so only one tooltip is ever needed on screen.


using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// The same InventorySlotView class from earlier in Section 8, with
// two more interfaces and two more methods added.
public class InventorySlotView : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler,
    IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText;
    [SerializeField] private Image dragGhost;
    [SerializeField] private TooltipUI tooltip; // one shared instance, not per slot

    public int SlotIndex { get; set; }
    private Inventory inventory;

    // ...Init, Bind, OnBeginDrag, OnDrag, OnEndDrag, OnDrop stay exactly
    // as shown earlier in this section. Only the two methods below are new.

    public void OnPointerEnter(PointerEventData eventData)
    {
        InventorySlotData data = inventory.GetSlot(SlotIndex);
        if (data.item == null) return;

        tooltip.Show(data.item.displayName, data.item.description, transform.position);
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        tooltip.Hide();
    }
}

using UnityEngine;
using UnityEngine.UI;

public class TooltipUI : MonoBehaviour
{
    [SerializeField] private GameObject panel;
    [SerializeField] private Text nameText;
    [SerializeField] private Text descriptionText;

    public void Show(string itemName, string description, Vector3 anchorPosition)
    {
        panel.SetActive(true);
        nameText.text = itemName;
        descriptionText.text = description;
        transform.position = anchorPosition + new Vector3(20f, 20f, 0f);
    }

    public void Hide()
    {
        panel.SetActive(false);
    }
}

Expected result: the cursor enters slot 5's rectangle. OnPointerEnter fires once, calls tooltip.Show(...) — the single shared panel becomes visible near the cursor, filled with slot 5's item name and description. Moving to hover slot 6 fires slot 5's OnPointerExit (hides the panel) then slot 6's OnPointerEnter (shows it again with new text). At no point do 40 tooltip panels exist — one panel is reused and repositioned, which is a small preview of the pooling idea the next section applies to an entire grid.

9. UI Virtualization: Why a 500-Item Bag Doesn't Spawn 500 Objects

Section 7's InventoryGridUI creates one InventorySlotView GameObject per inventory slot. For 30-40 slots this is completely fine. It breaks down for an endgame stash, a crafting-material stockpile, or an auction-house listing holding hundreds or thousands of entries: hundreds of GameObjects, each with an Image and a Text, each a child a Grid Layout Group has to measure and position — most of them scrolled off-screen and invisible at any given moment. Instantiate cost, memory, and layout-rebuild cost all scale with the data count, when they should scale with the screen count.

UI virtualization (sometimes called a "virtualized list" or "recycled list") is the fix: create only enough view objects to cover the visible viewport, plus a small buffer, and reuse them as the player scrolls — moving them and re-binding them to different data indices, instead of creating and destroying GameObjects. This is the exact same idea as the Object Pool pattern from an earlier chapter (pre-allocate once, hand out and take back, never Instantiate/Destroy in the hot path) — applied to UI rows, with one addition: every time a pooled view is handed a new data index, it must be re-bound, because unlike a pooled bullet, a reused row has to show completely different content each time.

Data: 500 InventorySlotData objects in an array. All 500 are real, all 500 live in memory -- that part is cheap, plain data. Screen shows about 8 rows at a time. Only 8-10 InventorySlotView GameObjects are ever created, for the entire 500-item bag: scrollOffset = 0 scrollOffset = 5 rows down +---------------------+ +---------------------+ | View0 <- data[0] | | View0 <- data[5] | | View1 <- data[1] | | View1 <- data[6] | | View2 <- data[2] | | View2 <- data[7] | | ... (8 views) | | ... (8 views) | | View7 <- data[7] | | View7 <- data[12] | +---------------------+ +---------------------+ Scrolling repositions and re-binds the SAME 8-10 views. It never calls Instantiate or Destroy after the initial pool is built.

using System.Collections.Generic;
using UnityEngine;

public class PooledInventoryScrollView : MonoBehaviour
{
    [SerializeField] private Inventory inventory;      // can hold 500+ slots
    [SerializeField] private InventorySlotView slotPrefab;
    [SerializeField] private RectTransform viewport;   // the visible window
    [SerializeField] private RectTransform content;    // scrolls inside the viewport
    [SerializeField] private float rowHeight = 80f;

    private readonly List<InventorySlotView> pool = new List<InventorySlotView>();
    private int firstVisibleIndex = -1;

    void Start()
    {
        // Only enough views to cover the viewport, plus 2 spare rows
        // as a buffer so nothing pops in late while scrolling fast.
        int visibleRows = Mathf.CeilToInt(viewport.rect.height / rowHeight) + 2;
        int poolSize = Mathf.Min(visibleRows, inventory.Capacity);

        for (int i = 0; i < poolSize; i++)
        {
            pool.Add(Instantiate(slotPrefab, content));
        }

        // Content height matches the FULL data count, so the scrollbar
        // behaves exactly as if all 500 rows really existed as objects.
        content.sizeDelta = new Vector2(content.sizeDelta.x, inventory.Capacity * rowHeight);

        RefreshVisible();
    }

    // Hook this to the ScrollRect's OnValueChanged in the Inspector.
    public void OnScroll(Vector2 unusedScrollPosition)
    {
        RefreshVisible();
    }

    void RefreshVisible()
    {
        int newFirst = Mathf.Max(0, Mathf.FloorToInt(content.anchoredPosition.y / rowHeight));
        if (newFirst == firstVisibleIndex) return; // scrolled less than one row -- nothing to rebind
        firstVisibleIndex = newFirst;

        for (int i = 0; i < pool.Count; i++)
        {
            int dataIndex = firstVisibleIndex + i;
            if (dataIndex >= inventory.Capacity)
            {
                pool[i].gameObject.SetActive(false);
                continue;
            }

            pool[i].gameObject.SetActive(true);
            pool[i].GetComponent<RectTransform>().anchoredPosition =
                new Vector2(0f, -dataIndex * rowHeight);
            pool[i].Bind(inventory.GetSlot(dataIndex)); // re-bind to the new data index
        }
    }
}

Expected result: with a 500-slot inventory, Instantiate runs roughly 10 times total, in Start() — never 500 times, and never again while scrolling. Scrolling only moves and re-binds those same 10 views. Whether the bag holds 50 items or 5,000, the per-frame cost of scrolling and the one-time setup cost of the pool stay the same, because both are driven by how many rows fit on screen, not by how much data exists.

Tip This is not a special "inventory" trick — Unity's own ScrollRect UI does not virtualize by default (a plain ScrollRect with a Grid Layout Group still creates one child per data item). Any list that can realistically grow past a screenful — a chat log, a leaderboard, a crafting-recipe browser, a friends list — benefits from the same pool-and-rebind approach.

10. Keeping Everything in Sync: Events, Not Per-Frame Rebuilds

Every system in this chapter so far — the health bar, the menu stack, the inventory grid, the pooled scroll view — follows the same discipline, worth stating on its own once all the pieces are visible together:

Compare that against the anti-pattern it replaces, which looks harmless in a prototype and gets expensive fast:


// ANTI-PATTERN: rebuilds all 40 slots every single frame, whether
// or not anything changed. Costs Instantiate/Destroy 60 times a
// second for a screen that visually looks completely static.
void Update()
{
    foreach (Transform child in gridParent)
    {
        Destroy(child.gameObject);
    }
    for (int i = 0; i < inventory.Capacity; i++)
    {
        InventorySlotView view = Instantiate(slotPrefab, gridParent);
        view.Bind(inventory.GetSlot(i));
    }
}
Common mistake "Just call RefreshAll() in Update(), it's simpler" is a tempting shortcut mid-prototype. It works, but every one of the four patterns in this chapter exists specifically to avoid it: destroying and recreating GameObjects every frame produces garbage for the collector to clean up, forces the layout system to recompute positions constantly, and does all of that whether or not anything actually changed. The event-driven version is not meaningfully harder to write — it just requires deciding, once, exactly what event fires when.

11. Gamepad and Keyboard Navigation and Focus

Everything so far assumed a mouse. Consoles and Steam Deck-style handhelds have no cursor — instead, Unity's EventSystem tracks one "currently selected" GameObject at a time, and moving the D-pad or an analog stick shifts that selection between UI elements with a Selectable component (Button, Slider, and similar all inherit from it).

Two things need explicit handling that a mouse gets for free. First: when a new screen is pushed, something must be selected, or the D-pad and Submit button do nothing at all, because nothing is highlighted yet.


using UnityEngine;
using UnityEngine.EventSystems;

public class PauseMenu : UIScreen
{
    [SerializeField] private GameObject firstSelected; // e.g. the Resume button

    public override void OnPush()
    {
        base.OnPush();
        Time.timeScale = 0f;

        // Without this, a gamepad/keyboard player sees the pause menu
        // but nothing is highlighted, and no button will respond.
        EventSystem.current.SetSelectedGameObject(firstSelected);
    }
}

Second: each Selectable has a Navigation setting in the Inspector, usually left on Automatic (Unity guesses Up/Down/Left/Right neighbors by screen position). Automatic works for a simple vertical button list but frequently guesses wrong on an irregular layout — an inventory grid, or a menu with buttons of different sizes. Switch to Explicit and assign each direction's neighbor by hand for anything that is not a single straight list.

Common mistake Combining gamepad focus with the pooled scroll view from Section 9 without extra care. A pooled InventorySlotView GameObject is reused for different data as the player scrolls — its identity as a GameObject stays fixed, but which inventory slot it represents keeps changing. If you track "the selected item" by remembering a GameObject reference, scrolling can silently swap what that GameObject represents out from under the player's selection. Track selection by data index, not by GameObject identity, and re-resolve which pooled view (if any) currently displays that index after every scroll.

12. Concrete Example: A Gacha/Summon Result Screen

A gacha or summon screen (common in mobile and live-service RPGs — the player spends a currency to randomly receive one or more items or characters) is a good closing example because it uses all three systems from this chapter at once: a currency display is a stat bar (Section 2-3), the result screen is pushed onto the menu stack on top of whatever screen requested it (Section 4-5), and the pulled results are shown in a small grid of slot views (Section 7), reusing the exact same InventorySlotView already built for the inventory.


using System;
using System.Collections.Generic;
using UnityEngine;

public class SummonService : MonoBehaviour
{
    public event Action<List<ItemData>> OnSummonCompleted;

    [SerializeField] private List<ItemData> possiblePulls;

    public void Summon(int pullCount)
    {
        var results = new List<ItemData>();
        for (int i = 0; i < pullCount; i++)
        {
            int roll = UnityEngine.Random.Range(0, possiblePulls.Count);
            results.Add(possiblePulls[roll]);
        }
        OnSummonCompleted?.Invoke(results);
    }
}

using System.Collections.Generic;
using UnityEngine;

public class GachaResultScreen : UIScreen
{
    [SerializeField] private SummonService summonService;
    [SerializeField] private UIStackManager uiStack;

    // Small, fixed count (e.g. 10 for a "10-pull") -- no pooling needed
    // here the way Section 9 needed it, because the count never grows
    // past what a single pull screen ever shows at once.
    [SerializeField] private InventorySlotView[] resultSlots;

    void OnEnable()  { summonService.OnSummonCompleted += ShowResults; }
    void OnDisable() { summonService.OnSummonCompleted -= ShowResults; }

    void ShowResults(List<ItemData> results)
    {
        uiStack.Push(this); // same push used for every other screen in this chapter

        for (int i = 0; i < resultSlots.Length; i++)
        {
            bool hasResult = i < results.Count;
            resultSlots[i].gameObject.SetActive(hasResult);
            if (hasResult)
            {
                var slotData = new InventorySlotData { item = results[i], count = 1 };
                resultSlots[i].Bind(slotData);
            }
        }
    }

    // Wired to a full-screen "tap anywhere to continue" button.
    public void OnTapToContinue()
    {
        uiStack.Pop();
    }
}

Expected result, traced: the player taps "10x Summon." SummonService.Summon(10) rolls 10 random ItemData results and fires OnSummonCompleted. GachaResultScreen.ShowResults runs: it pushes itself onto the same UIStackManager used for the pause menu — whatever screen was open underneath (a shop, a summon-currency screen) is automatically covered and frozen, with zero code written specifically for that freezing, because UIStackManager already handles it. The 10 results are bound onto 10 pre-existing InventorySlotView instances — no Instantiate call happens at pull time, because, like the inventory grid in Section 7, the views already exist and are simply re-bound. The player taps anywhere: OnTapToContinue calls uiStack.Pop(), and the screen underneath reappears exactly as it was, because that is what Pop() always does.

Player taps "10x Summon" | v SummonService.Summon(10) --(rolls RNG)--> 10 ItemData results | v OnSummonCompleted event fires | v GachaResultScreen.ShowResults |-- uiStack.Push(this) [Section 4-5: menu stack] |-- resultSlots[i].Bind(...) x10 [Section 7: bind, don't rebuild] | v Player taps to continue --> uiStack.Pop() --> previous screen reappears

13. Glossary

14. Exercises

Exercise 1 — Low-Mana Warning, Still Event-Driven Extend the StatBarUI from Section 3 so that whenever it is bound with a ratio (current / max) below 0.25, the bar's fill image turns red, and returns to white otherwise. The change must happen only inside the existing event-driven Bind call — do not add an Update() method or any per-frame polling.
Show answer

using UnityEngine;
using UnityEngine.UI;

public class StatBarUI : MonoBehaviour
{
    [SerializeField] private Slider slider;
    [SerializeField] private Image fillImage; // the Slider's Fill Area/Fill image

    private static readonly Color LowColor = Color.red;
    private static readonly Color NormalColor = Color.white;

    public void Bind(int current, int max)
    {
        slider.minValue = 0;
        slider.maxValue = max;
        slider.value = current;

        float ratio = max > 0 ? (float)current / max : 0f;
        fillImage.color = ratio < 0.25f ? LowColor : NormalColor;
    }
}

The color check lives inside Bind, which already only runs when OnManaChanged (or OnHealthChanged) fires. No new subscription, no new loop, and no per-frame cost was added — the existing event pipeline from Section 3 already calls Bind at exactly the right moments, so any logic placed inside it inherits that same "only when it changes" behavior for free.

Exercise 2 — A Confirm-Quit Dialog Using the UIStackManager and UIScreen base class from Section 5, write a ConfirmQuitDialog screen with two buttons: "Yes" calls Application.Quit(), and "No" simply returns to whatever screen was open before the dialog (the pause menu, most likely) without needing to know that screen's name.
Show answer

using UnityEngine;

public class ConfirmQuitDialog : UIScreen
{
    [SerializeField] private UIStackManager uiStack;

    // Wired to the Yes button's OnClick in the Inspector.
    public void OnYesButton()
    {
        Application.Quit();
    }

    // Wired to the No button's OnClick in the Inspector.
    public void OnNoButton()
    {
        uiStack.Pop();
    }
}

OnNoButton does not need a reference to the pause menu at all — it just calls Pop(), and whatever was underneath this dialog on the stack (pushed there by a PauseMenu.OnQuitButton() calling uiStack.Push(confirmQuitDialog)) automatically becomes visible again through OnRevealed(). This is the entire benefit of the stack model from Section 4: the dialog never has to know or care who opened it.

Exercise 3 — Spot the Anti-Pattern A teammate wrote the inventory UI below. It shows the correct items, but the game's frame rate visibly drops whenever the inventory panel is open, even while the player is standing still and not touching the bag. Using this chapter's ideas, explain in two or three sentences what is wrong, then rewrite it correctly.

public class NaiveInventoryUI : MonoBehaviour
{
    public Inventory inventory;
    public InventorySlotView slotPrefab;
    public Transform gridParent;

    void Update()
    {
        foreach (Transform child in gridParent)
        {
            Destroy(child.gameObject);
        }
        for (int i = 0; i < inventory.Capacity; i++)
        {
            InventorySlotView view = Instantiate(slotPrefab, gridParent);
            view.Bind(inventory.GetSlot(i));
        }
    }
}
Show answer

This is the per-frame rebuild anti-pattern from Section 10: Update() destroys and recreates every single slot GameObject 60 times a second, regardless of whether the inventory changed at all. Each frame pays for Destroy, Instantiate, and a full layout recompute on every slot, all for a screen that looks completely static to the player most of the time. The fix is to build the views once and update only in response to the inventory's own change event, exactly like the Section 7 InventoryGridUI:


public class InventoryGridUI : MonoBehaviour
{
    public Inventory inventory;
    public InventorySlotView slotPrefab;
    public Transform gridParent;

    private InventorySlotView[] views;

    void Start()
    {
        views = new InventorySlotView[inventory.Capacity];
        for (int i = 0; i < inventory.Capacity; i++)
        {
            views[i] = Instantiate(slotPrefab, gridParent);
            views[i].Bind(inventory.GetSlot(i));
        }
    }

    void OnEnable()  { inventory.OnSlotChanged += RefreshSlot; }
    void OnDisable() { inventory.OnSlotChanged -= RefreshSlot; }

    void RefreshSlot(int index)
    {
        views[index].Bind(inventory.GetSlot(index));
    }
}

If this were a 500-slot bag instead of a small backpack, the same fix would additionally need the pooling from Section 9, so the number of live views scales with the visible viewport instead of with the full slot count.

← Back to all chapters