Combat & Damage Calculation

The full damage pipeline — hit chance, damage, mitigation order, block, resistances, crushing blow, life leech, and attack speed — with the real formulas.

On this page

This page is the exact reference for how a swing turns into damage in Echoes of Theron. Every number below is read straight from the engine (src/core/formulas.ts, src/core/simulation.ts) and its hand-tunable constants (src/data/balance/). For a visual box-by-box version, see the in-game Damage Flow Chart.

Overview — the damage pipeline

Each landed swing resolves in a strict order. Physical and elemental damage are separate hits that ride the same swing and obey opposite rules.

  1. Roll to hit — compare the attacker’s Attack Rating (AR) to the defender’s Defense Rating (DR) on a tanh curve, then apply a small level-difference shift. Miss → 0 damage.
  2. Roll raw damage — a uniform integer between the fighter’s Min and Max Damage.
  3. Outgoing multipliers — skill damage multiplier, then the passive/rage multiplier (Wrath + Apex + Rampage − Last Stand). Round → scaledRaw.
  4. Mitigation (only when the hero is the defender) — block first, then the capped percentage-reduction bucket, then flat reduction.
  5. On-hit effects — life leech, crushing blow, thorns, elemental riders, bleed/DoTs, procs.

Two asymmetries are critical and never change:

  • Physical damage to enemies ignores resistance, AR and DR, and enemies never block. AR/DR only decide whether a swing lands, never how hard.
  • Elemental damage is a separate flat-per-hit rider. It bypasses the skill multiplier and Wrath, and it is the only player damage reduced by the target’s resistance.

Hit chance — Attack Rating vs Defense Rating

AR and DR feed a smooth log-ratio sigmoid centred on 70% at AR = DR. This is the whole of what AR/DR do to offense (Defense Rating additionally grants the defender a small capped physical reduction — see Incoming mitigation).

From hitChanceFromRatings(AR, DR):

logRatio = ln( AR / DR )
curve    = tanh( logRatio × 1.1 )
chance   = 0.70 + curve × (0.95 − 0.70)   if curve ≥ 0   (toward the ceiling)
         = 0.70 + curve × (0.70 − 0.05)   if curve < 0   (toward the floor)
chance   = clamp(chance, 0.05, 0.95)

AR and DR are each floored at 1 before the ratio. The constants (FORMULA_CONFIG):

ConstantValueMeaning
hitChanceAtEqualRatings0.70Hit chance when AR = DR
hitChanceCurveSteepness1.1Multiplier on ln(AR/DR) inside tanh
maxHitChance0.95Ceiling, even with enormous AR
minHitChance0.05Floor, even against overwhelming DR

The range is asymmetric: an AR advantage climbs from 70% toward the 95% ceiling (a 25-point band), while an AR deficit falls from 70% toward the 5% floor (a 65-point band), so being out-rated costs more than being over-rated gains.

Worked hit-chance values

ARDRln(AR/DR)tanh(×1.1)Hit chance
1001000070.0%
2001000.693+0.64286.1%
4001001.386+0.89592.4%
50100−0.693−0.64228.3%
25100−1.386−0.89511.8%

Flat hit-chance bonus

Some affixes and passives add a flat bonus on top of the sigmoid via applyFlatHitChanceBonus, which re-clamps to [0.05, 0.95]. Gear flat hit-chance is capped at +30% (MAX_HIT_CHANCE_FLAT_BONUS = 0.3); the combined total (gear + Keen Eye + tavern drinks) is capped at +50% in aggregate before the clamp.

Level-difference shift

The level gap between attacker and defender adds a further flat shift through the same applyFlatHitChanceBonus path (levelDeltaHitChanceShift), so it obeys the same [0.05, 0.95] clamp. It applies symmetrically — to the hero attacking and to enemies attacking the hero — and at every level:

shift = clamp( (attackerLevel − defenderLevel) × 0.01, −0.10, +0.10 )

An attacker gains +1% hit chance per level it out-levels the defender, loses 1%/level when under-levelled, and the shift saturates at ±10% (±10 levels). It is deliberately ungated: in the Endless Delve, where enemy level climbs past the hero cap of 99, this settles into a constant −10% for the hero (and +10% for the enemy) rather than a runaway penalty. Constants (FORMULA_CONFIG): levelDeltaHitChancePerLevel = 0.01, levelDeltaHitChanceMaxShift = 0.10.

Derived combat stats

These are built once per stat-panel rebuild in computePlayerStats. Attributes are floored at 0; each (1 + percent) multiplier is floored at 0.2 (MIN_STAT_MULTIPLIER) so a −100% roll can never fully zero a stat. Per-point coefficients (src/data/balance/player.ts):

AttributePer-point effect
Vitality+5 Max HP (HERO_HP_PER_VITALITY)
Strength+0.25% physical damage (STR_DAMAGE_PERCENT_PER_POINT = 0.0025) and +2 Attack Rating (HERO_AR_PER_STRENGTH)
Dexterity+4 Attack Rating (HERO_AR_PER_DEXTERITY) and +0.05% attack speed (DEX_ATTACK_SPEED_PER_POINT = 0.0005)
Constitution+5 Defense Rating (HERO_DR_PER_CONSTITUTION), +0.1% physical reduction (CON_PHYS_REDUCTION_PER_POINT = 0.001), and flat per-hit reduction of 0.0025 × level (CON_FLAT_REDUCTION_PER_POINT_PER_LEVEL)

Per level (independent of attributes): +3 Max HP, +2 Attack Rating, +2 Defense Rating (HERO_*_PER_LEVEL). A fresh hero starts at base 70 HP, 50 AR, 1.12 s interval.

Max HP

MaxHP = max( 12, (baseHp + flatHpAffix + 5×VIT + 3×level) × (1 + hp%) )

Attack Rating

AR = max( 1, baseAR + flatARaffix + 4×DEX + 2×STR + 2×level )
AR × (1 + gearAR% + trainingAR% + drinkAR% + Σ buffAR%)     ← one pooled multiplier
AR × (1 + S(Strike rank) × 2.5%)                           ← main-skill perk, separate

Note the archetype-rebalance coefficients: DEX gives 4 AR/pt (not 5) and STR gives 2 AR/pt, so Strength now buys accuracy and DEX no longer monopolises it. All AR% sources — unique-item affixes, Leather armor / Blades weapon training, the tavern AR drink and active buff skills — are summed into a single additive pool and applied once on top of the flat AR (in applyPlayerDerivedStats); Strike’s AR% is a separate multiplier after that. S(rank) is the skill’s accumulated effect steps — 28 at rank 20, so a maxed Strike is AR × 1.70. Magic/Rare gear only rolls flat AR.

Defense Rating

DR = max( 1, (baseDR + flatDRaffix + 5×CON + 2×level) × (1 + DR%) )

Min / Max damage

Flat-damage affixes are speed-normalised by the weapon’s own interval so they yield equal DPS on fast and slow weapons; the weapon’s own min/max (and the enhanced-damage % that scales it) are already DPS-normalised and left untouched. Strength is a speed-neutral multiplier, not flat per hit.

Scope: Flat Damage and Enhanced Damage rolled on a weapon boost that weapon only, and the same affixes on a shield boost only its Shield-Strike damage — both are already folded into the range printed on the item’s own card (archetype passive included). From every other slot (ring, amulet, armour) they are character-wide and add on top of whichever damage source you currently swing.

normFlat = round( (flatDamageAffix_weapon + flatDamageAffix_otherSlots) × weaponBaseInterval / 1.0 )
                                                                   # RAGE_REFERENCE_INTERVAL = 1.0
edOther  = max( 0.2, 1 + enhancedDamage% from non-weapon, non-shield slots )
strMult  = max( 0.2, 1 + 0.0025 × STR )
MinDamage = max( 1, (weaponMin + normFlat) × edOther × strMult )
MaxDamage = max( 2, (weaponMax + normFlat) × edOther × strMult )

weaponMin/weaponMax already include the weapon’s own enhanced-damage % (which scales weapon base only, before flat and before Strength) plus any smith-temper / witch-enchant. After the stored stat is built, the flow chart’s later layers — archetype weapon-class multiplier, main-passive enhanced-damage, and War Cry — are applied to the stored damage stat before the swing. There is no critical-hit chance; variance comes only from the Min–Max roll and elemental rolls.

Shield-Strike damage

Shield Strike and Smite swing the shield, so the stored Min/Max damage is replaced by the shield’s own damage — built the same way, but normalised against the shield’s interval. The equipped weapon contributes nothing here.

normFlat = round( (flatDamage_shield + flatDamage_otherSlots) × shieldInterval / 1.0 ) + blessedShield
MinDamage = (shieldMin × (1 + enhancedDamage%_shield) + normFlat) × edOther × strMult
MaxDamage = (shieldMax × (1 + enhancedDamage%_shield) + normFlat) × edOther × strMult

Blessed Shield sits outside the normalisation: its bonus is already a per-hit value (the skill tooltip prints it directly), so the shield’s speed never scales it.

Attack speed & swing interval

Gear IAS and Dexterity are summed and capped first, then skill / passive / buff speed multipliers divide the interval one after another.

iasMod   = clamp( attackSpeed% + 0.0005×DEX, −0.85, +0.85 )        # ATTACK_SPEED_MODIFIER_CAP
interval = clamp( weaponBaseInterval × (1 − iasMod), 0.38, 3.6 )   # intermediate clamp
# then ÷ main-skill IAS mult, ÷ (1 + main-passive IAS), ÷ (1 + buff/Flurry IAS)
interval = clamp( interval, 0.45, 6.0 )                            # FINAL clamp

The 0.38 … 3.6 clamp inside computePlayerStats is intermediate; the final interval is re-clamped to 0.45 s (fastest) … 6.0 s (slowest) after the multiplicative speed layers. Throughput ≈ damage ÷ interval.

No attack-speed breakpoints. Unlike many ARPGs, the swing interval is a continuous float, not a frame-quantised value — every point of IAS shortens the timer smoothly. There is nothing to “round up” to; the only discontinuities are the two clamp edges at 0.45 s and 6.0 s (MIN_ATTACK_INTERVAL = 0.45 in src/data/balance/player.ts; the final clamps live in src/core/simulation-helpers.ts:1480-1656).

Outgoing physical damage at swing time

After the stored Min/Max stat is built, each swing (applyResolvedHit) does:

raw        = randomInt(minDamage, maxDamage)
scaledRaw  = round( raw × skillDamageMultiplier × outgoingPassiveMult )

outgoingPassiveMult = 1 + Wrath + Apex Predator + Rampage − Last Stand (clamped ≥ 0), evaluated per-hit because rage, defender HP and player HP all move mid-fight. Wrath turns the live rage pool (0–100) into a physical multiplier. These are the last physical multipliers and hit the rolled value — they do not touch elemental riders.

Incoming mitigation (defending hero)

When the hero is the defender, mitigation runs in a strict order: block → capped percentage bucket → flat reduction. (Enemies get none of this — enemy hits are only the raw roll.)

Order of operations

  1. Block — with probability blockChance (cap 0.75), the hit is multiplied by (1 − 0.60). A block removes 60% of the hit (blockDamageReduction); it is partial, never full negation. Applied to the raw hit before the percentage bucket.
  2. Percentage-reduction bucket — a single shared bucket capped at 0.5 (MAX_PHYSICAL_REDUCTION):
    totalReduction = min( 0.5,
        constitution%          (0.001 × CON)
      + reducedPhysicalAffix
      + conditionalPassives    (e.g. Last Stand)
      + physReductionFromDR )
  3. Flat reduction — Constitution’s flat cut plus the Damage-Reduced-Flat affix is subtracted last. It bypasses the 0.5% cap, but a single hit can never lose more than half its remaining damage this way (FLAT_REDUCTION_MAX_FRACTION_OF_HIT = 0.5):
    flat = floor( damageReducedFlatAffix + 0.0025 × CON × level )
    out  = out − min( flat, floor(out × 0.5) )

Every stage floors the result at 1 — a landed hit always deals at least 1.

Defense Rating → physical reduction

Beyond hit-avoidance, DR grants a level-invariant, capped physical reduction that folds into the 0.5 bucket above (physicalReductionFromDefenseRating):

physReductionFromDR = min( 0.40, 0.40 × DR / (DR + 2000) )

Constants: DR_MITIGATION_CAP = 0.40, DR_MITIGATION_HALF_VALUE = 2000 (at 2000 DR you get half the cap, i.e. 20%). It is kept below the 0.5 shared cap so DR can never fill the bucket alone — Constitution, Reduced-Physical and Last Stand keep headroom. A given DR is always worth the same reduction; armour never goes obsolete as enemies grow. DR reduces physical only, never elemental.

Defense RatingPhysical reduction from DR
5008.0%
100013.3%
200020.0%
400026.7%
800032.0%

Constitution

Constitution has a dual defensive identity, present in both the % bucket and the flat step:

  • % physical reduction: 0.001 × CON (into the capped 0.5 bucket).
  • Flat per-hit reduction: 0.0025 × CON × level (level-scaled, applied after the cap, itself limited to half the hit).

It also grants +5 DR/point (which further feeds the DR→reduction curve above). Constitution grants no HP regen.

Worked example

Incoming physical hit scaledRaw = 200; defender at level 50 with CON 100, DR 2000, a +5% Reduced-Physical affix, no Last Stand.

  • physReductionFromDR = 0.40 × 2000/(2000+2000) = 0.20
  • constitution% = 0.001 × 100 = 0.10
  • totalReduction = min(0.5, 0.20 + 0.10 + 0.05) = 0.35
  • flat = floor(0.0025 × 100 × 50) = 12

Not blocked: 200 × (1 − 0.35) = 130, then 130 − min(12, 65) = 118.

Blocked: 200 × (1 − 0.60) = 8080 × (1 − 0.35) = 5252 − min(12, 26) = 40.

Block multiplies before the cap, and flat is always last.

Worked examples

The sections above give each stage in isolation. These two swings walk the whole pipeline end-to-end so you can see the pieces compose.

Example A — outgoing physical swing (hero → enemy)

A weapon whose stored Min/Max damage is 100–150, using Crush (skill multiplier ×1.50), at full rage (Wrath = +35%), against an ordinary enemy:

  1. Roll to hit — assume it lands (AR/DR only decide whether, never how hard).
  2. Roll raw damage — the uniform roll comes up at the top: raw = 150.
  3. Skill multiplier — Crush: 150 × 1.50 = 225.
  4. Outgoing passive multiplier1 + Wrath + Apex + Rampage − Last Stand = 1 + 0.35 = 1.35: scaledRaw = round(225 × 1.35) = round(303.75) = 304.
  5. Enemy mitigation — none. Physical damage to enemies ignores resistance, AR and DR, and enemies never block. The hit lands for 304.

Any flat elemental rider on the weapon resolves as a separate hit on the same swing — it skips steps 3–4 entirely (no skill multiplier, no Wrath) and is reduced only by the enemy’s matching resistance.

Example B — incoming physical hit (enemy → hero)

A raw enemy hit of 200 against a hero at level 60 with CON 200, DR 4000, a conditional Last Stand +15% active, and no Reduced-Physical affix. Mitigation runs block → %-bucket → flat, each stage floored at 1:

First, the shared values:

  • physReductionFromDR = min(0.40, 0.40 × 4000/(4000+2000)) = 0.267
  • constitution% = 0.001 × 200 = 0.20
  • conditionalPassive (Last Stand) = 0.15
  • totalReduction = min(0.5, 0.267 + 0.20 + 0.15) = min(0.5, 0.617) = 0.50hits the 0.5 cap
  • flat = floor(0.0025 × 200 × 60) = floor(30) = 30

Not blocked:

200 × (1 − 0.50) = 100                     # capped %-bucket
100 − min(30, floor(100 × 0.5)=50) = 70    # flat, ≤50% of the hit
→ 70 damage

Blocked (blockDamageReduction = 0.60, applied before the bucket):

200 × (1 − 0.60) = 80                      # block first
80  × (1 − 0.50) = 40                      # capped %-bucket
40  − min(30, floor(40 × 0.5)=20) = 20     # flat clamped to half the remaining hit
→ 20 damage

Note two caps engaging at once: the % bucket is clamped to 0.5 even though DR + CON + Last Stand would sum to 0.617, and on the blocked path the 30-point flat cut is clamped down to 20 because a single hit can never lose more than half its remaining damage to flat reduction.

Resistances & elemental damage

Elemental damage (fire, cold, lightning, poison) is a separate hit that resolves against the target’s matching resistance only — never DR, block, or the physical bucket:

elementalDamage = rawElementalDamage × (1 − resistance)

resistance = max(0, baseResistance − resistDebuffs − targetResReduction) on the enemy side. Resistance caps (FORMULA_CONFIG):

WhoCap
Hero75% (maxResistance) per element
Enemy95% (maxEnemyResistance)

Debuff/penetration effectiveness is capped at 90% (MAX_DEBUFF_EFFECTIVENESS). Flat elemental affixes are speed-normalised per hit exactly like flat physical damage. Elemental hits can also inflict status effects (values from the Elements help section):

ElementStatusProcDurationEffect
FireBurn (DoT)10%/hit6 s25% of the hit’s fire damage over the duration (ticks every 0.5 s)
ColdFreeze10%/hit, speed-normalised2 s−30% enemy attack speed
LightningStun10%/hit, speed-normalised2 sPauses the enemy attack timer
PoisonPoison (DoT)Always (every hit)6 sFlat poison value over 6 s, reduced by Poison resistance

DoT instances stack (a new burn does not refresh an old one). Physical bleeds (Rend) are unresisted.

Block

  • Chance cap: 75% (maxBlockChance = 0.75) — the maximum block chance regardless of gear or skills. Requires a shield.
  • Damage reduction: 60% (blockDamageReduction = 0.6) — a blocked hit loses 60% of its damage, applied before the percentage bucket.
  • Side effects: thorns deal 150% on a blocked hit, and the Rage-on-Block affix generates rage on a successful block.

Note: the block chance cap is 75% and the block damage reduction is 60% — two different numbers that are easy to conflate.

Crushing Blow

Some weapon affixes and the Crush main skill grant a Crushing Blow chance. On proc, it deals a fraction of the enemy’s current HP as extra damage.

  • Chance is speed-normalised by the weapon’s intrinsic interval (× weaponBaseInterval / 1.0), so fast weapons don’t proc more per second than slow ones. Gear chance + skill (Crush) chance are both normalised, then capped at 0.75 (MAX_CRUSHING_BLOW_CHANCE).
  • If a slow weapon’s scaled chance exceeds the cap, the clipped share overflows into magnitude (a bigger crush, up to ), preserving expected value per second.
  • Block does not reduce Crushing Blow (it is applied with block chance 0).

Fraction of current HP by target rarity:

TargetFraction of current HP
Common12% (CRUSHING_BLOW_HEALTH_FACTOR_BY_RARITY.common)
Rare9%
Special6%
Dungeon / echo boss4% (CRUSHING_BLOW_BOSS_HEALTH_FACTOR)

Higher-rarity foes take a smaller chunk so high-HP bosses aren’t trivialised, while it melts trash.

Life Leech

A fraction of your post-mitigation physical damage is returned as healing (applyLifeLeechFromHit): heal = round(mitigatedDamage × leechFactor).

  • Gear leech cap: 90% (MAX_LIFE_LEECH).
  • Total leech cap: 95% (MAX_TOTAL_LIFE_LEECH) — gear + Bloodlust + Reaver + buffs; the runtime clamp is 0.95.
  • Leech triggers only from the physical weapon hit — never from elemental, crushing blow, or thorns.

Thorns

Two thorn types stack, both dealing physical damage that can kill:

  • Flat thorns — a fixed value returned to the attacker on every hit you take.
  • Reflect thorns — a percentage of incoming raw damage returned.

Both deal 150% on a blocked hit (reflect uses the pre-block raw × 1.5).

Rage (feeds the outgoing multiplier)

Rage powers active skills and drives Wrath. It caps at 100, decays 5/sec out of combat (RAGE_DECAY_PER_SECOND, a full 100-rage bar in 20 s), and each active-skill cast costs 20 (ACTIVE_SKILL_RAGE_COST). Plain attacks grant 10 rage/swing, speed-normalised by the weapon’s own interval (RAGE_REFERENCE_INTERVAL = 1.0) so rage-per-second stays neutral across weapon speeds. Reduced-Rage-Decay affixes cut decay by up to 95% (MAX_RAGE_DECAY_REDUCTION).

Order-of-operations gotchas

  • Enhanced-Damage % scales weapon base only — applied before flat-damage and before Strength. It does nothing for flat or elemental affixes.
  • Flat-Damage is speed-normalised, then added inside the Strength multiplier, so STR scales your flat adds too — and the skill multiplier and Wrath (applied later, at swing time) scale them again.
  • Skill multiplier + Wrath / Apex / Rampage hit the rolled value multiplicatively, after the whole stat panel is built. They are the last physical multipliers.
  • Elemental damage bypasses the skill multiplier and Wrath entirely; it is flat-per-hit and is the only player damage reduced by enemy resistance. Physical to enemies ignores resistance, AR and DR, and enemies never block.
  • AR / DR are hit-chance only. Stacking DR-reduction on an enemy (Sunder/Shatter) raises your hit-rate; it does not amplify each hit.
  • Incoming mitigation order: block (−60%) → %-reduction bucket (DR-curve ≤40% + Reduced-Phys + CON + Last Stand, total capped at 50%) → flat reduction (≤50% of the hit). Order means block multiplies before the cap, and flat is always last.
  • The Hero-tab damage number is optimistic: it adds elemental as raw (no resistance), assumes every swing lands, and omits crushing blow, ignite/bleed/poison DoTs, procs, and conditional passives.

Caps & constants quick reference

ConstantValueSource
Hit chance at AR = DR0.70FORMULA_CONFIG.hitChanceAtEqualRatings
Hit chance curve steepness1.1hitChanceCurveSteepness
Hit chance floor / ceiling0.05 / 0.95minHitChance / maxHitChance
Flat hit-chance cap (gear / total)+30% / +50%MAX_HIT_CHANCE_FLAT_BONUS
Level-difference hit shift (per level / cap)±1% / ±10%levelDeltaHitChancePerLevel / levelDeltaHitChanceMaxShift
Attack-speed modifier cap±0.85ATTACK_SPEED_MODIFIER_CAP
Attack interval final clamp0.45 – 6.0 sMIN_ATTACK_INTERVAL + flow
Block chance cap0.75maxBlockChance
Block damage reduction0.60blockDamageReduction
Shared physical-reduction cap0.50MAX_PHYSICAL_REDUCTION
DR reduction cap / half-value0.40 / 2000DR_MITIGATION_CAP / DR_MITIGATION_HALF_VALUE
Flat reduction max per hit0.50 of hitFLAT_REDUCTION_MAX_FRACTION_OF_HIT
Resistance cap (hero / enemy)0.75 / 0.95maxResistance / maxEnemyResistance
Debuff effectiveness cap0.90MAX_DEBUFF_EFFECTIVENESS
Crushing Blow chance cap0.75MAX_CRUSHING_BLOW_CHANCE
Crushing Blow HP% (common/rare/special/boss)12 / 9 / 6 / 4%crushing-blow factors
Life leech cap (gear / total)0.90 / 0.95MAX_LIFE_LEECH / MAX_TOTAL_LIFE_LEECH
Min stat multiplier floor0.20MIN_STAT_MULTIPLIER
Rage cap / decay / cast cost100 / 5·s⁻¹ / 20RAGE_*

If a number here ever contradicts what the game actually does, the game wins — this is generated from the engine source.