Every RPG has an inventory: potions picked up from chests, ore mined from rocks, swords looted from bosses, and in gacha games, hundreds of weapons and character materials. This chapter is about representing all of that as data so cleanly that a designer can add five hundred new items without a programmer touching code, and so the exact same inventory logic can sit behind a phone screen, a controller menu, or a mail-attachment system. We build it piece by piece: an item's definition, an item's instance in a slot, the list of slots that makes up an inventory, the add/remove rules, equipment, the UI, and saving it all to disk.
You already know lists, dictionaries, and their Big-O costs from the data structures chapter — that shows up again here, because a naive inventory search is O(n) and a well-indexed one is O(1). Keep that in mind; we come back to it near the end.
A beginner's first instinct is to give every item its own C# class: a HealthPotion class, a Sword class, an IronOre class, each with its own fields and maybe its own Use() method. That works for a school project with five items. It breaks down fast in a real RPG, and it is completely unworkable for a gacha game with hundreds or thousands of items.
// The instinct that does NOT scale: one class per item
public class HealthPotion { public string name = "Health Potion"; public int healAmount = 20; }
public class ManaPotion { public string name = "Mana Potion"; public int manaAmount = 15; }
public class IronSword { public string name = "Iron Sword"; public int damage = 8; }
// ...repeat this pattern 500 more times for a gacha game's item list
Three problems with this: (1) adding a new item means writing and compiling a new C# class, so a game designer with no programming background cannot add items themselves; (2) there is no single list you can loop over, because every class is a different type; (3) most items do not actually behave differently in code — a "Health Potion" and a "Mana Potion" both just have a name, an icon, and a number. The data is different, the behavior is nearly the same.
The fix is to make items data: one class with fields like name, icon, and stack size, and many instances of that one class — one per item, each filled in with different values. Unity gives us a purpose-built tool for exactly this: the ScriptableObject (a Unity base class for data that lives as a file on disk, not attached to any GameObject in a scene).
A ScriptableObject is a class you write that Unity can save as its own asset file (a .asset file in your Project window), the same way a texture or a prefab is a file. Unlike a MonoBehaviour (which must live on a GameObject in a scene to do anything), a ScriptableObject is just data sitting on disk that anything in your project can reference. One asset, reused everywhere it is needed — nothing about it is duplicated per scene.
using UnityEngine;
public enum ItemType { Material, Consumable, Weapon, Armor, QuestItem }
[CreateAssetMenu(fileName = "NewItem", menuName = "Inventory/Item Definition")]
public class ItemDefinition : ScriptableObject
{
[Header("Identity")]
public string id; // stable id used for saving, e.g. "potion_health_01"
public string itemName; // shown in the UI, e.g. "Health Potion"
public Sprite icon; // the picture the UI draws for this item
[Header("Stacking")]
public int maxStackSize = 1; // 1 = cannot stack (most weapons), 99 = stacks a lot (materials)
[Header("Type")]
public ItemType itemType;
[TextArea]
public string description;
}
The [CreateAssetMenu(...)] line is a Unity attribute (a tag placed above a class or member that tells Unity or the editor to treat it specially). This one adds a menu item under Assets > Create > Inventory > Item Definition. Click it, and Unity creates a new .asset file on disk — an actual saved instance of ItemDefinition with its own values, editable in the Inspector like any other Unity object. A designer creates IronSword.asset, drags in an icon sprite, types "Iron Sword" into itemName, sets maxStackSize to 1 — no code written, no recompiling.
Here is a tiny script that just reads one and prints it, to see the values land correctly:
using UnityEngine;
public class ItemPrinter : MonoBehaviour
{
public ItemDefinition item; // dragged onto this field in the Inspector
void Start()
{
Debug.Log($"{item.itemName} (id={item.id}) stacks up to {item.maxStackSize}");
}
}
Output (Console window, after dragging the IronSword asset onto item and pressing Play):
Iron Sword (id=sword_iron_01) stacks up to 1
ItemDefinition a hand-typed id string and never change it once items exist in shipped save files. The file name (IronSword.asset) and the display name (itemName) can be renamed freely during a redesign; the id is the one thing that must stay stable, because saves and any server-side data refer to items by id (more on this in section 9).Here is the idea this whole chapter is built around, and the single most common beginner mistake with inventories. IronSword.asset is one file. There is exactly one of it in the whole project. If the player picks up three iron swords, you do not get three copies of the asset — you still have one IronSword.asset, and somewhere you store the number 3. The asset is the definition ("what an Iron Sword IS": its name, icon, max stack size — shared, one copy, never changes per player). The number 3 living in a slot is the instance ("how many of it THIS player currently has, in THIS slot" — one per pickup, changes constantly).
The instance needs a way to say "which item" and "how many". That is a small class holding a reference to the definition plus a count:
[System.Serializable]
public class ItemStack
{
public ItemDefinition definition; // reference to the shared asset -- the "what"
public int count; // how many in THIS slot -- the "how many"
public bool IsEmpty => definition == null || count <= 0;
}
Let us prove two stacks really do share one definition object, but keep independent counts:
void Start()
{
ItemStack slotA = new ItemStack { definition = ironSword, count = 1 };
ItemStack slotB = new ItemStack { definition = ironSword, count = 1 };
slotB.count = 5; // change only slotB's count
Debug.Log($"same asset in memory? {ReferenceEquals(slotA.definition, slotB.definition)}");
Debug.Log($"slotA.count={slotA.count}, slotB.count={slotB.count}");
}
Output:
same asset in memory? True
slotA.count=1, slotB.count=5
Both slots point at the exact same IronSword.asset object — that is why ReferenceEquals reports True. But count lives on the stack, not on the definition, so each slot tracks its own amount independently. That is the whole trick.
count field directly onto ItemDefinition and mutating it (e.g. item.count++ when the player picks something up). Because ItemDefinition is one shared asset, that single count is shared by every reference to it across the entire game — every player, every slot, every place that item ever appears. Worse, if you change it while testing in the Unity Editor's Play mode, Unity may leave that change written into the actual asset file after you stop playing, silently corrupting your item data. Per-player, per-slot numbers (count, current durability, "is equipped") always belong on the instance, never on the definition.An inventory is a fixed-size collection of slots, and each slot holds one ItemStack (which may be empty). We store it as a plain List<ItemStack> with a capacity chosen up front — the same "reserve the size you need" idea from the data structures chapter, so the list never has to resize while the player is playing.
using System.Collections.Generic;
using UnityEngine;
public class Inventory
{
public List<ItemStack> slots = new List<ItemStack>();
public int capacity;
public Inventory(int capacity)
{
this.capacity = capacity;
for (int i = 0; i < capacity; i++)
slots.Add(new ItemStack()); // starts empty: definition == null, count == 0
}
}
Notice the slot list does not care what kind of item is in each slot — a weapon and a potion sit in the same kind of list entry. All the special behavior (can it stack, what happens when you use it) comes from the ItemDefinition the slot points at, not from the slot itself. This is the same "data, not a class per item" idea from section 1, now applied to storage.
Picking up an item should not always eat a new slot. If the player already has a stack of 5 Health Potions (max 99) and picks up 3 more, those 3 should top up the existing stack, not create a second one. Only once every matching stack is full do we reach for an empty slot. The algorithm runs in two passes:
public int AddItem(ItemDefinition item, int amount)
{
// Pass 1: top up existing stacks of the same item first.
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.definition == item && slot.count < item.maxStackSize)
{
int room = item.maxStackSize - slot.count;
int add = Mathf.Min(room, amount);
slot.count += add;
amount -= add;
}
}
// Pass 2: whatever is left over goes into empty slots.
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.IsEmpty)
{
int add = Mathf.Min(item.maxStackSize, amount);
slot.definition = item;
slot.count = add;
amount -= add;
}
}
return amount; // 0 = everything fit; > 0 = this many did not fit (inventory full)
}
Trace it with a small inventory. Capacity 3, and Health Potion's maxStackSize is 10 (a small number so the trace is easy to follow):
Inventory inv = new Inventory(3);
inv.AddItem(healthPotion, 5); // nothing existed yet -> pass 2 creates slot 0 = 5
int leftover = inv.AddItem(healthPotion, 8); // pass 1 tops slot 0 to 10 (+5), pass 2 makes slot 1 = 3 (+3)
Debug.Log($"leftover: {leftover}");
for (int i = 0; i < inv.slots.Count; i++)
{
ItemStack s = inv.slots[i];
Debug.Log($"slot {i}: {(s.IsEmpty ? "empty" : $"{s.definition.itemName} x{s.count}")}");
}
Output:
leftover: 0
slot 0: Health Potion x10
slot 1: Health Potion x3
slot 2: empty
Walk through it: the first call has nothing to top up, so pass 1 does nothing and pass 2 fills slot 0 with 5. The second call's pass 1 finds slot 0 at 5/10, tops it up by 5 (now 10, using up 5 of the 8), leaving 3 left over; pass 2 then places that 3 into the next empty slot, slot 1. Zero leftover, because 3 slots × 10 capacity was plenty of room.
Now watch what happens when the inventory really is full — capacity 2, same 10-stack potion, adding 25 at once:
Inventory small = new Inventory(2);
int leftover2 = small.AddItem(healthPotion, 25);
Debug.Log($"leftover: {leftover2}");
leftover: 5
Two slots × 10 max = 20 total room. We tried to add 25, so 20 fit and 5 could not — AddItem hands that 5 back as the return value instead of silently losing it. The caller decides what to do with it: drop it on the ground, show an "inventory full" popup, or refuse the pickup entirely.
Removing needs one extra step beginners often skip: check you actually have enough before you start subtracting. Crafting a recipe that needs 3 Iron Ore and 2 Wood should never consume the 3 ore and then discover there is not enough wood — that leaves the player's inventory in a half-consumed, wrong state.
public bool RemoveItem(ItemDefinition item, int amount)
{
int have = 0;
foreach (ItemStack slot in slots)
if (slot.definition == item)
have += slot.count;
if (have < amount)
return false; // not enough -- change nothing
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.definition == item)
{
int take = Mathf.Min(slot.count, amount);
slot.count -= take;
amount -= take;
if (slot.count == 0)
slot.definition = null; // slot becomes empty again, ready for reuse
}
}
return true;
}
Trace it on the inventory from section 5 (slot 0 = Health Potion x10, slot 1 = Health Potion x3, slot 2 empty), calling RemoveItem(healthPotion, 12):
bool ok = inv.RemoveItem(healthPotion, 12);
Debug.Log($"removed ok: {ok}");
for (int i = 0; i < inv.slots.Count; i++)
{
ItemStack s = inv.slots[i];
Debug.Log($"slot {i}: {(s.IsEmpty ? "empty" : $"{s.definition.itemName} x{s.count}")}");
}
removed ok: True
slot 0: empty
slot 1: Health Potion x1
slot 2: empty
We had 13 potions total (10 + 3) and asked to remove 12: slot 0 gives up all 10 and becomes empty, slot 1 gives up 2 of its 3, leaving 1. Had we asked for 20, have would be 13, which is less than 20, so the method would return false and touch nothing at all.
Equipment is a different shape of storage than the backpack. The backpack is many interchangeable slots you fill in any order. Equipment is a small fixed, named set of slots — one Weapon slot, one Head slot, one Chest slot — and each holds at most one item. A Dictionary keyed by a slot type fits this well, because you look things up by name (Weapon, Head, ...), not by index:
using System.Collections.Generic;
public enum EquipmentSlotType { Weapon, Head, Chest, Accessory }
public class Equipment
{
private Dictionary<EquipmentSlotType, ItemDefinition> equipped =
new Dictionary<EquipmentSlotType, ItemDefinition>();
public ItemDefinition Get(EquipmentSlotType slot)
{
equipped.TryGetValue(slot, out ItemDefinition item);
return item; // null if nothing is equipped there
}
public ItemDefinition Equip(EquipmentSlotType slot, ItemDefinition item)
{
equipped.TryGetValue(slot, out ItemDefinition previous);
equipped[slot] = item;
return previous; // whatever WAS there -- caller decides where it goes
}
}
Equip never destroys the old item — it hands it back so the caller can put it somewhere sensible, usually back into the backpack inventory. Here is a full swap, wiring Inventory and Equipment together:
Equipment gear = new Equipment();
Inventory backpack = new Inventory(5);
backpack.AddItem(ironSword, 1);
backpack.RemoveItem(ironSword, 1);
ItemDefinition previous = gear.Equip(EquipmentSlotType.Weapon, ironSword);
Debug.Log($"equipped: {gear.Get(EquipmentSlotType.Weapon).itemName}");
Debug.Log($"previous weapon: {(previous == null ? "none" : previous.itemName)}");
// later, equip a second weapon
backpack.AddItem(steelAxe, 1);
backpack.RemoveItem(steelAxe, 1);
ItemDefinition oldWeapon = gear.Equip(EquipmentSlotType.Weapon, steelAxe);
backpack.AddItem(oldWeapon, 1); // the iron sword goes back into the backpack
Debug.Log($"equipped now: {gear.Get(EquipmentSlotType.Weapon).itemName}");
Debug.Log($"backpack slot 0: {backpack.slots[0].definition.itemName} x{backpack.slots[0].count}");
equipped: Iron Sword
previous weapon: none
equipped now: Steel Axe
backpack slot 0: Iron Sword x1
The first equip has nothing to swap out, so previous is null. The second equip returns the Iron Sword, which the caller immediately hands back to AddItem — nothing is ever lost, it just moves between the two containers.
ItemStack-like object per slot instead of a bare ItemDefinition, so an equipped sword can carry its own durability separately from a spare one sitting in the backpack.A very common beginner mistake is writing item logic inside the UI — stacking math inside a drag-and-drop handler, or a "Use Potion" button that directly pokes at fields. The rule to follow: the data drives the display; the UI never decides game rules, it only shows what the data currently says. Inventory should work perfectly with zero UI at all (which also means you can test it without ever pressing Play). The UI's only job is: read the current slots, draw them, and forward player input (like "use slot 2") back to Inventory as a method call.
The cleanest wiring is an event (a way for one object to announce "something happened" without needing to know who, if anyone, is listening): Inventory fires OnChanged whenever a slot changes, and the UI just redraws itself whenever it hears that.
public class Inventory
{
public event System.Action OnChanged;
public List<ItemStack> slots = new List<ItemStack>();
// ... capacity, AddItem, RemoveItem as before ...
public int AddItem(ItemDefinition item, int amount)
{
// ... same two-pass logic as section 5 ...
OnChanged?.Invoke(); // tell any listeners: something changed, redraw
return amount;
}
}
using UnityEngine;
public class InventoryUI : MonoBehaviour
{
public Inventory inventory; // just reads the data, never edits slots directly
public SlotView[] slotViews; // one pre-made UI element per capacity slot
void OnEnable()
{
inventory.OnChanged += Redraw;
Redraw();
}
void OnDisable()
{
inventory.OnChanged -= Redraw;
}
void Redraw()
{
for (int i = 0; i < slotViews.Length; i++)
{
ItemStack stack = inventory.slots[i];
if (stack.IsEmpty)
slotViews[i].SetDisplay(null, "");
else
slotViews[i].SetDisplay(stack.definition.icon, stack.count.ToString());
}
}
public void OnSlotClicked(int index)
{
// forward the click as a request -- Inventory decides what it means, not the UI
inventory.UseSlot(index);
}
}
With this split, you could throw away the entire UI and build a completely different one (a controller-friendly grid, a compact mobile layout) and Inventory would not change at all. You could also write an automated test that adds and removes items and checks the resulting slots, with no GameObject, no scene, and no Play mode involved.
Inventory.A save file is not a running game — it is text or bytes sitting on disk (or on a server) long after the game closed. You cannot put a live C# object reference into it; there is nothing on the other end for that reference to point at once the game restarts. So a save stores plain data: for each slot, the item's id string (from section 2) and the count — nothing else.
[System.Serializable]
public class ItemStackSaveData
{
public string id; // e.g. "potion_health_01" -- NOT an object reference
public int count;
}
[System.Serializable]
public class InventorySaveData
{
public List<ItemStackSaveData> slots = new List<ItemStackSaveData>();
}
Turning live slots into save data, and back, needs a lookup table from id to the real ItemDefinition asset — usually built once at game startup by scanning every item asset in the project into a Dictionary<string, ItemDefinition>:
public InventorySaveData ToSaveData()
{
InventorySaveData data = new InventorySaveData();
foreach (ItemStack slot in slots)
{
data.slots.Add(new ItemStackSaveData
{
id = slot.IsEmpty ? "" : slot.definition.id,
count = slot.count
});
}
return data;
}
public void LoadFromSaveData(InventorySaveData data, Dictionary<string, ItemDefinition> itemDatabase)
{
for (int i = 0; i < slots.Count && i < data.slots.Count; i++)
{
ItemStackSaveData entry = data.slots[i];
if (string.IsNullOrEmpty(entry.id))
{
slots[i].definition = null;
slots[i].count = 0;
}
else
{
slots[i].definition = itemDatabase[entry.id]; // look the real asset back up
slots[i].count = entry.count;
}
}
}
Trace it on the inventory from section 6 (slot 0 empty, slot 1 = Health Potion x1, slot 2 empty):
InventorySaveData data = inv.ToSaveData();
Debug.Log(JsonUtility.ToJson(data));
{"slots":[{"id":"","count":0},{"id":"potion_health_01","count":1},{"id":"","count":0}]}
That single line of JSON (JavaScript Object Notation — plain text key/value data, easy to write to a file or send to a server) is everything needed to rebuild the exact same slots later: no icons, no descriptions, no C# object references, just ids and numbers. On load, itemDatabase["potion_health_01"] hands back the one shared HealthPotion.asset reference, and the slot points at it again — exactly like it did before saving.
id string. Instance IDs are only valid for the current run and are different next time. Asset GUIDs can shift if you reorganize your project depending on your workflow, and neither means anything to a server that has never opened the Unity project. A short id string that you control fully (and never change once players have it in a save) is the only thing safe to store long-term — it also works unchanged if your inventory later needs to sync with a server, which almost every gacha game does.A game like this can have thousands of ItemDefinition assets (weapons, relics, ascension materials, one set of mats for every character) and a single player's inventory can hold hundreds of stacks. Every method above still works at that scale, but a few habits keep it fast instead of slow and stuttery.
The first cost to watch: "does the player have at least 5 Iron Ore?" as written in RemoveItem scans every slot — O(n) (using the Big-O notation from the data structures chapter). Fine once. But a crafting screen that checks fifteen ingredients, every frame, while the player hovers over a recipe, is now doing fifteen full inventory scans per frame. The fix is the same one from that chapter: keep a side index, a Dictionary<string, int> mapping item id to total count, updated incrementally whenever a slot changes:
private Dictionary<string, int> countByItemId = new Dictionary<string, int>();
public int GetTotalCount(ItemDefinition item)
{
countByItemId.TryGetValue(item.id, out int total);
return total; // O(1) average, instead of scanning every slot
}
// inside AddItem, after slot.count += add:
// countByItemId[item.id] = countByItemId.GetValueOrDefault(item.id) + add;
// inside RemoveItem, after slot.count -= take:
// countByItemId[item.id] -= take;
The second cost is on the UI side, not the data side: redrawing a huge inventory screen by destroying and instantiating a fresh GameObject per slot every time anything changes is slow and creates garbage (memory the garbage collector has to clean up later, which can cause a visible frame hitch). Instead, create the slot UI elements once — a fixed pool — and only ever update their icon and text, exactly like InventoryUI.Redraw() in section 8 already does. That pattern scales to a screen with a scrollable list of a thousand materials without allocating anything per frame.
Last habit: avoid convenience code that quietly allocates in a hot path. A one-line query built with LINQ (a query-style library, for example slots.Where(...).Sum(...)) reads nicely but allocates extra objects on every call; a plain for loop or the dictionary index above does the same job without the hidden cost. None of this matters for a five-item test project — it matters a great deal once an inventory has hundreds of stacks and a UI checking them every frame.
AddItem once per pickup would be effort spent on a cost that was never really there.[CreateAssetMenu] placed above a class or member that tells Unity (or the editor) to treat it specially.Inventory) to announce that something happened, without needing to know which, if any, listeners exist.Dictionary<string, int> kept alongside the slot list so "how many of item X do I have" is an O(1) lookup instead of an O(n) scan.Inventory has capacity 2. "Wood" has maxStackSize = 20. Trace these two calls and give the final contents of both slots plus the return value of each call.
inv.AddItem(wood, 15);
int leftover = inv.AddItem(wood, 30);
Call 1, AddItem(wood, 15): pass 1 finds nothing to top up (both slots empty). Pass 2 fills slot 0: add = min(20, 15) = 15, so slot 0 becomes wood x15, and amount reaches 0. This call returns 0.
Call 2, AddItem(wood, 30): pass 1 finds slot 0 holding wood with room (15 < 20), room is 20 - 15 = 5, so it adds min(5, 30) = 5. Slot 0 becomes wood x20, and amount drops from 30 to 25. Pass 2 then finds slot 1 empty and adds min(20, 25) = 20, making slot 1 wood x20, leaving amount = 5. That 5 could not fit anywhere (both slots are now full at 20/20), so the call returns 5.
Final state: slot 0 = wood x20, slot 1 = wood x20, leftover from call 2 = 5. That matches the total room: 2 slots × 20 = 40 capacity, and 15 + 30 = 45 was requested, so 5 had nowhere to go.
ItemDefinition and this pickup script. Explain what goes wrong once several potion pickups in the scene reference the same HealthPotion.asset, and say how you would fix it.
[CreateAssetMenu(menuName = "Inventory/Item Definition")]
public class ItemDefinition : ScriptableObject
{
public string itemName;
public int count; // teammate added this to track how many the player has
}
public class Pickup : MonoBehaviour
{
public ItemDefinition item;
void OnTriggerEnter(Collider other)
{
item.count += 1;
Destroy(gameObject);
}
}
The bug is exactly the mistake from section 3's warning box: count was added to the definition, but ItemDefinition is one shared asset. Every Pickup in the scene that references the same HealthPotion.asset is mutating the exact same count field on the exact same object. Walking over three separate potion pickups does not give the player "1 potion in three different places" — it increments one shared number to 3, with no idea of which slot, which player, or which inventory it belongs to. If two players in a multiplayer game both pick up potions, they would even be fighting over the same number. And if this is tested by pressing Play in the editor, Unity can leave the incremented value saved into the actual .asset file after Play mode stops, corrupting the source data.
The fix: remove count from ItemDefinition entirely — it does not belong on the definition. Give the player an Inventory (section 4) and have Pickup call inventory.AddItem(item, 1) instead of touching the definition. The count then lives on an ItemStack instance inside a specific slot of a specific inventory, exactly where it belongs.
{"slots":[{"id":"ore_iron_01","count":40},{"id":"","count":0},{"id":"sword_iron_01","count":1}]}
In your own words, explain everything LoadFromSaveData needs in order to rebuild the real slots from this, and why storing the display text "Iron Sword" instead of the id "sword_iron_01" would be a worse long-term choice.
To rebuild the slots, LoadFromSaveData needs an itemDatabase: a Dictionary<string, ItemDefinition> mapping every item's id to its actual asset, normally built once at game startup by scanning all ItemDefinition assets in the project. It then loops over the saved entries in order: entry 0 has id "ore_iron_01", so slot 0 gets itemDatabase["ore_iron_01"] and count 40; entry 1 has an empty id, so slot 1 stays empty (definition = null, count = 0); entry 2 has id "sword_iron_01", so slot 2 gets that definition and count 1.
Storing the display name "Iron Sword" instead of the id would be worse for a few reasons: display names are meant to be changed freely (renames, redesigns, translations into other languages), while an id is meant to never change once it ships in a save. If a designer later renames itemName to "Iron Longsword" for flavor, any save file matching on the old text would silently fail to find the item, while matching on id = "sword_iron_01" still works fine because the id never changed. Text matching is also fragile (capitalization, spacing, punctuation), and a localized game would have a different display name per language, which cannot double as a stable lookup key across all of them.
That is a full inventory system: a shared ItemDefinition asset per item, an ItemStack instance per slot, a List<ItemStack> inventory with two-pass adding and checked removing, a small fixed set of named equipment slots, a UI that only ever reads and redraws, save data built from ids and counts, and a count index for when the inventory gets big. The recurring theme, worth carrying forward: keep the shared "what is this item" data separate from the per-player "how many do I have," and let data drive everything built on top of it.