Most inventory bugs come from one decision: keeping what an item is, how many of it the player has and how it's drawn in the same MonoBehaviour. Split those three apart and the rest of the system gets small.
- What an item is: name, icon, how many fit in a stack. Shared by every copy, so it's an asset.
- What the player has: which item sits in which slot, and how many. Plain C#, owned by the player.
- How it's drawn: the UI, which listens and never decides anything.
Items are assets
A ScriptableObject per item lets designers add potions and swords in the Project window without touching code:
using UnityEngine;
[CreateAssetMenu(menuName = "Inventory/Item")]
public class ItemDefinition : ScriptableObject
{
[Tooltip("Unique and permanent: saves store this. Never change it once players have saves.")]
public string id;
public string displayName;
public Sprite icon;
[Min(1)] public int maxStack = 1;
}The id is a string you type, such as potion_small, not the asset's file name. File names get changed by whoever tidies the folders; the id is a promise to every save file already on a player's phone.
Slots are plain C#
A slot is an item and a count. A struct is enough:
using System;
[Serializable]
public struct ItemStack
{
public ItemDefinition item;
public int count;
public bool IsEmpty => item == null || count <= 0;
public static readonly ItemStack Empty = default;
}The inventory itself isn't a MonoBehaviour. It doesn't need a GameObject, so a chest, a shop and the player can each own one, and you can test it without a scene.
using System;
public class Inventory
{
readonly ItemStack[] slots;
/// Raised with a slot's index whenever that slot changes.
public event Action<int> SlotChanged;
public Inventory(int size) => slots = new ItemStack[size];
public int Size => slots.Length;
public ItemStack this[int index] => slots[index];
/// Adds as many as fit and returns how many didn't.
public int Add(ItemDefinition item, int amount)
{
// Top up stacks of the same item first...
for (int i = 0; i < slots.Length && amount > 0; i++)
if (slots[i].item == item && slots[i].count < item.maxStack)
amount -= Fill(i, item, amount);
// ...then start new stacks in empty slots.
for (int i = 0; i < slots.Length && amount > 0; i++)
if (slots[i].IsEmpty)
amount -= Fill(i, item, amount);
return amount;
}
int Fill(int index, ItemDefinition item, int amount)
{
int current = slots[index].IsEmpty ? 0 : slots[index].count;
int moved = Math.Min(item.maxStack - current, amount);
slots[index] = new ItemStack { item = item, count = current + moved };
SlotChanged?.Invoke(index);
return moved;
}
}Add returning the leftover is the detail that pays off. Picking up 30 arrows with room for 12 leaves 18 on the ground, and the caller decides what that means: leave them, drop them, or show "Inventory full".
Removing, moving and splitting
Removing works from the last slot backwards, so the stack at the front stays full:
/// Removes up to `amount` and returns how many it removed.
public int Remove(ItemDefinition item, int amount)
{
int removed = 0;
for (int i = slots.Length - 1; i >= 0 && removed < amount; i--)
{
if (slots[i].IsEmpty || slots[i].item != item) continue;
int take = Math.Min(slots[i].count, amount - removed);
int left = slots[i].count - take;
slots[i] = left > 0 ? new ItemStack { item = item, count = left } : ItemStack.Empty;
removed += take;
SlotChanged?.Invoke(i);
}
return removed;
}Dragging between slots and splitting a stack are the same operation: move some of one slot onto another, which must be empty or hold the same item.
/// Moves up to `amount` from one slot onto an empty slot or the same item.
public bool Move(int from, int to, int amount)
{
ItemStack source = slots[from], target = slots[to];
if (from == to || amount <= 0 || source.IsEmpty) return false;
if (!target.IsEmpty && target.item != source.item) return false;
int current = target.IsEmpty ? 0 : target.count;
int moved = Math.Min(Math.Min(amount, source.count), source.item.maxStack - current);
if (moved == 0) return false;
slots[to] = new ItemStack { item = source.item, count = current + moved };
int left = source.count - moved;
slots[from] = left > 0 ? new ItemStack { item = source.item, count = left } : ItemStack.Empty;
SlotChanged?.Invoke(from);
SlotChanged?.Invoke(to);
return true;
}Splitting a stack in half is Move(slot, emptySlot, count / 2). When Move refuses because the target holds a different item, the UI swaps the two slots instead.
The UI listens
Each slot view redraws itself when it hears its index, and never changes the inventory directly:
inventory.SlotChanged += index => slotViews[index].Show(inventory[index]);Every change, from a pickup, a crafting recipe or a quest reward, goes through Add, Remove or Move, so the UI can never show something the inventory doesn't hold.
Save ids, not assets
Saving needs one more asset: a catalogue of every item, to turn an id back into its ItemDefinition.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Inventory/Item Catalog")]
public class ItemCatalog : ScriptableObject
{
[SerializeField] List<ItemDefinition> items = new();
Dictionary<string, ItemDefinition> byId;
void OnValidate() => byId = null; // rebuild after edits in the editor
public ItemDefinition Find(string id)
{
if (byId == null)
{
byId = new Dictionary<string, ItemDefinition>();
foreach (ItemDefinition item in items) byId.Add(item.id, item); // throws on a duplicate id
}
return byId.TryGetValue(id, out ItemDefinition found) ? found : null;
}
}The save stores the slot, the id and the count, nothing else:
using System;
using System.Collections.Generic;
[Serializable]
public class InventorySave
{
public List<Slot> slots = new();
[Serializable]
public struct Slot
{
public int index;
public string id;
public int count;
}
}Add these two methods to Inventory:
public InventorySave ToSave()
{
var save = new InventorySave();
for (int i = 0; i < slots.Length; i++)
if (!slots[i].IsEmpty)
save.slots.Add(new InventorySave.Slot { index = i, id = slots[i].item.id, count = slots[i].count });
return save;
}
public void Load(InventorySave save, ItemCatalog catalog)
{
Array.Clear(slots, 0, slots.Length);
foreach (InventorySave.Slot saved in save.slots)
{
ItemDefinition item = catalog.Find(saved.id);
// An item removed in an update, or a bag that got smaller: skip it rather than fail.
if (item == null || saved.index < 0 || saved.index >= slots.Length) continue;
slots[saved.index] = new ItemStack { item = item, count = Math.Min(saved.count, item.maxStack) };
}
for (int i = 0; i < slots.Length; i++) SlotChanged?.Invoke(i);
}JsonUtility.ToJson(inventory.ToSave()) turns that into text, and JsonUtility.FromJson<InventorySave>(json) reads it back.
For writing that file safely, so an update never wipes a player's bag, see Save files that survive your next update.



