Topic 12 of 15 14 min intermediate

Inventory Systems

Items, stacks, and slots that survive a save

Inventories look simple and then quietly become the most tangled system in a project. Almost every problem comes from one mistake: storing item data and item state in the same object.

Definition versus instance

A sword's name, icon, and max stack size never change — that is the definition, and every sword in the game shares it. How many you hold, and its current durability, are instance state, unique to your inventory.

DataBelongs inWhy
Name, icon, descriptionScriptableObjectSame for every copy.
Max stack size, valueScriptableObjectBalance data, edited once.
Quantity heldRuntime classDiffers per slot.
Durability, enchantmentsRuntime classDiffers per item.
Which slot it sits inRuntime classChanges constantly.
Put quantity on the ScriptableObject and every sword in the game shares one count — and because ScriptableObject edits persist in the editor, your starting inventory changes every time you playtest. This is the single most common Unity inventory bug.

The item definition

ItemData.cs
C#
using UnityEngine;

[CreateAssetMenu(fileName = "Item", menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    [Header("Identity")]
    public string id = "sword_iron";      // stable, used by saves
    public string displayName = "Iron Sword";
    [TextArea] public string description;
    public Sprite icon;

    [Header("Rules")]
    public int maxStack = 1;
    public ItemType type = ItemType.Weapon;
    public int value = 10;

    public bool IsStackable => maxStack > 1;
}

public enum ItemType { Weapon, Armour, Consumable, Material, Quest }
That id string matters. Saves should store the id, not a reference to the asset — rename or move the asset later and a reference-based save breaks, while an id-based one keeps working.

The runtime stack

ItemStack.cs
C#
[System.Serializable]
public class ItemStack
{
    public ItemData item;
    public int quantity;

    public ItemStack(ItemData item, int quantity = 1)
    {
        this.item = item;
        this.quantity = quantity;
    }

    public bool IsEmpty => item == null || quantity <= 0;
    public int SpaceLeft => item == null ? 0 : item.maxStack - quantity;

    public bool CanMergeWith(ItemData other) =>
        item == other && item.IsStackable && SpaceLeft > 0;
}

Adding items, properly

The order matters: fill existing stacks first, then use empty slots. Do it the other way round and picking up 3 arrows when you already hold 7 creates a second stack instead of making 10.

Inventory.cs
C#
public class Inventory : MonoBehaviour
{
    [SerializeField] private int slotCount = 24;

    private ItemStack[] slots;

    public event System.Action Changed;

    private void Awake() => slots = new ItemStack[slotCount];

    /// Returns how many could not be added.
    public int Add(ItemData item, int amount)
    {
        // 1. Top up stacks that already hold this item
        for (int i = 0; i < slots.Length && amount > 0; i++)
        {
            if (slots[i] == null || !slots[i].CanMergeWith(item)) continue;

            int moved = Mathf.Min(slots[i].SpaceLeft, amount);
            slots[i].quantity += moved;
            amount -= moved;
        }

        // 2. Then spill into empty slots
        for (int i = 0; i < slots.Length && amount > 0; i++)
        {
            if (slots[i] != null && !slots[i].IsEmpty) continue;

            int moved = Mathf.Min(item.maxStack, amount);
            slots[i] = new ItemStack(item, moved);
            amount -= moved;
        }

        Changed?.Invoke();
        return amount;   // leftover, so the caller can refuse the pickup
    }
}
Returning the leftover rather than a bool is what lets a chest say "you took 4 of 6" and leave the rest behind. A bool forces you to either drop items on the floor or silently delete them.

Keeping the UI in sync

The inventory should not know the UI exists. Fire an event when it changes; let the UI subscribe. Then you can add a hotbar, a chest window, and a shop without touching inventory code.

C#
public class InventoryUI : MonoBehaviour
{
    [SerializeField] private Inventory inventory;

    private void OnEnable()  => inventory.Changed += Redraw;
    private void OnDisable() => inventory.Changed -= Redraw;

    private void Redraw()
    {
        // Only runs when something actually changed
        for (int i = 0; i < slotViews.Length; i++)
            slotViews[i].Show(inventory.GetSlot(i));
    }
}

Saving it

Serialise ids and quantities, not object references. Unity cannot serialise a ScriptableObject reference into JSON in a way that survives a rebuild.

C#
[System.Serializable]
public class SavedSlot
{
    public string itemId;
    public int quantity;
}

// On load, look the id back up in a registry of every ItemData
public ItemData Resolve(string id) => database.FirstOrDefault(i => i.id == id);
If Resolve returns null because an item was removed from the game, drop that slot rather than throwing. Old saves outliving item definitions is normal, not exceptional.

Grid inventories

When slots are not enough

Some games want items that occupy multiple cells, Resident Evil style. That is a grid problem, not an inventory problem — each item gets a width and height, and placement asks whether every cell it would cover is free.

The Grids & Tilemaps topic covers the bounds checking and cell maths that this needs. Keep the two concerns separate: the grid answers "does it fit?", the inventory answers "what is in it?".

What to take away

  • Item definitions go in ScriptableObjects; quantities go in runtime classes.
  • Never store quantity on the ScriptableObject.
  • Fill existing stacks before using empty slots.
  • Return the leftover count from Add, not a bool.
  • Fire an event on change; never poll the inventory in Update.
  • Save stable string ids, and handle ids that no longer resolve.