# 5. Damage Formulas
Part of [[Stat-Driven RPG Systems]]. Requires Unity 6000.5+. Previous: [[4.Equipment Items and Classes]]. Next: [[6.Saving Stats and Character Sheet UI]].
## 5.1 Choosing a defense model
| Model | Formula | Feel |
|---|---|---|
| Subtractive | `dmg − armor` | Simple, but armor makes weak hits do 0, and the numbers explode at high levels |
| **Ratio (diminishing)** | `dmg × K / (K + armor)` | Armor never reaches 100%, and each point is worth less than the last. The usual choice. |
| Percent resist | `dmg × (1 − resist)`, resist capped at e.g. 75% | Good for elemental resistances |
With the ratio model, `K` sets the scale. When `armor == K`, damage is halved. Scaling `K` with the attacker's level (`K = 50 + 10 × attackerLevel`) keeps armor from losing value as players level up.
## 5.2 Pipeline order
1. Roll base damage (weapon min..max) and add attack power scaling.
2. Apply the attacker's damage-percent stats.
3. Roll a crit (chance and multiplier).
4. Apply the defender's armor (physical) or resistance (elemental).
5. Apply flat reduction and clamp to a minimum of 1 (so hits always register).
6. Round **once**, at the end.
Keep the calculator **pure and static**: data in, result out, no `MonoBehaviour`. Then it can be unit-tested and reused for tooltips ("expected DPS") and AI decisions.
```csharp
// DamageCalculator.cs
using System;
using UnityEngine;
public enum DamageType { Physical, Fire, Frost, Poison }
public struct AttackInput
{
public float weaponMin, weaponMax;
public float attackPower; // from STR etc.
public float attackPowerScale; // e.g. 0.1 → +1 damage per 10 AP
public float damagePercent; // e.g. 0.25 = +25%
public float critChance; // 0..1
public float critMultiplier; // e.g. 1.5
public int attackerLevel;
public DamageType type;
}
public struct DefenseInput
{
public float armor;
public float fireResist, frostResist, poisonResist; // 0..1
public float flatReduction;
}
public struct DamageResult
{
public int amount;
public bool crit;
public float mitigatedFraction;
}
public static class DamageCalculator
{
public const float ResistCap = 0.75f;
public static DamageResult Calculate(in AttackInput a, in DefenseInput d, System.Random rng)
{
float raw = Mathf.Lerp(a.weaponMin, a.weaponMax, (float)rng.NextDouble());
raw += a.attackPower * a.attackPowerScale;
raw *= 1f + a.damagePercent;
bool crit = rng.NextDouble() < Mathf.Clamp01(a.critChance);
if (crit) raw *= Mathf.Max(1f, a.critMultiplier);
float mitigation = a.type == DamageType.Physical
? ArmorMitigation(d.armor, a.attackerLevel)
: Mathf.Clamp(ResistFor(a.type, d), 0f, ResistCap);
float final = raw * (1f - mitigation) - d.flatReduction;
return new DamageResult
{
amount = Mathf.Max(1, Mathf.RoundToInt(final)),
crit = crit,
mitigatedFraction = mitigation
};
}
/// <summary>armor / (armor + K): 0 armor → 0%, armor == K → 50%, never reaches 100%.</summary>
public static float ArmorMitigation(float armor, int attackerLevel)
{
float k = 50f + 10f * Mathf.Max(1, attackerLevel);
return Mathf.Max(0f, armor) / (Mathf.Max(0f, armor) + k);
}
static float ResistFor(DamageType t, in DefenseInput d) => t switch
{
DamageType.Fire => d.fireResist,
DamageType.Frost => d.frostResist,
DamageType.Poison => d.poisonResist,
_ => 0f
};
/// <summary>Deterministic average for tooltips (no RNG).</summary>
public static float ExpectedDamage(in AttackInput a, in DefenseInput d)
{
float avg = (a.weaponMin + a.weaponMax) * 0.5f + a.attackPower * a.attackPowerScale;
avg *= 1f + a.damagePercent;
avg *= 1f + Mathf.Clamp01(a.critChance) * (Mathf.Max(1f, a.critMultiplier) - 1f);
float mit = a.type == DamageType.Physical ? ArmorMitigation(d.armor, a.attackerLevel) : Mathf.Clamp(ResistFor(a.type, d), 0, ResistCap);
return Mathf.Max(1f, avg * (1f - mit) - d.flatReduction);
}
}
```
## 5.3 Building inputs from CharacterStats
```csharp
// CombatStats.cs
using UnityEngine;
/// <summary>Maps stat assets to the calculator's inputs. Assign the StatDefinition assets in the Inspector.</summary>
[CreateAssetMenu(menuName = "RPG/Combat Stat Map")]
public class CombatStats : ScriptableObject
{
public StatDefinition attackPower, damagePercent, critChance, critMultiplier, armor, fireResist, frostResist, poisonResist, flatReduction;
public AttackInput BuildAttack(CharacterStats s, CharacterLevel lvl, ItemInstance weapon, DamageType type) => new()
{
weaponMin = weapon?.def ? weapon.def.minDamage : 1,
weaponMax = weapon?.def ? weapon.def.maxDamage : 2,
attackPower = s.GetValue(attackPower),
attackPowerScale = 0.1f,
damagePercent = s.GetValue(damagePercent),
critChance = s.GetValue(critChance),
critMultiplier = critMultiplier ? s.GetValue(critMultiplier) : 1.5f,
attackerLevel = lvl ? lvl.Level : 1,
type = type
};
public DefenseInput BuildDefense(CharacterStats s) => new()
{
armor = s.GetValue(armor),
fireResist = s.GetValue(fireResist),
frostResist = s.GetValue(frostResist),
poisonResist = s.GetValue(poisonResist),
flatReduction = s.GetValue(flatReduction)
};
}
```
## 5.4 Test it
Create an Edit Mode test assembly (**Window › General › Test Runner**, then create the folder) and lock the formula's behavior in place:
```csharp
// DamageCalculatorTests.cs (Editor test assembly referencing your runtime assembly)
using NUnit.Framework;
public class DamageCalculatorTests
{
[Test]
public void ArmorEqualToK_HalvesDamage()
{
// attackerLevel 5 → K = 100
Assert.AreEqual(0.5f, DamageCalculator.ArmorMitigation(100, 5), 1e-4f);
}
[Test]
public void DamageNeverBelowOne()
{
var a = new AttackInput { weaponMin = 1, weaponMax = 1, critMultiplier = 1, attackerLevel = 1 };
var d = new DefenseInput { armor = 10000, flatReduction = 50 };
Assert.AreEqual(1, DamageCalculator.Calculate(a, d, new System.Random(1)).amount);
}
[Test]
public void ResistIsCapped()
{
var a = new AttackInput { weaponMin = 100, weaponMax = 100, critMultiplier = 1, type = DamageType.Fire, attackerLevel = 1 };
var d = new DefenseInput { fireResist = 2f };
Assert.AreEqual(25, DamageCalculator.Calculate(a, d, new System.Random(1)).amount);
}
}
```
Passing a seeded `System.Random` in, instead of calling `UnityEngine.Random`, makes the results reproducible in tests and in replays.