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.
| Data | Belongs in | Why |
|---|---|---|
Name, icon, description | ScriptableObject | Same for every copy. |
Max stack size, value | ScriptableObject | Balance data, edited once. |
Quantity held | Runtime class | Differs per slot. |
Durability, enchantments | Runtime class | Differs per item. |
Which slot it sits in | Runtime class | Changes constantly. |
The item definition
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 }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
[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.
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
}
}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.
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));
}
}public class InventoryUI : MonoBehaviour
{
private void Update()
{
// Rebuilds 24 slots every frame, forever, to catch a change
// that happens a few times a minute. It also dirties the
// canvas every frame - see the Canvas & UI topic.
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.
[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);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.