DEV Community

Cover image for How We Reverse-Engineered ARPG Damage Formulas With Zero Documentation
Chen Tao
Chen Tao

Posted on

How We Reverse-Engineered ARPG Damage Formulas With Zero Documentation

Most modern action RPGs treat their core math like state secrets.

You equip a new weapon, level up, and see a tooltip that says: "Agility increases attack speed and bleed chance."

Cool. By how much? Does Agility scale linearly, or does it hit a wall at 50 points? Does Arcane multiply your weapon’s base affinity or just slap a flat damage kicker onto hit ticks?

If you’re playing an un-datamined indie title or a hit Roblox ARPG like Dungeon Lootr, there is no official API. No leaked GitHub repos. No official wiki tables. Just thousands of players dumping rare forge materials into sub-optimal builds because nobody actually knows how the damage pipeline works.

Here is how we reverse-engineered the combat formulas, modeled the calculations in TypeScript, and turned raw combat logs into an interactive community tool.


Step 1: Hunting for Ground Truth (The Dummy Test)

Before writing a single line of frontend code, you need empirical baseline points.

The approach is simple:

  1. Strip all armor, titles, and passive modifiers so your character is a blank slate.
  2. Find a static training dummy with zero armor rating.
  3. Record raw damage ticks at fixed stat increments: Base (0), +10, +20, +50, and +100.

When we plotted the numbers for primary damage stats, we immediately saw two distinct mathematical patterns at play:

// Pattern A: Primary Attack Power (Linear with Threshold Scaling)
Raw Damage = BaseWeaponDamage * (1 + (StatPoints * 0.025))

// Pattern B: Secondary Crit & Proc Chance (Asymptotic Diminishing Returns)
Proc Chance = 1 - (1 / (1 + (StatPoints * 0.015)))
Enter fullscreen mode Exit fullscreen mode

Why do games do this? If critical chance scaled linearly at 1% per point, a player could hit 100% crit at level 40 and completely break the combat economy. The asymptotic formula guarantees that the first 20 points feel explosive (reaching ~23%), while pushing from 80 to 100 points only nets an extra 2.5%.


Step 2: The Lua-to-JavaScript Trap (Rounding Order Matters)

Once you know the math, the instinct is to write a one-liner calculation function and call it a day:

// ❌ WRONG: Don't do this
export function calculateDamage(base: number, stat: number, multiplier: number) {
  return Math.round(base * (1 + stat * 0.025) * multiplier);
}
Enter fullscreen mode Exit fullscreen mode

If you do this, your numbers will drift from actual in-game values by 2 to 5 points. Why? Because the underlying game engine (in this case, Luau on Roblox) does pipeline truncation, not end-of-chain rounding.

In game logic, damage calculation is usually phased:

  1. Base weapon roll is floored.
  2. Stat scalar is multiplied and floored.
  3. Multipliers (Aspect traits, temporary buffs) are applied sequentially, each truncated to integer values.

Here is the typed pipeline implementation we landed on:

export interface StatAllocation {
  strength: number;
  agility: number;
  arcane: number;
  vitality: number;
}

export interface WeaponProfile {
  name: string;
  baseDamage: number;
  primaryScaling: 'strength' | 'agility' | 'arcane';
  forgeLevel: number;
}

export function computeCombatOutput(
  weapon: WeaponProfile,
  stats: StatAllocation,
  passiveMultiplier = 1.0
): { dps: number; critRate: number; effectiveHealth: number } {
  const primaryStatValue = stats[weapon.primaryScaling];

  // 1. Forge Level Compound Bonus (3% per tier, compounded)
  const forgeMultiplier = Math.pow(1.03, weapon.forgeLevel);
  const forgedBase = Math.floor(weapon.baseDamage * forgeMultiplier);

  // 2. Stat Affinity Scaling (2.5% per point)
  const statBonus = Math.floor(forgedBase * (primaryStatValue * 0.025));
  const rawHit = (forgedBase + statBonus) * passiveMultiplier;

  // 3. Asymptotic Crit Chance Calculation (Soft cap around 65%)
  const rawCrit = 1 - (1 / (1 + (stats.agility * 0.018)));
  const cappedCrit = Math.min(Number((rawCrit * 100).toFixed(1)), 75.0);

  // 4. Effective HP (Vitality provides flat HP + passive mitigation)
  const maxHp = 100 + stats.vitality * 12;
  const mitigation = stats.vitality / (stats.vitality + 150);
  const ehp = Math.floor(maxHp / (1 - mitigation));

  return {
    dps: Math.floor(rawHit),
    critRate: cappedCrit,
    effectiveHealth: ehp,
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice forgeMultiplier. A common mistake is assuming forge bonuses are additive (1 + tier * 0.03). In practice, each forge hammer strike calculates off the previous tier's value. At Forge +10, additive math gives +30%, but compounded math gives +34.39%. In an ARPG, that 4.4% difference makes or breaks an endgame build.


Step 3: Pure State Derivation (Ditching Input Lag)

When building an interactive calculator, the naive React approach is to keep every input in its own state:

// ⚠️ State chaos waiting to happen
const [strength, setStrength] = useState(0);
const [agility, setAgility] = useState(0);
const [forgeTier, setForgeTier] = useState(0);
const [dps, setDps] = useState(0); // Syncing this in useEffect is a nightmare
Enter fullscreen mode Exit fullscreen mode

If you manage stats this way, you inevitably run into desync bugs where sliders lag behind user input, or state updates trigger re-render cascades across sibling components.

Instead, treat all combat statistics as a pure derived projection:

export function useCharacterBuild() {
  const [allocation, setAllocation] = useState<StatAllocation>({
    strength: 10,
    agility: 10,
    arcane: 10,
    vitality: 10,
  });

  const [weapon, setWeapon] = useState<WeaponProfile>({
    name: 'Obsidian Greatsword',
    baseDamage: 120,
    primaryScaling: 'strength',
    forgeLevel: 5,
  });

  // Derived synchronously during render — zero useEffect needed
  const combatStats = useMemo(() => {
    return computeCombatOutput(weapon, allocation);
  }, [weapon, allocation]);

  return {
    allocation,
    setAllocation,
    weapon,
    setWeapon,
    combatStats,
  };
}
Enter fullscreen mode Exit fullscreen mode

When someone drags a slider from 10 to 60 points, the computation executes in under 0.1ms. The UI remains pinned at 60fps even on lower-end mobile devices without a single external state library.


Putting It in Front of Real Players

We packaged this engine into the Dungeon Lootr Wiki as a full-fledged interactive stat and forge calculator.

Instead of burning thousands of gold coins and rare iron ingots in-game just to see if a re-spec works, players can preview exact damage curves, compare class scaling archetypes, and verify forge breakpoints before touching their inventory.

Key Takeaways

  1. Don’t wait for official APIs: If an indie or multiplayer game has numbers floating on screen, you have enough data to deduce the underlying curve.
  2. Watch your rounding boundaries: Game engines truncate at specific pipeline gates. If your formula is off by a constant offset, check whether integers are cast before or after multipliers.
  3. Derived state beats synchronized state: For calculators and configuration tools, keep inputs raw and derive outputs synchronously during render. Your code stays clean, and your UI stays snappy.

Top comments (0)