How to Build an Inventory System in Godot 4
Every RPG, survival game, and adventure game needs an inventory. It sounds simple - a list of items - but a well-built inventory system touches data architecture, UI design, drag-and-drop input, and signal-driven updates. Get the foundation right and everything else clicks into place.
This tutorial walks through the core architecture of an inventory system in Godot 4 using GDScript. By the end, you'll have a clean, extensible system that handles item data, stacking, and a grid-based UI.
The Architecture: Resources, Not Nodes
The biggest mistake beginners make with inventory systems is storing item data in nodes. You end up with item scenes that carry sprites, collision shapes, and scripts - and then you try to stuff those into an array and wonder why everything breaks.
The correct approach: use Resources.
A Resource in Godot is a lightweight data container. It doesn't exist in the scene tree. It doesn't have a position or a sprite. It's just data - which is exactly what an inventory item is.
# item.gdclass_name Itemextends Resource@export var id: String@export var name: String@export var description: String@export var icon: Texture2D@export var max_stack: int = 1@export var item_type: ItemTypeenum ItemType { WEAPON, ARMOR, CONSUMABLE, MATERIAL, KEY_ITEM }This is your item definition. You create items in the Inspector by right-clicking in the FileSystem → New Resource → Item. Fill in the fields, save it as sword.tres or health_potion.tres, and you have a reusable item definition.
The key insight: the Resource is the blueprint, not the instance. Your inventory doesn't hold a "sword node" - it holds a reference to the sword Resource plus a quantity.
The Inventory Data Structure
Your inventory is a simple array of slots. Each slot holds an optional item reference and a count:
# inventory.gdclass_name Inventoryextends Resourcesignal inventory_changed@export var slots: Array[InventorySlot] = []@export var max_slots: int = 20func _init() -> void: for i in range(max_slots): slots.append(InventorySlot.new())And each slot:
# inventory_slot.gdclass_name InventorySlotextends Resourcevar item: Item = nullvar quantity: int = 0func is_empty() -> bool: return item == nullfunc can_stack(new_item: Item) -> bool: return item == new_item and quantity < item.max_stackNotice that Inventory extends Resource, not Node. This means you can save it, load it, duplicate it, and pass it around without it being tied to the scene tree.
Adding and Removing Items
The core operations are straightforward, but you need to handle stacking correctly:
# inventory.gd (continued)func add_item(new_item: Item, amount: int = 1) -> int: var remaining := amount # First pass: try to stack with existing slots for slot in slots: if remaining <= 0: break if slot.can_stack(new_item): var space := new_item.max_stack - slot.quantity var to_add := mini(remaining, space) slot.quantity += to_add remaining -= to_add # Second pass: fill empty slots for slot in slots: if remaining <= 0: break if slot.is_empty(): slot.item = new_item var to_add := mini(remaining, new_item.max_stack) slot.quantity = to_add remaining -= to_add inventory_changed.emit() return remaining # Returns leftover that didn't fitfunc remove_item(target_item: Item, amount: int = 1) -> bool: var remaining := amount for slot in slots: if remaining <= 0: break if slot.item == target_item: var to_remove := mini(remaining, slot.quantity) slot.quantity -= to_remove remaining -= to_remove if slot.quantity <= 0: slot.item = null slot.quantity = 0 inventory_changed.emit() return remaining <= 0 # True if we removed everything requestedThe two-pass approach for add_item is important: always try to stack first, then use empty slots. This prevents the inventory from fragmenting (five separate stacks of 1 potion instead of one stack of 5).
Building the UI
The UI mirrors the data structure: a grid of slot panels, each displaying the item icon and quantity.
# inventory_ui.gdextends Control@export var inventory: Inventory@export var slot_scene: PackedScene@onready var grid: GridContainer = $GridContainerfunc _ready() -> void: inventory.inventory_changed.connect(_refresh) _refresh()func _refresh() -> void: # Clear existing UI slots for child in grid.get_children(): child.queue_free() # Rebuild from data for slot in inventory.slots: var ui_slot: InventoryUISlot = slot_scene.instantiate() ui_slot.set_slot_data(slot) grid.add_child(ui_slot)Each UI slot is a simple scene:
# inventory_ui_slot.gdextends PanelContainer@onready var icon: TextureRect = $Icon@onready var quantity_label: Label = $QuantityLabelfunc set_slot_data(slot: InventorySlot) -> void: if slot.is_empty(): icon.texture = null quantity_label.text = "" else: icon.texture = slot.item.icon quantity_label.text = str(slot.quantity) if slot.quantity > 1 else ""The signal connection is the glue: when the inventory data changes, the UI rebuilds. The data and the display are completely decoupled - you can change how items are stored without touching the UI, and you can redesign the UI without touching the inventory logic.
Drag and Drop
Godot has a built-in drag-and-drop system on Control nodes. Override three methods:
# inventory_ui_slot.gd (extended)func _get_drag_data(_pos: Vector2) -> Variant: if slot_data.is_empty(): return null # Create a preview that follows the mouse var preview := TextureRect.new() preview.texture = slot_data.item.icon preview.size = Vector2(64, 64) set_drag_preview(preview) return {"slot": slot_data, "source": self}func _can_drop_data(_pos: Vector2, data: Variant) -> bool: return data is Dictionary and data.has("slot")func _drop_data(_pos: Vector2, data: Variant) -> void: var source_slot: InventorySlot = data.slot # Swap the two slots var temp_item := slot_data.item var temp_qty := slot_data.quantity slot_data.item = source_slot.item slot_data.quantity = source_slot.quantity source_slot.item = temp_item source_slot.quantity = temp_qty inventory.inventory_changed.emit()Three methods, zero external libraries. Godot handles the mouse tracking, the preview rendering, and the drop detection. You just define what data to carry and what to do on drop.
Common Mistakes
Storing items as nodes in an array. Nodes belong in the scene tree. Inventory items are data. Use Resources.
Rebuilding the entire UI on every change. For small inventories (< 30 slots) this is fine. For larger ones, update only the slots that changed. But don't optimize prematurely - simple rebuilds are fast enough for most games.
Forgetting to emit signals. If your UI isn't updating, check that inventory_changed.emit() is called after every mutation. It's the most common bug.
Comparing items by value instead of reference. Two different Item resources with the same id are not the same object. If you need to compare items by ID, use slot.item.id == target.id instead of slot.item == target.
Going Further
This covers the data layer and basic UI. A complete inventory system also needs:
- Right-click context menus - Use, Equip, Drop, Inspect
- Item tooltips - Hover to see description, stats, rarity
- Equipment slots - Separate panel for equipped items with stat bonuses
- Item categories and filtering - Tabs for weapons, consumables, materials
- Persistence - Serializing inventory to JSON for save/load
We built a complete, production-ready inventory system in our free Inventory System quest - 10 hands-on lessons that take you from Resources to a fully functional drag-and-drop inventory with stacking, UI, and integration testing. It's free, no account required to start.