# 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.