Item stacking and splitting a stack in Godot 4

How item stacking works in a Godot 4 inventory, then how to build a right-click split-stack on top of add_item and remove_item without ever losing an item.

You have forty arrows in one slot and you want twenty in another, so you can hand a stack to a teammate or keep a reserve. The player right-clicks the stack and expects it to break in half. That single gesture turns out to be the hardest thing to build on a stacking inventory, and the reason is exactly the thing that makes stacking pleasant: the inventory works hard to keep identical items merged into as few stacks as possible. Splitting asks it to do the opposite, on purpose, in one specific place. This post is about how the stacking actually works underneath, and then how to fight it, cleanly, to get a split.

Everything here sits on the Inventory Lite core. An InventoryLite is a plain Node with a capacity in slots and an internal list of {item, count} entries. You never touch that list directly. Two methods do all the work, and both of them return a number that tells you what really happened; that return value is the whole game. add_item(item, amount) hands back the leftover that did not fit. remove_item(item, amount) hands back how much it actually pulled out. Hold onto both of those. The split falls out of them.

How a stack forms in the first place

Before you can split a stack you should know what add_item does when it merges, because the merge is the behavior you are about to work around. Here is the real method from the addon, lightly trimmed of its signals so you can read the flow.

func add_item(item: Resource, amount: int = 1) -> int:
    # Returns leftover that didn't fit.
    if item == null or amount <= 0:
        return amount
    var max_stack: int = max(1, int(item.max_stack)) if "max_stack" in item else 99
    var remaining := amount
    # Top up existing stacks of the same item.
    for s in _slots:
        if remaining <= 0:
            break
        if s.item == null or String(s.item.id) != String(item.id):
            continue
        var space: int = max_stack - int(s.count)
        if space <= 0:
            continue
        var take: int = min(space, remaining)
        s.count += take
        remaining -= take
    # Open new slots while capacity remains.
    while remaining > 0 and _slots.size() < capacity:
        var take: int = min(max_stack, remaining)
        _slots.append({"item": item, "count": take})
        remaining -= take
    return remaining

Two passes. The first walks every existing slot and pours the incoming amount into any stack of the same item that still has headroom, capped by that item's max_stack. The match is on String(s.item.id), not on object identity, so two different ItemLite resources with the same id stack together; this matters more than it looks, and it comes back to bite in a moment. The second pass opens fresh slots for whatever is left, each new stack capped at max_stack again, and it stops the instant _slots.size() hits capacity. Whatever still has not landed is the return value.

So max_stack is enforced in exactly two spots: the space calculation when topping up, and the min(max_stack, remaining) when opening a new slot. There is no third place. If you add 150 of an item whose max_stack is 99, and there is room, you get a stack of 99 and a stack of 51, automatically, because the top-up loop fills the first and the open-slot loop spills the rest. Stacking and stack-capping are the same code path. You do not call anything special to get it.

remove_item is the mirror, and it runs back to front:

func remove_item(item: Resource, amount: int = 1) -> int:
    # Returns the number actually removed.
    if item == null or amount <= 0:
        return 0
    var removed := 0
    var i := _slots.size() - 1
    while i >= 0 and removed < amount:
        var s = _slots[i]
        if s.item != null and String(s.item.id) == String(item.id):
            var take: int = min(int(s.count), amount - removed)
            s.count -= take
            removed += take
            if int(s.count) <= 0:
                _slots.remove_at(i)
        i -= 1
    return removed

It walks from the last slot to the first, draining matching stacks until it has taken what you asked for or run out of matches. When a stack hits zero it gets removed from the list entirely with remove_at, which is why iterating backwards is correct here; deleting an index you have already passed cannot shift anything you still need to visit. This is a compacting bag. Emptied slots do not linger as blanks, they vanish and the list closes up.

Why the obvious split does not work

Here is the split you would write first, and it is wrong in a way that is worth understanding rather than just avoiding. You have a stack of forty arrows. You want to move twenty into a new stack. So you remove twenty, then add twenty back:

# Looks right. Does nothing useful.
var half := int(stack_count / 2.0)
inventory.remove_item(arrow, half)   # 40 -> 20
inventory.add_item(arrow, half)      # 20 -> 40 again, same slot

You end up exactly where you started: one stack of forty. add_item matched the arrows you still had by id, found the stack with headroom, and poured the twenty right back in. The inventory has no concept of "put this in a different slot," because it has no addressable slots to aim at. It stacks by id and closes gaps. That is the design, and for a bag or a chest it is the right design, but it means a split cannot be expressed as remove-then-add against the same container.

Split by moving between two containers

The clean version needs a destination that is a different container. That is not a workaround, it is what a split actually is: half the stack leaves this inventory and lands somewhere else, whether that somewhere is another bag or a temporary one-slot inventory you spawn for the drag. Because add_item merges by id within a container but two separate InventoryLite nodes share nothing, moving across the boundary gives you a genuine second stack.

# Move `amount` of `item` out of `src` and into `dst`.
# Returns how many actually made the trip.
func split_into(src: InventoryLite, dst: InventoryLite, item: Resource, amount: int) -> int:
    if amount <= 0 or not src.has_item(item, amount):
        return 0
    var leftover := dst.add_item(item, amount)   # dst may be full or capped
    var moved := amount - leftover
    src.remove_item(item, moved)                 # pull only what dst accepted
    return moved

Add to the destination first, and let it tell you how much it could take. Only then remove that exact amount from the source. This ordering is the safety: nothing leaves the source until the destination has confirmed the room, so a full destination or a max_stack cap on the other side can never destroy items. If dst is a fresh single-slot inventory, leftover is zero and moved equals amount, and you now hold a clean split stack you can attach to the drag cursor.

For the everyday case, half of the stack, compute the amount at the call site and round however your game prefers. Twenty from forty-one can go either way:

# Right-click handler on a slot control. Half goes to the cursor inventory.
func _on_slot_right_clicked(entry: Dictionary) -> void:
    var item: Resource = entry.item
    var have: int = int(entry.count)
    if have < 2:
        return                      # nothing to split off a single item
    var half := int(have / 2.0)     # 41 -> 20 stays behind, 21 splits (floor)
    split_into(inventory, cursor_inv, item, half)

The have < 2 guard is not decoration. Splitting a stack of one has no meaning, and without the guard a right-click on a lone sword would compute a half of zero and quietly do nothing anyway, which is fine but slower to reason about than just saying so. For a "split a chosen amount" prompt, the same split_into takes whatever number the player types. Clamp it to have - 1 on the low end if you want the source to always keep at least one, or allow the full move if a right-click-drag of the entire stack is legal in your UI.

Redrawing after the split

Both containers fire contents_changed when their counts move, so the UI work is already done for you if you wired it the usual way. Connect each inventory's signal to whatever rebuilds its view and the split repaints itself. The source loses half its count, the destination shows the new stack, and neither view knows or cares that a split happened rather than an ordinary transfer.

func _ready() -> void:
    inventory.contents_changed.connect(_rebuild_bag)
    cursor_inv.contents_changed.connect(_rebuild_cursor)

That is the shape of it. Stacking is add_item merging by id and capping at max_stack, in one method, with the leftover as the honest return. Splitting is the same two methods pointed at two containers, destination first so nothing is lost, with the half computed wherever you call it. The Lite core does not ship a split method, because a split is a policy question about where the other half goes, and that answer lives in your UI rather than in the bag. Give it a destination and the arithmetic you already have does the rest.

Or use mine

Inventory Lite, free on GitHub · Inventory System, $4.99

All Godot posts · Home