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?