M5.4 — Stat Modifiers & Recompute¶
What you'll learn
- Combine flat, increased, and more modifiers with
(base+flat)*(1+Σinc)*Π(1+more). - Rebuild derived stats from an immutable base + modifier list on every change (recompute pattern).
- Replace M5.3's direct mutation, setting up M7's equip/unequip.
How it applies
- Modifiers are where ARPG depth lives. "+5 damage," "20% increased attack damage," "30% more damage while at full health" — items, levels, and buffs all speak in these terms. The taxonomy is the vocabulary every piece of loot (M6) and every equipment slot (M7) will use; getting it right once makes all of them composable.
- 'Increased' vs 'more' is a real, exploited distinction. Players stack many increased sources (they add together) but prize more multipliers (they compound separately). A game that conflates them either trivializes stacking or makes it incomprehensible. The split is the difference between a build system and a pile of plus signs.
- Recompute-from-base prevents drift. Mutating a stat in place (M5.3's first pass) means un-applying a modifier requires remembering exactly what it added — and rounding makes that lossy. Rebuilding the derived value from the immutable base plus the current modifier list means removing a modifier is just dropping it from the list and recomputing. No drift, no "I took off the armor but kept half its bonus" bug.
- It's the same recompute discipline across the book. This is the idle textbook's income-recalculation pattern in ARPG clothing: a base, a set of contributors, and a derived total rebuilt on change rather than nudged.
Concepts¶
The three modifier types¶
ARPGs (the Diablo / Path of Exile lineage) classify stat modifiers into three kinds that combine differently:
- Flat — adds a raw amount to the base before scaling. "+5 damage." Multiple flats sum.
- Increased — a percentage that is additive with other increased of the same stat. "20% increased" and "30% increased" make +50%, applied once. Cheap and abundant on items.
- More (and less) — a percentage that is multiplicative, applied separately from everything
else. Two "10% more" sources give
1.10 × 1.10 = 1.21(a 21% gain), not 20%. Rare and powerful.
The combine order matters and is fixed:
final = (base + sum_flat) * (1 + sum_increased) * product_over_i(1 + more_i)
Flats adjust the base; all increased percentages are summed and applied as one multiplier; each more multiplier is applied on its own. This is why stacking ten "15% increased" (= +150%, a ×2.5) is fundamentally weaker than the same budget spent on "more" multipliers that compound.
Example
Base damage 100, with: +20 flat, two increased (25% and 15%), and two more (10% and 30%).
- Flat:
100 + 20 = 120 - Increased: sum
25% + 15% = 40%→120 * 1.40 = 168 - More:
1.10 * 1.30 = 1.43→168 * 1.43 ≈ 240
Final ≈ 240. Note the two more multipliers gave ×1.43 by compounding, while the two increased only added to a single ×1.40 — the structural reason "more" is the prized modifier.
A modifier as data¶
Represent each modifier as a small value: which stat, which type, how much. A typed structure (a tiny Resource or a typed dictionary) keeps it inspectable and serializable for items (M6) and saves (M8).
Example
A modifier and an enum for its type:
# res://scripts/stat_modifier.gd
class_name StatModifier
extends Resource
enum Type { FLAT, INCREASED, MORE }
@export var stat: StringName # e.g. &"damage", &"max_health", &"armor"
@export var type: Type = Type.FLAT
@export var amount: float = 0.0 # FLAT: raw; INCREASED/MORE: fraction (0.20 == 20%)
stat names the affected field; type selects how it combines; amount is raw for flat and a
fraction for the percentages. An item in M6 is, mechanically, a base type plus a list of these.
The recompute pattern¶
Keep three things: an immutable base StatBlock (the character sheet from M5.1, never mutated), a list of active modifiers (from level-ups, equipped items, buffs), and a derived StatBlock that is the result of applying the modifiers to the base. Whenever the modifier list changes — equip, unequip, level up, buff expires — you recompute the derived block from scratch:
Example
Recomputing one stat from base + modifiers. A full implementation loops every stat; the shape per stat is:
static func combine(base_value: float, mods: Array[StatModifier], stat: StringName) -> float:
var flat := 0.0
var increased := 0.0
var more_product := 1.0
for m in mods:
if m.stat != stat:
continue
match m.type:
StatModifier.Type.FLAT: flat += m.amount
StatModifier.Type.INCREASED: increased += m.amount
StatModifier.Type.MORE: more_product *= (1.0 + m.amount)
return (base_value + flat) * (1.0 + increased) * more_product
Called once per stat (max_health, damage_min, damage_max, armor, …) to build the derived
StatBlock. The function is pure — same inputs, same output — so it is trivially testable: feed it a
base and a modifier list, assert the number.
Example
The actor recomputes when its modifiers change, and re-reads the derived values:
var base_stats: StatBlock # immutable character sheet
var modifiers: Array[StatModifier] = []
var derived: StatBlock
func recompute_stats() -> void:
derived = StatBlock.new()
derived.max_health = int(StatModifier.combine(base_stats.max_health, modifiers, &"max_health"))
derived.damage_min = int(StatModifier.combine(base_stats.damage_min, modifiers, &"damage_min"))
derived.damage_max = int(StatModifier.combine(base_stats.damage_max, modifiers, &"damage_max"))
derived.armor = int(StatModifier.combine(base_stats.armor, modifiers, &"armor"))
# ... crit, move_speed ...
health.max_health = derived.max_health
SignalBus.stats_recomputed.emit(derived)
Adding a modifier is modifiers.append(m); recompute_stats(); removing is
modifiers.erase(m); recompute_stats(). Because the derived block is rebuilt from the untouched base
every time, removal is exact — there is no leftover from a modifier you took off.
Replacing M5.3's direct mutation¶
M5.3 raised stats by mutating the StatBlock directly (stats.max_health += 5). Re-express level growth as
modifiers: a level-up appends flat modifiers (+5 max_health, +1 damage) and recomputes. Now level
growth, item bonuses, and temporary buffs all live in the same modifiers list and combine through the
same formula — and any of them can be removed cleanly. This is the production approach M7's equipment
depends on: equipping an item appends its modifiers and recomputes; unequipping erases them and recomputes.
Walkthrough¶
- Create
res://scripts/stat_modifier.gd(theStatModifierResource with theTypeenum) and add thecombinestatic to it (or aStatMathhelper). - On the player, introduce
base_stats(the duplicated per-instance StatBlock from M5.3),modifiers(typed array),derived, andrecompute_stats(). Callrecompute_stats()in_readyafter settingbase_stats. - Point combat at the derived stats: the damage roll (M5.2) and
Health.max_healthread fromderived, notbase_stats. Addstats_recomputedtoSignalBus. - Convert level-up (M5.3) to append modifiers: in
_on_level_up, append flatStatModifiers for the per-level growth and callrecompute_stats()instead of mutating fields directly. - Add
stats_recomputedlisteners later (the HUD in M7/M8). For now, a temporary printer confirms it:SignalBus.stats_recomputed.connect(func(d): print("dmg ", d.damage_min, "-", d.damage_max, " hp ", d.max_health)). - Press
F5, level up a few times, and confirm the derived damage and health rise via appended modifiers — identical outcome to M5.3, but now removable and composable. Remove the temp printer.
Optional sanity check
Temporarily append one INCREASED 0.5 and one MORE 0.5 damage modifier, recompute, and verify the
final equals (base) * 1.5 * 1.5, not base * 2.0 — proof increased and more combine differently.
Then erase the more modifier and recompute; confirm damage returns to base * 1.5 exactly, with no
residue — proof the recompute-from-base removal is lossless. Remove the test modifiers.
Self-check quiz¶
Q1 — Two '10% more damage' modifiers are active. What multiplier do they produce together, and why does it differ from two '10% increased'?
A. Both produce ×1.20. B. Two 'more' produce ×1.21 (1.10 × 1.10, compounding separately); two 'increased' produce ×1.20 (sum to +20%, applied once). C. Both produce ×1.21. D. 'More' modifiers don't stack.
Reveal answer
B. 'More' multipliers each apply separately, so they compound (1.10 × 1.10 = 1.21);
'increased' percentages sum into a single multiplier (+10% + 10% = +20% → ×1.20). The gap is
small at two sources and large as they accumulate, which is why 'more' is the prized modifier. A
ignores compounding. C conflates the two. D is false — they stack, just multiplicatively.
Q2 — Why rebuild the derived StatBlock from the immutable base each time modifiers change, instead of adding/subtracting from the current values in place?
A. Recomputing is faster. B. In-place add/subtract is lossy (rounding) and requires remembering exactly what each modifier contributed to undo it; rebuilding from base + current modifier list makes removal exact — just drop the modifier and recompute. C. The base StatBlock can't be read more than once. D. Derived blocks can't be mutated.
Reveal answer
B. In-place mutation accumulates rounding error and forces you to track per-modifier contributions to reverse them; rebuilding from the untouched base plus the live list means unequipping is just erasing from the list and recomputing, with no residue. A is false (recompute is slightly more work, and worth it). C and D are fabricated.
Q3 — How does this chapter change M5.3's level-up, and what does that enable for M7's equipment?
A. It removes leveling entirely. B. Level growth becomes appended flat modifiers + recompute instead of direct field mutation, so leveling, item bonuses, and buffs all share one modifier list and one combine formula — and items can be equipped/unequipped by appending/erasing their modifiers. C. It makes level-ups instant. D. It stores stats on the enemy instead.
Reveal answer
B. Recasting level growth as modifiers unifies every stat source under one list and one formula; equipping an item (M7) becomes appending its modifiers and recomputing, unequipping becomes erasing them and recomputing — clean and lossless. A, C, D are false.
Integration question¶
Q4 — open
The recompute pattern (immutable base + modifier list + rebuilt derived) is described as the same discipline as the idle textbook's income recalculation. Explain that parallel precisely, then trace how a single dropped item in M6 and equipped in M7 will flow through this system to change the player's on-screen damage — naming what is appended, what is recomputed, and what signal updates the UI.
Reveal expected answer
The parallel: in both systems there is an immutable base (here the character-sheet StatBlock;
in the idle game, a building's base output), a set of contributors (here the modifier list from
levels/items/buffs; there the owned upgrades/prestige multipliers), and a derived total that is
rebuilt from the base and the current contributors whenever anything changes, rather than
nudged in place. Both use recompute-on-change because it makes adding and removing a contributor
symmetric and lossless — you never have to "un-apply" a past edit, you just rebuild. The flow for a
dropped-and-equipped item: in M6 the item is generated as a base type plus a list of StatModifier
rolls (e.g., +8 flat damage, 20% increased damage); in M7, equipping it appends those modifiers
to the player's modifiers array and calls recompute_stats(), which rebuilds the derived
StatBlock from base_stats and the now-larger list using the (base + flat) * (1 + sum_increased)
* product(1 + more) formula; the damage roll (M5.2) reads the new derived.damage_min/max, so
the very next hit is stronger; and SignalBus.stats_recomputed.emit(derived) notifies the HUD,
which updates the displayed damage. Unequipping erases exactly those modifiers and recomputes,
returning damage to its prior value with no residue — the lossless removal that the
rebuild-from-base design guarantees.