Skip to main content

๐ŸŽ’ Lesson 7.3: An Inventory System

Potions, keys, ammo, gold, that legendary sword โ€” games are full of things you carry. In Module 2 you learned that a ScriptableObject is data living as an asset, shared and edited without touching code. That is exactly the right home for item definitions. This lesson builds a real inventory: items as data, slots that stack, add/remove logic that behaves, and an event that keeps the UI in sync.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Model an item as an ItemData ScriptableObject (icon, name, stackable, max stack)
  • Explain why item definitions are assets while item counts are runtime state
  • Represent held items as a list of InventorySlots (item + count)
  • Write Add logic that stacks into existing slots before opening a new one
  • Write Remove logic and raise an OnInventoryChanged event to refresh the UI

Estimated Time: 50 minutes  ยท  Prerequisite: Module 2 (Lesson 2.3 ScriptableObjects, Lesson 2.2 Events)

In This Lesson

ItemData: An Item as an Asset

Every "Health Potion" in your game is the same thing: same icon, same name, same "heals 25" rule, same max stack of 20. That shared definition should exist once, as an asset, not be copied into every potion GameObject. This is the ScriptableObject pattern from Lesson 2.3 โ€” data as an asset.

using UnityEngine;

[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    public string displayName = "New Item";
    [TextArea] public string description;
    public Sprite icon;

    [Header("Stacking")]
    public bool stackable = true;
    public int maxStack = 20;
}

The [CreateAssetMenu] attribute adds an entry to the Assets โ–ธ Create โ–ธ Inventory โ–ธ Item menu. Right-click in the Project window, create an ItemData, and fill in the fields in the Inspector. Make a HealthPotion, a Key (not stackable, maxStack 1), some Ammo (maxStack 99). No code per item โ€” just assets a designer can author.

๐Ÿ“– Definition

Definition vs. instance: the ItemData asset is the definition ("what a Health Potion is") โ€” shared, never changed at runtime. How many potions you are holding is runtime state that belongs on the player's inventory, not on the asset. Keep these two apart and the whole system stays clean.

โš ๏ธ Never store the player's count on the ItemData asset

A ScriptableObject asset is a single shared instance. If you put a count field on ItemData and change it, every reference to that potion โ€” the shop, the loot table, every save slot โ€” sees the change, and it persists in the editor between play sessions. Counts live in the inventory's slots, which we build next.

Slots: Item + Count

An inventory is a list of slots. A slot pairs an ItemData definition with how many of it you're holding. This is plain runtime data, so a small serializable class does the job:

using System;

[Serializable]
public class InventorySlot
{
    public ItemData item;   // WHICH item (the shared asset)
    public int count;       // HOW MANY (this player's runtime state)

    public InventorySlot(ItemData item, int count)
    {
        this.item = item;
        this.count = count;
    }

    public bool IsFull => item != null && count >= item.maxStack;
    public int SpaceLeft => item == null ? 0 : item.maxStack - count;
}

The [Serializable] attribute lets Unity show the slot in the Inspector and (later, in Module 5's save system) write it to disk. IsFull and SpaceLeft are convenience helpers the Add logic will lean on.

The Inventory Component

The Inventory MonoBehaviour holds the slots and exposes Add/Remove. It has a fixed number of slots (like most game inventories โ€” a grid of, say, 20) and raises an event whenever anything changes so the UI can redraw. Here is the skeleton; we fill in Add and Remove next:

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

public class Inventory : MonoBehaviour
{
    [SerializeField] int capacity = 20;                 // number of grid slots
    readonly List<InventorySlot> slots = new();

    // UI subscribes to this; Inventory never touches the UI directly
    public event Action OnInventoryChanged;

    // read-only view for the UI to render
    public IReadOnlyList<InventorySlot> Slots => slots;
    public int Capacity => capacity;

    // ... Add() and Remove() below ...
}

Notice the same decoupling principle from the whole module: Inventory exposes a read-only Slots list and an OnInventoryChanged event. It knows nothing about buttons or icons โ€” the UI reads the data and listens for the ping.

Adding with Stacking Logic

Adding an item is the interesting part, because of stacking. If you pick up a potion and already have 3 (max 20), it should become 4 in the same slot โ€” not open a new one. Only when existing stacks are full, or the item isn't stackable, do you take a fresh slot. And if the bag is full, the add should fail gracefully.

The algorithm, in order:

  1. If the item is stackable, look for existing slots of that item with room and top them up first.
  2. Whatever amount remains goes into new slots, up to maxStack each, while there's capacity.
  3. Return how many we couldn't fit (0 means everything went in).
  4. If anything at all changed, fire OnInventoryChanged.
public int Add(ItemData item, int amount)
{
    if (item == null || amount <= 0) return amount;
    int remaining = amount;

    // 1) Top up existing stacks of this item
    if (item.stackable)
    {
        foreach (var slot in slots)
        {
            if (slot.item != item || slot.IsFull) continue;
            int moved = Mathf.Min(slot.SpaceLeft, remaining);
            slot.count += moved;
            remaining -= moved;
            if (remaining == 0) break;
        }
    }

    // 2) Open new slots for the leftover, while we have capacity
    while (remaining > 0 && slots.Count < capacity)
    {
        int stack = item.stackable ? Mathf.Min(item.maxStack, remaining) : 1;
        slots.Add(new InventorySlot(item, stack));
        remaining -= stack;
    }

    // 4) Notify listeners if anything actually landed
    if (remaining < amount)
        OnInventoryChanged?.Invoke();

    return remaining;   // 3) leftover that didn't fit (0 = all added)
}

Returning the leftover is a small touch that matters: a pickup can check the result and only destroy itself if everything was collected, leaving a partial pile on the ground if the bag was nearly full. We use that in the next lesson's mini-project.

Here is the whole decision as a flow โ€” trace a potion through it:

flowchart TD START(["Add(item, amount)"]) --> STACK{"Item stackable AND
a matching slot has room?"} STACK -- "Yes" --> TOP["Top up that stack
(reduce remaining)"] TOP --> MORE{"remaining > 0?"} STACK -- "No" --> MORE MORE -- "No" --> PING["Fire OnInventoryChanged"] MORE -- "Yes" --> CAP{"Free slot in
capacity?"} CAP -- "Yes" --> NEW["Open a new slot
(up to maxStack)"] NEW --> MORE CAP -- "No" --> FULL["Bag full โ€” return
leftover amount"] FULL --> PING PING --> DONE(["Return remaining"])

Figure 1: The add-with-stacking algorithm. Existing stacks fill first, then new slots open until capacity runs out; any leftover is returned.

โœ… Pro Tip

Filling existing stacks before opening new slots is what makes 30 arrows collapse into two neat stacks of 20 and 10, rather than scattering across many slots. Players expect this; get the order right and stacking "just feels correct."

Removing & the Change Event

Removing is the mirror image: subtract from stacks of that item until the requested amount is gone, and drop any slot that hits zero. Use it when a potion is consumed, ammo is spent, or an item is dropped.

public bool Remove(ItemData item, int amount)
{
    if (item == null || amount <= 0) return false;
    if (CountOf(item) < amount) return false;   // not enough โ€” refuse

    int remaining = amount;
    // walk backwards so we can safely remove emptied slots
    for (int i = slots.Count - 1; i >= 0 && remaining > 0; i--)
    {
        if (slots[i].item != item) continue;
        int taken = Mathf.Min(slots[i].count, remaining);
        slots[i].count -= taken;
        remaining -= taken;
        if (slots[i].count == 0) slots.RemoveAt(i);
    }

    OnInventoryChanged?.Invoke();
    return true;
}

public int CountOf(ItemData item)
{
    int total = 0;
    foreach (var slot in slots)
        if (slot.item == item) total += slot.count;
    return total;
}

Two details worth noting. We iterate backwards so removing an emptied slot doesn't shuffle indices we haven't visited yet. And Remove checks CountOf first and refuses if you don't have enough โ€” an all-or-nothing removal is far easier for callers to reason about than a partial one.

Both Add and Remove end by firing OnInventoryChanged. That single event is the entire connection between the data and the screen โ€” exactly the event pattern from Lesson 2.2, applied to inventory.

๐Ÿ’ก Why an event and not a direct UI call? If Inventory called inventoryUI.Refresh() itself, it would depend on the UI, couldn't exist without it, and couldn't be tested or reused headless. Firing an event flips the dependency: the UI depends on the inventory, never the other way round. Save systems, achievement trackers, and quest checkers can all listen to the same ping.

The Inventory Grid UI

The UI is a grid of slot cells. Each cell shows an item's icon and, for stacks, a count in the corner; empty cells show nothing. When OnInventoryChanged fires, the UI rebuilds its cells from inventory.Slots. Here is the grid we're aiming for, drawn as it appears in-game:

An in-game inventory grid panel A dark rounded inventory panel titled Inventory. It holds a five-by-four grid of square slots. Several slots contain item icons with a stack count in the bottom-right corner: a red health potion showing four, a stack of arrows showing twenty, a yellow key with no number because it is not stackable, and a stack of coins showing ninety-nine. The remaining slots are empty. ๐ŸŽ’ Inventory 20 slots 4 20 $ 99 Stack count shows in the corner ยท non-stackable items show none
Figure 2: The inventory grid UI (faithfully recreated). Stacks show a count in the corner; the non-stackable key shows none. The panel simply mirrors the Inventory.Slots data.

A slot-view script per cell keeps this simple โ€” it takes an InventorySlot and paints itself:

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class SlotView : MonoBehaviour
{
    [SerializeField] Image icon;
    [SerializeField] TMP_Text countLabel;

    public void Render(InventorySlot slot)
    {
        bool has = slot != null && slot.item != null;
        icon.enabled = has;
        if (has) icon.sprite = slot.item.icon;

        // show a number only for stacks greater than 1
        bool showCount = has && slot.item.stackable && slot.count > 1;
        countLabel.enabled = showCount;
        if (showCount) countLabel.text = slot.count.ToString();
    }
}

The parent InventoryUI subscribes to OnInventoryChanged, then loops the slots calling Render on each cell โ€” the same subscribe-in-OnEnable, unsubscribe-in-OnDisable discipline from Lesson 7.1.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Author items and prove stacking

Objective: Build the data layer and verify the add logic.

  1. Create the ItemData ScriptableObject and author three assets: HealthPotion (stackable, maxStack 20), Key (not stackable, maxStack 1), Ammo (stackable, maxStack 99).
  2. Add the Inventory, InventorySlot classes with the full Add/Remove/CountOf code.
  3. Write a tester that on key presses calls Add(healthPotion, 5) and logs the slots.
  4. Add 5 potions, then 18 more. Confirm you end with one full stack of 20 and a second slot of 3 โ€” not 23 scattered slots.
๐Ÿ’ก Hint: every potion opens a new slot

Check that stackable is ticked on the potion asset and that the "top up existing stacks" loop runs before the "open new slots" loop. If you open new slots first, nothing ever stacks.

โœ… Success check

23 potions occupy exactly two slots (20 + 3). Adding a Key always opens its own single-count slot. Add returns 0 while there's room and a positive leftover once the bag is full.

๐Ÿ‹๏ธ Exercise 2: Refresh the grid on change

Build the grid panel from Figure 2 with a GridLayoutGroup and cell prefabs carrying SlotView. Have InventoryUI subscribe to OnInventoryChanged and repaint every cell. Add and remove items from your tester and watch the icons and counts update live โ€” without the Inventory class ever referencing the UI. Confirm the non-stackable key shows no number.

๐ŸŽฏ Quick Quiz

Question 1: Where does the number of Health Potions the player is carrying belong?

Question 2: Why does Add top up existing stacks before opening new slots?

Question 3: Why does Inventory raise an event instead of calling the UI's Refresh() directly?

Summary

๐ŸŽ‰ Key Takeaways

  • ItemData is a ScriptableObject โ€” the shared definition of an item (icon, name, stackable, maxStack), authored as an asset.
  • Item counts are runtime state and live in InventorySlots (item + count), never on the asset.
  • Add tops up existing stacks first, then opens new slots up to capacity, and returns any leftover that didn't fit.
  • Remove walks slots backwards, refuses if you lack the amount, and drops emptied slots.
  • Both raise OnInventoryChanged โ€” one event that keeps the UI (and anything else) in sync without coupling.
  • The grid UI simply mirrors Inventory.Slots; it listens for the ping and repaints.

๐Ÿš€ What's Next?

You have items and a bag to hold them โ€” but nothing to pick up yet, and no way to use what you carry. In Lesson 7.4: Pickups & Equipping (Mini-Project) we tie the whole module together: world pickups that add themselves to the inventory on touch, equipping a weapon that changes your damage output (back to Lesson 7.2!), and dropping items back into the world.

๐ŸŽ’ Data-driven and decoupled

Items are assets, counts are state, and one event keeps the screen honest. Add a new item type tomorrow and there's nothing to code โ€” just a new asset.