Lesson Goal

Lesson 7 gave you flags and dialogue. This lesson gives pickups a real home: definitions you can browse in the inspector, counts that stack, and a single inventory authority the quest runner and future shops can query.

By the end of this lesson, you will:

  • define items as ItemData Resources (id, display name, icon placeholder, stack rules)
  • run inventory through an autoload with item_added, item_removed, and changed signals
  • spawn pickup scenes that grant items and optionally set ProgressState flags
  • keep a minimal equipment stub (for example one weapon id) without building full paper-doll UI yet

Step 1 - ItemData Resource

Create res://systems/items/item_data.gd:

extends Resource
class_name ItemData

@export var id: String = "wood_stick"
@export var display_name: String = "Wood Stick"
@export var max_stack: int = 99
@export var is_quest_item: bool = false
@export var debuff_movement_while_equipped: bool = false # future hook

Rules stay boring on purpose: id is the stable key your save file and quests will use. max_stack of 1 makes keys and unique gear.

Mini task:
Create res://data/items/cellar_key.tres and health_herb.tres. Give the herb max_stack 20 and the key max_stack 1 with is_quest_item true.


Step 2 - Inventory autoload

Add Inventory as a Project Settings autoload pointing to res://systems/items/inventory.gd:

extends Node

signal inventory_changed
signal item_count_changed(item_id: String, new_count: int)

var counts: Dictionary = {} # item_id -> int
var equipped_weapon_id: String = ""

func add_item(item: ItemData, amount: int = 1) -> void:
    if item == null or item.id.is_empty():
        return
    var cap = max(item.max_stack, 1)
    var current: int = int(counts.get(item.id, 0))
    var next_count: int = mini(current + amount, cap)
    counts[item.id] = next_count
    item_count_changed.emit(item.id, next_count)
    inventory_changed.emit()

func remove_item(item_id: String, amount: int = 1) -> void:
    var current: int = int(counts.get(item_id, 0))
    counts[item_id] = max(current - amount, 0)
    item_count_changed.emit(item_id, int(counts[item_id]))
    inventory_changed.emit()

func count_of(item_id: String) -> int:
    return int(counts.get(item_id, 0))

func has_at_least(item_id: String, amount: int = 1) -> bool:
    return count_of(item_id) >= amount

func equip_weapon(item_id: String) -> void:
    equipped_weapon_id = item_id
    inventory_changed.emit()

Pro tip:
If you need overflow for stackables later, either spill into a loot bag or show a “inventory full” UI event. For this slice, hard clamping keeps surprises visible in playtest.

Common mistake:
Letting every chest scene mutate counts directly. Always go through Inventory so audio, UI, and analytics have one choke point.


Step 3 - Pickup scene

Build pickups/item_pickup.tscn:

  • Area2D root with CollisionShape2D (circle, slightly larger than sprite)
  • child Sprite2D or AnimatedSprite2D for readability
  • optional AudioStreamPlayer2D for pickup SFX

Script item_pickup.gd:

extends Area2D

@export var item: ItemData
@export var grant_amount: int = 1
@export var grant_flag: String = ""
@export var consume_on_pickup: bool = true

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

func _on_body_entered(body: Node) -> void:
    if not body.is_in_group("player"):
        return
    if item == null:
        return
    Inventory.add_item(item, grant_amount)
    if not grant_flag.is_empty() and has_node("/root/ProgressState"):
        ProgressState.set_flag(grant_flag, true)
    if consume_on_pickup:
        queue_free()

Add the player root to group player if you have not already (Lesson 4 pattern).


Step 4 - Hook the cellar key quest

Replace the “invisible flag only” key flow from Lesson 7 with a real pickup:

  1. instance item_pickup.tscn near the well
  2. assign cellar_key.tres to item
  3. set grant_flag to has_cellar_key

Your QuestRunner can still listen for the flag, but designers can now see the key in the inspector and you can print Inventory.count_of("cellar_key") during QA.


Step 5 - Equipment stub

You are not building a full equipment paper doll yet. You are locking one convention:

  • equipped_weapon_id mirrors an ItemData.id that exists in counts (optional rule: only equip if has_at_least).

Add a helper:

func try_equip_weapon(item: ItemData) -> void:
    if item == null:
        return
    if not has_at_least(item.id, 1):
        return
    equip_weapon(item.id)

Wire a debug key (for example cycle_weapon_debug) that swaps between two test items so you feel the signal path before UI exists.


Step 6 - Debug HUD row

Drop a Label into your HUD that listens to Inventory.inventory_changed.

Temporary text can be:

func _format_inventory() -> String:
    var parts: PackedStringArray = []
    for id in Inventory.counts.keys():
        if Inventory.count_of(id) > 0:
            parts.append("%s x%s" % [id, Inventory.count_of(id)])
    var eq = Inventory.equipped_weapon_id
    return " | ".join(parts) + " | EQ: " + (eq if not eq.is_empty() else "none")

Delete or replace this label when Lesson 10 builds real inventory UI.


Troubleshooting

  • Pickup fires twice: two overlapping Area2D layers or duplicate signals; disable monitoring after first grant or use a picked guard bool.
  • Item shows zero in HUD: wrong item.id string versus Resource, or pickup references an empty @export var item.
  • Quest still stuck: grant_flag empty or typo; match spelling exactly to QuestData.completion_flag from Lesson 7.

Common Mistakes to Avoid

  • using file paths as ids (breaks when you move folders)
  • storing ItemData Resources inside the save dictionary without a versioned serializer (Lesson 11 will formalize saves; for now store ids and counts only)
  • building drag-and-drop UI before the autoload contract is stable

Mini Challenge

Add a consumable herb that calls Inventory.remove_item("health_herb", 1) and heals the player when a dedicated “use consumable” action is pressed, but only if count_of is positive.


FAQ

Dictionary vs typed arrays for inventory?
A count map plus ItemData lookup keeps saves small. Arrays of structs are fine later; start simple.

When do I migrate to JSON or CSV?
When non-Godot collaborators edit loot tables. Until then, Resources stay fastest to iterate.


Quick Recap

You now have:

  • reusable ItemData definitions with stack semantics
  • a centralized Inventory autoload with clear signals
  • pickup scenes that tie physical world events to items and flags
  • a forward-compatible equipment string for weapons

Next, you will layer audio and ambience so pickups, combat, and exploration share buses and spatial rules that scale.

For architecture parallels with data-driven items in another engine, see How to Build a Modular Inventory System in Unity Without Spaghetti Code. For Godot project hygiene around systems like this, revisit Lesson 2: Godot Project Architecture and Naming.

Bookmark this lesson before you add loot tables in Lesson 14; the id and stack rules you choose here become your economy vocabulary.