Stat modifiers from gear in Godot 4

How equipping gear applies stat modifiers in Godot 4 and unequipping removes them cleanly, and why you recompute totals from worn gear instead of adding and subtracting deltas.

You pull off the iron helm and put on the steel one. The steel helm gives +8 armor; the iron gave +5. The player's armor total should now read 3 higher than it did a second ago, and not a point more. Do this wrong and every gear swap in Godot 4 leaves a little residue behind, so after an hour of play the character sheet shows numbers that no combination of worn items can explain. The bug is almost never in the item data. It sits in how you chose to apply the change.

There are two ways to turn worn gear into a stat total, and only one of them survives contact with a real inventory screen. The tempting one is to adjust as you go: item goes on, add its modifiers to the running total; item comes off, subtract them back out. It reads naturally and it is wrong in a way that takes a week to notice. The other way throws the running total away on every change and rebuilds it from whatever is currently equipped. That one is boring and correct, and the rest of this post wires it to the Equipment Lite addon.

Where the gear modifiers live

Equipment Lite keeps the gear model deliberately small. EquipmentLite is a Node with four fixed slots, weapon_main, chest, boots, and ring, and its whole job is bookkeeping: which Resource sits in which slot. It does not know what a stat is. The item type it ships (item_resource.gd) is a plain Resource with id, name, icon, and a metadata Dictionary, and it is duck-typed, so any Resource carrying those fields works. That metadata bag is the seam. It already carries equip_slot to tell try_equip where the item belongs, so we hang the stat modifiers off the same Dictionary.

# Authoring a piece of gear. metadata carries both the slot and the stats.
var steel_helm := preload("res://addons/equipment_lite/item_resource.gd").new()
steel_helm.id = "steel_helm"
steel_helm.name = "Steel Helm"
steel_helm.metadata = {
    "equip_slot": "chest",          # Lite has no head slot; use what exists
    "modifiers": {
        "armor": 8,
        "max_health": 20,
    },
}

Nothing about this is special to the addon; you are putting a Dictionary inside a Dictionary. The reason it goes in metadata rather than a typed @export var armor: int on the item is that the modifier set is open. A ring buffs crit while a cursed chest subtracts health. If every stat were its own exported field, every new stat would mean touching the item class. A modifiers Dictionary keyed by stat name costs you nothing when you invent a new stat next month.

Recompute from what's worn

Here is the core, and it is short because it has to be. One method walks every equipped slot, reads each item's modifiers, and sums them into a fresh Dictionary. It keeps no memory of the previous total. EquipmentLite.all_equipped() hands back the slot-to-item map, so the loop is over that.

# StatBlock.gd  --  turns worn gear into a stat total. Owns no running sum.
extends Node
class_name StatBlock

@export var equipment: EquipmentLite

# Base values before any gear. Everything not listed here defaults to 0.
var base_stats := {"armor": 0, "max_health": 100, "move_speed": 300}

var totals: Dictionary = {}

func _ready() -> void:
    # One signal drives everything: equip, unequip, and clear() all emit it.
    equipment.contents_changed.connect(recompute)
    recompute()

func recompute() -> void:
    var out := base_stats.duplicate(true)   # start from base, every time
    for slot_id in equipment.all_equipped():
        var item: Resource = equipment.get_equipped(slot_id)
        var md: Variant = item.get("metadata")
        if not (md is Dictionary):
            continue
        var stat_mods: Variant = md.get("modifiers")
        if not (stat_mods is Dictionary):
            continue
        for stat_name in stat_mods:
            out[stat_name] = out.get(stat_name, 0) + stat_mods[stat_name]
    totals = out

Read recompute again and notice what it does not have. There is no unequip branch, no subtraction, no code path for "item removed." Removal is just the absence of that item the next time the loop runs. Swapping the iron helm for the steel one is not two operations that have to net out correctly; it is one full rebuild over a slot map that now contains the steel helm and not the iron. The +5 never has to be found and undone, because it was never persisted anywhere to begin with.

The wiring is one line: connect contents_changed to recompute. Equipment Lite emits contents_changed on every equip, every unequip, and inside clear() too, so a single connection covers all of them. You do not need to listen to item_equipped and item_unequipped separately for this. Those two carry the slot and the item, and they are for things that care about a specific transition, a sound effect on equip, say. The stat total does not care which item moved, only what the set now looks like.

Why the additive approach rots

It is worth being concrete about the failure mode, because the additive version passes every test you are likely to write first. Equip a helm, armor goes up. Unequip it, armor goes back down. Looks airtight. The residue comes from the swap, and Equipment Lite's equip makes the swap easy to get wrong precisely because it is so convenient.

# EquipmentLite.equip: equipping into an occupied slot silently returns
# the old occupant AND emits item_unequipped for it, then item_equipped
# for the new one. Both signals, from one call.
var prev := equipment.equip("chest", steel_helm)   # prev == the iron helm

The instinct behind add-and-subtract is that recomputing four slots on every change feels wasteful. It isn't. Four slots, a handful of stats each, is a few dozen dictionary reads, and it runs when a human clicks a gear slot, not sixty times a second. You are optimizing a thing that happens at the speed of a mouse click. Spend the cycles and keep the correctness; if you ever genuinely equip thousands of modifiers per frame, you will know, and you can cache then.

Flat versus percent, when you outgrow plain sums

The version above sums everything as a flat number, which is right up until an item says "+15% armor" instead of "+8 armor." You cannot add a percent to a flat value and get anything meaningful. The fix is to give each stat two buckets and combine them in a fixed order at the end. Flat bonuses add together, percent bonuses add together, and the final value is base-plus-flat scaled by one-plus-percent. Author it in the metadata as a small tagged shape.

# metadata.modifiers entry with a type. Backward compatible: a bare number
# is treated as flat.
"modifiers": {
    "armor": {"flat": 8},
    "damage": {"percent": 0.15},   # +15%
}
func recompute() -> void:
    var flat := {}
    var percent := {}
    for slot_id in equipment.all_equipped():
        var item: Resource = equipment.get_equipped(slot_id)
        var md: Variant = item.get("metadata")
        if not (md is Dictionary):
            continue
        var mods: Variant = md.get("modifiers")
        if not (mods is Dictionary):
            continue
        for stat_name in mods:
            var m = mods[stat_name]
            if m is Dictionary:
                flat[stat_name] = flat.get(stat_name, 0.0) + m.get("flat", 0.0)
                percent[stat_name] = percent.get(stat_name, 0.0) + m.get("percent", 0.0)
            else:
                flat[stat_name] = flat.get(stat_name, 0.0) + m   # bare number

    var out := {}
    for stat_name in base_stats:
        var b: float = base_stats[stat_name]
        out[stat_name] = (b + flat.get(stat_name, 0.0)) * (1.0 + percent.get(stat_name, 0.0))
    totals = out

The order matters and it is a design decision, not a fact of the universe. Additive-percent (two +15% rings give +30%, not +32.25%) is the readable default and what most RPGs actually ship. If you want multiplicative stacking, or a third tier of "final multiplier" that lands after everything else, that is another bucket and one more line in the combine step. Whatever you pick, pin it down once in recompute and never scatter the math across call sites. The moment two places compute a total differently, you are back to numbers that don't reconcile.

The boundary with Lite

Everything above sits on top of Equipment Lite without modifying it, and that is on purpose, because Lite draws its line right here. The addon does slot bookkeeping and emits contents_changed; it has no opinion about stats. The paid tier adds equipment.get_modifier_sum("damage") and apply_modifiers("damage", base) that do the flat-and-percent aggregation for you and pair with the Stats addon, plus the two-handed-weapon conflict handling that this four-slot core skips. You do not need any of that to build the recompute loop. The one contract you are leaning on is the free one, contents_changed firing on every mutation, and a StatBlock node listening for it is enough to keep a character sheet honest through as many gear swaps as the player cares to make.

Or use mine

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

All Godot posts · Home