[Godot] Designing an Inventory System: Item Management with Resources and Signals

Created: 2025-12-08Last updated: 2026-07-08

How to design an extensible inventory system in Godot that separates data, logic, and UI using Resources and signals, explained through a three-layer architecture and a practical end-to-end flow.

Logic for picking up, using, and dropping items gets scattered across the player script, and every new equipment type or non-stackable item means edits in several places. An inventory built ad hoc turns into spaghetti fast.

In this article we build an inventory design that splits data (item definitions), logic (management), and UI (display) into three layers. Combining Resources, a singleton, and signals, the goal is a core that keeps working unchanged no matter how many item types you add or how much you redesign the UI.

A game inventory screen: a grid of slots holding item icons such as potions, swords, keys, and gems

What You'll Learn

  • The three-layer architecture that separates data, logic, and UI
  • How to define items as .tres files with Resource (data-driven)
  • How a singleton InventoryManager keeps state in one place
  • How the inventory_changed signal updates the UI automatically

Sponsored

Designing in Three Layers

The key to a robust system is separation of concerns. We'll split the inventory into the following three layers.

Three-layer inventory architecture: the data layer (Resource), logic layer (Singleton), and view layer (UI) connected by signals
LayerRoleImplementation in Godot
Data layerDefines what an item isResource (.tres files)
Logic layerManages state: adding, removing, usingA singleton Node (Autoload)
View layer (UI)Shows the state to the playerControl nodes

Split this way, each layer can focus on its own job without knowing the internals of the others. Rebuild the UI and the logic is untouched; add more items and you never open the UI code.

Data Layer: Defining Items with Resources

Create the blueprint for an item with a Resource. Because Resources can be edited and saved right in the editor, you can churn out swords and potions as .tres files, and non-programmers can add them too. The data-driven mindset is covered in more depth in Creating and Using Custom Resources.

Concrete items (a sword, a potion, and a key) created as .tres files from the ItemResource blueprint
# item_resource.gd
class_name ItemResource
extends Resource

enum ItemType { CONSUMABLE, EQUIPMENT, KEY_ITEM, MATERIAL }

@export_group("Basic Info")
@export var item_name: String = "New Item"
@export_multiline var description: String = ""
@export var texture: Texture2D          # icon

@export_group("Inventory Settings")
@export var type: ItemType = ItemType.CONSUMABLE
@export var stackable: bool = true
@export var max_stack_size: int = 99

@export_group("Gameplay")
@export var can_be_used: bool = true
@export var heal_amount: int = 10

# Unique ID. Use the file path if saved, otherwise fall back to the instance ID
func get_id() -> String:
    return resource_path if not resource_path.is_empty() else str(get_instance_id())

Since the .tres path is itself a unique ID, you don't need to maintain a separate table of string IDs.

Sponsored

Logic Layer: Managing State in a Singleton

Inventory state lives in exactly one place: an Autoload singleton called InventoryManager. The player, chests, and enemies all move items in and out through it (a single source of truth).

InventoryManager holding the Dictionary state at the center, with add/use/remove calls coming from the player, chests, and enemies, and inventory_changed going out
# inventory_manager.gd (registered as an Autoload)
extends Node

signal inventory_changed                     # fired when the contents change
signal item_used(item: ItemResource)         # fired on use (for sound effects, etc.)

# Key: item ID, Value: { "resource": ItemResource, "count": int }
var _items: Dictionary = {}
const MAX_SLOTS := 30

func add_item(item: ItemResource, count := 1) -> bool:
    if item == null:
        return false
    var id := item.get_id()
    if item.stackable and _items.has(id):
        _items[id].count += count
        inventory_changed.emit()
        return true
    if _items.size() < MAX_SLOTS:
        _items[id] = {"resource": item, "count": count}
        inventory_changed.emit()
        return true
    return false   # inventory full

func use_item(id: String) -> void:
    if not _items.has(id):
        return
    var item: ItemResource = _items[id].resource
    if not item.can_be_used:
        return
    item_used.emit(item)
    if item.type == ItemResource.ItemType.CONSUMABLE:
        remove_item(id, 1)   # consumables get used up

func remove_item(id: String, count := 1) -> void:
    if not _items.has(id):
        return
    _items[id].count -= count
    if _items[id].count <= 0:
        _items.erase(id)
    inventory_changed.emit()

func get_inventory_data() -> Dictionary:
    return _items.duplicate(true)

Every operation that changes state (add / use / remove) ends by calling inventory_changed.emit(). That's the cue for the UI update below.

Sponsored

View Layer: Updating the UI Automatically with Signals

Make the UI something that only reflects state. When it receives inventory_changed, it redraws. That's all. A GridContainer is handy for the layout.

The UI redrawing its grid in response to the inventory_changed signal, with the UI acting purely as a reflection of state
# inventory_ui.gd
extends GridContainer

const SLOT := preload("res://ui/inventory_slot.tscn")

func _ready() -> void:
    InventoryManager.inventory_changed.connect(_redraw)   # subscribe to changes
    _redraw()

func _redraw() -> void:
    for c in get_children():
        c.queue_free()                       # clear everything first
    var data := InventoryManager.get_inventory_data()
    for id in data:
        var slot := SLOT.instantiate()
        slot.update_display(data[id].resource, data[id].count)
        # On click, simply ask the logic layer to use the item
        slot.gui_input.connect(func(e: InputEvent) -> void:
            if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
                InventoryManager.use_item(id))
        add_child(slot)

All the UI does is ask with InventoryManager.use_item(id). Whether the count actually goes down, or healing happens, is entirely up to the logic layer. That's why redesigning the UI doesn't change a single line of logic.

Hands-On: Picking Up and Using a Potion

With all three layers in place, let's run the full loop: pick up a potion lying on the ground, then use it from the inventory to heal. RPG, roguelike, or survival game, this round trip has the same shape.

The flow from picking up a potion to using it: pick up (add_item) -> inventory_changed -> the UI gains an entry -> use (use_item) -> item_used -> healing. Everything goes through InventoryManager

The pickup item (an Area2D) only adds itself to the InventoryManager on contact and then disappears.

# item_pickup.gd
extends Area2D

@export var item: ItemResource   # assign a .tres in the editor

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        if InventoryManager.add_item(item):   # hand it to the logic layer
            queue_free()                       # disappear once picked up

That's all it takes. The rest connects automatically.

  1. Pick up → add_item() adds to _items and emits inventory_changed
  2. The UI receives it and redraws the grid (the potion appears)
  3. Click the slot → use_item() emits item_used and decrements the count
  4. The healing itself is handled by a separate system listening to item_used (the player's HP)

There are two points to take away.

  • Nobody reaches into anyone else's internals: The pickup only says "add this," and the UI only says "use this." Everything goes through InventoryManager, so there is always exactly one place to fix.
  • Growing the system means adding data and subscribers: Want a sound effect or healing? Add one more node that listens to item_used. You don't touch existing code.
Sponsored

Common Mistakes and Best Practices

Common mistakeBest practice
Tightly coupling logic and UI with things like get_node("../Player").add_item()Stay loosely coupled with signals and a singleton: the UI gets notified, and the logic is callable from anywhere
Writing item data directly into scriptsMove it into .tres Resources and go data-driven
Rebuilding the UI every frameUpdate only when inventory_changed fires (and pool slots at scale)
Letting the player and UI each hold their own inventory dataSingle source of truth: only InventoryManager holds state, everyone else reads it

Bonus: Good to Know for Later

  • Save and load: Flatten _items into a Dictionary of item IDs and counts, save it with JSON or ConfigFile, and rebuild on load.
  • Reuse UI slots instead of rebuilding: Clearing and regenerating every slot each time gets heavy when there are many. Reusing slots (object pooling) keeps it light.
  • Style it with a theme: Pulling the look of slots and item frames into a theme keeps everything consistent.
  • Put item effects in the data: Store heal amounts and effects on ItemResource and let whoever listens to item_used apply them, so adding an item is purely a data task.

Summary

  • Three layers: Separate data (Resource), logic (singleton), and UI (Control)
  • Data layer: Define items as .tres. Adding one is just creating a file
  • Logic layer: InventoryManager owns the only copy of state and emits inventory_changed on every change
  • View layer: Listens to the signal and redraws. Using an item is just asking with use_item()

Follow these three principles and your core logic keeps working no matter how many item types you add or how much the UI design changes. Start by creating one ItemResource and running the round trip: add_item → UI display → use_item. Build the UI on top of layout containers and a theme and it starts to look like the real thing.