DEV Community

Cover image for The Dark Art of Roguelike Math: Deconstructing Hyperbolic Curves, Proc DAGs, and Floating-Point Traps in TypeScript
Chen Tao
Chen Tao

Posted on

The Dark Art of Roguelike Math: Deconstructing Hyperbolic Curves, Proc DAGs, and Floating-Point Traps in TypeScript

If you've ever played an action roguelike like Risk of Rain 2, you’ve probably experienced this exact moment of betrayal:

You pick up an item—let's say Tougher Times (the squeaking teddy bear). The tooltip cheerily informs you:

"15% (+15% per stack) chance to block incoming damage."

You get lucky. You stack 10 of them. Your elementary school brain does the math:

$$15\% \times 10 = 150\%$$

"I am an immortal god," you whisper to your monitor. Five seconds later, a blazing brass contraption snipes you across the map for 3,000 damage, sending your smoking corpse back to the main menu.

What happened? You didn't encounter a bug. You ran face-first into hyperbolic diminishing returns, coupled with a stealthy proc coefficient subsystem designed specifically to prevent client computers from melting into radioactive slag.

Most web articles talk about web performance, bundle size trimming, or optimizing images. Let's talk about something far more sadistic: how to engineer a deterministic, floating-point-safe simulator for complex game mathematics in TypeScript without causing stack overflows or hallucinated numbers.


1. The "+15% Per Stack" Lie: Linear vs. Hyperbolic Scaling

In game systems, modifiers fall into two broad architectural categories:

  1. Linear ($y = k \cdot n$): Each stack contributes an invariant flat delta. If an item gives $+20$ HP, 5 stacks give $+100$ HP.
  2. Hyperbolic ($y = 1 - \frac{1}{1 + k \cdot n}$): Each stack adds the nominal percentage to a divisor, rather than directly to the probability.

Why do game designers use hyperbolic curves for defensive and control stats? Because if block chance, freeze chance, or cooldown reduction scaled linearly, players would hit $100\%$ saturation. A player with $100\%$ block chance completely breaks the combat loop.

Here is the exact formula governing hyperbolic chance:

$$P(n) = 1 - \frac{1}{1 + k \cdot n}$$

Where:

  • $k$ is the per-stack coefficient (e.g., $0.15$ for Tougher Times).
  • $n$ is the stack count.

Let’s calculate what your 10 teddy bears actually yielded:

$$P(10) = 1 - \frac{1}{1 + (0.15 \times 10)} = 1 - \frac{1}{2.5} = 1 - 0.40 = 60.00\%$$

Instead of $150\%$ invincibility, you had a $40\%$ chance of taking full, unmitigated damage to the face.

The Marginal Utility Collapse

The real trap is the marginal rate of return ($\Delta P$), defined as the first derivative of the curve with respect to stack count $n$:

$$\frac{dP}{dn} = \frac{k}{(1 + k \cdot n)^2}$$

As $n$ climbs, the denominator explodes quadratically. Let's compare the incremental value of early stacks versus late stacks:

Stack ($n$) Total Block Chance Marginal Gain ($\Delta P$) Effective Value Relative to Stack 1
1 13.04% $+13.04\%$ $100\%$ (Baseline)
2 23.08% $+10.04\%$ $77.0\%$
5 42.86% $+5.00\%$ $38.3\%$
10 60.00% $+2.38\%$ 18.3%
20 75.00% $+0.94\%$ 7.2%
50 88.24% $+0.26\%$ 2.0%

By the time you pick up stack #10, that item card is delivering less than a fifth of the real-world value that stack #1 gave you.


2. The IEEE 754 Trap: Why JavaScript Floating-Point Arithmetic Corrupts Breakpoints

When you're building a client-side calculator or simulator, your first instinct is to write this:

// ❌ NAIVE IMPLEMENTATION
export function getHyperbolicChance(perStack: number, stacks: number): number {
  return 1 - 1 / (1 + perStack * stacks);
}
Enter fullscreen mode Exit fullscreen mode

This works fine for a casual text display. But what happens when you build a threshold simulator that computes execute breakpoints (like Old Guillotine, which kills elite monsters below a hyperbolic health threshold)?

Try running this in your Node.js or browser console:

const perStack = 0.13;
const stacks = 3;
console.log(perStack * stacks); 
// Output: 0.39000000000000007

const rawDivisor = 1 + perStack * stacks;
console.log(1 - 1 / rawDivisor);
// Output: 0.2805755395683454
Enter fullscreen mode Exit fullscreen mode

Because modern runtimes use IEEE 754 64-bit binary floating-point numbers, decimals like 0.13 cannot be represented precisely in base-2:
$$0.13_{10} = 0.001000010100011110101110..._2$$

In combat engines compiled in C# or C++, floating-point truncation behaves differently depending on whether registers are operating at 32-bit (float) or 64-bit (double) precision. If your web calculator performs naive floating-point comparisons:

if (monsterHealthPercent <= threshold) {
  triggerInstantKill();
}
Enter fullscreen mode Exit fullscreen mode

A monster with exactly 28.05755% HP might survive or die depending on a floating-point representation error at the 16th decimal place.

The Solution: Scaled Integer Quantization & Exact Rational Numbers

For deterministic web calculations, treat percentages as basis points (bps) or scaled integers until the very last moment of rendering:

export interface Rational {
  readonly num: bigint;
  readonly den: bigint;
}

/**
 * Constructs an exact rational number from integer parts.
 */
export function rational(num: bigint, den: bigint = 1n): Rational {
  if (den === 0n) throw new RangeError('Division by zero in rational construction');
  // Normalize sign
  if (den < 0n) {
    num = -num;
    den = -den;
  }
  return { num, den };
}

/**
 * Computes exact hyperbolic probability: 1 - 1 / (1 + k * n)
 * perStackBps: per-stack value in basis points (e.g., 1500 for 15%)
 */
export function calculateExactHyperbolic(perStackBps: number, stacks: number): {
  readonly probabilityFloat: number;
  readonly basisPoints: number;
  readonly displayPercent: string;
} {
  // k * n in exact rational form
  const knNum = BigInt(perStackBps) * BigInt(stacks);
  const knDen = 10000n; // 100% = 10,000 bps

  // Divisor: 1 + k * n = (knDen + knNum) / knDen
  const divisorNum = knDen + knNum;
  const divisorDen = knDen;

  // 1 / (1 + k * n) = divisorDen / divisorNum
  // Result: 1 - (divisorDen / divisorNum) = (divisorNum - divisorDen) / divisorNum
  const resultNum = divisorNum - divisorDen;
  const resultDen = divisorNum;

  // Convert to Basis Points with rounding
  const bps = Number((resultNum * 10000n + resultDen / 2n) / resultDen);
  const probabilityFloat = Number(resultNum) / Number(resultDen);

  return {
    probabilityFloat,
    basisPoints: bps,
    displayPercent: (bps / 100).toFixed(2),
  };
}
Enter fullscreen mode Exit fullscreen mode

By decoupling the arithmetic from floating-point accumulation, your calculation engine matches the underlying game assembly with zero jitter.


3. The Proc Coefficient Nightmare: Modeling Trigger Chains as a DAG

Now, let's step into the real rabbit hole: Proc Coefficients.

If every item effect had a fixed trigger rate, high-attack-speed weapons would shatter game balance. Consider:

  • A Commando fires a single primary bullet with Proc Coefficient = 1.0.
  • A MUL-T nailgun fires 12 nails per second with Proc Coefficient = 0.6.
  • A Huntress ballista volley fires 3 piercing bolts with Proc Coefficient = 1.0.

The real probability of an on-hit item firing is not $P_{\text{base}}$. It is:

$$P_{\text{effective}} = P_{\text{base}} \times C_{\text{proc}}$$

The Cyclic Trigger Catastrophe

What happens when an item proc causes damage?

  • You shoot an enemy.
  • Ukulele rolls: $25\% \times 1.0 = 25\%$ chance. It succeeds! Lightning arcs to 3 nearby enemies.
  • Each lightning arc deals damage. Because it dealt damage, it gets to roll on-hit items!
  • Enemy #2 gets hit by the lightning arc. Can it trigger an AtG Missile? Yes.
  • The missile flies across the arena and hits Enemy #3. Can it trigger a Sentient Meat Hook? Yes.
  • Can that Meat Hook hook back to Enemy #1 and trigger another Ukulele?

If your event architecture looks like this:

// 💣 ARCHITECTURAL DISASTER: Unbounded cyclic triggers
eventBus.on('damageDealt', (event) => {
  items.forEach((item) => {
    if (rollProc(item, event.procCoefficient)) {
      eventBus.emit('damageDealt', item.createDamageEvent());
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Congratulations: your browser tab just froze, the call stack overflowed with 10,000 recursive frames, and your user's CPU fan is attempting takeoff.

Modeling Proc Chains as a Decaying Directed Acyclic Graph (DAG)

In modern game engines, this cycle is broken using two structural constraints:

  1. Proc Inheritance Decay: Every child proc inherits the parent’s coefficient multiplied by its own: $$C_{\text{child}} = C_{\text{parent}} \times C_{\text{item}}$$ If Ukulele has $C = 0.2$, the child lightning only has an effective proc coefficient of $0.2$. Any item triggered by that lightning rolls against $P_{\text{base}} \times 0.2$.
  2. Terminal Nodes ($C = 0$): Damaging items like Gasoline, Will-o'-the-wisp, and Ceremonial Dagger have a hardcoded proc coefficient of $0.0$. They are terminal sinks in the graph. They cannot trigger downstream on-hit effects.

Here is how we model a deterministic proc chain evaluator in TypeScript using an explicit DAG depth-bound iterator:

export interface ProcNode {
  readonly id: string;
  readonly name: string;
  readonly baseChance: number;      // 0.0 - 1.0
  readonly inherentProcCoeff: number; // Item's own coefficient
  readonly maxChainDepth: number;
}

export interface SimulationStep {
  readonly itemId: string;
  readonly roll: number;
  readonly threshold: number;
  readonly triggered: boolean;
  readonly inheritedCoeff: number;
  readonly depth: number;
}

export class ProcChainSimulator {
  private readonly maxGlobalDepth = 6;

  /**
   * Evaluates a deterministic cascade given an initial attack and PRNG stream.
   */
  public simulateHit(
    items: readonly ProcNode[],
    initialCoeff: number,
    prng: () => number
  ): SimulationStep[] {
    const trace: SimulationStep[] = [];
    const queue: Array<{ coeff: number; depth: number }> = [
      { coeff: initialCoeff, depth: 0 }
    ];

    while (queue.length > 0) {
      const current = queue.shift()!;
      if (current.depth >= this.maxGlobalDepth) continue;
      if (current.coeff <= 0.001) continue; // Energy dissipation threshold

      for (const item of items) {
        if (current.depth >= item.maxChainDepth) continue;

        const effectiveChance = item.baseChance * current.coeff;
        const roll = prng();
        const triggered = roll <= effectiveChance;

        trace.push({
          itemId: item.id,
          roll,
          threshold: effectiveChance,
          triggered,
          inheritedCoeff: current.coeff,
          depth: current.depth,
        });

        // If triggered and item can propagate procs, push child to queue
        if (triggered && item.inherentProcCoeff > 0) {
          queue.push({
            coeff: current.coeff * item.inherentProcCoeff,
            depth: current.depth + 1,
          });
        }
      }
    }

    return trace;
  }
}
Enter fullscreen mode Exit fullscreen mode

With this architecture:

  • Recursion is completely eliminated in favor of a flattened FIFO queue.
  • Infinite loops are structurally impossible due to global depth caps and energy thresholds.
  • Every roll is logged for interactive visual timelines and debugging.

4. The Engineering Reality: Why We Put This on the Web

When we started building the open-source Petrichor Codex, our goal was simple: eliminate the bloat, ads, and inaccurate math that plague traditional game wikis.

Most gaming wikis today are bloated, ad-infested WordPress or MediaWiki farms. They take 8 seconds to load, bombard mobile users with auto-playing video overlays, and display raw, uncurated tables where linear items and hyperbolic items are dumped into the exact same template.

To solve this, we took a radically different architectural approach:

1. Zero-Database, Pure Static Compilation (SSG)

Every survivor skill, item coefficient, and base formula is stored as structured JSON in the repository. At build time, Next.js crawls the single source of truth and bakes canonical math directly into static HTML.

When a search engine or user lands on our Item Stacking Calculator, the initial probability thresholds (13.04% for 1 stack, 60.00% for 10 stacks) are already rendered in the raw HTML payload. There is zero Cumulative Layout Shift (CLS), zero database querying, and zero waiting for JavaScript hydration before the page becomes readable.

2. Client-Side Hydration as an Island

The interactive calculator widgets are isolated client islands. You can scrub through our interactive items database and slide from 1 to 100 stacks, computing real-time marginal curves directly in the browser thread using the exact BigInt/quantized algorithms shown above.

3. CI-Driven Data Drift Audits

Games update constantly. To ensure our formulas never go stale, our build pipeline runs automated drift detectors:

  • When official patch notes drop, scripts compare incoming string listings against our internal math contracts.
  • If a patch alters a per-stack coefficient without an explicit schema migration, the build pipeline fails before bad data can reach production.

5. Key Takeaways for Frontend & Game Engineers

  1. Beware the "Linear Illusion": Never present compounding probabilities to users as a raw linear sum. If an effect diminishes, make the formula explicit, or provide an interactive curve so users understand their actual ROI.
  2. Isolate Float Arithmetic from Game State: Use basis points ($10,000 = 100\%$) or exact rational fractions for game thresholds. Leave floating-point numbers for rendering animations and UI coordinates.
  3. Treat Cascading Events as DAGs: Never emit unattenuated events inside an event listener. Always track inheritance coefficients and depth limits to prevent recursion bombs.
  4. Static First, Interactive Second: Complex mathematical tools don't need heavyweight backend microservices. Pre-render the invariants to static HTML, and let modern client-side JavaScript handle the reactive exploration.

If you want to play with the live equations, check out our interactive implementations at the Petrichor Codex Stacking Calculator or explore the underlying mechanics in the Petrichor Codex.

How do you handle non-linear scaling and cascading event trees in your own frontend or game projects? Let's discuss in the comments below!

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.