Launch Discount: 25% off for the first 50 customers — use code LAUNCH25

StraySparkStraySpark
ProductsFree AssetsDocsBlogGamesAbout
StraySparkStraySpark

Game Studio & UE5 Tool Developers. Building professional-grade tools for the Unreal Engine community.

Products

  • Complete Toolkit (Bundle)
  • Procedural Placement Tool
  • Cinematic Spline Tool
  • Blueprint Template Library
  • DetailForge
  • Unreal MCP Server
  • Blender MCP Server
  • Godot MCP Server

Resources

  • Free Assets
  • Documentation
  • Blog
  • Changelog
  • Roadmap
  • FAQ
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 StraySpark. All rights reserved.

Back to Blog
tutorial
StraySparkMarch 31, 20265 min read
Building a Complete Inventory and Crafting System in Godot 4: From Resources to UI 
GodotGodot 4InventoryCraftingGame DesignTutorialGdscriptRpg

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:

  1. Data layer: Item definitions. What items exist, what properties they have.
  2. Logic layer: Inventory containers. Adding, removing, stacking, splitting items.
  3. Crafting layer: Recipes, ingredient validation, result generation.
  4. 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:

  1. Right-click in the FileSystem dock
  2. Select New Resource
  3. Choose WeaponData (or whichever type)
  4. Fill in the properties in the inspector
  5. Save as .tres file (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:

NamePath
ItemDBres://systems/item_database.gd
SaveSystemres://systems/save_system.gd
CraftingSystemres://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.

Tags

GodotGodot 4InventoryCraftingGame DesignTutorialGdscriptRpg

Continue Reading

tutorial

The 2026 Indie Game Marketing Playbook: Why You Should Market Before You Build

Read more
tutorial

AI Slop in Game Development: How to Use AI Without Becoming the Problem

Read more
tutorial

Blueprint Nativization in UE 5.7: When and How to Convert Blueprints to C++

Read more
All posts