All Scrolls

How to Build an Inventory System in Godot 4

Coding Quests/February 19, 2026/Tutorials

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.

GDScript
# item.gd
class_name Item
extends 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: ItemType
enum 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:

GDScript
# inventory.gd
class_name Inventory
extends Resource
signal inventory_changed
@export var slots: Array[InventorySlot] = []
@export var max_slots: int = 20
func _init() -> void:
for i in range(max_slots):
slots.append(InventorySlot.new())

And each slot:

GDScript
# inventory_slot.gd
class_name InventorySlot
extends Resource
var item: Item = null
var quantity: int = 0
func is_empty() -> bool:
return item == null
func can_stack(new_item: Item) -> bool:
return item == new_item and quantity < item.max_stack

Notice 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:

GDScript
# 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 fit
func 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 requested

The 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.

GDScript
# inventory_ui.gd
extends Control
@export var inventory: Inventory
@export var slot_scene: PackedScene
@onready var grid: GridContainer = $GridContainer
func _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:

GDScript
# inventory_ui_slot.gd
extends PanelContainer
@onready var icon: TextureRect = $Icon
@onready var quantity_label: Label = $QuantityLabel
func 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:

GDScript
# 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.

godottutorialinventory