doc 3 of 4

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_stack 99) and two empty slots. You call add(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 item to null. A stack holding count = 0 with a live item will pass stack.item == item checks in add() and silently break stacking.
  • Emit changed only if something was removed.
  • Never let count go 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
  • changed fires once per operation, and not at all when nothing moved
  • You can explain: why does changed carry 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.item but check you also set count, and that is_empty() returns true for genuinely fresh stacks (item == null).
  • Stacking never happens → stack.item == item compares object identity. Two preload()s of the same path give the same object, so this works — but a duplicate()d ItemData is a different object and will never stack. That's the shared-resource behaviour from part 01, seen from the useful side.
  • changed fires twice per pickup → you emitted inside a loop as well as at the end.
  • Two characters share one inventory → you load()ed the same .tres for 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 with Inventory.new(), or duplicate(true) a template.

Next: Part 03 — drawing it, where the UI turns out to be the easy part.