Free Tutorial · Inventory Systems

Godot 4 Inventory System — Build a Flexible Item System
with GDScript

SScriptSnap/March 2026/13 min read

Almost every game needs an inventory. RPGs, survival crafters, puzzle adventures — even platformers with power-ups need a way to store, display, and persist items. Yet most Godot tutorials stop at a simple array and a print statement.

In this Godot 4 inventory system tutorial, you'll build a production-ready item system from scratch. We'll use resource-based items for data-driven design, wrap them in an inventory container class, render a UI grid, add drag-and-drop basics, and save/load everything with JSON. Every example uses complete GDScript code you can paste directly into your project.

If you're new to GDScript patterns, grab our free GDScript Cheat Sheet first. Already comfortable? Let's build.

Tutorial

1 — Resource-Based Items

Godot's Resource class is the backbone of our gdscript item system. Resources are lightweight, serializable data containers that the editor can inspect. Instead of hard-coding item stats in scripts, we define a custom resource that any designer can tweak in the Inspector.

Create a new script and extend Resource. Add exported properties for everything an item needs — name, icon, description, stack size, and any custom stats:

item_data.gd
class_name ItemData
extends Resource

@export var id: String = ""
@export var display_name: String = ""
@export var description: String = ""
@export var icon: Texture2D
@export var max_stack: int = 1
@export var item_type: ItemType = ItemType.MISC

enum ItemType { WEAPON, ARMOR, CONSUMABLE, MATERIAL, MISC }

# Optional stats — extend for your game
@export var damage: int = 0
@export var heal_amount: int = 0
@export var sell_value: int = 0

With class_name set, the editor auto-registers ItemData as a new resource type. Right-click the FileSystem dock, choose New Resource, and search for ItemData. Save each item as a .tres file — e.g. iron_sword.tres, health_potion.tres.

This data-driven approach means you can add hundreds of items without touching code. It also integrates cleanly with signal bus patterns for broadcasting events like “item picked up” or “item used” across your scene tree.

2 — The Inventory Container Class

Now we need a place to hold items. Our inventory container manages a fixed array of slots, each containing an ItemData reference and a stack count. Signals notify the UI whenever the contents change.

inventory.gd
class_name Inventory
extends Node

signal inventory_changed
signal item_added(item: ItemData, slot_index: int)
signal item_removed(item: ItemData, slot_index: int)

@export var max_slots: int = 20

var slots: Array[Dictionary] = []

func _ready() -> void:
    # Initialize empty slots
    for i in max_slots:
        slots.append({ "item": null, "quantity": 0 })

func add_item(item: ItemData, amount: int = 1) -> int:
    # First, try stacking into existing slots
    for i in slots.size():
        var slot = slots[i]
        if slot["item"] == item and slot["quantity"] < item.max_stack:
            var can_add = mini(amount, item.max_stack - slot["quantity"])
            slot["quantity"] += can_add
            amount -= can_add
            item_added.emit(item, i)
            if amount <= 0:
                break

    # Then, fill empty slots with remaining amount
    if amount > 0:
        for i in slots.size():
            if slots[i]["item"] == null:
                var can_add = mini(amount, item.max_stack)
                slots[i] = { "item": item, "quantity": can_add }
                amount -= can_add
                item_added.emit(item, i)
                if amount <= 0:
                    break

    inventory_changed.emit()
    return amount  # Returns leftover that didn't fit

func remove_item(slot_index: int, amount: int = 1) -> void:
    if slot_index < 0 or slot_index >= slots.size():
        return
    var slot = slots[slot_index]
    if slot["item"] == null:
        return

    var removed_item = slot["item"]
    slot["quantity"] -= amount
    if slot["quantity"] <= 0:
        slots[slot_index] = { "item": null, "quantity": 0 }

    item_removed.emit(removed_item, slot_index)
    inventory_changed.emit()

func get_slot(index: int) -> Dictionary:
    if index >= 0 and index < slots.size():
        return slots[index]
    return { "item": null, "quantity": 0 }

func swap_slots(from: int, to: int) -> void:
    if from < 0 or from >= slots.size():
        return
    if to < 0 or to >= slots.size():
        return
    var temp = slots[from]
    slots[from] = slots[to]
    slots[to] = temp
    inventory_changed.emit()

The key design decisions here: add_item() returns the leftover amount that didn't fit (so you can drop overflow on the ground), and swap_slots() powers rearranging via drag-and-drop. If you want to manage complex game states alongside inventory, consider pairing this with a state machine to handle states like “shop open”, “crafting”, or “looting”.

Want clean architecture in Godot? Our State Machines in GDScript course covers FSMs, hierarchical states, and game flow — all in 15 minutes.

Take the course — $4.99 →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime

3 — UI Grid Display

Players need to see their items. We'll build a grid UI using Godot's GridContainer filled with slot panels. Each slot shows an icon and quantity label. First, create the individual slot scene:

inventory_slot.gd
class_name InventorySlot
extends PanelContainer

@onready var icon: TextureRect = $Icon
@onready var quantity_label: Label = $QuantityLabel

var slot_index: int = -1

func update_slot(data: Dictionary) -> void:
    var item: ItemData = data.get("item")
    var qty: int = data.get("quantity", 0)

    if item == null:
        icon.texture = null
        icon.modulate = Color.TRANSPARENT
        quantity_label.text = ""
    else:
        icon.texture = item.icon
        icon.modulate = Color.WHITE
        quantity_label.text = str(qty) if qty > 1 else ""

func clear_slot() -> void:
    update_slot({ "item": null, "quantity": 0 })

In the scene tree, the InventorySlot is a PanelContainer with a TextureRect child named “Icon” and a Label named “QuantityLabel” anchored to the bottom-right. Now create the grid controller:

inventory_ui.gd
extends Control

@export var inventory: Inventory
@export var slot_scene: PackedScene

@onready var grid: GridContainer = $GridContainer

func _ready() -> void:
    if inventory:
        inventory.inventory_changed.connect(_refresh_grid)
        _build_grid()

func _build_grid() -> void:
    # Clear existing children
    for child in grid.get_children():
        child.queue_free()

    # Create slot panels
    for i in inventory.max_slots:
        var slot_node: InventorySlot = slot_scene.instantiate()
        slot_node.slot_index = i
        grid.add_child(slot_node)

    _refresh_grid()

func _refresh_grid() -> void:
    for i in grid.get_child_count():
        var slot_node: InventorySlot = grid.get_child(i) as InventorySlot
        if slot_node:
            slot_node.update_slot(inventory.get_slot(i))

Set the GridContainer's columns property to 5 (or whatever your grid width is) in the Inspector. Each time the inventory emits inventory_changed, every slot refreshes its icon and quantity label. This reactive pattern mirrors how signal buses decouple your nodes from each other.

4 — Drag-and-Drop Basics

Godot has built-in drag-and-drop support via _get_drag_data(), _can_drop_data(), and _drop_data(). Override these on each slot to let players rearrange items:

inventory_slot.gd — drag-and-drop
# Add these methods to the InventorySlot class

func _get_drag_data(_at_position: Vector2) -> Variant:
    if icon.texture == null:
        return null

    # Create a visual preview
    var preview = TextureRect.new()
    preview.texture = icon.texture
    preview.custom_minimum_size = Vector2(48, 48)
    preview.modulate = Color(1, 1, 1, 0.7)
    set_drag_preview(preview)

    return { "from_slot": slot_index }

func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
    return data is Dictionary and data.has("from_slot")

func _drop_data(_at_position: Vector2, data: Variant) -> void:
    if data is Dictionary and data.has("from_slot"):
        var from: int = data["from_slot"]
        var inventory_node = get_parent().get_parent()
        if inventory_node and inventory_node.has_method("swap_from_ui"):
            inventory_node.swap_from_ui(from, slot_index)

Then add a helper method on the UI controller to bridge the swap back to the data layer:

inventory_ui.gd — swap helper
func swap_from_ui(from_index: int, to_index: int) -> void:
    inventory.swap_slots(from_index, to_index)

That's it. The call to swap_slots() fires inventory_changed, which triggers _refresh_grid(), and the UI updates automatically. Data flows in one direction: UI action → data mutation → signal → UI refresh. Clean and debuggable.

Love clean architecture? The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off.

Get the Pro Pack — $12.99 →

5 — Saving & Loading with JSON

Resources are great at design time, but for save files JSON is simpler and safer (no arbitrary code execution). We serialize each slot to a dictionary with the item's id and its quantity, then reconstruct on load by looking up resources from a registry.

inventory_saver.gd
class_name InventorySaver
extends Node

const SAVE_PATH := "user://inventory.json"

## Save inventory to JSON file
static func save_inventory(inventory: Inventory) -> void:
    var save_data: Array[Dictionary] = []
    for slot in inventory.slots:
        if slot["item"] != null:
            save_data.append({
                "id": slot["item"].id,
                "quantity": slot["quantity"],
            })
        else:
            save_data.append({ "id": "", "quantity": 0 })

    var json_string = JSON.stringify(save_data, "  ")
    var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file:
        file.store_string(json_string)
        file.close()
        print("Inventory saved to ", SAVE_PATH)

## Load inventory from JSON file
static func load_inventory(inventory: Inventory) -> void:
    if not FileAccess.file_exists(SAVE_PATH):
        print("No save file found.")
        return

    var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
    if not file:
        return

    var json_string = file.get_as_text()
    file.close()

    var json = JSON.new()
    if json.parse(json_string) != OK:
        push_error("Failed to parse inventory JSON.")
        return

    var data: Array = json.data
    for i in mini(data.size(), inventory.slots.size()):
        var entry: Dictionary = data[i]
        if entry["id"] == "":
            inventory.slots[i] = { "item": null, "quantity": 0 }
        else:
            var item = _load_item_by_id(entry["id"])
            if item:
                inventory.slots[i] = {
                    "item": item,
                    "quantity": entry["quantity"],
                }

    inventory.inventory_changed.emit()

## Look up an ItemData resource by its id
static func _load_item_by_id(id: String) -> ItemData:
    var path = "res://items/%s.tres" % id
    if ResourceLoader.exists(path):
        return load(path) as ItemData
    push_warning("Item not found: ", id)
    return null

The convention here is that each .tres item resource lives at res://items/<id>.tres. As long as your ItemData.id matches the filename, the loader finds it automatically. Call InventorySaver.save_inventory(inv) on quit and InventorySaver.load_inventory(inv) on startup.

For more complex projects you might save additional game state alongside the inventory. If your game uses randomized content, check out our procedural generation guide to see how seed-based systems keep worlds reproducible across save/load cycles.

6 — Putting It All Together

Here's how the pieces connect in a typical scene tree:

Scene Tree Layout
Main (Node)
├── Player (CharacterBody2D)
│   └── Inventory (inventory.gd)     ← data layer
├── CanvasLayer
│   └── InventoryUI (inventory_ui.gd) ← display layer
│       └── GridContainer
│           ├── InventorySlot 0
│           ├── InventorySlot 1
│           └── ... (instantiated at runtime)
└── InventorySaver (inventory_saver.gd)

Wire the InventoryUI's exported inventory reference to the player's Inventory node in the Inspector. To add items during gameplay, preload your resources and call add_item():

pickup.gd
extends Area2D

@export var item: ItemData
@export var quantity: int = 1

func _on_body_entered(body: Node2D) -> void:
    if body.has_node("Inventory"):
        var inv: Inventory = body.get_node("Inventory")
        var leftover = inv.add_item(item, quantity)
        if leftover == 0:
            queue_free()  # Fully picked up
        else:
            quantity = leftover  # Partial pickup

The pickup.gd script is attached to an Area2D node representing an item in the world. Set the item export to your .tres resource in the Inspector and you're done. The leftover handling means the player can't magically absorb more items than their inventory can hold.

7 — Tips & Next Steps

You now have a fully functional Godot 4 inventory system. Here are ideas to extend it:

  • Tooltip on hover — Show ItemData.description and stats in a floating panel.
  • Item categories & tabs — Filter the grid by ItemType with tab buttons.
  • Equipment slots — Add a separate “equipped” dictionary alongside the main slots.
  • Crafting system — Define recipes as another Resource type and consume items from the inventory.
  • Multiplayer sync — Pair with RPCs to synchronize inventories between clients.
  • State-driven UI — Use a state machine to transition between inventory, shop, and crafting screens without spaghetti conditionals.

Level up your GDScript with our free cheat sheet — 20+ copy-paste snippets for signals, tweens, exports, and common patterns.

Get Cheat Sheet →
EOF

You've built a flexible inventory system in Godot 4 that covers every essential layer: data (resources), logic (container class), presentation (grid UI), interaction (drag-and-drop), and persistence (JSON save/load). Each piece is decoupled, so you can swap, extend, or restyle without breaking the rest.

The godot inventory tutorial approach we used keeps data separate from display — a pattern that scales from a 10-slot backpack to a 500-slot warehouse. Fork the code, adapt it to your game, and ship something great.

Free download
+ weekly tips

Get the Free GDScript Cheat Sheet + Weekly Godot Tips

Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.