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:
- Strip all armor, titles, and passive modifiers so your character is a blank slate.
- Find a static training dummy with zero armor rating.
- 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)))
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);
}
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:
- Base weapon roll is floored.
- Stat scalar is multiplied and floored.
- 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,
};
}
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
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,
};
}
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
- 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.
- 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.
- 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)