Part 02 — The container
You will build: an Inventory holding a fixed number of slots, with stacking, adding,
removing, and a changed signal — all of it testable from a print statement, with no UI in
sight.
Keeping the UI out until part 03 is deliberate. If the inventory is correct without a screen, the screen can't corrupt it.
A slot holds a stack#
class_name ItemStack
extends Resource
## One slot's worth: an item type and how many. Runtime state — this one *does* change.
@export var item: ItemData
@export var count: int = 0
func is_empty() -> bool:
return item == null or count <= 0
## How many more of this item would fit here.
func space_left() -> int:
if item == null:
return 0
return item.max_stack - count
ItemStack is a Resource and ItemData is a Resource, but they play opposite roles: the
stack is yours and mutable, the data it points at is shared and frozen. Same base class,
opposite rules. Holding that distinction in your head is most of what this shelf is teaching.
The inventory#
class_name Inventory
extends Resource
@export var slots: Array[ItemStack] = []
func _init(size: int = 20) -> void:
if slots.is_empty():
for i in size:
slots.append(ItemStack.new())
You don't declare the signal — you already have it#
Notice what isn't in that script: a signal changed line. Resource already defines a
changed signal, and redeclaring an inherited member is a parse error — GDScript will
refuse the file with "Member 'changed' redefined (original in native class 'Resource')".
So you inherit it for free, and you emit it with the method Godot provides:
emit_changed() # emits the inherited `changed` signal
Listeners connect exactly as they would to any signal — inventory.changed.connect(_redraw).
This is a general habit worth forming: before declaring a signal on a subclass, check whether
the base class already gives you one. Custom Resources are the case where it most often does.
One signal, no arguments — on purpose#
changed carries nothing. Not the slot index, not the item, not the delta. Every listener
responds by re-reading the state it cares about.
That looks wasteful and it is the right call, for a reason worth internalising: a signal carrying "what changed" forces every listener to handle every kind of change correctly. Add stack-splitting later and a listener that only knew about "item added" is now silently wrong. A listener that just redraws is never wrong — it can only be slow. Redrawing twenty labels is nothing; being subtly out of sync is a bug hunt.
Optimise this later if a profiler tells you to, and not before.
Adding, with stacking#
The interesting method. Note what it returns:
## Adds up to `amount`. Returns the number that did NOT fit.
func add(item: ItemData, amount: int = 1) -> int:
var remaining: int = amount
# pass 1 — top up stacks of this item that already exist
for stack in slots:
if remaining <= 0:
break
if stack.item == item and stack.space_left() > 0:
var moved: int = mini(remaining, stack.space_left())
stack.count += moved
remaining -= moved
# pass 2 — spill into empty slots
for stack in slots:
if remaining <= 0:
break
if stack.is_empty():
var moved: int = mini(remaining, item.max_stack)
stack.item = item
stack.count = moved
remaining -= moved
if remaining < amount:
emit_changed()
return remaining
Three decisions in there, each of which you could have made differently:
It returns the leftover, not a bool. Picking up 30 wheat with room for 18 should put 18
away and leave 12 on the ground. A bool throws that number away and the caller has to
recompute it — badly.
Existing stacks fill before empty slots. Try it the other way and picking up one wheat at a time scatters singles across the whole bag. Two passes, cheap, matches what players expect.
changed fires once, at the end, not per slot touched — and only if something actually
moved. Adding to a full inventory should not make the UI flicker.
Predict before you run: your bag has one slot with 95 wheat (
max_stack99) and two empty slots. You calladd(wheat, 150). Write down the final contents of all three slots and the return value, then test it.
Removing#
Write this one yourself. The signature and the contract:
## Removes up to `amount`. Returns how many were actually removed.
func remove(item: ItemData, amount: int = 1) -> int:
# your code
pass
Requirements to satisfy:
- Drain from the last matching stack backwards, so partial stacks get consumed before full ones and the bag tends toward tidy.
- A stack that reaches zero must be cleared, not left as a zero-count ghost — set
itemtonull. A stack holdingcount = 0with a liveitemwill passstack.item == itemchecks inadd()and silently break stacking. - Emit
changedonly if something was removed. - Never let
countgo negative. Ever. A negative count propagates into every total the UI displays and every save file.
And a query you'll want constantly:
func count_of(item: ItemData) -> int:
var total: int = 0
for stack in slots:
if stack.item == item:
total += stack.count
return total
Testing it without a UI#
func _ready() -> void:
var inv := Inventory.new(4)
var wheat: ItemData = preload("res://items/wheat.tres")
var sword: ItemData = preload("res://items/iron_sword.tres")
inv.changed.connect(func() -> void: print("inventory changed"))
print(inv.add(wheat, 150)) # expect leftover
print(inv.count_of(wheat))
print(inv.add(sword, 3)) # max_stack 1 — how many slots does this take?
print(inv.remove(wheat, 200))
Run it and check every number against what you predicted. This is your test suite. An inventory that behaves correctly at a print statement will behave correctly behind a UI; the reverse is not true, and debugging counting bugs through a grid of buttons is genuinely awful.
The sword line is the interesting one. max_stack is 1, so three swords need three slots — and
the bag only has four. Does your add() handle that, or does pass 2's
mini(remaining, item.max_stack) quietly do the right thing already? Read it again and be sure
you know why it works before you move on.
The rule you must not break#
Nothing outside Inventory may touch slots directly.
inventory.slots[3].count += 5 # ← forbidden
inventory.add(wheat, 5) # ← the only way in
The direct write skips stacking rules, skips the max-stack clamp, and — worst — skips
emit_changed(), so the UI shows stale numbers until something unrelated happens to refresh
it. That's the kind of bug that gets misdiagnosed as "the UI is broken" for two days.
If you find yourself wanting to reach in, that's a missing method on Inventory, not a reason
to reach in. Add swap(a, b), add split(index, amount) — put the rule where the rules live.
Checkpoint — definition of done#
-
add()fills partial stacks before empty slots, and you verified it with the 95-wheat case - Adding more than fits returns the correct leftover
-
remove()clears emptied slots and never produces a negative count -
changedfires once per operation, and not at all when nothing moved - You can explain: why does
changedcarry no arguments, and what breaks the day you add stack-splitting to a signal that carried an item and a delta?
Stretch (no instructions)#
Add swap(from: int, to: int) for drag-and-drop reordering, handling the case where both slots
hold the same stackable item (they should merge, with the overflow staying behind). Then answer:
should swap emit changed when a player drags a stack onto its own slot?
If you get stuck#
- Items vanish when added → pass 2 sets
stack.itembut check you also setcount, and thatis_empty()returns true for genuinely fresh stacks (item == null). - Stacking never happens →
stack.item == itemcompares object identity. Twopreload()s of the same path give the same object, so this works — but aduplicate()dItemDatais a different object and will never stack. That's the shared-resource behaviour from part 01, seen from the useful side. changedfires twice per pickup → you emitted inside a loop as well as at the end.- Two characters share one inventory → you
load()ed the same.tresfor both, and the resource cache handed you one object twice. Part 01's trap, now biting a runtime resource rather than a definition. Build runtime inventories withInventory.new(), orduplicate(true)a template.
Next: Part 03 — drawing it, where the UI turns out to be the easy part.