M5.2 — The Damage Formula¶
What you'll learn
- Roll base damage from a min–max range and apply a crit multiplier.
- Choose diminishing-returns armor (
armor/(armor+K)) over the two formulas that break at the edges. - Share one damage function, and test it with boundary-value and equivalence-partition cases.
How it applies
- The damage formula is the game's central equation. Every fight resolves through it. Its shape decides whether stacking armor ever makes you invincible, whether crit is exciting or mandatory, and whether a level-1 hit on a level-30 enemy does anything. Get the shape wrong and no amount of content tuning rescues the feel.
- Wrong formulas fail at the boundaries, which is where testers look. Naive
damage - armorgoes negative (a hit heals) at high armor; flat% per armor pointreaches 100% mitigation (a hit does nothing, ever) and beyond. These are boundary-value defects: correct in the middle, broken at the edges. Choosing a formula with no bad edges is choosing one that survives QA. - Crit is variance, and variance is feel. A small crit chance with a moderate multiplier adds excitement without making non-crits feel worthless. The numbers are a design lever; the roll is a reproducibility concern (seed it for testing).
- One shared calculation, two attackers. Player-on-enemy and enemy-on-player run the same math. Centralizing it means a balance change or a bug fix applies to both, and a tester reasons about one function, not two.
Try it first
Before reading on: an attacker has a base damage and the defender has an armor stat. Write down how
armor should turn base damage into final damage. Then stress-test your formula at the edges: what
happens when armor is 0? When armor is huge (a defender who stacked it)? When base damage is 1 and
armor is large? Does your formula ever produce negative damage, or zero damage no matter how hard
the hit? Spend a few minutes; there is an obvious first formula and a reason ARPGs don't ship it.
Concepts¶
Rolling base damage and crit¶
A hit starts as a roll within the attacker's damage band (damage_min–damage_max from the M5.1
StatBlock), then may critically strike:
Example
Base roll plus crit, using the attacker's StatBlock and a seeded RNG for reproducibility:
func roll_damage(stats: StatBlock, rng: RandomNumberGenerator) -> int:
var base := rng.randi_range(stats.damage_min, stats.damage_max)
if rng.randf() < stats.crit_chance:
base = int(round(base * stats.crit_mult))
return base
randi_range(min, max) is inclusive on both ends — every value in the band is possible. randf() <
crit_chance is the crit roll: with crit_chance = 0.1, ~10% of hits multiply by crit_mult. Drawing
from a passed-in rng (e.g., GameState.rng) means a seeded run reproduces the same hits — a tester
can file "seed 42, third hit should crit for 12."
The three armor shapes (the productive-failure payoff)¶
You probably reached one of these:
1. Subtraction: final = base - armor. Intuitive and wrong. At armor > base, final goes
negative — the hit heals the target. You can clamp to zero, but then high armor makes the defender
immune to anything below its armor value, which is a cliff, not a curve: 49 damage vs 50 armor does
nothing; 51 does 1. Tiny stat changes flip between useless and effective.
2. Flat percent per point: final = base * (1 - armor * p). With p = 0.01, each armor point cuts
1% of damage. Clean until armor reaches 100, where mitigation hits 100% — the defender takes zero
forever — and past 100 it goes negative (healing again). It has a hard cap built into a linear function,
so you must clamp, and near the cap, one armor point is worth as much as the first; the curve is flat
where it should taper.
3. Diminishing returns: mitigation = armor / (armor + K); final = base * (1 - mitigation). This is
the ARPG-standard shape (the Diablo/Path-of-Exile lineage). Mitigation approaches but never reaches 100%:
at armor = K, exactly 50% mitigation; at armor = 3K, 75%; it keeps rising but never caps, so armor is
always worth a little more and never makes a defender invincible. K is a tuning constant, usually
scaled by attacker level so the same armor is stronger against weak hits and weaker against strong ones.
Example
The diminishing-returns mitigation, with K scaling by attacker level:
func apply_armor(base: int, armor: int, attacker_level: int) -> int:
var k := float(50 * attacker_level) # tuning constant; bigger K = armor matters less
var mitigation := float(armor) / (float(armor) + k)
var final_damage := int(round(base * (1.0 - mitigation)))
return max(1, final_damage) # a connected hit always does at least 1
armor / (armor + k) is the curve: zero armor → zero mitigation; armor == k → 0.5; large armor →
approaches 1.0 without reaching it. max(1, ...) guarantees a landed hit always does something,
which keeps high-armor enemies beatable (slowly) rather than literally immune — a deliberate floor,
not an accident.
Putting it together¶
The full hit: roll base (with crit), then mitigate by the defender's armor. This is one function both attackers call:
Example
func compute_hit(attacker: StatBlock, defender: StatBlock, level: int, rng: RandomNumberGenerator) -> int:
var base := roll_damage(attacker, rng)
return apply_armor(base, defender.armor, level)
The Hitbox (M3.1) carries the result of this — its damage — computed when the attack fires, or the
hurtbox can compute it on contact given both StatBlocks. Either way, one function defines a hit, so
player-on-enemy and enemy-on-player are the same math with the roles swapped.
Reading the formula as a tester¶
The diminishing-returns formula is built to have no bad boundaries, which is exactly what a tester verifies:
- Equivalence partitions over base damage: low rolls, mid rolls, high rolls, crit rolls — each class should mitigate consistently. One case per partition.
- Boundaries:
armor = 0(no mitigation), very largearmor(mitigation near but under 1.0, final clamps to themax(1, …)floor),base = 1against high armor (still 1, never 0 or negative). - Overflow / type: with the M5.1 integer stats, confirm large
base * crit_multdoesn't overflow the 64-bit int (it won't at sane values) and thatround/inttruncation behaves at the edges.
A formula whose worst-case behavior is "a hit does 1 instead of 0" is one with no catastrophic edge — the whole reason to prefer it over the two that break.
Walkthrough¶
- Decide where the damage math lives. A small autoload-free static helper or a
Damageutility (a script withstatic funcs) keeps it callable from both actors. Createres://scripts/damage.gdwithroll_damage,apply_armor, andcompute_hitasstatic funcs. - Wire the player's attack to use it. When the player's
Attackstate enables the hitbox (M3.3), set the hitbox'sdamagetoDamage.compute_hit(player.stats, target.stats, player.level, GameState.rng)— or, more simply for now, set the hitboxdamageto a rolled base and let the defender's hurtbox apply armor on contact (both are valid; pick one place to apply armor and be consistent). - Give the enemy the same treatment for its attack hitbox.
- Confirm StatBlocks (M5.1) carry the inputs:
damage_min/max,crit_chance,crit_multon attackers;armoron defenders. Add a smallarmorto the skeleton's.tresto see mitigation. - Press
F5. Hits now vary within the damage band, occasionally crit (watch for larger numbers), and are reduced by the defender's armor. WithGameState.rngseeded to a fixed value temporarily, confirm the same sequence of rolls each run.
Optional sanity check
Temporarily set the skeleton's armor to a huge number (e.g., 100000) and confirm the player's hits
do 1, never 0 or a negative — the max(1, …) floor holding at the boundary. Then set armor to
0 and confirm hits do full rolled damage — the zero boundary. Finally, set crit_chance to 1.0
and confirm every hit is multiplied — the crit path. These three probes exercise the formula's
edges, which is exactly where the rejected formulas would have failed.
Self-check quiz¶
Q1 — Why does the ARPG-standard armor formula use armor / (armor + K) instead of base - armor?
A. It is cheaper to compute.
B. Subtraction goes negative (a hit heals) or, clamped, creates a hard immunity cliff at armor ≈
base; the ratio gives diminishing returns that approach but never reach 100% mitigation, with no
bad boundary.
C. Subtraction isn't supported for integers in GDScript.
D. The ratio makes armor useless, which is the goal.
Reveal answer
B. The ratio's curve never caps and never inverts: armor is always worth a bit more and a defender is never fully immune, so there's no negative-damage or zero-forever edge. Subtraction fails exactly at the boundaries (negative, or a cliff when clamped). A is not the reason. C is false. D inverts it — the ratio keeps armor meaningful, just non-degenerate.
Q2 — A tester wants to verify the damage formula. Which set of cases best targets where formulas typically break?
A. A hundred random mid-range hits. B. The boundaries: armor = 0, armor very large, base damage = 1 vs high armor, and a forced crit — plus a couple of mid-range partitions. C. Only the average damage over a long run. D. The frame rate during combat.
Reveal answer
B. Boundary value analysis targets the edges where the rejected formulas fail (negative or zero damage, immunity), and one case per equivalence partition covers the middle. A and C check the easy middle and miss the dangerous edges. D is unrelated to the formula.
Q3 — Why draw the damage and crit rolls from a passed-in RandomNumberGenerator (e.g., GameState.rng) rather than the global randi/randf?
A. It is faster. B. A seeded generator makes hits reproducible, so a tester can seed a run and assert a specific hit sequence; the global functions can't be reset to reproduce a case. C. The global functions don't exist in Godot 4. D. It avoids crit entirely.
Reveal answer
B. A dedicated, seedable RNG gives reproducibility — the same seed yields the same rolls, so a QA report like "seed 42, third hit crits" is verifiable. The global RNG is harder to pin to a known sequence. A is negligible. C is false (they exist). D is unrelated.
Integration question¶
Q4 — open
The damage formula consumes the M5.1 StatBlock (damage range, crit, armor) and produces the number the M3 Hitbox/Hurtbox/Health pipeline carries. Explain why centralizing the calculation in one shared function is the same architectural value as the shared Hurtbox/Health components, and connect the choice of the diminishing-returns shape to the genre's progression: what would stacking armor do under each of the three formulas as the player levels, and why does only one of them keep the game playable at both low and high armor?
Reveal expected answer
Centralizing the formula in one shared function mirrors the shared-component value from M3: just as
one Hurtbox/Health scene serves every actor so a fix applies everywhere, one compute_hit
function serves both player and enemy attacks so a balance change or bug fix lands once for both
roles, and a tester reasons about a single equation rather than two divergent copies. The hit
pipeline (Hitbox carries damage → Hurtbox detects → Health applies) is unchanged; only the value
it carries comes from this one place. On the formula choice and progression: under subtraction,
stacking armor eventually exceeds incoming base damage and the defender becomes immune to anything
below its armor (a cliff that worsens as armor scales), so high-armor builds break the game; under
flat percent per point, armor reaches 100% mitigation at a fixed threshold and the defender
takes zero from then on (and negative past it), so armor has a hard wall that, once crossed, ends
combat. Under diminishing returns, mitigation rises toward but never reaches 100%, so a
heavily-armored defender takes less but never nothing — combat stays resolvable at every armor
level, low or extreme. Only the ratio formula keeps both early game (low armor, meaningful
mitigation) and late game (high armor, strong but not absolute) playable, which is why it is the
genre standard and why the max(1, …) floor reinforces it: a landed hit always advances the fight.