Drag and drop inventory UI in Godot 4
Godot 4 hands you three drag-and-drop callbacks and no opinion about what a drop means. Here is how to build inventory slots on top of a real stacking inventory, where a partial merge and a clean rejection both fall out of one method call, and the item never mutates behind your back.
The player drags a potion out of the bag and lets go over the chest. What should happen depends on what is already in the chest. If there is a half stack of the same potion, the two should merge. If that stack only has room for three and you dropped five, three go in and two stay in the bag. If the chest is full, nothing should move at all. Godot's drag-and-drop API will not make any of those calls for you. It gives you three callbacks and steps back, which is exactly right, because that decision belongs to your inventory rules and not to the engine.
Here is the shape of it before the detail. Every cell in a container view is a Control. You override _get_drag_data on the cell a drag starts from, then _can_drop_data and _drop_data on the cell it ends on. The trick that makes the rest easy is what you carry between them: not the item, but a note saying which container it came from and how many. Once the payload is identity rather than the item itself, the merge and the rejection stop being special cases you write and start being the return value of a method you already have.
This builds on the Inventory Lite core, so a bit of its surface matters first. An InventoryLite is a Node that stacks by item id, respects each item's max_stack, and holds a fixed number of slots. Two methods carry the whole post. add_item(item, amount) tops up existing stacks, opens new ones while there is capacity, and returns whatever did not fit. remove_item(item, amount) pulls a quantity back out and returns how much it actually took. That leftover return from add_item is the hero here, so hold onto it.
Starting the drag
Godot calls _get_drag_data(at_position) once, on the control under the cursor, the instant a drag begins. Whatever you return becomes the payload for the whole gesture. Return null and no drag starts, which is how an empty cell stays inert without a single guard elsewhere.
# InventorySlotUI.gd -- one draggable cell in a container view.
extends PanelContainer
var inventory: InventoryLite # the container this cell belongs to
var slot_index: int # which entry in inventory.slots() this cell shows
func _get_drag_data(_at_position: Vector2) -> Variant:
var view := inventory.slots() # a snapshot of {item, count} entries
if slot_index >= view.size():
return null # empty cell, nothing to drag
var entry: Dictionary = view[slot_index]
set_drag_preview(_make_preview(entry.item))
# Carry identity, not the entry. Which container, which item, how many.
return {
"from": inventory,
"item": entry.item,
"count": entry.count,
}The payload holds the source inventory, the item, and the count. It does not hold the entry dictionary from slots(), and that is deliberate for a reason worth its own callout.
Deciding whether a drop is allowed
_can_drop_data(at_position, data) runs on the cell under the cursor, every frame while you hover. It drives the cursor icon and gates _drop_data entirely; if it returns false, the drop callback never fires. Keep it cheap, because it runs constantly. All it needs to answer is whether the payload is one of ours.
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
return data is Dictionary and data.has("item") and data.has("from")You could do more here. You could reject items the container is not allowed to hold, or grey out a full chest before the player even releases. That is a good instinct and it belongs in _can_drop_data, as long as the check stays a fast lookup and not a full simulation of the drop.
The drop, where the API does the hard part for you
This is the payoff. _drop_data fires once on release, and because the payload named a source container instead of carrying the item, moving between two inventories is two lines. Add to the target. The target tells you how much did not fit. Remove exactly that much less from the source.
func _drop_data(_at_position: Vector2, data: Variant) -> void:
var from: InventoryLite = data["from"]
var item: Resource = data["item"]
var count: int = data["count"]
if from == inventory:
return # same container, handled elsewhere
var leftover := inventory.add_item(item, count) # stack + partial merge happen here
from.remove_item(item, count - leftover) # pull only what actually landedWalk the three outcomes from the opening through those two lines. Room for everything: add_item returns 0 leftover, so count - leftover is the full amount and the whole stack moves. Room for some: it returns the remainder, you remove only what fit, and the rest stays in the bag on its own. No room at all: leftover equals count, count - leftover is zero, and remove_item is called with zero, so nothing leaves the source. The rejection needs no branch. It is just arithmetic that happens to come out to zero.
The source was never touched speculatively. Nothing moved until the target confirmed how much it would take. That safety came free the moment the payload stopped carrying the live item, because there was no shared object for the two containers to fight over.
About the preview, since it trips people up. The control you pass to set_drag_preview is pinned to the cursor by its top-left corner, not its center. Hand it a 48-pixel icon and the whole thing floats down and to the right, half a slot off from where the player is aiming. Wrap the icon in a plain Control and shift the icon by half its size to bring it back under the pointer.
func _make_preview(item: Resource) -> Control:
var icon := TextureRect.new()
icon.texture = item.icon
icon.custom_minimum_size = Vector2(48, 48)
var wrapper := Control.new()
icon.position = -icon.custom_minimum_size * 0.5 # center under the cursor
wrapper.add_child(icon)
return wrapperThe one case this does not cover, and why
There is a fourth outcome the opening left out: the swap. Drop a sword onto a slot holding a different sword and you might want them to trade places. This code will not do that, and the reason is the data model, not an oversight. InventoryLite is a compacting bag. It stacks by id and closes gaps, so there is no such thing as a fixed, addressable slot for the player to swap against. add_item never displaces anything; it fills or it declines.
For a plain bag or a chest, that is the right model and the code above is the honest whole of it. If you want fixed grid positions the player can rearrange, gaps and all, you need a positional layer that tracks each slot by index and handles the swap explicitly. That is a real amount of extra bookkeeping, and it is a fair place for a different developer to make a different call. It is also where the paid version earns its keep, so I will point you there at the end rather than pretend the compacting bag does something it does not.
What you have now is a working drag-and-drop between containers, built on real stacking, with the merge and the rejection handled by the inventory instead of by you. Bag to chest, chest to hotbar, all the same two lines. Hover highlights and stack-splitting are polish on top of a model that already holds.