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
ItemDataResources (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
ProgressStateflags - 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:
Area2Droot withCollisionShape2D(circle, slightly larger than sprite)- child
Sprite2DorAnimatedSprite2Dfor readability - optional
AudioStreamPlayer2Dfor 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:
- instance
item_pickup.tscnnear the well - assign
cellar_key.trestoitem - set
grant_flagtohas_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_idmirrors anItemData.idthat exists incounts(optional rule: only equip ifhas_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
Area2Dlayers or duplicate signals; disable monitoring after first grant or use apickedguard bool. - Item shows zero in HUD: wrong
item.idstring versus Resource, or pickup references an empty@export var item. - Quest still stuck:
grant_flagempty or typo; match spelling exactly toQuestData.completion_flagfrom Lesson 7.
Common Mistakes to Avoid
- using file paths as ids (breaks when you move folders)
- storing
ItemDataResources 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
ItemDatadefinitions with stack semantics - a centralized
Inventoryautoload 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.