Unity Inventory System Tutorial: Stacking, Grid UI, Drag and Drop

Illustration of an inventory grid with item icons

An inventory system touches almost every other part of a game: pickups, UI, crafting, shops, equipment and saving. Get the foundations wrong and every feature built on top inherits the mess. This Unity inventory system tutorial builds a clean, reusable inventory with ScriptableObject item definitions, stackable slots, a grid UI that updates through events, drag-and-drop, and saving. Everything is plain C# you can adapt to a survival game, RPG or shop.

Items vs Item Instances

The most important design decision is separating what an item is from how many you have:

  • ItemData (ScriptableObject asset): the shared definition. Name, icon, description, max stack size, price. One asset per item type, never changed at runtime.
  • InventorySlot (plain C# class): a reference to an ItemData plus a quantity. This is runtime state.

Items with unique per-instance data (durability, random stats) need an extra instance class, covered in the FAQ. For most games, data + quantity is enough.

Item Definitions With ScriptableObjects

using UnityEngine;

[CreateAssetMenu(menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    [SerializeField] string id = System.Guid.NewGuid().ToString();
    public string Id => id;
    public string displayName;
    [TextArea] public string description;
    public Sprite icon;
    [Min(1)] public int maxStack = 1;
    public GameObject worldPrefab;   // what appears when dropped

    public virtual bool Use(GameObject user) => false;   // override for consumables
}

[CreateAssetMenu(menuName = "Inventory/Consumable")]
public class ConsumableItem : ItemData
{
    public float healAmount = 25f;

    public override bool Use(GameObject user)
    {
        if (!user.TryGetComponent(out Health h) || h.Current >= h.Max) return false;
        h.Heal(healAmount);
        return true;   // true = consume one
    }
}

The stable id matters for saving: asset names can change, ids shouldn’t. Inheritance lets consumables, weapons and quest items each define their own behaviour. Our ScriptableObjects guide explains why these assets are ideal for shared definitions.

The Inventory Model: Slots and Stacking

using System;
using UnityEngine;

[Serializable]
public class InventorySlot
{
    public ItemData item;
    public int quantity;
    public bool IsEmpty => item == null || quantity <= 0;
    public void Clear() { item = null; quantity = 0; }
}

public class Inventory : MonoBehaviour
{
    [SerializeField] int size = 20;
    public InventorySlot[] Slots { get; private set; }
    public event Action Changed;

    void Awake()
    {
        Slots = new InventorySlot[size];
        for (int i = 0; i < size; i++) Slots[i] = new InventorySlot();
    }

    /// Returns how many could NOT be added (0 = all added).
    public int Add(ItemData item, int amount)
    {
        // 1. Top up existing stacks
        foreach (var s in Slots)
        {
            if (amount == 0) break;
            if (s.item == item && s.quantity < item.maxStack)
            {
                int add = Mathf.Min(amount, item.maxStack - s.quantity);
                s.quantity += add; amount -= add;
            }
        }
        // 2. Fill empty slots
        foreach (var s in Slots)
        {
            if (amount == 0) break;
            if (s.IsEmpty)
            {
                int add = Mathf.Min(amount, item.maxStack);
                s.item = item; s.quantity = add; amount -= add;
            }
        }
        Changed?.Invoke();
        return amount;
    }

    public bool Remove(ItemData item, int amount)
    {
        if (Count(item) < amount) return false;
        for (int i = Slots.Length - 1; i >= 0 && amount > 0; i--)
        {
            var s = Slots[i];
            if (s.item != item) continue;
            int take = Mathf.Min(amount, s.quantity);
            s.quantity -= take; amount -= take;
            if (s.quantity == 0) s.Clear();
        }
        Changed?.Invoke();
        return true;
    }

    public int Count(ItemData item)
    {
        int total = 0;
        foreach (var s in Slots) if (s.item == item) total += s.quantity;
        return total;
    }

    public void Swap(int a, int b)
    {
        if (a == b) return;
        var sa = Slots[a]; var sb = Slots[b];
        if (sa.item == sb.item && sa.item != null)   // merge stacks
        {
            int move = Mathf.Min(sa.quantity, sa.item.maxStack - sb.quantity);
            sb.quantity += move; sa.quantity -= move;
            if (sa.quantity == 0) sa.Clear();
        }
        else (Slots[a], Slots[b]) = (sb, sa);
        Changed?.Invoke();
    }
}

Returning the leftover from Add lets pickups stay on the ground when the bag is full, instead of silently vanishing. The Changed event is how the UI knows to redraw.

Picking Items Up

public class ItemPickup : MonoBehaviour
{
    public ItemData item;
    public int amount = 1;

    void OnTriggerEnter(Collider other)
    {
        if (!other.TryGetComponent(out Inventory inv)) return;
        amount = inv.Add(item, amount);
        if (amount == 0) Destroy(gameObject);
    }
}

For “press E to pick up”, use the same interaction pattern as our dialogue tutorial NPCs, or a raycast from the camera (see the raycast tutorial).

The Grid UI

Create a panel with a Grid Layout Group (cell size 80×80, spacing 6, Fixed Column Count 5). Make a SlotUI prefab: a background Image with an Icon Image child and a TextMeshPro Quantity child in the corner.

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class SlotUI : MonoBehaviour
{
    [SerializeField] Image icon;
    [SerializeField] TMP_Text quantityText;
    public int Index { get; set; }

    public void Show(InventorySlot slot)
    {
        bool has = !slot.IsEmpty;
        icon.enabled = has;
        icon.sprite = has ? slot.item.icon : null;
        quantityText.text = has && slot.quantity > 1 ? slot.quantity.ToString() : "";
    }
}

public class InventoryUI : MonoBehaviour
{
    [SerializeField] Inventory inventory;
    [SerializeField] SlotUI slotPrefab;
    [SerializeField] Transform grid;
    SlotUI[] slotUIs;

    void Start()
    {
        slotUIs = new SlotUI[inventory.Slots.Length];
        for (int i = 0; i < slotUIs.Length; i++)
        {
            slotUIs[i] = Instantiate(slotPrefab, grid);
            slotUIs[i].Index = i;
        }
        inventory.Changed += Refresh;
        Refresh();
    }

    void OnDestroy() { if (inventory) inventory.Changed -= Refresh; }

    void Refresh()
    {
        for (int i = 0; i < slotUIs.Length; i++) slotUIs[i].Show(inventory.Slots[i]);
    }
}

Slots are created once and only refreshed on change, with no per-frame work. If the grid looks wrong on different screens, see our UI scaling guide.

Drag and Drop Between Slots

uGUI’s event interfaces make drag and drop straightforward. Add this to the SlotUI prefab:

using UnityEngine.EventSystems;

public class SlotDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
    [SerializeField] Image icon;
    [SerializeField] SlotUI slot;
    public static SlotUI DraggedFrom;
    Transform originalParent; Canvas canvas; CanvasGroup group;

    void Awake()
    {
        canvas = GetComponentInParent<Canvas>();
        group = icon.gameObject.AddComponent<CanvasGroup>();
    }

    public void OnBeginDrag(PointerEventData e)
    {
        if (!icon.enabled) return;
        DraggedFrom = slot;
        originalParent = icon.transform.parent;
        icon.transform.SetParent(canvas.transform, true);   // draw on top
        group.blocksRaycasts = false;                         // let drops reach the slot underneath
    }

    public void OnDrag(PointerEventData e)
    {
        if (DraggedFrom == slot) icon.transform.position = e.position;
    }

    public void OnEndDrag(PointerEventData e)
    {
        if (DraggedFrom != slot) return;
        icon.transform.SetParent(originalParent, false);
        icon.rectTransform.anchoredPosition = Vector2.zero;
        group.blocksRaycasts = true;
        DraggedFrom = null;
    }

    public void OnDrop(PointerEventData e)
    {
        if (DraggedFrom != null && DraggedFrom != slot)
            FindAnyObjectByType<Inventory>().Swap(DraggedFrom.Index, slot.Index);
    }
}

Turning off blocksRaycasts while dragging is the key detail. Otherwise the dragged icon sits under the pointer and swallows the drop event. In a real project pass the Inventory reference in rather than using FindAnyObjectByType.

🎒 Building a full RPG?

Complete inventory, equipment and crafting frameworks on the Asset Store can save weeks. We’ve listed the tools that are actually worth it for indies.

See our Asset Store picks →

Using and Dropping Items

public void UseSlot(int index, GameObject user)
{
    var s = Slots[index];
    if (s.IsEmpty) return;
    if (s.item.Use(user)) Remove(s.item, 1);
}

public void DropSlot(int index, Vector3 position)
{
    var s = Slots[index];
    if (s.IsEmpty || s.item.worldPrefab == null) return;
    var drop = Instantiate(s.item.worldPrefab, position, Quaternion.identity);
    if (drop.TryGetComponent(out ItemPickup p)) { p.item = s.item; p.amount = s.quantity; }
    s.Clear();
    Changed?.Invoke();
}

Wire UseSlot to a right-click (IPointerClickHandler with e.button == PointerEventData.InputButton.Right) or a “Use” button in a tooltip.

Saving the Inventory

You can’t serialize ScriptableObject references to JSON meaningfully, so save ids and quantities and look items up on load from an item database:

[Serializable] public class SlotSave { public string id; public int qty; }
[Serializable] public class InventorySave { public SlotSave[] slots; }

[CreateAssetMenu(menuName = "Inventory/Database")]
public class ItemDatabase : ScriptableObject
{
    public ItemData[] items;
    public ItemData Get(string id) => System.Array.Find(items, i => i.Id == id);
}

public InventorySave ToSave() => new InventorySave {
    slots = System.Array.ConvertAll(Slots, s => new SlotSave { id = s.IsEmpty ? null : s.item.Id, qty = s.quantity })
};

public void Load(InventorySave data, ItemDatabase db)
{
    for (int i = 0; i < Slots.Length && i < data.slots.Length; i++)
    {
        var item = string.IsNullOrEmpty(data.slots[i].id) ? null : db.Get(data.slots[i].id);
        Slots[i].item = item;
        Slots[i].quantity = item ? data.slots[i].qty : 0;
    }
    Changed?.Invoke();
}

Write ToSave() into your save file with JsonUtility, as shown in our JSON save system guide. And don’t use PlayerPrefs for inventories (here’s what to save where).

Troubleshooting

Inventory keeps items after leaving Play mode

You stored slots inside a ScriptableObject asset, and changes to assets persist in the Editor. Keep runtime slots on a MonoBehaviour or plain class.

Drop events never fire

The dragged icon blocks raycasts. Set blocksRaycasts = false on its CanvasGroup during the drag. Also make sure the slot background has an Image with Raycast Target ticked.

All item ids are identical

Duplicating an item asset copies its id. Regenerate ids for duplicates, for example with a context-menu button that assigns a new GUID, or use the asset’s GUID via AssetDatabase in an editor script.

UI doesn’t update after picking something up

Changed isn’t invoked, or the UI subscribed to a different Inventory instance (for example a prefab asset instead of the scene object).

Items vanish when the inventory is full

The pickup destroys itself without checking Add’s return value. Only destroy when the leftover is zero.

FAQ

Should inventory items be ScriptableObjects?

Item definitions should be. The runtime inventory (which items and how many) should be plain C# data on a MonoBehaviour.

How do I handle items with durability or random stats?

Add an ItemInstance class holding the ItemData reference plus per-instance fields, and store instances in slots. Unique items usually have a max stack of 1.

List or array for inventory slots?

A fixed array suits grid inventories with a set size. A List suits unlimited bag-style inventories.

How do I save an inventory in Unity?

Save each slot’s item id and quantity to JSON, then look items up from a database asset when loading.

How do I show item tooltips?

Implement IPointerEnterHandler/IPointerExitHandler on the slot and show a panel with the item’s name and description near the pointer.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *