Every RPG, survival game, and action-adventure needs an inventory system. It sounds simple: a list of items the player carries. Then you start building it and discover that a "simple" inventory touches item data modeling, UI layout, drag-and-drop input handling, serialization, weight limits, stacking rules, equipment slots, crafting recipes, and a dozen edge cases you didn't anticipate.
This guide walks through building a complete inventory and crafting system in Godot 4 from scratch. We'll use Resources for item data, arrays for inventory storage, signals for communication, and Control nodes for UI. By the end, you'll have a working system with equipment slots, weight limits, stacking, crafting, drag-and-drop, and save/load.
Fair warning: this is long. An inventory system has a lot of moving parts. We'll build it piece by piece, and each section builds on the previous one.
Architecture Overview
Before writing code, let's plan the architecture. A good inventory system has four layers:
- Data layer: Item definitions. What items exist, what properties they have.
- Logic layer: Inventory containers. Adding, removing, stacking, splitting items.
- Crafting layer: Recipes, ingredient validation, result generation.
- UI layer: Visual representation. Slots, tooltips, drag-and-drop.
Each layer communicates through signals. The UI doesn't directly modify inventory data. The crafting system doesn't know about UI slots. Clean separation makes the system maintainable and testable.
ItemDatabase (Resources)
|
v
InventoryContainer (Logic) <---> CraftingSystem (Logic)
| |
v v
InventoryUI (Control) CraftingUI (Control)
Part 1: Item Data with Resources
Godot's Resource system is perfect for item definitions. Resources are data containers that can be saved to disk, shared between nodes, and serialized automatically. They're the Godot equivalent of data assets in other engines.
Base Item Resource
Start with a base ItemData resource that all items inherit from.
# item_data.gd
class_name ItemData
extends Resource
@export var id: StringName = &""
@export var name: String = ""
@export var description: String = ""
@export var icon: Texture2D
@export var max_stack_size: int = 1
@export var weight: float = 0.0
@export var value: int = 0
@export var item_type: ItemType = ItemType.MISC
@export var rarity: Rarity = Rarity.COMMON
@export var is_consumable: bool = false
@export var is_quest_item: bool = false
enum ItemType {
WEAPON,
ARMOR,
CONSUMABLE,
MATERIAL,
QUEST,
MISC
}
enum Rarity {
COMMON,
UNCOMMON,
RARE,
EPIC,
LEGENDARY
}
func get_tooltip_text() -> String:
var text := "[b]%s[/b]\n" % name
text += "[color=gray]%s[/color]\n" % _get_rarity_string()
text += description
if weight > 0:
text += "\n[color=yellow]Weight: %.1f[/color]" % weight
if value > 0:
text += "\n[color=gold]Value: %d[/color]" % value
return text
func _get_rarity_string() -> String:
match rarity:
Rarity.COMMON: return "Common"
Rarity.UNCOMMON: return "Uncommon"
Rarity.RARE: return "Rare"
Rarity.EPIC: return "Epic"
Rarity.LEGENDARY: return "Legendary"
return "Unknown"
Why StringName for id? StringNames are interned strings in Godot. Comparing two StringNames is a pointer comparison, not a string comparison. For an ID that gets compared frequently (crafting recipe lookups, loot table checks), this matters.
Why @export on everything? So you can create items directly in the editor inspector. Create a new Resource, set it to ItemData, and fill in the fields visually. No code needed for item authoring.
Specialized Item Types
Extend the base resource for items with extra properties.
# weapon_data.gd
class_name WeaponData
extends ItemData
@export var damage: float = 10.0
@export var attack_speed: float = 1.0
@export var damage_type: DamageType = DamageType.PHYSICAL
@export var weapon_class: WeaponClass = WeaponClass.SWORD
enum DamageType { PHYSICAL, MAGICAL, FIRE, ICE, LIGHTNING }
enum WeaponClass { SWORD, AXE, MACE, DAGGER, BOW, STAFF, WAND }
func _init() -> void:
item_type = ItemType.WEAPON
max_stack_size = 1 # Weapons don't stack
# armor_data.gd
class_name ArmorData
extends ItemData
@export var defense: float = 5.0
@export var armor_slot: ArmorSlot = ArmorSlot.CHEST
@export var resistance_physical: float = 0.0
@export var resistance_magical: float = 0.0
enum ArmorSlot { HEAD, CHEST, LEGS, FEET, HANDS, SHIELD }
func _init() -> void:
item_type = ItemType.ARMOR
max_stack_size = 1
# consumable_data.gd
class_name ConsumableData
extends ItemData
@export var heal_amount: float = 0.0
@export var mana_restore: float = 0.0
@export var buff_effect: StringName = &""
@export var buff_duration: float = 0.0
func _init() -> void:
item_type = ItemType.CONSUMABLE
is_consumable = true
max_stack_size = 99
Creating Item Data Files
In the Godot editor:
- Right-click in the FileSystem dock
- Select New Resource
- Choose
WeaponData(or whichever type) - Fill in the properties in the inspector
- Save as
.tresfile (e.g.,res://data/items/iron_sword.tres)
Create a folder structure like:
data/
items/
weapons/
iron_sword.tres
steel_axe.tres
armor/
leather_helmet.tres
iron_chestplate.tres
consumables/
health_potion.tres
mana_potion.tres
materials/
iron_ore.tres
wood_plank.tres
Item Database (Registry)
You need a way to look up items by ID at runtime. An autoload singleton works well for this.
# item_database.gd (Autoload)
extends Node
var _items: Dictionary = {}
func _ready() -> void:
_load_all_items("res://data/items/")
func _load_all_items(path: String) -> void:
var dir = DirAccess.open(path)
if dir == null:
push_error("Failed to open item directory: %s" % path)
return
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
var full_path = path.path_join(file_name)
if dir.current_is_dir() and file_name != "." and file_name != "..":
_load_all_items(full_path)
elif file_name.ends_with(".tres") or file_name.ends_with(".res"):
var resource = load(full_path)
if resource is ItemData:
if resource.id == &"":
push_warning("Item at %s has no ID, skipping" % full_path)
elif _items.has(resource.id):
push_warning("Duplicate item ID: %s at %s" % [resource.id, full_path])
else:
_items[resource.id] = resource
file_name = dir.get_next()
dir.list_dir_end()
func get_item(id: StringName) -> ItemData:
if _items.has(id):
return _items[id]
push_error("Item not found: %s" % id)
return null
func get_all_items() -> Array[ItemData]:
var result: Array[ItemData] = []
for item in _items.values():
result.append(item)
return result
Register this as an autoload in Project Settings > Autoload with the name ItemDB.
Part 2: Inventory Container
The inventory container manages the actual items the player carries. It handles adding, removing, stacking, and validation.
Inventory Slot Structure
An inventory slot holds a reference to an item and a quantity.
# inventory_slot_data.gd
class_name InventorySlotData
extends Resource
@export var item: ItemData = null
@export var quantity: int = 0
func is_empty() -> bool:
return item == null or quantity <= 0
func can_stack_with(other_item: ItemData) -> bool:
if is_empty():
return true # Empty slot accepts anything
return item.id == other_item.id and quantity < item.max_stack_size
func available_stack_space() -> int:
if is_empty():
return 0 # Doesn't make sense for empty slot
return item.max_stack_size - quantity
func clear() -> void:
item = null
quantity = 0
Inventory Container
# inventory.gd
class_name Inventory
extends Resource
signal item_added(slot_index: int)
signal item_removed(slot_index: int)
signal item_changed(slot_index: int)
signal inventory_full
signal weight_changed(new_weight: float)
@export var slots: Array[InventorySlotData] = []
@export var max_slots: int = 20
@export var max_weight: float = 100.0
var current_weight: float = 0.0
func _init() -> void:
_initialize_slots()
func _initialize_slots() -> void:
slots.clear()
for i in max_slots:
slots.append(InventorySlotData.new())
## Attempts to add an item. Returns the number of items that could NOT be added.
func add_item(item: ItemData, amount: int = 1) -> int:
if item == null or amount <= 0:
return amount
var remaining = amount
# Check weight limit
var total_weight_to_add = item.weight * amount
if current_weight + total_weight_to_add > max_weight:
# Calculate how many we can actually carry
var weight_available = max_weight - current_weight
var can_carry = floori(weight_available / item.weight) if item.weight > 0 else amount
if can_carry <= 0:
inventory_full.emit()
return remaining
remaining = amount - can_carry
amount = can_carry
var to_add = amount
# First pass: stack with existing items
if item.max_stack_size > 1:
for i in slots.size():
if to_add <= 0:
break
if slots[i].item != null and slots[i].item.id == item.id:
var space = slots[i].available_stack_space()
if space > 0:
var add_amount = mini(to_add, space)
slots[i].quantity += add_amount
to_add -= add_amount
item_changed.emit(i)
# Second pass: fill empty slots
for i in slots.size():
if to_add <= 0:
break
if slots[i].is_empty():
var add_amount = mini(to_add, item.max_stack_size)
slots[i].item = item
slots[i].quantity = add_amount
to_add -= add_amount
item_added.emit(i)
# Update weight
var actually_added = amount - to_add
current_weight += item.weight * actually_added
weight_changed.emit(current_weight)
remaining += to_add
if remaining > 0:
inventory_full.emit()
return remaining
## Remove a specific quantity from a slot. Returns true if successful.
func remove_item_at(slot_index: int, amount: int = 1) -> bool:
if slot_index < 0 or slot_index >= slots.size():
return false
var slot = slots[slot_index]
if slot.is_empty() or amount <= 0 or amount > slot.quantity:
return false
var item_weight = slot.item.weight
slot.quantity -= amount
if slot.quantity <= 0:
slot.clear()
item_removed.emit(slot_index)
else:
item_changed.emit(slot_index)
current_weight -= item_weight * amount
weight_changed.emit(current_weight)
return true
## Remove items by ID from anywhere in the inventory. Returns amount actually removed.
func remove_item_by_id(item_id: StringName, amount: int = 1) -> int:
var removed = 0
for i in slots.size():
if removed >= amount:
break
if slots[i].item != null and slots[i].item.id == item_id:
var to_remove = mini(amount - removed, slots[i].quantity)
remove_item_at(i, to_remove)
removed += to_remove
return removed
## Count total quantity of an item across all slots.
func count_item(item_id: StringName) -> int:
var total = 0
for slot in slots:
if slot.item != null and slot.item.id == item_id:
total += slot.quantity
return total
## Check if the inventory contains at least `amount` of an item.
func has_item(item_id: StringName, amount: int = 1) -> bool:
return count_item(item_id) >= amount
## Swap two slots.
func swap_slots(from_index: int, to_index: int) -> void:
if from_index == to_index:
return
if from_index < 0 or from_index >= slots.size():
return
if to_index < 0 or to_index >= slots.size():
return
var temp_item = slots[to_index].item
var temp_qty = slots[to_index].quantity
slots[to_index].item = slots[from_index].item
slots[to_index].quantity = slots[from_index].quantity
slots[from_index].item = temp_item
slots[from_index].quantity = temp_qty
item_changed.emit(from_index)
item_changed.emit(to_index)
## Try to merge (stack) from one slot into another. Returns leftover amount.
func merge_slots(from_index: int, to_index: int) -> int:
if from_index == to_index:
return slots[from_index].quantity
var from_slot = slots[from_index]
var to_slot = slots[to_index]
if from_slot.is_empty():
return 0
# If target is empty, just move
if to_slot.is_empty():
swap_slots(from_index, to_index)
return 0
# If different items, swap
if from_slot.item.id != to_slot.item.id:
swap_slots(from_index, to_index)
return 0
# Same item: merge stacks
var space = to_slot.available_stack_space()
var transfer = mini(from_slot.quantity, space)
to_slot.quantity += transfer
from_slot.quantity -= transfer
if from_slot.quantity <= 0:
from_slot.clear()
item_removed.emit(from_index)
else:
item_changed.emit(from_index)
item_changed.emit(to_index)
return from_slot.quantity # Leftover
## Split a stack. Move half to an empty slot.
func split_stack(slot_index: int) -> int:
var slot = slots[slot_index]
if slot.is_empty() or slot.quantity <= 1:
return -1
# Find an empty slot
var empty_index = -1
for i in slots.size():
if slots[i].is_empty():
empty_index = i
break
if empty_index == -1:
return -1 # No empty slot
var split_amount = slot.quantity / 2
slot.quantity -= split_amount
slots[empty_index].item = slot.item
slots[empty_index].quantity = split_amount
item_changed.emit(slot_index)
item_added.emit(empty_index)
return empty_index
func get_weight_ratio() -> float:
if max_weight <= 0:
return 0.0
return current_weight / max_weight
func is_full() -> bool:
for slot in slots:
if slot.is_empty():
return false
return true
func recalculate_weight() -> void:
current_weight = 0.0
for slot in slots:
if not slot.is_empty():
current_weight += slot.item.weight * slot.quantity
weight_changed.emit(current_weight)
This is a lot of code. That's the reality of inventory systems -- the core concept is simple, but the edge cases (stacking, splitting, weight validation, signal emission) add up.
What to Watch For
A few things that trip people up:
Resource sharing. When you assign the same ItemData resource to multiple slots, they share the same Resource instance. This is fine for item definitions (read-only), but dangerous if you ever modify item properties at runtime. If items can be enchanted, upgraded, or modified, you need to duplicate() the resource before modifying it.
Signal ordering. The item_changed signal fires after the data changes. If your UI connects to this signal, the new data is already in the slot when the callback runs. Don't try to read "before" and "after" states from a single signal.
Weight precision. Float arithmetic accumulates errors. After many add/remove operations, current_weight may drift from the "true" weight. The recalculate_weight() function fixes this. Call it periodically or after save/load.
Part 3: Equipment Slots
Equipment is a separate container from the main inventory. Each slot accepts only specific item types.
# equipment.gd
class_name Equipment
extends Resource
signal equipment_changed(slot_name: StringName)
signal stats_changed
# Equipment slots mapped by name
var _slots: Dictionary = {}
@export var slot_definitions: Array[StringName] = [
&"head", &"chest", &"legs", &"feet", &"hands",
&"weapon_main", &"weapon_off", &"accessory_1", &"accessory_2"
]
func _init() -> void:
for slot_name in slot_definitions:
_slots[slot_name] = null
## Equip an item. Returns the previously equipped item (or null).
func equip(slot_name: StringName, item: ItemData) -> ItemData:
if not _slots.has(slot_name):
push_error("Invalid equipment slot: %s" % slot_name)
return item # Return the item back, can't equip
if not _can_equip_in_slot(slot_name, item):
return item
var previous = _slots[slot_name]
_slots[slot_name] = item
equipment_changed.emit(slot_name)
stats_changed.emit()
return previous
func unequip(slot_name: StringName) -> ItemData:
if not _slots.has(slot_name):
return null
var item = _slots[slot_name]
_slots[slot_name] = null
equipment_changed.emit(slot_name)
stats_changed.emit()
return item
func get_equipped(slot_name: StringName) -> ItemData:
return _slots.get(slot_name)
func is_slot_empty(slot_name: StringName) -> bool:
return _slots.get(slot_name) == null
func _can_equip_in_slot(slot_name: StringName, item: ItemData) -> bool:
if item == null:
return true # Unequipping is always valid
match slot_name:
&"head":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.HEAD
&"chest":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.CHEST
&"legs":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.LEGS
&"feet":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.FEET
&"hands":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.HANDS
&"weapon_main":
return item is WeaponData
&"weapon_off":
return item is ArmorData and item.armor_slot == ArmorData.ArmorSlot.SHIELD
&"accessory_1", &"accessory_2":
return item.item_type == ItemData.ItemType.MISC # Simplification
return false
## Calculate total stat bonuses from all equipment.
func get_total_stats() -> Dictionary:
var stats = {
"damage": 0.0,
"defense": 0.0,
"attack_speed": 0.0,
"resistance_physical": 0.0,
"resistance_magical": 0.0,
}
for slot_name in _slots:
var item = _slots[slot_name]
if item == null:
continue
if item is WeaponData:
stats["damage"] += item.damage
stats["attack_speed"] += item.attack_speed
elif item is ArmorData:
stats["defense"] += item.defense
stats["resistance_physical"] += item.resistance_physical
stats["resistance_magical"] += item.resistance_magical
return stats
The key pattern here: equipping returns the previous item. The caller is responsible for putting that item back in the inventory. This prevents items from disappearing when you equip something in an occupied slot.
# Usage example -- equip from inventory
func equip_from_inventory(inventory: Inventory, slot_index: int,
equipment: Equipment, equip_slot: StringName) -> void:
var inv_slot = inventory.slots[slot_index]
if inv_slot.is_empty():
return
var item_to_equip = inv_slot.item
var previous_item = equipment.equip(equip_slot, item_to_equip)
# Remove equipped item from inventory
inventory.remove_item_at(slot_index, 1)
# Put previously equipped item back in inventory
if previous_item != null:
var leftover = inventory.add_item(previous_item, 1)
if leftover > 0:
# Inventory full, revert the equip
equipment.equip(equip_slot, previous_item)
inventory.add_item(item_to_equip, 1)
push_warning("Inventory full, cannot swap equipment")
Notice the revert logic. If the previously equipped item can't fit back in the inventory, we undo the entire operation. This prevents item duplication or loss. Edge cases like this are why inventory systems take so long to build.
Part 4: Crafting System
Crafting needs recipes and a system to validate and execute them.
Recipe Resource
# crafting_recipe.gd
class_name CraftingRecipe
extends Resource
@export var id: StringName = &""
@export var recipe_name: String = ""
@export var description: String = ""
@export var category: StringName = &"general"
@export var ingredients: Array[RecipeIngredient] = []
@export var results: Array[RecipeResult] = []
@export var crafting_time: float = 0.0 # Seconds, 0 = instant
@export var required_station: StringName = &"" # Empty = craft anywhere
@export var required_level: int = 0
# recipe_ingredient.gd
class_name RecipeIngredient
extends Resource
@export var item_id: StringName = &""
@export var quantity: int = 1
# recipe_result.gd
class_name RecipeResult
extends Resource
@export var item_id: StringName = &""
@export var quantity: int = 1
@export var chance: float = 1.0 # 1.0 = guaranteed, 0.5 = 50% chance
Crafting Manager
# crafting_system.gd
class_name CraftingSystem
extends Node
signal recipe_crafted(recipe: CraftingRecipe)
signal crafting_failed(recipe: CraftingRecipe, reason: String)
var _recipes: Dictionary = {}
func _ready() -> void:
_load_recipes("res://data/recipes/")
func _load_recipes(path: String) -> void:
var dir = DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
var full_path = path.path_join(file_name)
if dir.current_is_dir() and file_name != "." and file_name != "..":
_load_recipes(full_path)
elif file_name.ends_with(".tres") or file_name.ends_with(".res"):
var resource = load(full_path)
if resource is CraftingRecipe:
_recipes[resource.id] = resource
file_name = dir.get_next()
dir.list_dir_end()
## Check if a recipe can be crafted with the given inventory.
func can_craft(recipe: CraftingRecipe, inventory: Inventory,
station: StringName = &"", player_level: int = 1) -> Dictionary:
var result = {"can_craft": true, "reason": ""}
# Check crafting station
if recipe.required_station != &"" and recipe.required_station != station:
result.can_craft = false
result.reason = "Requires %s" % recipe.required_station
return result
# Check level
if player_level < recipe.required_level:
result.can_craft = false
result.reason = "Requires level %d" % recipe.required_level
return result
# Check ingredients
for ingredient in recipe.ingredients:
var count = inventory.count_item(ingredient.item_id)
if count < ingredient.quantity:
result.can_craft = false
var item_data = ItemDB.get_item(ingredient.item_id)
var item_name = item_data.name if item_data else str(ingredient.item_id)
result.reason = "Need %d %s (have %d)" % [
ingredient.quantity, item_name, count
]
return result
# Check if results would fit in inventory
# (Approximate -- doesn't account for freed ingredient slots)
var empty_slots = 0
for slot in inventory.slots:
if slot.is_empty():
empty_slots += 1
var slots_needed = 0
for recipe_result in recipe.results:
if recipe_result.chance < 1.0:
continue # Don't count uncertain results
var item = ItemDB.get_item(recipe_result.item_id)
if item:
slots_needed += ceili(float(recipe_result.quantity) / item.max_stack_size)
# Ingredients will free up slots, so subtract those
var ingredient_slots_freed = recipe.ingredients.size() # Rough estimate
if slots_needed > empty_slots + ingredient_slots_freed:
result.can_craft = false
result.reason = "Not enough inventory space"
return result
return result
## Execute a craft. Returns true if successful.
func craft(recipe: CraftingRecipe, inventory: Inventory,
station: StringName = &"", player_level: int = 1) -> bool:
var check = can_craft(recipe, inventory, station, player_level)
if not check.can_craft:
crafting_failed.emit(recipe, check.reason)
return false
# Remove ingredients
for ingredient in recipe.ingredients:
var removed = inventory.remove_item_by_id(ingredient.item_id, ingredient.quantity)
if removed < ingredient.quantity:
# This shouldn't happen if can_craft passed, but safety check
push_error("Failed to remove ingredient: %s" % ingredient.item_id)
# TODO: Rollback logic here
return false
# Add results
for recipe_result in recipe.results:
# Check probability
if recipe_result.chance < 1.0:
if randf() > recipe_result.chance:
continue # Didn't get this result
var item = ItemDB.get_item(recipe_result.item_id)
if item:
var leftover = inventory.add_item(item, recipe_result.quantity)
if leftover > 0:
push_warning("Could not add all crafting results, %d lost" % leftover)
# In a real game, drop overflow on the ground
recipe_crafted.emit(recipe)
return true
func get_recipes_for_station(station: StringName = &"") -> Array[CraftingRecipe]:
var result: Array[CraftingRecipe] = []
for recipe in _recipes.values():
if recipe.required_station == station or recipe.required_station == &"":
result.append(recipe)
return result
func get_all_recipes() -> Array[CraftingRecipe]:
var result: Array[CraftingRecipe] = []
for recipe in _recipes.values():
result.append(recipe)
return result
func get_recipe(id: StringName) -> CraftingRecipe:
return _recipes.get(id)
Example Recipe Files
Create recipe .tres files in the editor:
# Iron Sword Recipe
id: &"iron_sword"
recipe_name: "Iron Sword"
ingredients:
- item_id: &"iron_ore", quantity: 3
- item_id: &"wood_plank", quantity: 1
results:
- item_id: &"iron_sword", quantity: 1
required_station: &"forge"
You'd author these visually in the editor inspector, but the above shows the logical structure.
The Crafting Queue (Optional Complexity)
If your game has crafting times (survival games, MMOs), you need a queue:
# crafting_queue.gd
class_name CraftingQueue
extends Node
signal craft_started(recipe: CraftingRecipe)
signal craft_progress(recipe: CraftingRecipe, progress: float)
signal craft_completed(recipe: CraftingRecipe)
var _current_recipe: CraftingRecipe = null
var _current_time: float = 0.0
var _is_crafting: bool = false
var _inventory: Inventory
func setup(inventory: Inventory) -> void:
_inventory = inventory
func start_craft(recipe: CraftingRecipe, crafting_system: CraftingSystem,
station: StringName = &"") -> bool:
if _is_crafting:
return false
var check = crafting_system.can_craft(recipe, _inventory, station)
if not check.can_craft:
return false
# Remove ingredients immediately
for ingredient in recipe.ingredients:
_inventory.remove_item_by_id(ingredient.item_id, ingredient.quantity)
_current_recipe = recipe
_current_time = 0.0
_is_crafting = true
craft_started.emit(recipe)
if recipe.crafting_time <= 0:
_complete_craft()
return true
func _process(delta: float) -> void:
if not _is_crafting or _current_recipe == null:
return
_current_time += delta
var progress = _current_time / _current_recipe.crafting_time
craft_progress.emit(_current_recipe, clampf(progress, 0.0, 1.0))
if _current_time >= _current_recipe.crafting_time:
_complete_craft()
func _complete_craft() -> void:
if _current_recipe == null:
return
for recipe_result in _current_recipe.results:
if recipe_result.chance < 1.0 and randf() > recipe_result.chance:
continue
var item = ItemDB.get_item(recipe_result.item_id)
if item:
_inventory.add_item(item, recipe_result.quantity)
craft_completed.emit(_current_recipe)
_is_crafting = false
_current_recipe = null
_current_time = 0.0
Part 5: Inventory UI
Now for the visual layer. This is where Godot's Control node system shines.
Slot UI
Each inventory slot is a visual representation of an InventorySlotData.
# inventory_slot_ui.gd
class_name InventorySlotUI
extends PanelContainer
signal slot_clicked(slot_index: int, button: MouseButton)
signal slot_drag_started(slot_index: int)
@onready var icon: TextureRect = $MarginContainer/Icon
@onready var quantity_label: Label = $QuantityLabel
@onready var highlight: ColorRect = $Highlight
var slot_index: int = -1
var _slot_data: InventorySlotData
func setup(index: int, slot_data: InventorySlotData) -> void:
slot_index = index
_slot_data = slot_data
refresh()
func refresh() -> void:
if _slot_data == null or _slot_data.is_empty():
icon.texture = null
quantity_label.visible = false
tooltip_text = ""
else:
icon.texture = _slot_data.item.icon
if _slot_data.quantity > 1:
quantity_label.text = str(_slot_data.quantity)
quantity_label.visible = true
else:
quantity_label.visible = false
tooltip_text = _slot_data.item.get_tooltip_text()
func set_highlighted(enabled: bool) -> void:
highlight.visible = enabled
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
slot_clicked.emit(slot_index, event.button_index)
func _get_drag_data(_at_position: Vector2) -> Variant:
if _slot_data == null or _slot_data.is_empty():
return null
slot_drag_started.emit(slot_index)
# Create drag preview
var preview = TextureRect.new()
preview.texture = _slot_data.item.icon
preview.custom_minimum_size = Vector2(48, 48)
preview.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
set_drag_preview(preview)
return {"slot_index": slot_index, "source": "inventory"}
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
return data is Dictionary and data.has("slot_index")
func _drop_data(_at_position: Vector2, data: Variant) -> void:
if data is Dictionary and data.has("slot_index"):
# Emit signal to let the parent handle the actual logic
var from_index = data["slot_index"]
var source = data.get("source", "inventory")
# Parent connects to this and handles merge/swap
slot_clicked.emit(slot_index, MOUSE_BUTTON_NONE)
Inventory Grid UI
The grid displays all slots and handles interactions.
# inventory_ui.gd
class_name InventoryUI
extends Control
@onready var grid: GridContainer = $Panel/MarginContainer/VBoxContainer/GridContainer
@onready var weight_bar: ProgressBar = $Panel/MarginContainer/VBoxContainer/WeightBar
@onready var weight_label: Label = $Panel/MarginContainer/VBoxContainer/WeightBar/Label
var _inventory: Inventory
var _slot_scene: PackedScene = preload("res://ui/inventory/inventory_slot_ui.tscn")
var _slot_uis: Array[InventorySlotUI] = []
var _drag_from_index: int = -1
func setup(inventory: Inventory) -> void:
_inventory = inventory
# Connect signals
_inventory.item_added.connect(_on_slot_updated)
_inventory.item_removed.connect(_on_slot_updated)
_inventory.item_changed.connect(_on_slot_updated)
_inventory.weight_changed.connect(_on_weight_changed)
_build_grid()
_update_weight_display()
func _build_grid() -> void:
# Clear existing
for child in grid.get_children():
child.queue_free()
_slot_uis.clear()
# Create slot UIs
for i in _inventory.slots.size():
var slot_ui: InventorySlotUI = _slot_scene.instantiate()
grid.add_child(slot_ui)
slot_ui.setup(i, _inventory.slots[i])
slot_ui.slot_clicked.connect(_on_slot_clicked)
slot_ui.slot_drag_started.connect(_on_drag_started)
_slot_uis.append(slot_ui)
func _on_slot_updated(slot_index: int) -> void:
if slot_index >= 0 and slot_index < _slot_uis.size():
_slot_uis[slot_index].refresh()
func _on_weight_changed(new_weight: float) -> void:
_update_weight_display()
func _update_weight_display() -> void:
if _inventory == null:
return
weight_bar.value = _inventory.get_weight_ratio() * 100.0
weight_label.text = "%.1f / %.1f" % [_inventory.current_weight, _inventory.max_weight]
func _on_slot_clicked(slot_index: int, button: MouseButton) -> void:
match button:
MOUSE_BUTTON_LEFT:
_handle_left_click(slot_index)
MOUSE_BUTTON_RIGHT:
_handle_right_click(slot_index)
MOUSE_BUTTON_NONE:
# Drag and drop
if _drag_from_index >= 0:
_handle_drop(slot_index)
func _on_drag_started(slot_index: int) -> void:
_drag_from_index = slot_index
func _handle_left_click(slot_index: int) -> void:
var slot = _inventory.slots[slot_index]
if slot.is_empty():
return
# Could open context menu, use item, etc.
if slot.item.is_consumable:
_use_consumable(slot_index)
func _handle_right_click(slot_index: int) -> void:
var slot = _inventory.slots[slot_index]
if slot.is_empty():
return
if slot.quantity > 1:
_inventory.split_stack(slot_index)
func _handle_drop(to_index: int) -> void:
if _drag_from_index < 0:
return
_inventory.merge_slots(_drag_from_index, to_index)
_drag_from_index = -1
func _use_consumable(slot_index: int) -> void:
var slot = _inventory.slots[slot_index]
if slot.is_empty() or not slot.item.is_consumable:
return
# Apply consumable effects (connect to your game systems)
if slot.item is ConsumableData:
var consumable = slot.item as ConsumableData
# Emit a signal or call your player health system here
print("Used %s: heal=%f mana=%f" % [
consumable.name, consumable.heal_amount, consumable.mana_restore
])
_inventory.remove_item_at(slot_index, 1)
func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_inventory"):
visible = not visible
Scene Structure
The scene tree for the inventory UI looks like this:
InventoryUI (Control) -- inventory_ui.gd
Panel (PanelContainer)
MarginContainer
VBoxContainer
Label ("Inventory")
GridContainer (columns: 5)
-- Slot UIs generated at runtime --
WeightBar (ProgressBar)
Label
Each slot scene:
InventorySlotUI (PanelContainer) -- inventory_slot_ui.gd
MarginContainer
Icon (TextureRect)
QuantityLabel (Label) -- bottom-right corner
Highlight (ColorRect) -- hidden by default
Part 6: Save and Load
Godot's ResourceSaver and ResourceLoader can handle inventory persistence, but there are gotchas.
The Simple Approach (ResourceSaver)
Since Inventory and InventorySlotData are Resources, you can save them directly:
# save_system.gd
extends Node
const SAVE_PATH = "user://save/"
func save_inventory(inventory: Inventory, filename: String = "inventory.tres") -> void:
var dir = DirAccess.open("user://")
if not dir.dir_exists("save"):
dir.make_dir("save")
var err = ResourceSaver.save(inventory, SAVE_PATH + filename)
if err != OK:
push_error("Failed to save inventory: %s" % error_string(err))
func load_inventory(filename: String = "inventory.tres") -> Inventory:
var path = SAVE_PATH + filename
if ResourceLoader.exists(path):
var inventory = ResourceLoader.load(path) as Inventory
if inventory:
inventory.recalculate_weight()
return inventory
return null
The problem with this approach: Resource files (.tres) save the full resource tree, including all nested resources. Your saved inventory file contains a copy of every ItemData resource in it. If you update an item's stats in a patch, saved inventories still have the old stats embedded in them.
The Better Approach (JSON with ID References)
Save inventory as item IDs and quantities. Load items from the database at runtime.
# save_system.gd
extends Node
const SAVE_PATH = "user://save/"
func save_inventory(inventory: Inventory, filename: String = "inventory.json") -> void:
var dir = DirAccess.open("user://")
if not dir.dir_exists("save"):
dir.make_dir("save")
var save_data = {
"version": 1,
"max_slots": inventory.max_slots,
"max_weight": inventory.max_weight,
"slots": []
}
for slot in inventory.slots:
if slot.is_empty():
save_data.slots.append({"empty": true})
else:
save_data.slots.append({
"item_id": str(slot.item.id),
"quantity": slot.quantity
})
var json_string = JSON.stringify(save_data, " ")
var file = FileAccess.open(SAVE_PATH + filename, FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
else:
push_error("Failed to save inventory")
func load_inventory(filename: String = "inventory.json") -> Inventory:
var path = SAVE_PATH + filename
if not FileAccess.file_exists(path):
return null
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
return null
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
push_error("Failed to parse inventory save: %s" % json.get_error_message())
return null
var save_data = json.data
var inventory = Inventory.new()
inventory.max_slots = save_data.get("max_slots", 20)
inventory.max_weight = save_data.get("max_weight", 100.0)
inventory._initialize_slots()
var slots_data = save_data.get("slots", [])
for i in mini(slots_data.size(), inventory.slots.size()):
var slot_data = slots_data[i]
if slot_data.get("empty", false):
continue
var item_id = StringName(slot_data.get("item_id", ""))
var quantity = slot_data.get("quantity", 0)
var item = ItemDB.get_item(item_id)
if item:
inventory.slots[i].item = item
inventory.slots[i].quantity = quantity
else:
push_warning("Unknown item ID in save: %s" % item_id)
inventory.recalculate_weight()
return inventory
func save_equipment(equipment: Equipment, filename: String = "equipment.json") -> void:
var dir = DirAccess.open("user://")
if not dir.dir_exists("save"):
dir.make_dir("save")
var save_data = {"version": 1, "slots": {}}
for slot_name in equipment.slot_definitions:
var item = equipment.get_equipped(slot_name)
if item:
save_data.slots[str(slot_name)] = str(item.id)
else:
save_data.slots[str(slot_name)] = ""
var json_string = JSON.stringify(save_data, " ")
var file = FileAccess.open(SAVE_PATH + filename, FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
func load_equipment(equipment: Equipment, filename: String = "equipment.json") -> void:
var path = SAVE_PATH + filename
if not FileAccess.file_exists(path):
return
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
return
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
if json.parse(json_string) != OK:
return
var save_data = json.data
var slots_data = save_data.get("slots", {})
for slot_name_str in slots_data:
var slot_name = StringName(slot_name_str)
var item_id_str = slots_data[slot_name_str]
if item_id_str != "":
var item = ItemDB.get_item(StringName(item_id_str))
if item:
equipment.equip(slot_name, item)
The JSON approach is more verbose but safer for live games. You can update item stats, add new items, and remove old items without breaking save files. The save file only stores references (IDs) and quantities, not the actual item data.
Part 7: Crafting UI
A basic crafting UI that shows available recipes, ingredients, and a craft button.
# crafting_ui.gd
class_name CraftingUI
extends Control
@onready var recipe_list: ItemList = $Panel/HSplitContainer/RecipeList
@onready var recipe_name_label: Label = $Panel/HSplitContainer/Details/RecipeName
@onready var ingredients_container: VBoxContainer = $Panel/HSplitContainer/Details/Ingredients
@onready var results_container: VBoxContainer = $Panel/HSplitContainer/Details/Results
@onready var craft_button: Button = $Panel/HSplitContainer/Details/CraftButton
var _crafting_system: CraftingSystem
var _inventory: Inventory
var _recipes: Array[CraftingRecipe] = []
var _selected_recipe: CraftingRecipe = null
var _current_station: StringName = &""
func setup(crafting_system: CraftingSystem, inventory: Inventory,
station: StringName = &"") -> void:
_crafting_system = crafting_system
_inventory = inventory
_current_station = station
_recipes = _crafting_system.get_recipes_for_station(station)
_build_recipe_list()
craft_button.pressed.connect(_on_craft_pressed)
recipe_list.item_selected.connect(_on_recipe_selected)
func _build_recipe_list() -> void:
recipe_list.clear()
for recipe in _recipes:
var can_craft = _crafting_system.can_craft(
recipe, _inventory, _current_station
)
var display_name = recipe.recipe_name
if not can_craft.can_craft:
display_name += " (missing materials)"
recipe_list.add_item(display_name)
func _on_recipe_selected(index: int) -> void:
if index < 0 or index >= _recipes.size():
return
_selected_recipe = _recipes[index]
_update_details()
func _update_details() -> void:
if _selected_recipe == null:
return
recipe_name_label.text = _selected_recipe.recipe_name
# Clear ingredients display
for child in ingredients_container.get_children():
child.queue_free()
# Show ingredients
for ingredient in _selected_recipe.ingredients:
var item = ItemDB.get_item(ingredient.item_id)
if item == null:
continue
var label = Label.new()
var have = _inventory.count_item(ingredient.item_id)
var need = ingredient.quantity
var color = "green" if have >= need else "red"
label.text = "%s: [color=%s]%d/%d[/color]" % [item.name, color, have, need]
label.use_bbcode = false # Use RichTextLabel if you want colors
label.text = "%s: %d/%d %s" % [item.name, have, need,
"(OK)" if have >= need else "(need more)"]
ingredients_container.add_child(label)
# Clear results display
for child in results_container.get_children():
child.queue_free()
# Show results
for recipe_result in _selected_recipe.results:
var item = ItemDB.get_item(recipe_result.item_id)
if item == null:
continue
var label = Label.new()
var chance_text = ""
if recipe_result.chance < 1.0:
chance_text = " (%d%% chance)" % int(recipe_result.chance * 100)
label.text = "%s x%d%s" % [item.name, recipe_result.quantity, chance_text]
results_container.add_child(label)
# Update craft button
var can_craft = _crafting_system.can_craft(
_selected_recipe, _inventory, _current_station
)
craft_button.disabled = not can_craft.can_craft
craft_button.tooltip_text = can_craft.reason if not can_craft.can_craft else "Craft!"
func _on_craft_pressed() -> void:
if _selected_recipe == null:
return
var success = _crafting_system.craft(
_selected_recipe, _inventory, _current_station
)
if success:
_build_recipe_list() # Refresh availability
_update_details() # Refresh ingredient counts
What Doesn't Work Well (Honest Assessment)
This system works for single-player games. Here's where it falls short.
Multiplayer
Nothing in this system is networked. In a multiplayer game, the server needs to validate every inventory operation. Client-side inventory manipulation is an invitation for cheating. You'd need to rewrite the Inventory class to send RPCs for every add/remove/swap operation and wait for server confirmation before updating the UI.
That's a significant amount of additional work -- essentially doubling the complexity of the system.
Modding Support
The Resource-based item system is hard to mod. Players can't easily add new item types without modifying GDScript classes. If modding is important to your game, consider an entirely data-driven approach where item properties are defined in JSON or CSV files that modders can edit.
Large Inventories
The grid UI rebuilds from an array. For inventories with hundreds of slots (warehouse, shared stash), you'll want virtual scrolling -- only instantiating UI nodes for visible slots. The simple approach shown here creates a node for every slot, which is fine for 20-50 slots but wasteful for 500.
Item Modifications
This system treats items as immutable references. Every "Iron Sword" is the same Iron Sword. If your game needs enchantments, durability, random stats, or sockets, you need a wrapper class that holds both the base ItemData reference and the per-instance modifications. This adds significant complexity to stacking logic (enchanted items don't stack with unenchanted ones), serialization (you need to save per-instance data), and UI (tooltips need to show modifications).
Performance at Scale
The count_item and has_item functions loop through every slot. For frequent crafting checks across many recipes, this is O(n*m) where n is slots and m is recipe ingredients. For most games this is fine. For games with thousands of inventory slots and hundreds of recipes, build a lookup dictionary that maps item IDs to slot indices, updated on every add/remove.
Comparing to Pre-Built Solutions
GLoot Addon
GLoot is the most popular inventory addon for Godot 4. It provides:
- Grid and list inventory layouts
- Stacking and weight management
- Serialization
- Equipment slots
- A visual editor for item prototypes
It's well-maintained and covers most of the functionality we built here. The tradeoff: you're dependent on an addon's update schedule and API decisions. If GLoot doesn't support a feature you need, you're patching someone else's code.
Use GLoot if: You want to ship faster and your inventory needs are standard. Build custom if: You need non-standard behavior, deep integration with your specific game systems, or you want to fully understand the system you're shipping.
The Time Investment Comparison
Building what we covered in this guide from scratch takes an experienced Godot developer roughly 2-3 weeks to build, test, and polish. That includes the data layer, logic, crafting, UI with drag-and-drop, and save/load. Edge cases and bug fixing account for about half that time.
Using GLoot cuts that to roughly 3-5 days for integration and customization.
For context, if you're evaluating Godot against Unreal Engine for an RPG project, the Blueprint Template Library provides 15 complete gameplay systems -- including inventory, equipment, and crafting -- with built-in networking and Server RPCs. That's the difference between building a system from scratch and starting with production-ready systems that handle the edge cases (multiplayer validation, replication, save/load) from day one.
The right choice depends on your project scope, timeline, and engine preference. Godot's custom approach gives you complete control. Pre-built solutions give you speed. Neither is universally better.
Putting It All Together
Here's how the pieces connect in a real project.
Player Node Setup
# player.gd
extends CharacterBody3D
var inventory: Inventory
var equipment: Equipment
var inventory_ui: InventoryUI
var crafting_system: CraftingSystem
func _ready() -> void:
# Load or create inventory
var save_system = get_node("/root/SaveSystem")
inventory = save_system.load_inventory()
if inventory == null:
inventory = Inventory.new()
inventory.max_slots = 24
inventory.max_weight = 150.0
inventory._initialize_slots()
_give_starter_items()
# Setup equipment
equipment = Equipment.new()
save_system.load_equipment(equipment)
# Connect equipment stat changes to player stats
equipment.stats_changed.connect(_on_equipment_stats_changed)
# Setup UI
inventory_ui = get_node("/root/GameUI/InventoryUI")
inventory_ui.setup(inventory)
# Crafting system is an autoload
crafting_system = get_node("/root/CraftingSystem")
func _give_starter_items() -> void:
var rusty_sword = ItemDB.get_item(&"rusty_sword")
var health_potion = ItemDB.get_item(&"health_potion")
var bread = ItemDB.get_item(&"bread")
if rusty_sword:
inventory.add_item(rusty_sword, 1)
if health_potion:
inventory.add_item(health_potion, 3)
if bread:
inventory.add_item(bread, 5)
func _on_equipment_stats_changed() -> void:
var stats = equipment.get_total_stats()
# Apply to player stats
# ... your stat system here
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
# Save on quit
var save_system = get_node("/root/SaveSystem")
save_system.save_inventory(inventory)
save_system.save_equipment(equipment)
Autoload Registration
In Project Settings > Autoload, register:
| Name | Path |
|---|---|
| ItemDB | res://systems/item_database.gd |
| SaveSystem | res://systems/save_system.gd |
| CraftingSystem | res://systems/crafting_system.gd |
File Structure
project/
data/
items/
weapons/
armor/
consumables/
materials/
recipes/
systems/
item_database.gd
crafting_system.gd
save_system.gd
scripts/
inventory/
item_data.gd
weapon_data.gd
armor_data.gd
consumable_data.gd
inventory_slot_data.gd
inventory.gd
equipment.gd
crafting_recipe.gd
recipe_ingredient.gd
recipe_result.gd
crafting_queue.gd
player/
player.gd
ui/
inventory/
inventory_ui.tscn
inventory_ui.gd
inventory_slot_ui.tscn
inventory_slot_ui.gd
crafting_ui.tscn
crafting_ui.gd
That's 12+ script files and several scene files for a "simple" inventory system. And this doesn't include item pickup from the game world, vendor/shop UI, loot generation, or tooltip styling.
Inventory systems are deceptively complex. The core concept is simple. The implementation is not. Budget your time accordingly, and don't feel bad about using pre-built solutions if your timeline demands it.
Next Steps
Once your inventory system is working, you'll want to add:
- Loot tables for procedural item drops
- Vendor/shop system with buy/sell
- Item tooltips with stat comparisons to equipped items
- Sort and filter options for large inventories
- Item pickup system connecting 3D world objects to inventory
- Notification popups ("Iron Ore x3 added" floating text)
Each of these is its own mini-system. The good news: if you built the inventory with clean signal-based communication, these additions plug in without modifying the core inventory logic. That's the payoff of the architecture work upfront.
If you're using the Godot MCP Server in your development workflow, it can help automate the repetitive parts of setting up inventory UI scenes, creating item resource files in bulk, and configuring the node trees -- saving time on the boilerplate so you can focus on the game-specific logic.