# Inventory Systems A deep dive into building an inventory in Unity 6000.5+: what it stores, how stacking and weight work, how items are authored and saved, how the player interacts with it, and how to test it. Companion to [[Quest Systems]], whose *Collect* objectives read this inventory. **Target:** Unity **6000.5+**. UI is **UI Toolkit** (runtime). If you want the inventory in a Quest 2 headset, build in **6000.3.27f2+** ([[0.Choosing a Unity Version]]) and read section 11. > [!note] Verification status > Checked 2026-11-09 against the 6000.5 Scripting Reference: > - `JsonUtility.ToJson`: supports plain classes/structs, `MonoBehaviour` and `ScriptableObject`; **references to other Unity objects are written as an instance ID that can only be deserialized in the same engine session**, and arrays/primitives must be wrapped in a class. This is why saves below store **item ids**, never object references. https://docs.unity3d.com/6000.5/Documentation/ScriptReference/JsonUtility.ToJson.html > - `ISerializationCallbackReceiver`: Unity's serializer does not serialize `Dictionary`; use lists in `OnBeforeSerialize` / `OnAfterDeserialize`; callbacks run on a different thread from most of the Unity API. https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ISerializationCallbackReceiver.html > - `Application.persistentDataPath` (Windows: usually `%userprofile%\AppData\LocalLow\<company>\<product>`; Android: under the app's external files dir; pass a full path via `Path.Combine`). https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Application-persistentDataPath.html > - UI Toolkit `IPointerEvent` (`position` is in **panel** coordinates, `localPosition` in the target's; `button` 0/1/2; `pointerId`) and `PointerCaptureHelper.CapturePointer(IEventHandler, int)` (while captured, all pointer events except over/out/enter/leave go to the capturing element). https://docs.unity3d.com/6000.5/Documentation/ScriptReference/UIElements.IPointerEvent.html and https://docs.unity3d.com/6000.5/Documentation/ScriptReference/UIElements.PointerCaptureHelper.CapturePointer.html > > **Not re-verified this pass (written from the established UI Toolkit/Unity APIs in the existing vault notes):** `PanelSettings`/`UIDocument` setup details, `StyleBackground` from a `Sprite`, `PointerCaptureHelper.HasPointerCapture` / `ReleasePointer`, and NUnit/Test Framework attributes. **None of the code has been compiled or play-tested in Unity.** The data layer is pure C#, so tests in section 10 are the quickest way to find mistakes. ## Parts of this note 1. [Design first: what kind of inventory?](#1-design-first-what-kind-of-inventory) 2. [Architecture](#2-architecture) 3. [Item definitions (ScriptableObjects)](#3-item-definitions-scriptableobjects) 4. [The Inventory class](#4-the-inventory-class) 5. [Holder component, using items, pickups and drops](#5-holder-component-using-items-pickups-and-drops) 6. [UI Toolkit slot grid with drag and drop](#6-ui-toolkit-slot-grid-with-drag-and-drop) 7. [Saving and loading by item id](#7-saving-and-loading-by-item-id) 8. [Variants: grid, weight, hotbar, equipment, containers](#8-variants-grid-weight-hotbar-equipment-containers) 9. [Integration points](#9-integration-points) 10. [EditMode tests](#10-editmode-tests) 11. [VR and Quest notes](#11-vr-and-quest-notes) 12. [Pitfalls, Level Up! and patterns, exercises](#12-pitfalls-level-up-and-patterns-exercises) ## 1. Design first: what kind of inventory? An inventory is a *design* decision before it is a data structure. Ask these before writing code: | Question | Options | Consequence | |---|---|---| | How much can the player carry? | Unlimited / slot count / weight / grid area | Slot count is simplest and teachable; grids add Tetris-like puzzle; weight adds a pacing system | | Does it stack? | Never / by item / by max stack | Needs `maxStack` and merge/split logic | | Are items unique instances? | Identical copies / per-copy data (durability, rolled stats) | Per-copy data forces per-stack data and non-stackability | | Is it a menu or a world object? | Pause menu / real-time overlay / diegetic (wrist, backpack) | Real-time + diegetic means the world keeps running while you rummage | | What is it *for*? | Resources, tools, loot, keys, quest items, souvenirs | Drives categories and "cannot drop" flags | Connect these to **Level Up!** (see [[Level Up! (Review)]]): - **Level 13 (power-ups, rewards, souvenirs, money).** Loot is a reward schedule: Rogers' headings include "Never underestimate the greed of the player" and "The best rewards are hard won." An inventory is where rewards become *visible*, so item feedback (a pickup sound, a toast, a slot highlight) matters as much as the data. - **Level 12 (puzzles).** "Show 'em the door and then send 'em after the key": a **key item** is the simplest inventory-driven puzzle. Key items must be flagged `questItem` (cannot be dropped, sold or lost), or the player can soft-lock. - **Level 8 (interface).** Icons and clarity: a 48-pixel slot must read at a glance. See the review for the exact scope of Level 8 before citing it in class. - **Level 13 again: "Love Thy Player."** A full bag that silently discards a pickup is the opposite. The code below returns a *leftover* so the game can say "Bag full" and leave the item in the world. ## 2. Architecture ``` ScriptableObject assets (authored) Plain C# (runtime state) MonoBehaviours / UI ItemDefinition (id, icon, stack, ...) ItemStack (struct: item+count) InventoryHolder (owns an Inventory) ItemEffect (use behaviours) Inventory (slots, stacking, weight) WorldPickup (trigger -> Add) ItemDatabase (id -> definition) InventorySave (JSON-friendly DTO) InventoryView (UI Toolkit grid) ``` Rules that keep this healthy: 1. **Definitions are assets; state is not.** Never write counts or durability into a `ScriptableObject` at runtime (in the Editor it persists into the asset; in a build it resets). State lives in `Inventory`/`ItemStack`. This is the **Flyweight** idea from *Game Programming Patterns*: one shared `ItemDefinition` per kind, many cheap stacks pointing at it. 2. **The data layer knows nothing about UI or scenes.** `Inventory` is a pure C# class that raises events. That is what makes it testable (section 10) and reusable for chests, shops and enemy loot. 3. **Reference items by stable string id when persisting.** Object references do not survive `JsonUtility` across sessions (verified above). 4. **Mutations go through one place** (`Inventory`), which raises `SlotChanged(index)` and `Changed`. UI and quests *observe*; they do not poke arrays. Create the folder `Assets/Inventory/` with `Scripts/`, `Items/` (assets), `UI/`. All scripts use `namespace Inventories` to avoid a clash with the `ItemDefinition` in [[4.Equipment Items and Classes]]; if you want one shared item type, delete one and merge the fields. ## 3. Item definitions (ScriptableObjects) ### 3.1 `ItemDefinition.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Inventories { public enum ItemCategory { Misc, Resource, Tool, Weapon, Armor, Consumable, Key, Collectible } [CreateAssetMenu(menuName = "Inventory/Item", fileName = "Item_")] public sealed class ItemDefinition : ScriptableObject { [Header("Identity")] [Tooltip("Stable id used in saves and quests. Lowercase, no spaces. NEVER change after release.")] public string id; public string displayName; [TextArea] public string description; public ItemCategory category = ItemCategory.Misc; [Header("Presentation")] public Sprite icon; [Tooltip("Prefab spawned in the world when dropped. Needs a WorldPickup.")] public GameObject worldPrefab; [Header("Rules")] [Min(1)] public int maxStack = 1; [Min(0f)] public float weight = 0f; [Min(0)] public int value = 0; [Tooltip("Cannot be dropped, sold or destroyed by the player (keys, quest items).")] public bool questItem; public bool consumable; [Tooltip("Cells used in a grid inventory (section 8).")] public Vector2Int gridSize = Vector2Int.one; [Header("Use")] public List<ItemEffect> useEffects = new List<ItemEffect>(); public bool Stackable => maxStack > 1; void OnValidate() { if (maxStack < 1) maxStack = 1; gridSize.x = Mathf.Max(1, gridSize.x); gridSize.y = Mathf.Max(1, gridSize.y); if (string.IsNullOrWhiteSpace(id)) id = name.ToLowerInvariant().Replace(' ', '_'); if (string.IsNullOrWhiteSpace(displayName)) displayName = name; } } } ``` **Why a string id and not the asset name or a GUID?** Asset names change when designers rename files; a string you choose and freeze does not. (You could use the asset's GUID in the Editor, but GUIDs are not available at runtime without extra work.) The `ItemDatabase` below enforces uniqueness. ### 3.2 `ItemEffect.cs` (Command pattern for "use") A use effect is a small command object that can be assigned in the Inspector. One asset per behaviour; any item can reuse it. ```csharp using UnityEngine; namespace Inventories { /// <summary>Anything that can heal. Implement on your health component.</summary> public interface IHealable { void Heal(float amount); } /// <summary>Base class for item use behaviours. Create subclasses as assets.</summary> public abstract class ItemEffect : ScriptableObject { /// <summary>Return true if the effect did something (so a consumable is spent).</summary> public abstract bool Apply(GameObject user); } } ``` ```csharp using UnityEngine; namespace Inventories { [CreateAssetMenu(menuName = "Inventory/Effects/Heal", fileName = "Effect_Heal_")] public sealed class HealEffect : ItemEffect { [Min(0f)] public float amount = 25f; public override bool Apply(GameObject user) { var healable = user.GetComponentInParent<IHealable>(); if (healable == null) return false; healable.Heal(amount); return true; } } } ``` Returning `bool` matters: a health potion on a full-health player can return `false` and not be consumed (your design call). Link to timed buffs in [[2.Modifiers and Timed Buffs]] for effects that apply stat modifiers. ### 3.3 `ItemDatabase.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Inventories { [CreateAssetMenu(menuName = "Inventory/Item Database", fileName = "ItemDatabase")] public sealed class ItemDatabase : ScriptableObject { public List<ItemDefinition> items = new List<ItemDefinition>(); Dictionary<string, ItemDefinition> _byId; // runtime cache, rebuilt on demand (not serialized) public ItemDefinition Get(string id) { if (string.IsNullOrEmpty(id)) return null; if (_byId == null) Rebuild(); _byId.TryGetValue(id, out var def); return def; } void Rebuild() { _byId = new Dictionary<string, ItemDefinition>(items.Count); foreach (var it in items) { if (it == null || string.IsNullOrEmpty(it.id)) continue; if (!_byId.TryAdd(it.id, it)) Debug.LogError(quot;[ItemDatabase] Duplicate item id '{it.id}' ({it.name} vs {_byId[it.id].name})", this); } } void OnValidate() => _byId = null; // editing in the Inspector invalidates the cache } } ``` Unity cannot serialize a `Dictionary`, so the database serializes a **list** and builds the dictionary lazily (the documented approach for dictionaries is the list-based callback pattern in `ISerializationCallbackReceiver`; a lazy rebuild is simpler here because the dictionary is read-only at runtime). Drag every `ItemDefinition` into the list; a later Editor tool (see [[Custom Inspectors and Editor Tooling]]) can auto-fill it. ## 4. The Inventory class ### 4.1 `ItemStack.cs` ```csharp namespace Inventories { /// <summary>One slot's contents. Struct: copy it, do not hold references into the slot array.</summary> [System.Serializable] public struct ItemStack { public ItemDefinition item; public int count; /// <summary>Per-copy data for non-stackable items (0 = unused). Extend as needed.</summary> public int durability; public bool IsEmpty => item == null || count <= 0; public static ItemStack Empty => default; public ItemStack(ItemDefinition item, int count, int durability = 0) { this.item = item; this.count = count; this.durability = durability; } } } ``` ### 4.2 `Inventory.cs` ```csharp using System; using System.Collections.Generic; namespace Inventories { /// <summary> /// Slot-based inventory with stacking and an optional weight limit. /// Pure C#: no UnityEngine scene dependencies. Raises events after state is consistent. /// </summary> public sealed class Inventory { readonly ItemStack[] _slots; /// <summary>0 or less = unlimited.</summary> public float MaxWeight { get; set; } public int Capacity => _slots.Length; public event Action<int> SlotChanged; // slot index public event Action Changed; // once per operation public Inventory(int capacity, float maxWeight = 0f) { if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity)); _slots = new ItemStack[capacity]; MaxWeight = maxWeight; } public ItemStack this[int index] => _slots[index]; public float TotalWeight { get { float w = 0f; for (int i = 0; i < _slots.Length; i++) if (!_slots[i].IsEmpty) w += _slots[i].item.weight * _slots[i].count; return w; } } // ---------- queries ---------- public int Count(ItemDefinition item) { int n = 0; for (int i = 0; i < _slots.Length; i++) if (_slots[i].item == item) n += _slots[i].count; return n; } public bool Has(ItemDefinition item, int count = 1) => Count(item) >= count; /// <summary>How many of this item could be added right now (stacking, free slots, weight).</summary> public int RoomFor(ItemDefinition item) { int room = 0; for (int i = 0; i < _slots.Length; i++) { if (_slots[i].IsEmpty) room += item.maxStack; else if (_slots[i].item == item) room += item.maxStack - _slots[i].count; } if (MaxWeight > 0f && item.weight > 0f) { int byWeight = (int)Math.Floor((MaxWeight - TotalWeight + 1e-4f) / item.weight); room = Math.Min(room, Math.Max(0, byWeight)); } return room; } // ---------- mutations ---------- /// <summary>Adds up to count items. Returns the LEFTOVER that did not fit (0 = all added).</summary> public int Add(ItemDefinition item, int count, int durability = 0) { if (item == null || count <= 0) return count; int toAdd = Math.Min(count, RoomFor(item)); int remaining = toAdd; var touched = new List<int>(4); // 1) top up existing stacks if (item.Stackable) { for (int i = 0; i < _slots.Length && remaining > 0; i++) { if (_slots[i].item != item) continue; int space = item.maxStack - _slots[i].count; if (space <= 0) continue; int put = Math.Min(space, remaining); _slots[i].count += put; remaining -= put; touched.Add(i); } } // 2) fill empty slots for (int i = 0; i < _slots.Length && remaining > 0; i++) { if (!_slots[i].IsEmpty) continue; int put = Math.Min(item.maxStack, remaining); _slots[i] = new ItemStack(item, put, durability); remaining -= put; touched.Add(i); } Notify(touched); return count - (toAdd - remaining); } /// <summary>Removes up to count of an item across stacks (last slots first). Returns how many were removed.</summary> public int Remove(ItemDefinition item, int count) { if (item == null || count <= 0) return 0; int remaining = count; var touched = new List<int>(4); for (int i = _slots.Length - 1; i >= 0 && remaining > 0; i--) { if (_slots[i].item != item) continue; int take = Math.Min(_slots[i].count, remaining); _slots[i].count -= take; if (_slots[i].count <= 0) _slots[i] = ItemStack.Empty; remaining -= take; touched.Add(i); } Notify(touched); return count - remaining; } /// <summary>Removes up to count from a specific slot and returns what was taken.</summary> public ItemStack RemoveAt(int index, int count = int.MaxValue) { if (_slots[index].IsEmpty || count <= 0) return ItemStack.Empty; int take = Math.Min(count, _slots[index].count); var taken = new ItemStack(_slots[index].item, take, _slots[index].durability); _slots[index].count -= take; if (_slots[index].count <= 0) _slots[index] = ItemStack.Empty; Notify(new List<int> { index }); return taken; } /// <summary>Drag-and-drop move: merges if same stackable item, otherwise swaps.</summary> public void Move(int from, int to) { if (from == to || _slots[from].IsEmpty) return; var a = _slots[from]; var b = _slots[to]; if (!b.IsEmpty && a.item == b.item && a.item.Stackable) { int space = a.item.maxStack - b.count; int put = Math.Min(space, a.count); if (put > 0) { _slots[to].count += put; _slots[from].count -= put; if (_slots[from].count <= 0) _slots[from] = ItemStack.Empty; Notify(new List<int> { from, to }); return; } } _slots[from] = b; _slots[to] = a; Notify(new List<int> { from, to }); } /// <summary>Splits off 'amount' from a stack into an empty slot. Returns true on success.</summary> public bool Split(int from, int amount, int toEmptySlot) { var s = _slots[from]; if (s.IsEmpty || amount <= 0 || amount >= s.count) return false; if (!_slots[toEmptySlot].IsEmpty) return false; _slots[from].count -= amount; _slots[toEmptySlot] = new ItemStack(s.item, amount, s.durability); Notify(new List<int> { from, toEmptySlot }); return true; } /// <summary>Direct write used by save/load. Raises events.</summary> public void Set(int index, ItemStack stack) { _slots[index] = stack.IsEmpty ? ItemStack.Empty : stack; Notify(new List<int> { index }); } public void Clear() { var touched = new List<int>(); for (int i = 0; i < _slots.Length; i++) if (!_slots[i].IsEmpty) { _slots[i] = ItemStack.Empty; touched.Add(i); } Notify(touched); } void Notify(List<int> touched) { if (touched.Count == 0) return; foreach (int i in touched) SlotChanged?.Invoke(i); Changed?.Invoke(); } } } ``` **How the algorithm works, and why** - `RoomFor` answers "how many can fit?" considering free slots, partial stacks and weight. `Add` clamps to that, **fills existing stacks first, then empty slots**, and returns the leftover: that is the feedback hook (full bag, too heavy). - **Events fire after the operation completes** so listeners never see a half-updated inventory. `Changed` fires once per operation, which keeps UI refreshes cheap. Keep listeners from calling mutating methods inside the handler (re-entrancy) or defer with a flag. - `Remove` walks **from the last slot** so partially filled stacks near the end are consumed first, leaving your full stacks intact; swap the loop direction if you prefer the opposite. - `Move` is what a UI drag calls. Same stackable item merges (and may leave a remainder), anything else swaps. - `new List<int>` per operation allocates a little. Operations are user-driven, not per frame, so it is fine; if you add/remove thousands per second (ammo), keep a reusable buffer. - Durability/instance data is **per stack**; non-stackable items (`maxStack = 1`) therefore each own their durability. Stackable items ignore it when merging, which is the right trade for a simple system. ## 5. Holder component, using items, pickups and drops ### 5.1 `InventoryHolder.cs` ```csharp using System; using System.Collections.Generic; using UnityEngine; namespace Inventories { public sealed class InventoryHolder : MonoBehaviour { [Serializable] public struct StartingItem { public ItemDefinition item; [Min(1)] public int count; } [SerializeField, Min(1)] int capacity = 24; [SerializeField, Min(0f)] float maxWeight = 0f; [SerializeField] List<StartingItem> startingItems = new List<StartingItem>(); [Tooltip("Where dropped items appear, relative to this object.")] [SerializeField] Vector3 dropOffset = new Vector3(0f, 0.5f, 1f); public Inventory Inventory { get; private set; } public event Action<ItemDefinition, int> Picked; // item, amount actually added public event Action<ItemDefinition, int> Used; public event Action<ItemDefinition, int> Dropped; public event Action<ItemDefinition, int> PickupRejected; // item, leftover void Awake() { Inventory = new Inventory(capacity, maxWeight); foreach (var s in startingItems) if (s.item != null) Inventory.Add(s.item, s.count); } /// <summary>Adds and reports. Returns the leftover that did not fit.</summary> public int Give(ItemDefinition item, int count) { int left = Inventory.Add(item, count); int added = count - left; if (added > 0) Picked?.Invoke(item, added); if (left > 0) PickupRejected?.Invoke(item, left); return left; } public bool Use(int slot) { var s = Inventory[slot]; if (s.IsEmpty || s.item.useEffects.Count == 0) return false; bool any = false; foreach (var fx in s.item.useEffects) if (fx != null) any |= fx.Apply(gameObject); if (any) { if (s.item.consumable) Inventory.RemoveAt(slot, 1); Used?.Invoke(s.item, 1); } return any; } public bool Drop(int slot, int count = int.MaxValue) { var s = Inventory[slot]; if (s.IsEmpty || s.item.questItem) return false; var taken = Inventory.RemoveAt(slot, count); if (taken.IsEmpty) return false; if (taken.item.worldPrefab != null) { Vector3 pos = transform.TransformPoint(dropOffset); var go = Instantiate(taken.item.worldPrefab, pos, Quaternion.identity); var pickup = go.GetComponent<WorldPickup>(); if (pickup != null) pickup.Configure(taken.item, taken.count, pickupDelay: 1.5f); } Dropped?.Invoke(taken.item, taken.count); return true; } } } ``` ### 5.2 `WorldPickup.cs` ```csharp using UnityEngine; namespace Inventories { [RequireComponent(typeof(Collider))] public sealed class WorldPickup : MonoBehaviour { [SerializeField] ItemDefinition item; [SerializeField, Min(1)] int count = 1; [SerializeField] bool autoCollect = true; float _enabledAt; void Reset() => GetComponent<Collider>().isTrigger = true; public void Configure(ItemDefinition def, int amount, float pickupDelay) { item = def; count = amount; _enabledAt = Time.time + pickupDelay; // stops a just-dropped item from instantly re-collecting } void OnTriggerEnter(Collider other) { if (autoCollect) TryCollect(other); } void OnTriggerStay(Collider other) { // Retry after the delay, or after the bag has freed space. if (autoCollect && Time.time >= _enabledAt) TryCollect(other); } /// <summary>Call from an interaction system if autoCollect is false.</summary> public bool TryCollect(Collider other) { if (Time.time < _enabledAt || item == null) return false; var holder = other.GetComponentInParent<InventoryHolder>(); if (holder == null) return false; int left = holder.Give(item, count); if (left <= 0) { Destroy(gameObject); return true; } count = left; // partial pickup: the rest stays in the world return false; } } } ``` **Notes** - A trigger needs a `Rigidbody` on at least one of the two objects to generate trigger events (the player usually has one or a `CharacterController`; see [[Character Controllers]] and [[Physics 3D]]). - `OnTriggerStay` retries each physics step. That is acceptable for a handful of pickups but for hundreds use a timer or an interaction key instead. - `PickupRejected` is your "Bag full" toast. Hook it to UI and a sound ([[Interactable Audio]]). - `Instantiate` for drops is fine for a bag; drop thousands of items and you want pooling (Object Pool in [[Game Programming Patterns (Review)]]). ## 6. UI Toolkit slot grid with drag and drop This builds the UI entirely in code so there are no UXML/USS files to wire. It reuses the approach in [[3.HUD and Inventory Example]] (`UIDocument` on a GameObject with a `PanelSettings` asset: see [[2.UI Toolkit Project Setup]]). For data binding and custom controls see [[4.Data Binding and Custom Controls]]. ### 6.1 `InventoryView.cs` ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.UIElements; namespace Inventories { [RequireComponent(typeof(UIDocument))] public sealed class InventoryView : MonoBehaviour { [SerializeField] InventoryHolder holder; [SerializeField, Min(1)] int columns = 6; [SerializeField] float slotSize = 64f; [SerializeField] KeyCode toggleKey = KeyCode.Tab; // see note: old Input API, swap for an Input System action Inventory _inv; VisualElement _root, _panel, _grid, _ghost; Label _detail, _weight; readonly List<VisualElement> _slots = new List<VisualElement>(); int _selected = -1; int _dragFrom = -1; bool _dragging; Vector2 _dragStart; void OnEnable() { _root = GetComponent<UIDocument>().rootVisualElement; BuildUi(); Bind(holder.Inventory); } void OnDisable() { if (_inv != null) _inv.Changed -= Refresh; _panel?.RemoveFromHierarchy(); } void Update() { if (Input.GetKeyDown(toggleKey)) // requires Active Input Handling: Both (or replace with an InputAction) _panel.style.display = _panel.style.display == DisplayStyle.None ? DisplayStyle.Flex : DisplayStyle.None; } void Bind(Inventory inv) { if (_inv != null) _inv.Changed -= Refresh; _inv = inv; _inv.Changed += Refresh; Refresh(); } // ---------- build ---------- void BuildUi() { _panel = new VisualElement { name = "inventory-panel" }; var s = _panel.style; s.position = Position.Absolute; s.right = 24; s.top = 24; s.paddingLeft = s.paddingRight = s.paddingTop = s.paddingBottom = 12; s.backgroundColor = new Color(0.08f, 0.09f, 0.12f, 0.92f); s.borderTopLeftRadius = s.borderTopRightRadius = s.borderBottomLeftRadius = s.borderBottomRightRadius = 8; s.display = DisplayStyle.None; _grid = new VisualElement { name = "inventory-grid" }; _grid.style.flexDirection = FlexDirection.Row; _grid.style.flexWrap = Wrap.Wrap; _grid.style.width = columns * (slotSize + 6); _panel.Add(_grid); _weight = new Label { name = "weight" }; _weight.style.color = new Color(0.8f, 0.8f, 0.85f); _weight.style.marginTop = 8; _panel.Add(_weight); _detail = new Label { name = "detail" }; _detail.style.color = Color.white; _detail.style.whiteSpace = WhiteSpace.Normal; _detail.style.marginTop = 6; _detail.style.minHeight = 48; _panel.Add(_detail); _root.Add(_panel); _ghost = new VisualElement { name = "drag-ghost", pickingMode = PickingMode.Ignore }; _ghost.style.position = Position.Absolute; _ghost.style.width = slotSize * 0.8f; _ghost.style.height = slotSize * 0.8f; _ghost.style.display = DisplayStyle.None; _root.Add(_ghost); _slots.Clear(); for (int i = 0; i < holder.Inventory.Capacity; i++) { var slot = MakeSlot(i); _slots.Add(slot); _grid.Add(slot); } } VisualElement MakeSlot(int index) { var slot = new VisualElement { name = quot;slot-{index}" }; var st = slot.style; st.width = slotSize; st.height = slotSize; st.marginRight = 6; st.marginBottom = 6; st.backgroundColor = new Color(1f, 1f, 1f, 0.07f); st.borderTopWidth = st.borderBottomWidth = st.borderLeftWidth = st.borderRightWidth = 2; SetBorder(st, new Color(1f, 1f, 1f, 0.15f)); var icon = new VisualElement { name = "icon", pickingMode = PickingMode.Ignore }; icon.style.flexGrow = 1; slot.Add(icon); var count = new Label { name = "count", pickingMode = PickingMode.Ignore }; count.style.position = Position.Absolute; count.style.right = 4; count.style.bottom = 2; count.style.color = Color.white; count.style.unityFontStyleAndWeight = FontStyle.Bold; slot.Add(count); slot.RegisterCallback<PointerDownEvent>(e => OnDown(index, slot, e)); slot.RegisterCallback<PointerMoveEvent>(e => OnMove(slot, e)); slot.RegisterCallback<PointerUpEvent>(e => OnUp(slot, e)); return slot; } static void SetBorder(IStyle st, Color c) { st.borderTopColor = st.borderBottomColor = st.borderLeftColor = st.borderRightColor = c; } // ---------- refresh ---------- void Refresh() { for (int i = 0; i < _slots.Count; i++) { var stack = _inv[i]; var icon = _slots[i].Q<VisualElement>("icon"); var count = _slots[i].Q<Label>("count"); if (stack.IsEmpty) { icon.style.backgroundImage = StyleKeyword.None; count.text = ""; } else { icon.style.backgroundImage = stack.item.icon != null ? new StyleBackground(stack.item.icon) : StyleKeyword.None; count.text = stack.count > 1 ? stack.count.ToString() : ""; } SetBorder(_slots[i].style, i == _selected ? new Color(1f, 0.8f, 0.2f) : new Color(1f, 1f, 1f, 0.15f)); } _weight.text = _inv.MaxWeight > 0f ? quot;Weight {_inv.TotalWeight:0.0} / {_inv.MaxWeight:0.0}" : quot;{CountUsed()} / {_inv.Capacity} slots"; UpdateDetail(); } int CountUsed() { int n = 0; for (int i = 0; i < _inv.Capacity; i++) if (!_inv[i].IsEmpty) n++; return n; } void UpdateDetail() { if (_selected < 0 || _inv[_selected].IsEmpty) { _detail.text = ""; return; } var it = _inv[_selected].item; _detail.text = quot;{it.displayName}\n{it.description}\nWeight {it.weight:0.0} Value {it.value}"; } // ---------- pointer handling ---------- void OnDown(int index, VisualElement slot, PointerDownEvent e) { if (e.button == 1) // right click = use { holder.Use(index); e.StopPropagation(); return; } if (e.button != 0) return; _selected = index; Refresh(); if (_inv[index].IsEmpty) return; _dragFrom = index; _dragging = false; _dragStart = e.position; // panel coordinates (IPointerEvent.position) slot.CapturePointer(e.pointerId); // keep receiving move/up even outside the slot } void OnMove(VisualElement slot, PointerMoveEvent e) { if (_dragFrom < 0 || !slot.HasPointerCapture(e.pointerId)) return; if (!_dragging && ((Vector2)e.position - _dragStart).sqrMagnitude > 16f) { _dragging = true; var icon = _inv[_dragFrom].item.icon; _ghost.style.backgroundImage = icon != null ? new StyleBackground(icon) : StyleKeyword.None; _ghost.style.display = DisplayStyle.Flex; } if (_dragging) { _ghost.style.left = e.position.x - _ghost.resolvedStyle.width * 0.5f; _ghost.style.top = e.position.y - _ghost.resolvedStyle.height * 0.5f; } } void OnUp(VisualElement slot, PointerUpEvent e) { if (_dragFrom < 0) return; if (slot.HasPointerCapture(e.pointerId)) slot.ReleasePointer(e.pointerId); if (_dragging) { int target = SlotAt(e.position); if (target >= 0) _inv.Move(_dragFrom, target); else if (!_panel.worldBound.Contains(e.position)) holder.Drop(_dragFrom); // released outside the panel } _ghost.style.display = DisplayStyle.None; _dragFrom = -1; _dragging = false; } int SlotAt(Vector2 panelPosition) { for (int i = 0; i < _slots.Count; i++) if (_slots[i].worldBound.Contains(panelPosition)) return i; return -1; } } } ``` **How the drag works** 1. `PointerDownEvent` on a slot: remember the source slot and **capture the pointer**, so subsequent move/up events go to this slot even when the cursor leaves it (documented behaviour of `CapturePointer`). 2. After a small movement threshold, show a **ghost** element (absolutely positioned in the root, `pickingMode = Ignore` so it never blocks hit-testing) and follow the pointer. `e.position` is in panel coordinates, matching `worldBound`, which is why the hit test and ghost positioning use it directly. 3. `PointerUpEvent`: find the slot under the pointer; `Inventory.Move` merges or swaps. Released outside the panel means *drop into the world* (`InventoryHolder.Drop`, which refuses quest items). 4. Right click uses the item. `Refresh` repaints everything from `Inventory.Changed`: for 24-48 slots this is trivial. If you build 500-slot inventories, switch to `ListView` virtualization ([[7.UI Toolkit Pitfalls and Performance]]). **Input note.** `Input.GetKeyDown` is the legacy Input Manager. In a project set to the Input System package only, replace it with an `InputAction` (see [[Input System]] and [[5. Worked Example Remappable Player]]), or set Active Input Handling to *Both*. I used it here to keep the example short. **Set-up checklist** 1. Scene: a `UIDocument` GameObject with a `PanelSettings` asset (no source asset needed because everything is code-built; see [[2.UI Toolkit Project Setup]]). 2. Add `InventoryView` to that GameObject and assign the player's `InventoryHolder`. 3. Make 4-5 `ItemDefinition` assets (a potion with `HealEffect`, a stackable `Wood`, a non-stackable sword, a key with `questItem` on), list them in the `ItemDatabase`, and give the holder starting items. 4. Press Play, press Tab. ## 7. Saving and loading by item id ### 7.1 `InventorySave.cs` ```csharp using System; using System.Collections.Generic; using System.IO; using UnityEngine; namespace Inventories { [Serializable] public sealed class InventorySave { public int version = 1; public List<SavedStack> stacks = new List<SavedStack>(); [Serializable] public struct SavedStack { public int slot; public string itemId; public int count; public int durability; } public static InventorySave Capture(Inventory inv) { var data = new InventorySave(); for (int i = 0; i < inv.Capacity; i++) { var s = inv[i]; if (s.IsEmpty) continue; data.stacks.Add(new SavedStack { slot = i, itemId = s.item.id, count = s.count, durability = s.durability }); } return data; } /// <summary>Unknown ids (removed items) are skipped with a warning so old saves still load.</summary> public void Restore(Inventory inv, ItemDatabase db) { inv.Clear(); foreach (var st in stacks) { var def = db.Get(st.itemId); if (def == null) { Debug.LogWarning(quot;[InventorySave] Unknown item id '{st.itemId}', skipped."); continue; } if (st.slot < 0 || st.slot >= inv.Capacity) { inv.Add(def, st.count, st.durability); continue; } inv.Set(st.slot, new ItemStack(def, Mathf.Clamp(st.count, 1, def.maxStack), st.durability)); } } public static string FilePath(string fileName) => Path.Combine(Application.persistentDataPath, fileName); public void WriteToDisk(string fileName) { string path = FilePath(fileName); string tmp = path + ".tmp"; File.WriteAllText(tmp, JsonUtility.ToJson(this, true)); if (File.Exists(path)) File.Replace(tmp, path, null); // swap in the new file; old one is not left half-written else File.Move(tmp, path); } public static InventorySave ReadFromDisk(string fileName) { string path = FilePath(fileName); return File.Exists(path) ? JsonUtility.FromJson<InventorySave>(File.ReadAllText(path)) : null; } } } ``` **What and why** - The DTO (`InventorySave`) is a **plain class of ids and numbers**: exactly what `JsonUtility` handles well (the docs note references to Unity objects would be written as instance IDs that only work in the same session). - Saves keep `slot` so the player's layout survives; unknown items are skipped so removing an item from the game does not brick old saves. - The temp-file-then-replace write avoids a half-written save if the game crashes mid-write. - `Application.persistentDataPath` is the right base (full path via `Path.Combine`, as the docs recommend). On Quest (Android) it resolves under the app's files directory. - To plug into the vault's save system, wrap `Capture/Restore` in the `ISaveable` interface from [[High Scores, Saves and Player Stats]]. - Add `version` migrations when you change the format; never reuse or rename an `id`. ### 7.2 Hooking it up ```csharp using UnityEngine; namespace Inventories { public sealed class InventorySaveController : MonoBehaviour { [SerializeField] InventoryHolder holder; [SerializeField] ItemDatabase database; [SerializeField] string fileName = "inventory.json"; void Start() { var data = InventorySave.ReadFromDisk(fileName); if (data != null) data.Restore(holder.Inventory, database); } void OnApplicationQuit() => Save(); public void Save() => InventorySave.Capture(holder.Inventory).WriteToDisk(fileName); } } ``` On Android and iOS, `OnApplicationQuit` is not reliable (the OS may kill the app); save on `OnApplicationPause(true)` too, and after significant events. ## 8. Variants: grid, weight, hotbar, equipment, containers ### 8.1 Weight (already built) `Inventory.MaxWeight` plus `ItemDefinition.weight` gives a carry-limit system. Design pointers: show the weight bar; let weight slow the character using a stat modifier (see [[2.Modifiers and Timed Buffs]]), rather than a hard stop, so it feels like a trade-off and not a wall. ### 8.2 Grid inventory (Resident Evil / Diablo style) Items occupy `gridSize` cells. This class is a standalone alternative to `Inventory`: ```csharp using System; using System.Collections.Generic; using UnityEngine; namespace Inventories { public sealed class GridInventory { public readonly int Width, Height; readonly int[,] _cellOwner; // -1 = empty, else index into _placed readonly List<Placed> _placed = new List<Placed>(); public event Action Changed; public struct Placed { public ItemDefinition item; public Vector2Int origin; public bool alive; } public GridInventory(int width, int height) { Width = width; Height = height; _cellOwner = new int[width, height]; for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) _cellOwner[x, y] = -1; } public bool CanPlace(ItemDefinition item, Vector2Int origin) { if (origin.x < 0 || origin.y < 0) return false; if (origin.x + item.gridSize.x > Width || origin.y + item.gridSize.y > Height) return false; for (int x = 0; x < item.gridSize.x; x++) for (int y = 0; y < item.gridSize.y; y++) if (_cellOwner[origin.x + x, origin.y + y] != -1) return false; return true; } public bool TryAutoPlace(ItemDefinition item, out Vector2Int where) { for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) { var o = new Vector2Int(x, y); if (CanPlace(item, o)) { Place(item, o); where = o; return true; } } where = default; return false; } public bool Place(ItemDefinition item, Vector2Int origin) { if (!CanPlace(item, origin)) return false; int id = _placed.Count; _placed.Add(new Placed { item = item, origin = origin, alive = true }); for (int x = 0; x < item.gridSize.x; x++) for (int y = 0; y < item.gridSize.y; y++) _cellOwner[origin.x + x, origin.y + y] = id; Changed?.Invoke(); return true; } public bool RemoveAt(Vector2Int cell) { if (cell.x < 0 || cell.y < 0 || cell.x >= Width || cell.y >= Height) return false; int id = _cellOwner[cell.x, cell.y]; if (id < 0) return false; var p = _placed[id]; for (int x = 0; x < p.item.gridSize.x; x++) for (int y = 0; y < p.item.gridSize.y; y++) _cellOwner[p.origin.x + x, p.origin.y + y] = -1; p.alive = false; _placed[id] = p; Changed?.Invoke(); return true; } public IEnumerable<Placed> Items() { foreach (var p in _placed) if (p.alive) yield return p; } } } ``` `_cellOwner` is the occupancy map; `CanPlace` checks bounds then every covered cell. `TryAutoPlace` is first-fit scanning (row by row), which is simple but not optimal. Rotation is an exercise: try swapping `gridSize.x/y` and calling `CanPlace` again. UI: draw each placed item as one absolutely positioned element sized `gridSize * cellSize`. ### 8.3 Hotbar A hotbar is **a view over inventory slots**, not a second inventory: store 4-9 inventory slot indices (or item ids) and call `holder.Use(slotIndex)`. If the item runs out, the binding either goes empty or follows the *item type* (like many survival games). Decide and write it down. ### 8.4 Equipment Keep equipment separate from the bag. Equipping removes one item from the inventory (or swaps with the equipped one, returning it with `Add`), and the equip slot applies/removes **stat modifiers** with the item as the *source*, as in [[4.Equipment Items and Classes]] and [[2.Modifiers and Timed Buffs]]. Rule of thumb: the inventory knows nothing about stats; an `Equipment` component listens to what was equipped and calls the stat system. ### 8.5 Containers (chests, shops, enemy loot) Because `Inventory` is plain C#, a chest is just `new Inventory(n)` in a component. A shop adds a currency item and a buy/sell price rule; loot tables roll into a temporary `Inventory`, then the loot window uses the same `InventoryView` pattern (bind a second view to the second inventory; `Move` between two inventories needs a small `Transfer(from, fromSlot, to)` helper that removes from one and `Add`s to the other, restoring on failure). ## 9. Integration points - **Quests.** [[Quest Systems]]: *Collect* objectives count `Inventory.Count(item)` and watch `Changed`, so dropping an item can un-complete a step. Quest rewards call `InventoryHolder.Give`. - **Stats and equipment.** [[Stat-Driven RPG Systems]], especially [[4.Equipment Items and Classes]]. - **Dialogue.** NPC dialogue conditions can check `Has(item)`; see [[0.Dialogue System]] and [[Conversation Engine Design for Unity 6]]. - **Audio.** Pickup/drop/use sounds through the pooled service in [[Interactable Audio]]; "bag full" as a distinct, *readable* sound. - **Saves.** [[High Scores, Saves and Player Stats]]. - **Input.** [[Input System]] for opening/using; [[UI Toolkit and Diegetic UI]] for the UI family of notes. - **Level flow.** Persist the inventory across scenes with a bootstrap scene or `DontDestroyOnLoad` holder; see [[SceneManagement in Unity 6]] and [[Level Manager]]. ## 10. EditMode tests Because `Inventory` has no scene dependencies you can test it in EditMode. Install the **Test Framework** from Package Manager (a core package; a dedicated Unity Test Framework tutorial is in the queue, so this is a quick start only) and create an *Edit Mode* test assembly. ```csharp using Inventories; using NUnit.Framework; using UnityEngine; public class InventoryTests { static ItemDefinition Make(string id, int maxStack, float weight = 0f) { var d = ScriptableObject.CreateInstance<ItemDefinition>(); d.id = id; d.displayName = id; d.maxStack = maxStack; d.weight = weight; return d; } [Test] public void Add_StacksThenFillsNewSlots() { var inv = new Inventory(3); var wood = Make("wood", 10); Assert.AreEqual(0, inv.Add(wood, 25)); // 10 + 10 + 5 Assert.AreEqual(25, inv.Count(wood)); Assert.AreEqual(10, inv[0].count); Assert.AreEqual(5, inv[2].count); } [Test] public void Add_ReturnsLeftoverWhenFull() { var inv = new Inventory(1); var wood = Make("wood", 10); Assert.AreEqual(5, inv.Add(wood, 15)); Assert.AreEqual(10, inv.Count(wood)); } [Test] public void Add_RespectsWeightLimit() { var inv = new Inventory(10, maxWeight: 10f); var rock = Make("rock", 99, weight: 2f); Assert.AreEqual(2, inv.Add(rock, 7)); // only 5 fit by weight Assert.AreEqual(5, inv.Count(rock)); } [Test] public void Remove_TakesAcrossStacks() { var inv = new Inventory(3); var wood = Make("wood", 10); inv.Add(wood, 15); Assert.AreEqual(12, inv.Remove(wood, 12)); Assert.AreEqual(3, inv.Count(wood)); } [Test] public void Move_MergesSameStackableItem() { var inv = new Inventory(3); var wood = Make("wood", 10); inv.Set(0, new ItemStack(wood, 6)); inv.Set(1, new ItemStack(wood, 7)); inv.Move(0, 1); Assert.AreEqual(10, inv[1].count); Assert.AreEqual(3, inv[0].count); } [Test] public void Events_FireOncePerOperation() { var inv = new Inventory(3); var wood = Make("wood", 10); int changed = 0; inv.Changed += () => changed++; inv.Add(wood, 25); Assert.AreEqual(1, changed); } } ``` Run them in *Window > General > Test Runner*. Write a test **before** you fix any inventory bug a student reports. ## 11. VR and Quest notes - **Build target.** Quest 2 projects use **Unity 6.3 LTS (6000.3.27f2+)** and the stack from [[2.Quest 2 SDK Stack]]; the inventory scripts are version-agnostic. - **Diegetic inventory.** A flat screen-space menu is uncomfortable in VR. Common patterns: a **wrist panel** ([[6.Diegetic Terminal and Wrist Display]]), a **backpack zone** over the shoulder that stores a grabbed object when released, **holster slots** at the hips. All of these are *views* over the same `Inventory`; the grab/release interactions call `Give`/`Drop`. See [[5.Diegetic UI Approaches]] and [[8.UI Toolkit for VR]]. Pointer events in a world-space panel need an interaction source; the mouse-based drag code above is a desktop pattern. - **Physical drops.** Dropped items should be real rigidbodies with a delay before re-pickup (already in `WorldPickup.Configure`). - **Saving.** `Application.persistentDataPath` works on Android; save on pause because the headset can sleep. - **Budget.** Keep `Refresh` off per-frame paths; update on `Changed` only ([[5.Quest 2 Performance and Rendering]]). ## 12. Pitfalls, Level Up! and patterns, exercises ### 12.1 Pitfalls | Pitfall | Fix | |---|---| | Writing runtime counts into a ScriptableObject | State in `Inventory`/`ItemStack`; assets are read-only at runtime | | Saving object references | Save ids; `JsonUtility` references are session-only (docs) | | Duplicate or renamed ids | `ItemDatabase` logs duplicates; never rename a released id | | Silent pickup failure | Use the leftover return; show "Bag full"; keep the world item | | Soft-locks | `questItem` blocks drop/sell; also never let a stack of quest items be destroyed by mistake | | Re-entrancy in event handlers | Do not mutate the inventory inside `SlotChanged`/`Changed` handlers; defer a frame | | Using the legacy `Input` class in an Input-System-only project | Use an `InputAction` or enable Both | | Mixed stack rules | One `maxStack` per definition; no per-slot overrides | | Dropped item instantly re-collected | Pickup delay | | Trigger events never fire | Missing Rigidbody on one side, or wrong layer matrix ([[3. Layers Matrix and Queries]]) | ### 12.2 Level Up! tie-ins (recap) Level 13 rewards, greed and souvenirs (collectibles are `ItemCategory.Collectible` with `maxStack = 1`); Level 12 key-and-door puzzles; Level 8 interface clarity and icons; and the **beat chart** of Level 4: add a *rewards* column listing which items each level introduces, then turn it into `ItemDefinition` assets ([[ScriptableObject Architecture]]). ### 12.3 Game Programming Patterns - **Flyweight** - `ItemDefinition` is shared intrinsic data; `ItemStack` holds the extrinsic count and durability. - **Observer** - `SlotChanged`/`Changed`, `Picked`/`Used`/`Dropped`. - **Command** - `ItemEffect.Apply` objects, interchangeable and data-assigned. - **Type Object** - item "kinds" as data instead of a subclass per item. - **Object Pool** - drops, if you spawn many. - **Dirty Flag** - the UI refreshes on `Changed`, not each frame. See [[Game Programming Patterns (Review)]]. ### 12.4 Exercises 1. Add **sorting** (`Inventory.Sort(Comparison<ItemStack>)`) and a "Sort" button; write a test that sorting merges partial stacks. 2. Implement **split stack** in the UI: shift-drag half the stack into an empty slot using `Inventory.Split`. 3. Add **durability** to swords: decrement on attack, remove at zero, and show a bar in the slot. 4. Add a **shop**: a `Gold` item, buy/sell with `value`, and a `Transfer` helper that is atomic (restores on failure). 5. Port the view to a **world-space panel** on a wrist for Quest, in a 6000.3.27f2+ project. 6. Build a **hotbar** (1-5 keys) bound to slot indices. 7. Replace the legacy `Input` toggle with an `InputAction` ([[Input System]]). ## Related (existing) - [[Quest Systems]] - quests that read and reward this inventory - [[3.HUD and Inventory Example]] / [[UI Toolkit and Diegetic UI]] - UI Toolkit background - [[4.Equipment Items and Classes]] / [[Stat-Driven RPG Systems]] - equipment and stats - [[ScriptableObject Architecture]] - why definitions are assets - [[High Scores, Saves and Player Stats]] - the save system to plug into - [[Game Programming Patterns (Review)]], [[Level Up! (Review)]] - [[Input System]], [[Interactable Audio]], [[0.Dialogue System]] ## Official docs (index) - `JsonUtility.ToJson`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/JsonUtility.ToJson.html - `ISerializationCallbackReceiver`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ISerializationCallbackReceiver.html - `Application.persistentDataPath`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Application-persistentDataPath.html - `IPointerEvent`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/UIElements.IPointerEvent.html - `PointerCaptureHelper.CapturePointer`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/UIElements.PointerCaptureHelper.CapturePointer.html ## See also (added 2026-11-10) - [[Unity Test Framework]] / [[5. Worked Example - Inventory and Stats Tests]] See also (added 2026-11-10): [[Hazards, Traps and Key-Door Puzzles]] / [[3. Lock-and-Key Data]] - doors that call `Inventory.Has` on a `questItem` key.