Skip to content

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 - armor goes negative (a hit heals) at high armor; flat % per armor point reaches 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_mindamage_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 large armor (mitigation near but under 1.0, final clamps to the max(1, …) floor), base = 1 against high armor (still 1, never 0 or negative).
  • Overflow / type: with the M5.1 integer stats, confirm large base * crit_mult doesn't overflow the 64-bit int (it won't at sane values) and that round/int truncation 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

  1. Decide where the damage math lives. A small autoload-free static helper or a Damage utility (a script with static funcs) keeps it callable from both actors. Create res://scripts/damage.gd with roll_damage, apply_armor, and compute_hit as static funcs.
  2. Wire the player's attack to use it. When the player's Attack state enables the hitbox (M3.3), set the hitbox's damage to Damage.compute_hit(player.stats, target.stats, player.level, GameState.rng) — or, more simply for now, set the hitbox damage to 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).
  3. Give the enemy the same treatment for its attack hitbox.
  4. Confirm StatBlocks (M5.1) carry the inputs: damage_min/max, crit_chance, crit_mult on attackers; armor on defenders. Add a small armor to the skeleton's .tres to see mitigation.
  5. Press F5. Hits now vary within the damage band, occasionally crit (watch for larger numbers), and are reduced by the defender's armor. With GameState.rng seeded 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.