Every RPG, survival game, and looter needs one: an inventory system. It sounds simple — a list of items the player carries — but a well-built inventory touches item data architecture, UI layout, drag-and-drop interaction, and persistent save/load. Get it wrong and you'll refactor it five times. Get it right and every system in your game can hook into it cleanly.
TL;DR: This tutorial walks through building a full inventory system in Unity using ScriptableObjects for item definitions, a slot-based InventoryManager for runtime state, Unity UI with drag-and-drop support, and JSON serialization for save/load. If you want the finished, production-ready version, grab our Inventory System game system bundle.
By the end of this guide you'll have a working inventory with stackable items, drag-and-drop rearrangement, tooltip popups, and persistent saving. Let's build it piece by piece.
What You'll Build
The system we're building has four layers:
- Data layer — ScriptableObject assets that define every item (name, icon, description, stack limit, item type).
- Logic layer — An InventoryManager that tracks which items occupy which slots, handles adding/removing/stacking, and fires events when the inventory changes.
- UI layer — A grid of InventorySlot UI elements that visually represent the data, support drag-and-drop, and show tooltips on hover.
- Persistence layer — JSON serialization that saves the inventory to disk and restores it on load.
This architecture keeps data, logic, and presentation separated. You can swap the UI without touching the manager, or change the save format without touching either.
Setting Up Item Data
ScriptableObjects are the ideal way to define items in Unity. Each item becomes an asset you can edit in the Inspector, reference from any script, and share across scenes without duplication.
using UnityEngine;
[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/Item Definition")]
public class ItemDefinition : ScriptableObject
{
[Header("Basic Info")]
public string itemName;
public string itemID; // unique identifier for save/load
[TextArea(2, 4)]
public string description;
public Sprite icon;
[Header("Stacking")]
public bool isStackable = true;
public int maxStackSize = 64;
[Header("Classification")]
public ItemType itemType;
public Rarity rarity = Rarity.Common;
}
public enum ItemType { Consumable, Equipment, Material, Quest, Key }
public enum Rarity { Common, Uncommon, Rare, Epic, Legendary }Create a few item assets via Right-click > Create > Inventory > Item Definition in your Project window. Give each one a unique itemID — this is what the save system uses to reconstruct the inventory. The icon sprite will display in the UI slots.
The ItemType enum lets you filter items (show only consumables in the quick-use bar) and the Rarity enum drives color-coded borders in the UI. Keep these enums lean — you can always add categories later.
Creating the Inventory Manager
The InventoryManager is a singleton that owns the runtime inventory state. It stores an array of slots, each of which holds an optional item reference and a quantity. Every mutation goes through public methods that validate input and fire change events.
using System;
using System.Collections.Generic;
using UnityEngine;
public class InventoryManager : MonoBehaviour
{
public static InventoryManager Instance { get; private set; }
[SerializeField] private int slotCount = 24;
public InventorySlotData[] Slots { get; private set; }
public event Action OnInventoryChanged;
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
Slots = new InventorySlotData[slotCount];
}
public bool AddItem(ItemDefinition item, int quantity = 1)
{
// Try stacking onto existing slots first
if (item.isStackable)
{
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i].item == item && Slots[i].quantity < item.maxStackSize)
{
int space = item.maxStackSize - Slots[i].quantity;
int toAdd = Mathf.Min(quantity, space);
Slots[i].quantity += toAdd;
quantity -= toAdd;
if (quantity <= 0) { OnInventoryChanged?.Invoke(); return true; }
}
}
}
// Place remainder in empty slots
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i].item == null)
{
int toAdd = item.isStackable ? Mathf.Min(quantity, item.maxStackSize) : 1;
Slots[i] = new InventorySlotData(item, toAdd);
quantity -= toAdd;
if (quantity <= 0) { OnInventoryChanged?.Invoke(); return true; }
}
}
OnInventoryChanged?.Invoke();
return quantity <= 0; // false = inventory full, some items not added
}
public void RemoveItemAt(int slotIndex, int quantity = 1)
{
if (slotIndex < 0 || slotIndex >= Slots.Length) return;
Slots[slotIndex].quantity -= quantity;
if (Slots[slotIndex].quantity <= 0)
Slots[slotIndex] = default;
OnInventoryChanged?.Invoke();
}
public void SwapSlots(int indexA, int indexB)
{
(Slots[indexA], Slots[indexB]) = (Slots[indexB], Slots[indexA]);
OnInventoryChanged?.Invoke();
}
}
[Serializable]
public struct InventorySlotData
{
public ItemDefinition item;
public int quantity;
public InventorySlotData(ItemDefinition item, int quantity)
{ this.item = item; this.quantity = quantity; }
}Key design decisions: the AddItem method stacks first, then fills empty slots, and returns false when the inventory is full. The OnInventoryChanged event lets the UI refresh only when something actually changes, avoiding per-frame polling.
Building the UI
The inventory UI is a Canvas with a Grid Layout Group containing slot prefabs. Each slot prefab has an Image for the item icon, a Text for the stack count, and a background Image for the rarity border.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class InventorySlotUI : MonoBehaviour
{
[SerializeField] private Image iconImage;
[SerializeField] private TextMeshProUGUI quantityText;
[SerializeField] private Image borderImage;
[SerializeField] private GameObject emptyOverlay;
private int slotIndex;
public void Initialize(int index)
{
slotIndex = index;
}
public void Refresh(InventorySlotData data)
{
bool hasItem = data.item != null;
iconImage.gameObject.SetActive(hasItem);
quantityText.gameObject.SetActive(hasItem && data.quantity > 1);
emptyOverlay.SetActive(!hasItem);
if (hasItem)
{
iconImage.sprite = data.item.icon;
quantityText.text = data.quantity.ToString();
borderImage.color = GetRarityColor(data.item.rarity);
}
}
private Color GetRarityColor(Rarity rarity) => rarity switch
{
Rarity.Common => new Color(0.6f, 0.6f, 0.6f),
Rarity.Uncommon => new Color(0.2f, 0.8f, 0.2f),
Rarity.Rare => new Color(0.2f, 0.4f, 1f),
Rarity.Epic => new Color(0.6f, 0.2f, 0.8f),
Rarity.Legendary => new Color(1f, 0.8f, 0.2f),
_ => Color.white,
};
}Create a parent panel with a GridLayoutGroup set to your desired column count (6 columns for a 24-slot, 4-row inventory). Instantiate slot prefabs at runtime or place them in the scene and call Initialize with each slot's index. Subscribe to InventoryManager.OnInventoryChanged to refresh all slots when the data changes.
Adding Drag & Drop
Drag-and-drop lets players rearrange their inventory intuitively. Unity's EventSystem provides the IBeginDragHandler, IDragHandler, and IEndDragHandler interfaces to implement this without writing raycasting code.
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class DraggableSlot : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
private Canvas canvas;
private CanvasGroup canvasGroup;
private RectTransform rectTransform;
private Vector2 originalPosition;
private int slotIndex;
void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvasGroup = GetComponent<CanvasGroup>();
canvas = GetComponentInParent<Canvas>();
}
public void SetSlotIndex(int index) => slotIndex = index;
public void OnBeginDrag(PointerEventData eventData)
{
originalPosition = rectTransform.anchoredPosition;
canvasGroup.blocksRaycasts = false;
canvasGroup.alpha = 0.6f;
transform.SetAsLastSibling();
}
public void OnDrag(PointerEventData eventData)
{
rectTransform.anchoredPosition += eventData.delta / canvas.scaleFactor;
}
public void OnEndDrag(PointerEventData eventData)
{
canvasGroup.blocksRaycasts = true;
canvasGroup.alpha = 1f;
rectTransform.anchoredPosition = originalPosition;
}
public void OnDrop(PointerEventData eventData)
{
DraggableSlot dragged = eventData.pointerDrag?.GetComponent<DraggableSlot>();
if (dragged != null && dragged != this)
{
InventoryManager.Instance.SwapSlots(dragged.slotIndex, slotIndex);
}
}
}Attach this component alongside your InventorySlotUI. The CanvasGroup component is required for the blocksRaycasts toggle — add one to each slot prefab. When the player drops a slot onto another, SwapSlots is called on the InventoryManager, which fires the change event and refreshes all slot UIs automatically.
Save/Load with JSON
Saving an inventory to JSON requires converting ScriptableObject references to serializable data — you can't serialize asset references directly. Instead, save each slot's itemID and quantity, then look up the ScriptableObject by ID on load.
using System.IO;
using System.Collections.Generic;
using UnityEngine;
public class InventorySaveManager : MonoBehaviour
{
[SerializeField] private ItemDefinition[] itemDatabase;
private string SavePath => Path.Combine(Application.persistentDataPath, "inventory.json");
public void Save()
{
var data = new InventorySaveData();
var slots = InventoryManager.Instance.Slots;
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].item != null)
{
data.entries.Add(new SlotEntry
{
slotIndex = i,
itemID = slots[i].item.itemID,
quantity = slots[i].quantity
});
}
}
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
Debug.Log($"Inventory saved to {SavePath}");
}
public void Load()
{
if (!File.Exists(SavePath)) return;
string json = File.ReadAllText(SavePath);
var data = JsonUtility.FromJson<InventorySaveData>(json);
// Clear current inventory
var slots = InventoryManager.Instance.Slots;
for (int i = 0; i < slots.Length; i++)
slots[i] = default;
// Restore saved items
foreach (var entry in data.entries)
{
ItemDefinition item = FindItemByID(entry.itemID);
if (item != null)
slots[entry.slotIndex] = new InventorySlotData(item, entry.quantity);
}
}
private ItemDefinition FindItemByID(string id)
{
foreach (var item in itemDatabase)
if (item.itemID == id) return item;
return null;
}
}
[System.Serializable]
public class InventorySaveData
{
public List<SlotEntry> entries = new List<SlotEntry>();
}
[System.Serializable]
public class SlotEntry
{
public int slotIndex;
public string itemID;
public int quantity;
}The itemDatabase array is populated in the Inspector with every ItemDefinition asset in your project. On load, each saved itemID is matched against this database to reconstruct the ScriptableObject reference. This approach survives asset moves, renames, and recompiles — the itemID string is your source of truth.
Pro tip: For production games with hundreds of items, replace the array lookup with a Dictionary<string, ItemDefinition> built at startup. Our Inventory System bundle includes this optimization along with item filtering, equipment slots, and a crafting hook.
Download the Complete System
Building an inventory from scratch is a fantastic learning exercise, but if you're on a deadline, our Inventory System game system bundle includes everything covered here plus:
- Equipment slots with stat modifiers
- Item tooltip with rich text formatting
- Hotbar / quick-use bar integration
- Crafting recipe system with ingredient validation
- Loot drop table with weighted random selection
- Full JSON save/load with migration support
- Tested on Unity 2022.3 LTS through Unity 6
Every script is MIT-licensed, zero-dependency, and fully documented with Inspector tooltips. Drop it into your project, configure your items as ScriptableObjects, and you've got a professional inventory system running in minutes.
Need a health bar or score tracker to go alongside your inventory? Check out our Health System and Score Manager scripts for a complete gameplay foundation.