Building a paper doll equipment system in Godot 4
Build a paper doll equipment system in Godot 4: named slots that accept only matching gear, and the displaced item returning to your bag on every swap.
The player opens the character screen and there are the gear slots: head, chest, main hand, boots, a ring or two. Drag a plate cuirass onto the chest slot and it should snap in. Drag that same cuirass onto the ring slot and nothing should happen, because a chest piece is not a ring. And if the chest slot already holds leather armor when the cuirass lands, the leather must not vanish; it should come back to the bag you dragged from. That is three separate rules living on one little square of UI, and none of them are things Godot hands you. A paper doll equipment system in Godot 4 is mostly you wiring those rules on top of a small equipment core, and knowing where the core deliberately stops.
The core here is Equipment Lite, an addon I put on GitHub. It is smaller than you might expect, and that is on purpose; it does slot bookkeeping and nothing else. Understanding exactly what it does and does not do is most of the work, so start there.
What the slot manager actually owns
EquipmentLite is a Node you hang off the player. Internally it is one dictionary, _equipped, mapping a slot id to whatever Resource is worn there. The slots are fixed and named:
const SLOTS: Array = ["weapon_main", "chest", "boots", "ring"]Four slots, hardcoded. That is the Lite boundary talking. A fully customizable layout with arbitrary slot ids and more of them is a Pro-tier EquipmentLayout Resource, and I will not pretend the free core has it. Four is enough to build the whole pattern against, and the pattern is what transfers. The method that does the real work is equip, and its return value is the thing this entire post hinges on.
func equip(slot_id: String, item: Resource) -> Resource:
# Returns whatever was previously equipped (null if empty / failed).
if not SLOTS.has(slot_id):
return null
if item == null:
return unequip(slot_id)
var prev: Resource = _equipped.get(slot_id, null)
_equipped[slot_id] = item
if prev != null:
item_unequipped.emit(slot_id, prev)
item_equipped.emit(slot_id, item)
contents_changed.emit()
return prevRead the return value carefully, because it is doing more than it looks like. When you equip into an occupied slot, prev is the item that was already there, and you get it handed straight back to you. equip does not throw it on the floor and it does not delete it. It gives it to you and makes it your problem, which is exactly correct, because only you know where it should go. In most games the answer is the bag it came from. We will get there.
How an item knows which slot it belongs to
An item does not get a slot assigned to it by the equipment node. It carries its own slot, as data. The item Resource in the Lite addon is deliberately tiny and duck-typed, so it does not fight with the inventory addon's item type:
extends Resource
@export var id: String = ""
@export var name: String = ""
@export var icon: Texture2D
@export var metadata: Dictionary = {} # supports "equip_slot": "<slot_id>"The slot lives in metadata under the key equip_slot. So the iron sword resource sets metadata = {"equip_slot": "weapon_main"} in the inspector, the plate cuirass sets "chest", and so on. Nothing about the item is an equipment subclass. It is a plain Resource that happens to name a slot. That matters because the same Resource is what your inventory and your loot tables and your save file are already passing around, so bolting equipment onto it is one dictionary key rather than a new type hierarchy.
With the slot living on the item, auto-equip falls out almost for free. That is what try_equip is:
func try_equip(item: Resource) -> String:
if item == null:
return ""
# Duck-typed: tolerate items that don't carry a `metadata` Dictionary.
var md: Variant = item.get("metadata")
if not (md is Dictionary):
return ""
var slot_id := String(md.get("equip_slot", ""))
if slot_id == "" or not SLOTS.has(slot_id):
return ""
equip(slot_id, item)
return slot_idYou call equipment.try_equip(some_item) and it reads the slot off the item, checks that slot is real, and routes it. The return is the slot id it used, or an empty string if it could not place the item. Right-click a piece of gear in your bag, call try_equip, done; the player never has to know which square to aim at. And because try_equip funnels through equip, it inherits the swap-out behavior. Whatever was in that slot comes back as the return of the inner equip call. Which, annoyingly, try_equip does not forward to you. Note that. We will fix it.
Category matching, and the honest gap
Here is where I have to be straight about a boundary. The opening promised that a chest piece dropped on the ring slot gets rejected. The Lite core does not enforce that. Look again at try_equip: it checks that the item names a slot and that the slot exists in SLOTS. It never checks that the slot you are dropping onto is the slot the item wants. So an explicit equipment.equip("ring", plate_cuirass) succeeds, cheerfully, and now you have a cuirass worn as a ring. Category gating per slot is a Pro-tier feature, and it is not in the free code.
So you gate it yourself, at the UI boundary, which is where drop validation belongs anyway. When the player releases an item over a slot widget, compare the item's declared slot to the slot the widget represents, and bail if they disagree:
# On a paper-doll slot Control that represents `my_slot_id`.
func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
if not (data is Dictionary and data.has("item")):
return false
var item: Resource = data["item"]
var md: Variant = item.get("metadata")
if not (md is Dictionary):
return false
# The chest slot only accepts items whose equip_slot is "chest".
return String(md.get("equip_slot", "")) == my_slot_idBecause _can_drop_data gates _drop_data entirely, a mismatched item never reaches the equip call. The cursor even shows the no-drop icon while the player hovers, so the rejection is visible before they let go. That is the whole category rule, and it is four lines because the item told us its slot and the widget knows its own. You are not building a category system. You are comparing two strings.
The swap: getting the old item back to the bag
Now the part that people get subtly wrong. The player drags a new cuirass onto a chest slot that already holds leather. You want the leather back in the bag. The equip return gives it to you; you just have to catch it and hand it off. In the drop handler on the slot widget:
# `equipment` is the EquipmentLite node, `bag` your inventory.
func _drop_data(_pos: Vector2, data: Variant) -> void:
var item: Resource = data["item"]
var from_bag = data["from"]
# Take the incoming item out of the bag first.
from_bag.remove_item(item, 1)
# Equip it. Whatever was worn here comes back as `displaced`.
var displaced: Resource = equipment.equip(my_slot_id, item)
# Put the old occupant back where the new one came from.
if displaced != null:
from_bag.add_item(displaced, 1)The order is the load-bearing detail. Pull the incoming item out of the bag, then equip, then return the displaced item to the bag. If you equip before removing, and the incoming and displaced items happen to be the same resource with a stack, your counts get confusing fast. Remove-then-equip-then-return keeps the arithmetic clean; the bag loses one item and gains at most one back, and the slot always holds exactly what the player dropped. When the slot was empty, equip returns null, the if is skipped, and it is a plain equip with no return trip. Same code path, no special case for the empty slot.
Unequip is the same shape, run backwards. Right-click a worn slot, call unequip, and route the return into the bag:
func _on_slot_right_clicked(slot_id: String) -> void:
var removed: Resource = equipment.unequip(slot_id)
if removed != null:
bag.add_item(removed, 1)unequip returns null if the slot was already empty, so the guard covers the double-click case for free. There is no way to end up adding null to your bag.
Keeping the doll drawn
The last piece is redraw. EquipmentLite emits contents_changed after any equip, unequip, or clear, on top of the more specific item_equipped and item_unequipped signals. Connect the broad one to a refresh method and let it repaint every slot widget from get_equipped:
func _ready() -> void:
equipment.contents_changed.connect(_refresh)
func _refresh() -> void:
for slot_id in EquipmentLite.SLOTS:
var worn: Resource = equipment.get_equipped(slot_id)
var widget: TextureRect = _slot_widgets[slot_id]
widget.texture = worn.icon if worn != null else nullDrive the UI off the signal, not off the drop handler. If your drop code repaints the slot directly, then the day something equips an item from outside the UI (a quest reward, say, or a save load), the doll goes stale and you get a slot that is full in data and empty on screen. Let every path through equip announce itself, and let the UI listen. One _refresh, wired once, and it does not matter who changed the gear.
That is a working paper doll. Named slots, each rejecting gear that names a different slot, with the worn item returning to the bag on a swap and the old item returning on an unequip. The equipment node owns the slot-to-item map and the change signals; you own the two policy calls it cannot make for you, which are what counts as a valid drop and where a displaced item goes. Category gating and the auto-return bridge are the two lines I drew at the Lite boundary, and both are a handful of lines to build yourself on top of the return values the core already gives you.