Skip to content

M7.3 — The Inventory UI

What you'll learn

  • How to build an inventory screen from Control nodes: a GridContainer of slot buttons that renders the M7.1 model.
  • How to redraw the grid from the model's changed signal rather than tracking state in the UI.
  • Godot's drag-and-drop on Control nodes_get_drag_data, _can_drop_data, _drop_data — to drag items between bag slots and onto equipment slots.
  • Where the inventory screen lives (the UI CanvasLayer from M1.4) and how it toggles with the inventory action.

How it applies

  • The inventory screen is where the player spends the metagame. Between fights, this is the screen they live on — comparing, equipping, deciding. Its clarity determines whether managing loot is a pleasure or a chore, and the genre's audience is unusually demanding about it.
  • Rendering from the model keeps the UI honest. A grid that redraws from Inventory.changed can never disagree with the bag's contents, because it has no contents of its own — it is a view. UIs that cache their own copy of the data drift from it, which is the source of "the item's in my bag but not on screen" bugs.
  • Drag-and-drop is the genre's expected verb. Players expect to drag an item onto an equipment slot to wear it and drag it off to remove it. Godot has first-class drag-and-drop on Control nodes; learning the three-method protocol once covers inventory, equipment, hotbars, and stash.
  • Control layout is its own skill. Containers, anchors, and sizing flags lay out a grid that scales with the window (M1.1's stretch). Getting the layout to behave is the difference between a UI that holds together at every resolution and one that breaks the first time someone resizes.

Concepts

The screen lives on the UI layer

The inventory screen is a Control hierarchy parented under the UI CanvasLayer (M1.4), so it draws in screen space and ignores the camera. It is hidden by default and toggled by the inventory action (M1.3). A typical structure:

InventoryScreen (Control, full-rect, hidden)
└── Panel
    └── MarginContainer
        └── GridContainer (columns = bag width)
            ├── SlotButton (×capacity)
            └── ...

The GridContainer lays its children into a grid of N columns; give it capacity slot buttons and it wraps them into rows automatically. Anchors set the panel's position; sizing flags let the grid fill the panel.

Render from the model

Each slot is a button showing an item's icon (or empty). The grid does not store items — it reads Inventory.items and paints. Redraw whenever the model changes:

Example

The screen redraws from Inventory.changed, mapping each slot index to the item at that index:

# res://scripts/inventory_screen.gd
extends Control

@onready var grid: GridContainer = $Panel/MarginContainer/GridContainer

func _ready() -> void:
    Inventory.changed.connect(_redraw)
    _redraw()

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("inventory"):
        visible = not visible

func _redraw() -> void:
    var slots := grid.get_children()
    for i in slots.size():
        var slot := slots[i] as TextureRect      # or a Button with a TextureRect child
        if i < Inventory.items.size():
            slot.texture = Inventory.items[i].icon
        else:
            slot.texture = null

The grid is a pure view: _redraw walks the slot widgets and sets each one's icon from the corresponding item, or clears it. Because it redraws on Inventory.changed, picking up, equipping, or dropping an item updates the screen with no UI-side bookkeeping. Toggling visibility on the inventory action shows/hides the whole screen.

Godot drag-and-drop: three methods

Control nodes implement drag-and-drop with three virtual methods on the source and target:

  • _get_drag_data(at_position) — called on the node where a drag starts. Return the data being dragged (here, the item and its source slot index) and optionally set a drag preview. Returning non-null begins the drag.
  • _can_drop_data(at_position, data) — called on a node the cursor is over during a drag. Return true if this node accepts that data (e.g., an equipment slot accepts only items of its slot type).
  • _drop_data(at_position, data) — called on the target when the drag is released over it. Perform the move (swap items, equip).

Example

A bag slot as a drag source and drop target. Dragging carries the item and its index; dropping swaps the two slots:

# on a bag SlotButton (knows its index)
func _get_drag_data(_pos: Vector2) -> Variant:
    var item := Inventory.items[index] if index < Inventory.items.size() else null
    if item == null:
        return null                       # nothing to drag from an empty slot
    var preview := TextureRect.new()
    preview.texture = item.icon
    set_drag_preview(preview)             # the icon follows the cursor
    return { "item": item, "from_index": index }

func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
    return data is Dictionary and data.has("item")

func _drop_data(_pos: Vector2, data: Variant) -> void:
    Inventory.swap(data["from_index"], index)   # reorder within the bag

_get_drag_data returns null for an empty slot (nothing to drag) and otherwise packages the item and its index, with set_drag_preview making the icon trail the cursor. _can_drop_data accepts any item payload; an equipment slot would tighten this to data["item"].slot == my_slot. _drop_data performs the move — here a reorder via an Inventory.swap(i, j) helper.

Dragging onto equipment

An equipment slot (a separate Control, rendering Equipment.equipped_in(slot)) is a drop target that accepts only matching items and equips on drop:

Example

# on an equipment SlotButton (knows its slot type)
func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
    return data is Dictionary and data.has("item") and data["item"].slot == slot_type

func _drop_data(_pos: Vector2, data: Variant) -> void:
    Equipment.equip(data["item"])         # M7.2 handles swap + recompute

The _can_drop_data check enforces the rule that a helm can't go in the weapon slot — the cursor shows "can't drop" over a mismatched slot. On drop, it calls Equipment.equip (M7.2), which does the swap and recompute; both Inventory.changed and Equipment.changed fire, so both panels redraw. No stat code in the UI — the UI only moves data and lets the models react.

Layout that survives resizing

Use containers and sizing flags rather than absolute positions: a MarginContainer for padding, the GridContainer with a fixed column count, slot buttons with a minimum size and SIZE_EXPAND_FILL where appropriate. Anchor the panel (centered, or full-rect with margins). With M1.1's canvas_items stretch, a container-based layout scales cleanly to any window; an absolutely-positioned one drifts. This is the Control-layout discipline the genre's busy screens demand.

Walkthrough

  1. Build res://scenes/ui/inventory_screen.tscn: a Control (full-rect, hidden) → PanelMarginContainerGridContainer (set Columns to your bag width, e.g., 6). Add capacity slot widgets (a TextureButton or Button with a TextureRect, each knowing its index).
  2. Attach inventory_screen.gd; connect to Inventory.changed; toggle visible on the inventory action. Add the screen under Main/UI (M1.4) in main.tscn.
  3. Implement the bag slot's _get_drag_data / _can_drop_data / _drop_data for reordering, and add an Inventory.swap(i, j) helper to the model.
  4. Build an equipment panel (a few equipment slot widgets, each with its slot_type), rendering Equipment.equipped_in(slot) and redrawing on Equipment.changed. Implement its _can_drop_data (matching slot) and _drop_data (Equipment.equip).
  5. Press F5, collect items (they fill the grid via changed), open the inventory with the inventory action, drag an item onto its equipment slot, and watch your stats change (M7.2 recompute) and both panels redraw. Drag it off to unequip.

Optional sanity check

With the inventory open, equip an item by dragging it to its slot, then close and reopen the inventory (toggle the action twice). The grid and equipment panel should show the same state on reopen — proof the UI is rendering from the model, not from transient UI state. Then try dragging a helm onto the weapon slot: _can_drop_data should refuse it (the cursor shows no-drop), proof the slot-type rule is enforced at the UI boundary while the equip logic stays in M7.2.

Self-check quiz

Q1 — Why does the grid redraw from Inventory.changed instead of updating itself when the player picks something up?

A. It is faster. B. The grid is a view with no data of its own; redrawing from the model's change signal guarantees the screen always matches the bag, with no UI-side state to drift out of sync. C. Because GridContainer can't be updated directly. D. Because the model can't be read directly.

Reveal answer

B. Rendering from changed keeps a single source of truth (the model) and makes the UI incapable of disagreeing with it — pickups, equips, drops all flow through one redraw. A UI that updated itself on pickup would have a second copy of the truth that drifts. A is not the reason. C and D are false.

Q2 — In Godot's drag-and-drop, which method decides whether a target accepts the dragged item, and how does an equipment slot use it?

A. _get_drag_data; it checks the slot type. B. _can_drop_data; an equipment slot returns true only when the dragged item's slot matches the slot's type, so a helm can't be dropped on the weapon slot. C. _drop_data; it rejects mismatches by doing nothing. D. _ready; it pre-filters droppable items.

Reveal answer

B. _can_drop_data is the per-target acceptance check during a drag; the equipment slot compares data["item"].slot to its own type, so the cursor shows can/can't-drop accordingly. _get_drag_data (A) starts the drag on the source. _drop_data (C) performs the move after acceptance — relying on it to reject is too late and messy. D is unrelated.

Q3 — Why build the screen from containers and sizing flags rather than absolute positions?

A. Absolute positions are deprecated. B. Container-based layout scales cleanly with the window under M1.1's stretch; absolute positions drift and break at other resolutions. C. Containers render faster. D. Drag-and-drop only works inside containers.

Reveal answer

B. Containers + sizing flags + anchors produce a layout that holds together as the window resizes (with canvas_items stretch from M1.1); absolute coordinates assume one size and break elsewhere. A is false. C is negligible. D is false (drag-and-drop is per-Control, not container-bound).

Integration question

Q4 — open

The inventory screen reads the M7.1 model, equips via M7.2, lives on the M1.4 UI layer, and toggles with the M1.3 inventory action — and contains no stat or storage logic of its own. Explain how drag-to-equip flows through the three drag-and-drop methods and into the models, naming what redraws and why the UI's lack of its own data is what makes 'open inventory, equip, reopen' always show correct state.

Reveal expected answer

Drag-to-equip flows entirely through data the UI moves, with the models doing the work. The drag starts on a bag slot: _get_drag_data packages {item, from_index} and sets a drag preview, so the icon follows the cursor. As the cursor moves over an equipment slot, that slot's _can_drop_data returns true only if data["item"].slot matches its type — enforcing the helm-can't-go-in-weapon rule at the UI boundary. On release, the equipment slot's _drop_data calls Equipment.equip(item) (M7.2), which removes any previously equipped item (modifiers off, recompute, returned to the bag), puts the new item in the slot, removes it from the bag, appends its modifiers, and recomputes (M5.4) — so the player's stats change immediately and stats_recomputed updates the HUD. Both Inventory.changed and Equipment.changed fire; the bag grid and the equipment panel each redraw from their model. The UI added no stat or storage logic — it only carried a payload and invoked model operations. "Open, equip, reopen always shows correct state" works because the screen owns no data: every time it is shown or anything changes, it paints from Inventory.items and Equipment.slots, so there is no cached UI copy to fall out of sync — the same single-source-of-truth discipline that governs Health and stats, now at the presentation layer. The models are the truth; the UI is a window onto them.