doc 4 of 4

Part 03 — Drawing it

You will build: a grid UI that displays the inventory from part 02 and updates itself whenever the data changes — while containing zero rules about what an inventory is.

If part 02 went well this part is short, and its shortness is the entire argument for the order we did things in.

The one rule#

The UI reads. It never decides.

A slot widget may not add items, may not enforce stack limits, may not know that swords don't stack. It gets handed an ItemStack and draws it. When the player drags something, the widget reports the drag; Inventory decides whether it's legal.

Break this rule and you get an inventory that behaves differently depending on whether the window is open — because half the rules live in a Control that only exists while it's visible.

The slot widget#

A small scene, inventory_slot.tscn:

InventorySlot (Panel)
├── Icon  (TextureRect)   — expand mode: Ignore Size, stretch: Keep Aspect Centered
└── Count (Label)         — anchored bottom-right
class_name InventorySlot
extends Panel

@onready var icon: TextureRect = %Icon
@onready var count_label: Label = %Count


func display(stack: ItemStack) -> void:
	if stack == null or stack.is_empty():
		icon.texture = null
		count_label.text = ""
		tooltip_text = ""
		return

	icon.texture = stack.item.icon
	# a lone item doesn't need a "1" cluttering the corner
	count_label.text = str(stack.count) if stack.count > 1 else ""
	tooltip_text = "%s\n%s" % [stack.item.display_name, stack.item.description]

One public method, no state of its own, no reference to the Inventory. You could display a shop's stock with the same widget and never touch it. That's the test for whether a UI component is doing too much: could it draw someone else's data unchanged?

Turn on Access as Unique Name (right-click a node → the % option) for Icon and Count so the % paths above work and survive you rearranging the scene later.

The panel#

class_name InventoryView
extends Control

@export var inventory: Inventory:
	set(value):
		if inventory != null and inventory.changed.is_connected(_redraw):
			inventory.changed.disconnect(_redraw)
		inventory = value
		if inventory != null:
			inventory.changed.connect(_redraw)
		if is_node_ready():
			_redraw()

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

@onready var grid: GridContainer = %Grid


func _ready() -> void:
	_redraw()


func _redraw() -> void:
	if inventory == null:
		return

	# build widgets on first draw, reuse them after
	while grid.get_child_count() < inventory.slots.size():
		grid.add_child(SLOT_SCENE.instantiate())

	for i in inventory.slots.size():
		var slot := grid.get_child(i) as InventorySlot
		slot.display(inventory.slots[i])

Two details doing real work:

The setter connects and disconnects. Assigning a new inventory to a view that was already watching another one must drop the old connection, or the view redraws on both inventories' changes forever. This is the single most common leak in observer UIs, and it's invisible until a chest window starts flickering when the player picks up wheat.

Widgets are created once, then reused. Freeing and re-instantiating twenty nodes on every pickup is wasteful and, worse, destroys focus and drag state mid-interaction. Build once, display() after.

_redraw takes no arguments, which is what lets it connect straight to an argument-less changed — the design decision from part 02 paying off.

Predict before you run: you have the panel open and you walk over a wheat pickup. Trace the exact chain of calls from the pickup's Area2D to the label text changing. Write it down — every link should be something you can point at. If any step is "and then somehow the UI knows", find it and fix it.

Two views, one truth#

Open a second InventoryView pointed at the same Inventory — a hotbar alongside the bag. Both update. Neither knows about the other. You wrote no extra code.

This is what "the truth lives in the data" buys, and it's worth stopping to appreciate, because the alternative — the version where items live in UI nodes — cannot do this at all without an explicit sync step that will eventually be forgotten.

Where it gets hard: dragging#

Godot gives Control three methods for drag-and-drop: _get_drag_data, _can_drop_data, _drop_data. The temptation is to move items between slots inside _drop_data. Don't. That puts inventory rules in a widget.

Instead: _drop_data works out which two slot indices are involved and calls inventory.swap(from, to). The inventory merges, rejects, or swaps according to its own rules and emits changed; both views redraw through the path they already use. The drop handler stays about four lines long, and a bug in swapping is in one place rather than per-widget.

Checkpoint — definition of done#

  • The grid shows items and counts, and a count of 1 shows no number
  • Picking something up updates the panel with no explicit refresh call anywhere
  • Two views on one inventory both update
  • Reassigning inventory on a live view stops it reacting to the old one
  • InventorySlot contains no reference to Inventory and no rules
  • You can explain: why is the drag-and-drop rule enforced in Inventory.swap() and not in _drop_data?

Stretch (no instructions)#

Add a hotbar that shows only the first five slots and highlights the selected one, driven by number keys. Constraint: you may not add a single line to Inventory. If you find you need one, that's a real finding — write down what it is and why the split didn't anticipate it.

If you get stuck#

  • Panel is blank → is inventory actually assigned? An @exported Resource is null until something sets it in the Inspector or in code.
  • Nothing updates → print inside _redraw. Never called means the signal isn't connected; called but wrong means the data is fine and display() is at fault. That one print splits the problem in half.
  • Icons stretch or overflow → this is TextureRect's stretch mode plus the container's sizing flags, not your code. Containers control their children's size; fighting them by setting positions never works.
  • Everything doubles on reopening → you rebuild widgets without checking existing children. That's what the while guard prevents.

Back to the overview. Next stop worth taking: the save/load shelf — an inventory built this way serialises in about fifteen lines, and it's a good demonstration of why the data/presentation split was worth the discipline.