wrote a stat modifier system for Godot 4: flat adds, percent multipliers, and source-based removal

274 views 10 replies

Spent way too long with ad-hoc buff math scattered across my character scripts before finally sitting down to write a proper stat system. Sharing it here because I couldn't find a clean Godot 4 example that handled modifier ordering correctly: flat adds first, then multiply. This ordering matters: a +50 flat bonus on a base of 300, then a 20% multiplier, should give 420, not 390.

Two classes: StatModifier holds the value, type, and a source string for batch removal. StatValue owns the list and recalculates on demand.

class_name StatModifier
extends RefCounted

enum Type { FLAT, PERCENT }

var type: Type
var value: float
var source: String

func _init(p_type: Type, p_value: float, p_source := "") -> void:
    type = p_type
    value = p_value
    source = p_source
class_name StatValue
extends RefCounted

signal changed

var base: float
var _mods: Array[StatModifier] = []

func _init(p_base: float) -> void:
    base = p_base

func get_value() -> float:
    var flat := base
    var pct := 1.0
    for mod in _mods:
        if mod.type == StatModifier.Type.FLAT:
            flat += mod.value
        else:
            pct += mod.value
    return flat * pct

func add_mod(mod: StatModifier) -> void:
    _mods.append(mod)
    changed.emit()

func remove_mod(mod: StatModifier) -> void:
    _mods.erase(mod)
    changed.emit()

func remove_by_source(source: String) -> void:
    _mods = _mods.filter(func(m): return m.source != source)
    changed.emit()

Usage:

var speed := StatValue.new(300.0)
speed.add_mod(StatModifier.new(StatModifier.Type.FLAT, 50.0, "boots"))
speed.add_mod(StatModifier.new(StatModifier.Type.PERCENT, 0.2, "haste_buff"))
# get_value() -> (300 + 50) * 1.2 = 420

speed.remove_by_source("boots")
# get_value() -> 300 * 1.2 = 360

The changed signal is optional but makes stat-reactive UI trivial to wire up. The source string is the real workhorse. Unequipping an item just calls remove_by_source("sword_of_whatever") and every stat it touched gets cleaned up in one shot, no bookkeeping on the item side.

One thing I haven't settled on: whether to cache get_value() with a dirty flag or just recalculate each call. For most stats it's fine. You're reading on events, not every frame. But for something like movement speed that gets sampled every physics tick, iterating the modifier array constantly feels wasteful even if the list is short. Is dirty-flag caching worth the added complexity for typical modifier counts, or am I solving a non-problem?

Replying to AetherMesh: One thing worth building in from the start: dirty-flagging the computed stat val...

yeah dirty flagging is the right call but i'd also pair it with a signal, something like stat_changed(stat_name, old_value, new_value) that only fires when the recalculated value actually differs from the cached one. UI elements just connect and stay reactive without polling. combined with source tracking you basically get a proper observable stat system for free, which is way more than just an optimization at that point.

Replying to ByteHawk: One thing that tripped me up early: the order you apply flat adds vs. percent mu...

yeah this one bites harder than it looks because both orderings produce plausible numbers in most test cases. we actually switched implementations mid-project and the design team didn't notice for two weeks, only caught it because a playtester mentioned damage stacking "felt different." it did. same inputs, subtly different outputs, nobody spotted it during normal play.

the formula-as-comment approach is exactly right. either ordering is defensible as long as everyone knows which one it is. the only genuinely wrong answer is leaving it implicit and undocumented.

everything is fine burning background
Replying to ByteHawk: One thing that tripped me up early: the order you apply flat adds vs. percent mu...

the ordering thing burned us too. we ended up writing the formula explicitly as a comment at the top of the stat class:

# final = (base + sum(flat)) * (1.0 + sum(additive_pct)) * product(multiplicative_pct)

and then split the modifier type enum into ADDITIVE_PERCENT and MULTIPLICATIVE_PERCENT instead of one vague "percent" bucket. more modifier types to deal with, but at least nobody's guessing which behavior they're getting. the ambiguity was causing actual design disagreements before we made it explicit in code.

One thing that tripped me up early: the order you apply flat adds vs. percent multipliers actually changes the result, and different games have very different expectations for which behavior is "correct." Stack flat adds first then apply multipliers, and a +10 flat with a 2x multiplier on a base 50 stat gives you 120. Apply multipliers first and you get 110. Neither answer is wrong, but pick one and write it down somewhere your designers can actually find. Content gets authored around whatever people assume is happening, and "the numbers feel off" is a genuinely miserable bug to trace back to operator ordering.

Replying to CrystalMesh: The overlay point is underrated specifically as a design tool, not just a debugg...

I'd extend that to QA as well. Once testers can see the active modifier list live, they can describe bugs with actual specifics instead of "the damage numbers feel off." Had a playtester on our last project tell me directly that the fire debuff wasn't stacking right, because they could read it off the overlay in real time. Without that visibility, tracking down the same issue would have been a half-hour logging session to find what they spotted in thirty seconds.

One thing worth building in if it's not already there: explicit floors and ceilings on the computed stat value. A speed stat that goes negative from stacked slows causes bizarre movement behavior. Damage resistance that reaches 100% makes enemies unkillable. I store min_value and max_value directly on the stat definition and clamp as the very last step after all modifiers are resolved.

Sounds obvious, but it's the kind of thing that gets skipped early on because "we won't stack that many debuffs anyway". Then QA finds the edge case three months later when someone finally builds a dedicated slow build and the player just stops moving entirely.

One thing worth building in from the start: dirty-flagging the computed stat value. If you're recalculating from scratch on every get(), you're doing unnecessary work for stats that don't change between frames. A simple _is_dirty flag, set whenever modifiers are added or removed, lets you cache the result and skip recalculation until something actually changes. Matters most for stats polled frequently: HP bars, AI decision scoring, tooltip displays. Much easier to include early than to retrofit after the system is already wired up across half your codebase.

Replying to GlitchFox: The debug visibility point is worth expanding on. Once you have source tracking,...

The overlay point is underrated specifically as a design tool, not just a debugging aid. When you can see every active modifier on a stat in real time, it becomes obvious when stacking is out of hand. Multiple large percentage multipliers from different sources on the same stat look fine in a spreadsheet but quietly break balance in actual play. Making the stack visible turns that from a bug-report discovery into something you catch while playing the game.

The source-based removal is the part that always gets cut and then comes back to bite you later. Every "simple" stat system I've seen just tracks a flat bonus value, which works until you need to remove a specific buff on expiry. Then you're either recalculating from scratch or accumulating drift bugs where removing one item removes the wrong amount.

Worth being explicit about evaluation order too. The classic question is whether two separate 20% bonuses stack to 40% (additive) or 44% (multiplicative). Most RPGs use additive within a category and multiplicative across categories, but make sure it's an actual decision somewhere and not something you discover is inconsistent mid-playtest.

Replying to CrystalFox: The source-based removal is the part that always gets cut and then comes back to...

The debug visibility point is worth expanding on. Once you have source tracking, you're one small step from an in-game overlay that lists every active modifier per stat with the source that applied it. QA can actually see what's stacking instead of guessing why a damage number looks wrong, and designers can tune without digging through code. Honestly that's more useful than the removal mechanic itself.

Moonjump
Forum Search Shader Sandbox
Sign In Register