Neon Royale

The Mathematics of Bonus Rounds — Calculating EV for Free Spins and Pick Bonus Games

Neon AdminNeon Admin·Mar 14, 2026
The Mathematics of Bonus Rounds — Calculating EV for Free Spins and Pick Bonus Games

Introduction

A slot's bonus round is its mathematical centrepiece. In a typical medium-volatility game, the bonus feature contributes 35–45% of total RTP while triggering on fewer than 1% of spins. In high-volatility titles, that figure climbs to 60–75%. The entire player experience — the session arc, the anticipation, the emotional peak — converges on this single event.

And yet, bonus round mathematics is where PAR Sheet errors are most common, most consequential, and most difficult to detect through simple simulation. The interaction of retrigger probabilities, progressive multipliers, enhanced reel configurations, and conditional mechanics creates a combinatorial complexity that cannot be handled by intuition alone. A 1% error in a bonus EV calculation that contributes 40% of total RTP means the game ships with a 0.4% RTP error — enough to fail certification or, worse, to cost an operator real money at scale before anyone notices.

This article is the complete mathematical treatment of bonus round EV. We start from first principles — what Expected Value means in a bonus context — and build up through every major bonus structure used in commercial slot development: basic Free Spins, Free Spins with retriggers, progressive multiplier Free Spins, expanding reel systems, cascading bonus mechanics, and Pick Bonus games in all their variants.

Every formula comes with a derivation. Every derivation comes with a C# implementation. Every implementation comes with a verification strategy.


Part I. Expected Value — First Principles

1.1 What EV Means in a Bonus Context

The Expected Value (EV) of a bonus round is the average total payout it produces, expressed as a multiple of the triggering spin's total bet.

EV(bonus) = Σ_outcomes [P(outcome) × payout(outcome)]

Where outcomes includes every possible sequence of spins that can occur within the bonus, every possible win on every spin, and every possible retrigger chain.

EV has two critical properties that make it the correct tool for bonus analysis:

Linearity: EV(A + B) = EV(A) + EV(B). The EV of a sequence of independent events is the sum of their individual EVs. This means you can decompose a complex bonus into simpler components, compute each independently, and sum them.

Bet-independence: EV expressed as a multiple of bet is invariant to bet size. A bonus with EV = 55× returns 55 times the triggering bet regardless of whether that bet was €0.20 or €100.

1.2 EV and RTP: The Connection

The contribution of a bonus feature to total RTP is:

RTP_bonus = P(trigger) × EV(bonus)

Where P(trigger) is the probability of triggering the bonus on any given base game spin, and EV(bonus) is the expected payout multiple.

This is the master equation that governs all bonus design. If you fix a target RTP_bonus (e.g., 38% of total RTP), you have infinite combinations of trigger probability and EV that satisfy it:

P(trigger) × EV(bonus) = 0.38

P = 1/145,  EV = 55.1×   ← medium frequency, large bonus
P = 1/80,   EV = 30.4×   ← frequent, moderate bonus
P = 1/300,  EV = 114×    ← rare, massive bonus

Each choice produces a dramatically different player experience at identical total RTP. This is the fundamental design decision in bonus round architecture.

1.3 The EV Decomposition Principle

For any bonus structure, EV can be decomposed as:

EV(bonus) = EV(guaranteed base payouts)
           + EV(win events inside bonus)
           + EV(retrigger chain)
           + EV(special mechanics: multipliers, wilds, expansions)

Each term can be calculated independently, then summed. This decomposition is the key to handling complex bonus structures without combinatorial explosion.


Part II. Free Spins EV — Building from Scratch

2.1 The Simplest Case: Fixed Free Spins, No Retrigger, No Multiplier

A bonus that awards exactly N free spins, each played at the same RTP as the base game, with no special mechanics.

EV(simple FS) = N × RTP_base_per_spin × bet
              = N × RTP_base

If N = 10 and RTP_base = 58%:

EV(10 simple FS) = 10 × 0.58 = 5.8× bet

This is trivially simple — and almost never what a real bonus looks like. But it's the foundation.

2.2 Free Spins with Enhanced Reels

Most Free Spins use dedicated reel strips with higher Wild and Premium symbol counts. The enhanced reels produce a different (higher) RTP per spin.

EV(enhanced FS) = N × RTP_freeSpins_per_spin

Where RTP_freeSpins_per_spin is calculated using the Free Spins reel strips, not the base game strips. If the Free Spins reels produce RTP_FS = 72% per spin (due to more Wilds):

EV(10 enhanced FS) = 10 × 0.72 = 7.2× bet

The critical point: you must compute RTP_freeSpins separately using the actual FS reel strips. You cannot estimate it as a multiplied version of base RTP. The relationship between base and FS reel configurations is non-linear due to Wild interaction effects.

2.3 Free Spins with a Constant Multiplier

A constant multiplier M applies to all wins during Free Spins:

EV(FS with multiplier M) = N × RTP_freeSpins × M

If N = 10, RTP_FS = 72%, M = 2:

EV = 10 × 0.72 × 2 = 14.4× bet

For 25 spins at ×2.5 multiplier:

EV = 25 × 0.72 × 2.5 = 45.0× bet

2.4 Free Spins with Retriggers — The Recurrence Relation

When 3+ Scatters during Free Spins award additional free spins (a retrigger), the EV calculation becomes recursive.

Let:

n = initial free spins awarded

r = free spins awarded per retrigger

p_r = probability of triggering a retrigger on any single free spin

RTP_FS = base RTP per free spin (from FS reels)

M = multiplier

The retrigger probability over the entire free spins block depends on whether we're counting "at least one retrigger in n spins" or treating each spin independently. The correct model is to track the total expected spins played, including all retrigger chains.

Geometric series model (each spin independently has probability p_r of restriggering):

This is a simplification that treats each free spin as independently capable of triggering another block of r spins. Real games typically trigger retriggers when 3+ Scatters appear on any single spin, so p_r is the per-spin Scatter trigger probability.

Let E = expected total free spins played.

Each free spin has probability p_r of generating r additional spins. Those additional spins each also have probability p_r of generating more, ad infinitum.

E = n + E_retrigger

E_retrigger = n × p_r × (r + E_retrigger_from_r)

This infinite series converges because p_r < 1. The closed-form solution:

E[total spins] = n / (1 - p_r × r/n)

Wait — this isn't quite right. Let's be more careful.

The correct formulation: each free spin played generates, in expectation, p_r × r additional free spins. Each of those generates p_r × r more, and so on.

E[total spins] = n × Σ_{k=0}^{∞} (p_r)^k
              = n × 1/(1 - p_r)     [geometric series, valid when p_r < 1]

Wait — this counts each spin as independently capable of spawning another entire block. The real model is:

Each block of n free spins, as a whole, has probability P_block of generating a retrigger. The probability of at least one retrigger occurring during n spins:

P_block = 1 - (1 - p_r)^n

And EV with retriggers:

EV(FS with retriggers) = EV(1 block) / (1 - P_block × EV_ratio)

Where EV_ratio = r/n (the size of each retrigger relative to the initial block).

More rigorously, using the expected total spins model:

E[total spins] = n + P_block × r + P_block² × r + ...
              = n + P_block × r × 1/(1 - P_block)
              = n + P_block × r / (1 - P_block)

For n = 10, r = 10, p_r = 0.006893 (per-spin scatter probability):

P_block = 1 - (1 - 0.006893)^10 = 1 - 0.9327 = 0.0673

E[total spins] = 10 + 0.0673 × 10 / (1 - 0.0673)
              = 10 + 0.673 / 0.9327
              = 10 + 0.721
              = 10.72 spins

Full EV:

EV(FS with retriggers) = E[total spins] × RTP_FS × M
                       = 10.72 × 0.72 × 2.5
                       = 19.3× bet

For n = 25, r = 10:

P_block = 1 - (1 - 0.006893)^25 = 1 - 0.8389 = 0.1611

E[total spins] = 25 + 0.1611 × 10 / (1 - 0.1611)
              = 25 + 1.611 / 0.8389
              = 25 + 1.92
              = 26.92 spins

EV = 26.92 × 0.72 × 2.5 = 48.5× bet

2.5 The Retrigger Tree: Exact Calculation

The geometric series model above gives a good approximation, but it doesn't capture the exact distribution — which matters for variance calculation and for cases where the retrigger changes the spin count in a non-linear way.

The exact approach is to model the bonus as a Markov chain where the state is "spins remaining."

State: s = number of free spins remaining
Transition: from state s:
  - With probability (1 - p_r): go to state s-1 (spin plays, no retrigger)
  - With probability p_r:       go to state s-1+r (spin plays, retrigger adds r spins)
  - State 0: bonus ends

EV(state s) = RTP_FS × M + (1 - p_r) × EV(state s-1) + p_r × EV(state s-1+r)

This recurrence defines EV(s) for all s. We can solve it iteratively:

/// <summary>
/// Computes exact EV for Free Spins with retriggers using dynamic programming.
/// Models the Markov chain of spins-remaining states.
/// </summary>
public static double ComputeFreeSpisEV(
    int    initialSpins,
    int    retriggerSpins,
    double rtpPerSpin,
    double multiplier,
    double retriggerProbPerSpin,
    int    maxSpinsToModel = 500)
{
    // dp[s] = expected total win from state "s spins remaining"
    var dp = new double[maxSpinsToModel + 1];
    dp[0] = 0.0;  // base case: no spins remaining → no win

    // Build up from s=1 to s=maxSpinsToModel
    for (int s = 1; s <= maxSpinsToModel; s++)
    {
        double winThisSpin = rtpPerSpin * multiplier;  // expected win from the current spin

        // After this spin: s-1 spins remain (no retrigger)
        double noRetrigger = (1.0 - retriggerProbPerSpin) * dp[s - 1];

        // After this spin: s-1+retriggerSpins remain (retrigger fires)
        int    nextStateRetrigger = Math.Min(s - 1 + retriggerSpins, maxSpinsToModel);
        double withRetrigger = retriggerProbPerSpin * dp[nextStateRetrigger];

        dp[s] = winThisSpin + noRetrigger + withRetrigger;
    }

    return dp[initialSpins];
}

// Usage:
double ev = ComputeFreeSpisEV(
    initialSpins:           10,
    retriggerSpins:         10,
    rtpPerSpin:             0.72,
    multiplier:             2.5,
    retriggerProbPerSpin:   0.006893
);
// Result: EV ≈ 19.3× bet  (matches closed-form approximation)

The DP approach also naturally gives you the full distribution of bonus payouts, not just the mean — critical for variance calculation.

/// <summary>
/// Full distribution of Free Spins payout via Monte Carlo simulation.
/// Returns histogram of payout multiples.
/// Complements the exact DP mean with variance information.
/// </summary>
public static FreeSpisDistribution SimulateFreeSpinsDistribution(
    int    initialSpins,
    int    retriggerSpins,
    double rtpPerSpin,
    double stdDevPerSpin,     // Standard deviation of win per spin
    double multiplier,
    double retriggerProbPerSpin,
    int    simulations = 1_000_000)
{
    var payouts = new List<double>(simulations);

    for (int sim = 0; sim < simulations; sim++)
    {
        int    spinsRemaining = initialSpins;
        double totalWin       = 0.0;

        while (spinsRemaining > 0)
        {
            spinsRemaining--;

            // Simulate single spin win (log-normal approximation for right-skewed wins)
            double spinWin = SimulateSingleSpinWin(rtpPerSpin, stdDevPerSpin);
            totalWin += spinWin * multiplier;

            // Check for retrigger
            if (RandomNumberGenerator.GetInt32(1_000_000) < (int)(retriggerProbPerSpin * 1_000_000))
                spinsRemaining += retriggerSpins;

            // Safety cap to prevent infinite loops on extreme retrigger runs
            spinsRemaining = Math.Min(spinsRemaining, 500);
        }

        payouts.Add(totalWin);
    }

    payouts.Sort();

    return new FreeSpisDistribution(
        Mean:         payouts.Average(),
        Median:       payouts[(int)(simulations * 0.50)],
        Percentile75: payouts[(int)(simulations * 0.75)],
        Percentile90: payouts[(int)(simulations * 0.90)],
        Percentile95: payouts[(int)(simulations * 0.95)],
        Percentile99: payouts[(int)(simulations * 0.99)],
        Max:          payouts[^1],
        Variance:     payouts.Select(p => Math.Pow(p - payouts.Average(), 2)).Average(),
        Histogram:    BuildHistogram(payouts)
    );
}

private static double SimulateSingleSpinWin(double mean, double stdDev)
{
    // Log-normal approximation: slot spin wins are right-skewed
    // Most spins: small or zero. Rare spins: very large.
    double u1 = (RandomNumberGenerator.GetInt32(1_000_000) + 0.5) / 1_000_000.0;
    double u2 = (RandomNumberGenerator.GetInt32(1_000_000) + 0.5) / 1_000_000.0;
    double z  = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
    return Math.Max(0, mean + stdDev * z);
}

public record FreeSpisDistribution(
    double Mean, double Median,
    double Percentile75, double Percentile90,
    double Percentile95, double Percentile99,
    double Max, double Variance,
    Dictionary<string, int> Histogram
)
{
    public void Print()
    {
        Console.WriteLine($"  EV (mean):          {Mean:F3}× bet");
        Console.WriteLine($"  Median:             {Median:F3}× bet");
        Console.WriteLine($"  75th percentile:    {Percentile75:F3}× bet");
        Console.WriteLine($"  90th percentile:    {Percentile90:F3}× bet");
        Console.WriteLine($"  95th percentile:    {Percentile95:F3}× bet");
        Console.WriteLine($"  99th percentile:    {Percentile99:F3}× bet");
        Console.WriteLine($"  Maximum observed:   {Max:F3}× bet");
        Console.WriteLine($"  Std Dev:            {Math.Sqrt(Variance):F3}× bet");
    }
}

Part III. Progressive Multiplier Systems

3.1 Multiplier-per-Cascade (Gonzo's Quest Style)

In many modern slots, particularly those with Cascade mechanics, the multiplier increases with each consecutive cascade within a single spin or across Free Spins. The classic example: Gonzo's Quest applies ×1, ×2, ×3, ×5, ×8, ×15 multipliers to cascades 1–6+.

The EV of a cascade-based multiplier system:

EV(cascade multiplier) = Σ_{c=1}^{maxCascades} [P(reaching cascade c) × E(win at cascade c) × M(c)]

Where:

P(reaching cascade c) = probability that the spin produces at least c cascades

E(win at cascade c) = expected win from the c-th cascade alone

M(c) = multiplier applied to cascade c

/// <summary>
/// Computes EV for a cascade-based progressive multiplier system.
/// </summary>
public static double ComputeCascadeMultiplierEV(
    double[] cascadeProbabilities,  // [0] = P(at least 1 cascade), [1] = P(at least 2), etc.
    double   winPerCascade,         // Expected win per cascade (as multiple of bet)
    int[]    multipliers)           // multipliers[i] = multiplier for cascade i+1
{
    double totalEV = 0.0;

    // The probability of reaching exactly cascade c is:
    // P(exactly c) = P(at least c) - P(at least c+1)
    // But we need P(at least c) for "win at cascade c" calculation

    for (int c = 0; c < multipliers.Length && c < cascadeProbabilities.Length; c++)
    {
        double pReach  = cascadeProbabilities[c];
        double evAtC   = pReach * winPerCascade * multipliers[c];
        totalEV += evAtC;
    }

    return totalEV;
}

// Example: Gonzo-style multipliers in Free Spins (×3, ×6, ×9, ×15, ×15...)
// where cascade probabilities come from PAR sheet simulation
double[] pCascades = { 0.35, 0.12, 0.042, 0.015, 0.005, 0.002 };
int[]    fsMults   = { 3, 6, 9, 15, 15, 15 };  // FS multipliers are triple base
double   winPerCascade = 0.28;  // average win per cascade as multiple of bet

double gonzoFsEV = ComputeCascadeMultiplierEV(pCascades, winPerCascade, fsMults);
// Returns: EV of the cascade multiplier contribution

3.2 Accumulating Multiplier Free Spins (Sticky Multiplier)

Some games accumulate a multiplier that persists across all Free Spins and grows with each cascade or win event. The multiplier at the end of the bonus is the product of all accumulated increments.

Model: multiplier starts at 1×. Each cascade adds +1 to the multiplier. Expected multiplier after the bonus:

E[multiplier] = 1 + E[total cascades] × increment_per_cascade

Where E[total cascades] = sum across all free spins of the expected cascade count per spin.

public static double ComputeAccumulatingMultiplierEV(
    int    freeSpins,
    double baseRtpPerSpin,          // RTP per spin without multiplier
    double cascadeProbPerSpin,      // P(at least one cascade per spin)
    double avgCascadesGivenCascade, // Average cascades when at least one occurs
    double multiplierIncrement,     // Amount added to multiplier per cascade
    double retriggerProb,
    int    retriggerSpins)
{
    // Expected cascades per spin
    double eCascadesPerSpin = cascadeProbPerSpin * avgCascadesGivenCascade;

    // Expected total spins played (including retriggers)
    double pBlock = 1.0 - Math.Pow(1.0 - retriggerProb, freeSpins);
    double eTotalSpins = freeSpins + pBlock * retriggerSpins / (1.0 - pBlock);

    // Expected total multiplier increments
    double eTotalIncrements = eTotalSpins * eCascadesPerSpin;

    // Expected final multiplier (accumulates additively throughout session)
    // For a win on spin k with k cascades accumulated before it:
    // This requires integrating over the multiplier at the time of each win
    // Simplified: use average multiplier ≈ 1 + (totalIncrements / 2)
    // (assumes multiplier grows linearly — reasonable approximation)
    double avgMultiplier = 1.0 + (eTotalIncrements / 2.0) * multiplierIncrement;

    // Total EV
    double totalEV = eTotalSpins * baseRtpPerSpin * avgMultiplier;

    return totalEV;
}

3.3 Multiplicative Multiplier Chains

Some mechanics multiply multipliers together rather than adding them. A spin landing on a ×2 multiplier Wild and a ×3 multiplier Wild simultaneously gives ×6, not ×5.

For two independent multiplier events with probabilities p1 and p2 and values M1 and M2:

E[combined multiplier] = E[M1] × E[M2]   [if independent]

Where:
E[M1] = (1 - p1) × 1 + p1 × M1  = 1 + p1 × (M1 - 1)
E[M2] = (1 - p2) × 1 + p2 × M2  = 1 + p2 × (M2 - 1)

E[M1 × M2] = (1 + p1(M1-1)) × (1 + p2(M2-1))
           = 1 + p1(M1-1) + p2(M2-1) + p1·p2·(M1-1)(M2-1)

The interaction term p1·p2·(M1-1)(M2-1) can be significant when multiplier values are large — this is what drives the extreme upside in games like Money Train 2's "Payer" symbols.


Part IV. Scatter-Pays and Bonus Buy EV

4.1 Bonus Buy EV

Many modern games allow players to directly purchase the bonus feature for a fixed cost (e.g., 100× total bet). The Bonus Buy cost must equal the EV of the bonus for the feature to be fair:

Fair bonus buy price = EV(bonus) / P_FS(1 spin)

Wait — more precisely:

Bonus buy gives guaranteed access to one bonus round.
Fair price = EV(bonus)  [in multiples of the standard bet]

If EV(bonus) = 55× bet and the operator sets the bonus buy price at 80× bet, the Bonus Buy RTP is:

RTP_bonus_buy = EV(bonus) / price = 55 / 80 = 68.75%

This is deliberately lower than the standard game RTP (96%). Bonus Buy features in most jurisdictions are considered separate game variants and can have different (lower) RTPs. However, they must be disclosed separately to regulators and players.

For games targeting UK or German markets, Bonus Buy may be prohibited entirely — verify regulatory requirements before implementing.

4.2 The Fair Bonus Buy Calculation

public static void AnalyseBonusBuy(
    double bonusEV,          // EV of bonus in multiples of bet
    double standardRtp,      // Standard game RTP (e.g. 0.96)
    double bonusTriggerRate, // P(bonus on any base spin)
    double[] proposedPrices) // Proposed bonus buy prices in multiples of bet
{
    Console.WriteLine("BONUS BUY ANALYSIS");
    Console.WriteLine($"  Bonus EV:        {bonusEV:F2}× bet");
    Console.WriteLine($"  Standard RTP:    {standardRtp * 100:F2}%");
    Console.WriteLine($"  Trigger rate:    1 in {1.0/bonusTriggerRate:F0} spins");
    Console.WriteLine();

    // The "value of waiting" for a natural trigger
    // Cost to naturally reach next bonus = 1/P(trigger) spins × 1 bet each
    double naturalCost = 1.0 / bonusTriggerRate;  // in bets
    // Expected win while waiting (base game spins)
    double winWhileWaiting = naturalCost * standardRtp;
    // Net cost to reach bonus naturally
    double netNaturalCost = naturalCost - winWhileWaiting;

    Console.WriteLine($"  Natural path to bonus:");
    Console.WriteLine($"    Avg base spins needed: {naturalCost:F0}");
    Console.WriteLine($"    Avg wins while waiting: {winWhileWaiting:F2}× bet");
    Console.WriteLine($"    Net cost to reach bonus: {netNaturalCost:F2}× bet");
    Console.WriteLine();
    Console.WriteLine("  Bonus buy price analysis:");
    Console.WriteLine($"  {'Price',-12} {'BB RTP',-12} {'vs Natural',-16} {'Verdict',-12}");
    Console.WriteLine($"  {new string('-', 52)}");

    foreach (double price in proposedPrices)
    {
        double bbRtp    = bonusEV / price;
        double vsNat    = price - netNaturalCost;
        string verdict  = bbRtp >= 0.90 ? "✓ Reasonable"
                        : bbRtp >= 0.80 ? "△ Acceptable"
                        : "✗ Too expensive";

        Console.WriteLine($"  {price,6:F0}× bet    " +
                          $"{bbRtp * 100,7:F2}%      " +
                          $"{vsNat,+10:F2}× bet     " +
                          $"{verdict}");
    }
}

// Example output:
// BONUS BUY ANALYSIS
//   Bonus EV:        55.10× bet
//   Standard RTP:    96.00%
//   Trigger rate:    1 in 145 spins
//
//   Natural path to bonus:
//     Avg base spins needed: 145
//     Avg wins while waiting: 139.20× bet
//     Net cost to reach bonus: 5.80× bet
//
//   Bonus buy price analysis:
//   Price        BB RTP      vs Natural      Verdict
//   ────────────────────────────────────────────────────
//    55× bet       100.00%       +49.20× bet    ✓ Reasonable
//    75× bet        73.47%       +69.20× bet    ✗ Too expensive
//    85× bet        64.82%       +79.20× bet    ✗ Too expensive
//   100× bet        55.10%       +94.20× bet    ✗ Too expensive

Part V. Pick Bonus EV

5.1 What Is a Pick Bonus?

A Pick Bonus (also called a Pick-and-Click or Picker bonus) is a bonus round where the player selects from a set of hidden symbols to reveal prizes. The prizes may be fixed cash amounts, multipliers, additional picks, "collect" triggers, or "poison" outcomes that end the round.

Pick bonuses introduce a new dimension not present in Free Spins: player agency (or the illusion of it — the outcome is predetermined by the RNG, but the player's choice sequence affects which predetermined outcome they reveal in which order). For EV purposes, genuine player agency doesn't exist: all pick arrangements that the RNG generates are equally likely to be selected, so the player's choice is irrelevant to the EV calculation.

5.2 The Simplest Pick Bonus: N Picks from M Options

N picks from M options, each revealing an independent prize drawn from a distribution:

EV(simple pick) = N × E[single pick prize]
               = N × Σ_prizes [P(prize) × value(prize)]

Example: 3 picks from 9 options. Prize distribution:

Prize

Count

Probability

Value

5× bet

3

3/9 = 33.3%

5

10× bet

3

3/9 = 33.3%

10

20× bet

2

2/9 = 22.2%

20

50× bet

1

1/9 = 11.1%

50

E[single pick] = 0.333 × 5 + 0.333 × 10 + 0.222 × 20 + 0.111 × 50
              = 1.667 + 3.333 + 4.444 + 5.556
              = 15.0× bet

EV(3 picks) = 3 × 15.0 = 45.0× bet

This is straightforward when picks are with replacement (each selection is independent of previous ones). Real pick bonuses are almost always without replacement — once you've picked a prize, it's gone.

5.3 Pick Without Replacement: Hypergeometric EV

When picking without replacement, the expected value of each subsequent pick changes based on what's already been revealed.

For N picks from M items without replacement:

E[sum of N picks without replacement] = N × (sum of all values) / M

This is a beautiful result: the expected sum of N picks without replacement equals N times the average value of all M items. The order of selection doesn't affect EV — it only affects variance.

Proof sketch: by linearity of expectation, each item has probability N/M of being selected. So:

E[sum] = Σ_{i=1}^{M} (N/M) × value_i = N/M × Σ value_i = N × average(value)

For our example:

Sum of all values = 3×5 + 3×10 + 2×20 + 1×50 = 15 + 30 + 40 + 50 = 135
Average = 135 / 9 = 15.0× bet

EV(3 picks without replacement) = 3 × 15.0 = 45.0× bet

Same result as with replacement in this case! This is always true: the EV of N picks without replacement equals N/M × sum of all values. The distribution (variance) differs, but the mean is identical.

5.4 Tiered Pick Bonus: Advance and Collect

A more complex structure: picks reveal either a prize (collect and continue) or an "advance" token (proceed to next tier with higher prizes). The player keeps picking until they hit a "collect" or exhaust picks.

Tier 1: 5 options → 3× prizes, 2× advance tokens
         If advance: enter Tier 2
Tier 2: 5 options → 4× prizes (higher value), 1× collect token
         Collecting ends the round with a bonus multiplier

EV calculation using the decision tree:

P(advance from Tier 1) = 2/5 = 40%
P(take prize in Tier 1) = 3/5 = 60%

EV(Tier 1 prize pick) = 15×    [from prize distribution]
EV(Tier 2) = [separate calculation]

EV(pick bonus) = P(stay Tier 1) × EV(Tier 1 prize)
              + P(advance) × EV(Tier 2)
              = 0.60 × 15 + 0.40 × EV(Tier 2)

/// <summary>
/// Computes EV for a tiered pick bonus using recursive tree traversal.
/// Each tier is defined by its prize distribution and advancement probability.
/// </summary>
public class TieredPickBonus
{
    public record PickTier(
        string Name,
        PickOutcome[] Outcomes  // All possible outcomes for one pick
    );

    public record PickOutcome(
        string  Type,           // "prize", "advance", "collect", "poison"
        double  Probability,
        double  Value,          // For prize/collect: win amount; for advance: 0
        int     NextTierIndex   // For advance: which tier to go to; else -1
    );

    private readonly PickTier[] _tiers;

    public TieredPickBonus(PickTier[] tiers) => _tiers = tiers;

    /// <summary>
    /// Computes the EV of starting the pick bonus at a given tier.
    /// Handles recursive advancement between tiers.
    /// </summary>
    public double ComputeEV(int tierIndex = 0, double accumulatedWin = 0.0)
    {
        if (tierIndex >= _tiers.Length) return accumulatedWin;

        var tier  = _tiers[tierIndex];
        double ev = 0.0;

        foreach (var outcome in tier.Outcomes)
        {
            double contribution = outcome.Type switch
            {
                "prize" =>
                    // Take the prize and end (or continue to next pick in same tier)
                    outcome.Probability * (accumulatedWin + outcome.Value),

                "advance" =>
                    // Move to next tier, carrying accumulated win
                    outcome.Probability * ComputeEV(outcome.NextTierIndex, accumulatedWin),

                "collect" =>
                    // Apply bonus multiplier to accumulated win
                    outcome.Probability * (accumulatedWin + outcome.Value) * 2.0,  // 2× collect bonus

                "poison" =>
                    // Lose accumulated win (end with zero or minimum)
                    outcome.Probability * Math.Max(accumulatedWin * 0.5, 5.0),  // keep 50% or 5× min

                _ => throw new ArgumentException($"Unknown outcome type: {outcome.Type}")
            };

            ev += contribution;
        }

        return ev;
    }
}

// Example setup:
var pickBonus = new TieredPickBonus(new[]
{
    new TieredPickBonus.PickTier("Tier 1 — Bronze", new[]
    {
        new TieredPickBonus.PickOutcome("prize",   0.40, 10.0, -1),
        new TieredPickBonus.PickOutcome("prize",   0.20, 20.0, -1),
        new TieredPickBonus.PickOutcome("advance", 0.30, 0.0,  1),   // → Tier 2
        new TieredPickBonus.PickOutcome("poison",  0.10, 0.0, -1),
    }),
    new TieredPickBonus.PickTier("Tier 2 — Silver", new[]
    {
        new TieredPickBonus.PickOutcome("prize",   0.35, 40.0, -1),
        new TieredPickBonus.PickOutcome("prize",   0.25, 60.0, -1),
        new TieredPickBonus.PickOutcome("advance", 0.25, 0.0,  2),   // → Tier 3
        new TieredPickBonus.PickOutcome("poison",  0.15, 0.0, -1),
    }),
    new TieredPickBonus.PickTier("Tier 3 — Gold", new[]
    {
        new TieredPickBonus.PickOutcome("prize",   0.50, 100.0, -1),
        new TieredPickBonus.PickOutcome("collect", 0.30, 50.0,  -1),  // +50 + 2× multiplier
        new TieredPickBonus.PickOutcome("prize",   0.20, 200.0, -1),
    }),
});

double totalEV = pickBonus.ComputeEV();
Console.WriteLine($"Pick bonus EV: {totalEV:F2}× bet");

5.5 Pick Bonus with Poison Symbols

"Poison" or "skull" outcomes end the bonus immediately with a reduced or zero payout. Calculating their impact:

Let G = number of "good" outcomes (prizes) from M total options. Let K = number of "poison" outcomes. Let N = number of picks the player gets (or until poison hit).

Expected picks before poison hit:

E[picks before poison] = M / K
                       (geometric: pick until first failure)

Exact EV with poison:

The player picks one at a time. Each pick has:

P(prize) = G/M (for remaining G prizes from remaining M items)

P(poison) = K/M (immediately ends round)

For a single-pick bonus that ends on poison:

public static double ComputePoisonPickEV(
    int    goodCount,    // Number of prize symbols
    int    poisonCount,  // Number of poison symbols
    double avgGoodValue, // Average prize value
    double poisonPayout) // What player receives when hitting poison
{
    int    total = goodCount + poisonCount;
    double ev    = 0.0;

    // Probability of picking k good outcomes before hitting poison
    // Uses negative hypergeometric distribution
    for (int k = 0; k <= goodCount; k++)
    {
        // P(pick exactly k goods, then poison)
        // = C(goodCount, k) × C(poisonCount, 1) ×
        //   k! × 1! × (total-k-1)! / total!
        // Simplified for sequential without-replacement picks:
        double pExactlyK = HypergeometricPoisonProbability(k, goodCount, poisonCount, total);

        double winAmount = k * avgGoodValue;  // accumulated prizes
        if (k == goodCount)
            winAmount += poisonPayout;  // no more poison possible; player collects all prizes
        else
            winAmount += poisonPayout;  // hit poison — apply poison payout logic

        ev += pExactlyK * winAmount;
    }

    return ev;
}

private static double HypergeometricPoisonProbability(
    int k, int G, int K, int total)
{
    if (k == G)
    {
        // Picked all good symbols without hitting poison
        // P = (G/total) × (G-1)/(total-1) × ... × 1/(total-G+1)
        // × (K remaining means we never hit poison)
        double p = 1.0;
        for (int i = 0; i < G; i++)
            p *= (double)(G - i) / (total - i);
        return p;
    }
    else
    {
        // Picked exactly k goods then hit a poison
        // P = product of k good picks × probability poison is next
        double p = 1.0;
        for (int i = 0; i < k; i++)
            p *= (double)(G - i) / (total - i);
        p *= (double)K / (total - k);
        return p;
    }
}

5.6 Pick Bonus with Multiple Picks and Running Total

The most sophisticated pick bonus variant: the player makes multiple sequential picks, each contributing to a running total. No poison — picks reveal prizes only. But the game adds drama through visual design and a "collect or continue" decision.

When there is a genuine decision point (collect your accumulated winnings now, or gamble them on another pick that could give more or less), the EV analysis must account for optimal player strategy — which maximises EV.

For a "double or nothing" style decision:

E[continue] = P(good pick) × (current + extra) + P(bad pick) × (reduced)
E[collect]  = current

Continue if: E[continue] > current

This becomes a backwards induction dynamic programming problem identical to the Free Spins Markov chain from Part II.


Part VI. Hold & Win / Lock & Spin Bonus EV

6.1 The Mechanic

Hold & Win bonuses (popularised by Leprechaun Riches, Catch the Gold, Buffalo Hold & Win) work as follows:

Triggering the bonus lands some Money symbols on the grid

All non-Money symbols are removed; Money symbols stay ("hold")

Player gets 3 respins — count resets to 3 on each new Money symbol landing

When the respin counter reaches 0, the player collects all Money symbols' values

Special symbols: Jackpot symbols (Mini/Minor/Major/Grand) add fixed jackpot amounts

6.2 The Respin Markov Chain

The Hold & Win bonus can be modelled as a Markov chain:

State: (number of respins remaining, number of money symbols already locked, total positions filled)

Transition: on each respin:

New Money symbols land in unfilled positions

If any land: counter resets to 3

If none land: counter decrements by 1

If counter reaches 0: bonus ends

This is computationally tractable for small grids (e.g., 15 positions on a 5×3 grid) but becomes expensive for larger grids. In practice, the EV is computed by Monte Carlo simulation or by limiting the Markov chain to tractable state spaces.

public class HoldAndWinBonusSimulator
{
    private readonly HoldWinConfig _config;

    public record HoldWinConfig(
        int    GridPositions,      // Total grid cells (e.g., 15 for 5×3)
        int    InitialLockedCount, // Money symbols locked at trigger
        double MoneySymbolProb,    // P(any single empty position fills per respin)
        double AverageMoneyValue,  // Average value of each money symbol
        JackpotConfig[] Jackpots   // Mini/Minor/Major/Grand probabilities and values
    );

    public record JackpotConfig(string Name, double Probability, double Value);

    public BonusSimResult Simulate(int simulations = 1_000_000)
    {
        var totalPayouts = new List<double>(simulations);
        long jackpotHits = 0;
        int  fullBoardHits = 0;

        for (int sim = 0; sim < simulations; sim++)
        {
            double payout = SimulateSingleBonus(out bool hitJackpot, out bool fullBoard);
            totalPayouts.Add(payout);
            if (hitJackpot)  jackpotHits++;
            if (fullBoard)   fullBoardHits++;
        }

        totalPayouts.Sort();

        return new BonusSimResult(
            EV:               totalPayouts.Average(),
            Median:           totalPayouts[(int)(simulations * 0.50)],
            Percentile95:     totalPayouts[(int)(simulations * 0.95)],
            Percentile99:     totalPayouts[(int)(simulations * 0.99)],
            MaxPayout:        totalPayouts[^1],
            JackpotRate:      (double)jackpotHits / simulations,
            FullBoardRate:    (double)fullBoardHits / simulations
        );
    }

    private double SimulateSingleBonus(out bool hitJackpot, out bool hitFullBoard)
    {
        int    emptyPositions = _config.GridPositions - _config.InitialLockedCount;
        double totalValue     = _config.InitialLockedCount * _config.AverageMoneyValue;
        int    respinsLeft    = 3;
        hitJackpot            = false;
        hitFullBoard          = false;

        while (respinsLeft > 0 && emptyPositions > 0)
        {
            // Each empty position independently has P(fill) per respin
            bool anyLanded = false;
            int  newLanded = 0;

            for (int pos = 0; pos < emptyPositions; pos++)
            {
                if (RandomNumberGenerator.GetInt32(1_000_000) <
                    (int)(_config.MoneySymbolProb * 1_000_000))
                {
                    newLanded++;
                    anyLanded = true;

                    // Determine symbol value: money or jackpot?
                    double symbolValue = DetermineSymbolValue(out bool isJackpot);
                    totalValue += symbolValue;
                    if (isJackpot) hitJackpot = true;
                }
            }

            emptyPositions -= newLanded;

            if (anyLanded)
                respinsLeft = 3;  // reset counter
            else
                respinsLeft--;
        }

        if (emptyPositions == 0) hitFullBoard = true;

        // Full board bonus: Grand Jackpot awarded
        if (hitFullBoard)
            totalValue += _config.Jackpots.First(j => j.Name == "Grand").Value;

        return totalValue;
    }

    private double DetermineSymbolValue(out bool isJackpot)
    {
        isJackpot = false;

        foreach (var jackpot in _config.Jackpots.OrderByDescending(j => j.Value))
        {
            if (RandomNumberGenerator.GetInt32(1_000_000) <
                (int)(jackpot.Probability * 1_000_000))
            {
                isJackpot = true;
                return jackpot.Value;
            }
        }

        return _config.AverageMoneyValue;
    }
}

public record BonusSimResult(
    double EV, double Median, double Percentile95, double Percentile99,
    double MaxPayout, double JackpotRate, double FullBoardRate)
{
    public void Print()
    {
        Console.WriteLine($"  EV:              {EV:F2}× bet");
        Console.WriteLine($"  Median:          {Median:F2}× bet");
        Console.WriteLine($"  95th pct:        {Percentile95:F2}× bet");
        Console.WriteLine($"  99th pct:        {Percentile99:F2}× bet");
        Console.WriteLine($"  Max observed:    {MaxPayout:F2}× bet");
        Console.WriteLine($"  Jackpot rate:    1 in {1.0/JackpotRate:F0}");
        Console.WriteLine($"  Full board:      1 in {1.0/FullBoardRate:F0}");
    }
}

6.3 Contribution to Total RTP

double holdWinEV = 45.2;   // from simulation
double triggerProb = 0.004; // 1 in 250 spins

double rtpContribution = triggerProb * holdWinEV;
Console.WriteLine($"Hold & Win RTP contribution: {rtpContribution * 100:F2}%");
// Output: Hold & Win RTP contribution: 18.08%

Part VII. Full Bonus Math Section in the PAR Sheet

7.1 Complete Bonus Math Template

╔══════════════════════════════════════════════════════════════════╗
║           Awesome Slot — BONUS FEATURE MATHEMATICS               ║
╠══════════════════════════════════════════════════════════════════╣
║                                                                  ║
║  BONUS TRIGGER                                                   ║
║  ──────────────────────────────────────────────────              ║
║  Trigger condition:    3+ Scatter symbols visible on grid        ║
║  Trigger probability:  0.6893%  (1 in 145.07 spins)              ║
║                                                                  ║
║    Breakdown by Scatter count:                                   ║
║    3 Scatters:  0.6776%   →  10 Free Spins                       ║
║    4 Scatters:  0.0116%   →  15 Free Spins                       ║
║    5 Scatters:  0.0001%   →  20 Free Spins                       ║
║                                                                  ║
║  FREE SPINS CONFIGURATION                                        ║
║  ──────────────────────────────────────────────────              ║
║  Reels:                  Enhanced FS reel strips                 ║
║  Wild counts (per reel): 4 / 5 / 6 / 5 / 4                       ║
║  Multiplier:             ×2.5 (constant, all wins)               ║
║  Retrigger:              3+ Scatters → +10 Free Spins            ║
║                                                                  ║
║  FREE SPINS MATHEMATICS                                          ║
║  ──────────────────────────────────────────────────              ║
║  Base RTP per FS spin (FS reels):         72.00%                 ║
║  FS multiplier:                           ×2.50                  ║
║  Effective RTP per FS spin:               180.00%                ║
║                                                                  ║
║  Retrigger probability per FS spin:       0.6893%                ║
║  P(retrigger in 10-spin block):           6.73%                  ║
║  Expected total spins (3 Scatter):        10.72                  ║
║  Expected total spins (4 Scatter):        16.09                  ║
║  Expected total spins (5 Scatter):        21.46                  ║
║                                                                  ║
║  EV by trigger count (× total bet):                              ║
║    3 Scatters (10 FS): 10.72 × 0.72 × 2.5  = 19.30×              ║
║    4 Scatters (15 FS): 16.09 × 0.72 × 2.5  = 28.96×              ║
║    5 Scatters (20 FS): 21.46 × 0.72 × 2.5  = 38.63×              ║
║                                                                  ║
║  Weighted average EV (by trigger distribution):                  ║
║    = 0.6776 × 19.30 + 0.0116 × 28.96 + 0.0001 × 38.63            ║
║      ──────────────────────────────────────────────              ║
║         0.6893                                                   ║
║    = 19.64× bet                                                  ║
║                                                                  ║
║  RTP CONTRIBUTION                                                ║
║  ──────────────────────────────────────────────────              ║
║  RTP_freespins = P(trigger) × EV(bonus)                          ║
║               = 0.006893 × 19.64                                 ║
║               = 0.13539 = 13.54%    ← per-spin EV basis          ║
║                                                                  ║
║  [Note: For 38% FS contribution target, enhanced reels           ║
║   must produce RTP_FS ≈ 2.90× base — see reel strip rev. 2.4]    ║
║                                                                  ║
║  BONUS PAYOUT DISTRIBUTION (1,000,000 simulated bonuses)         ║
║  ──────────────────────────────────────────────────              ║
║  Mean:                                 19.64× bet                ║
║  Median:                               14.2×  bet                ║
║  75th percentile:                      24.8×  bet                ║
║  90th percentile:                      41.3×  bet                ║
║  95th percentile:                      58.7×  bet                ║
║  99th percentile:                      118.4× bet                ║
║  Maximum observed:                     4,847× bet                ║
║                                                                  ║
╚══════════════════════════════════════════════════════════════════╝

7.2 Integrating Bonus EV into Total RTP Calculation

public class TotalRtpCalculator
{
    public TotalRtpResult Calculate(
        double baseRtp,
        BonusFeature[] bonusFeatures)
    {
        double totalRtp         = baseRtp;
        double totalBonusRtp    = 0;
        var    contributions    = new List<RtpContribution>();

        contributions.Add(new RtpContribution("Base Game", baseRtp, baseRtp));

        foreach (var bonus in bonusFeatures)
        {
            double contribution = bonus.TriggerProbability * bonus.ExpectedValue;
            totalRtp         += contribution;
            totalBonusRtp    += contribution;

            contributions.Add(new RtpContribution(
                bonus.Name,
                contribution,
                contribution / (totalRtp + contribution)  // proportion of total
            ));
        }

        return new TotalRtpResult(totalRtp, baseRtp, totalBonusRtp, contributions);
    }
}

public record BonusFeature(
    string Name,
    double TriggerProbability,
    double ExpectedValue
);

public record RtpContribution(
    string FeatureName,
    double AbsoluteRtp,
    double ProportionOfTotal
);

public record TotalRtpResult(
    double TotalRtp,
    double BaseRtp,
    double TotalBonusRtp,
    List<RtpContribution> Contributions
)
{
    public void PrintBreakdown()
    {
        Console.WriteLine("RTP BREAKDOWN");
        Console.WriteLine($"{"Feature",-28} {"RTP",-10} {"Share"}");
        Console.WriteLine(new string('─', 50));

        foreach (var c in Contributions)
        {
            Console.WriteLine($"{c.FeatureName,-28} " +
                              $"{c.AbsoluteRtp * 100,7:F3}%   " +
                              $"{c.AbsoluteRtp / TotalRtp * 100,5:F1}%");
        }

        Console.WriteLine(new string('─', 50));
        Console.WriteLine($"{"TOTAL RTP",-28} {TotalRtp * 100,7:F3}%   100.0%");
    }
}

// Crystal Forge example:
var calculator = new TotalRtpCalculator();
var result = calculator.Calculate(
    baseRtp: 0.5800,
    bonusFeatures: new[]
    {
        new BonusFeature("Free Spins (3–5 Scatter)", 0.006893, 19.64),
        // If there were additional bonus features, add here
    }
);
result.PrintBreakdown();

// Output:
// RTP BREAKDOWN
// Feature                      RTP        Share
// ──────────────────────────────────────────────────
// Base Game                    58.000%    85.1%
// Free Spins (3–5 Scatter)     13.539%    19.8%     ← 0.006893 × 19.64
//                              ← note: still below 96% target, FS reels need enhancement
// ──────────────────────────────────────────────────
// TOTAL RTP                    71.539%    100.0%

The 71.5% result shows our bonus design still needs work to reach 96%. The Free Spins EV must increase significantly — which means enhancing the FS reel strips further or adjusting the multiplier structure.


Part VIII. Common Mistakes in Bonus EV Calculation

Mistake 1: Ignoring the Retrigger Amplification Factor

// WRONG: ignoring retriggers
EV(bonus) = N_initial × RTP_FS × multiplier
          = 10 × 0.72 × 2.5 = 18.0×

// CORRECT: including retriggers
E[total spins] = 10 / (1 - p_retrigger × r/n) ... = 10.72
EV(bonus) = 10.72 × 0.72 × 2.5 = 19.3×

// The difference: 7.2% understatement
// At trigger rate 1/145: 0.072% RTP error — may cause certification failure

The compounding nature of retriggers means ignoring them always understates EV. Even a 5% retrigger probability (common in medium-volatility games) adds 8–10% to the total bonus EV.

Mistake 2: Using Base Game RTP for Free Spins Spins

// WRONG: assuming FS RTP equals base RTP
EV(FS) = N × RTP_base × multiplier
       = 10 × 0.58 × 2.5 = 14.5×

// CORRECT: FS reels have different (higher) RTP
EV(FS) = N × RTP_freeSpins × multiplier
       = 10 × 0.72 × 2.5 = 18.0×

// Difference: 24% understatement → massive RTP error

Free Spins reels almost always have more Wilds and Premiums than base game reels. The RTP per spin in Free Spins can be 20–50% higher than base game RTP. This must be calculated separately.

Mistake 3: Miscounting the Trigger Distribution Weighting

When different Scatter counts award different Free Spins counts, the weighted average EV must be computed correctly:

// WRONG: using simple average free spins count
avg_FS = (10 + 15 + 20) / 3 = 15 spins
EV = 15 × RTP_FS × multiplier

// CORRECT: weight by actual trigger probabilities
avg_FS = (P(3S) × 10 + P(4S) × 15 + P(5S) × 20) / P(trigger)
       = (0.6776 × 10 + 0.0116 × 15 + 0.0001 × 20) / 0.6893
       = (6.776 + 0.174 + 0.002) / 0.6893
       = 6.952 / 0.6893
       = 10.08 average spins

// The difference: 15 vs 10.08 — factor of 1.49!
// 3-Scatter is so much more common that 4S and 5S barely move the average

Mistake 4: Double-Counting Scatter Trigger in FS Spins

During Free Spins, if Scatters trigger another Free Spins block (retrigger), those Scatters should be counted for triggering purposes but must not be credited again for their base trigger probability in the base game RTP calculation.

The correct separation:

RTP_freespins includes retrigger EV

P(base trigger) × EV(bonus) is the base game's contribution

Do not add a separate P(base_trigger) × P(retrigger_in_FS) × EV(bonus) term — this is already inside the EV(bonus) calculation

Mistake 5: Forgetting the Minimum Payout Guarantee in Pick Bonuses

Many Pick Bonus rounds guarantee a minimum payout to avoid a "dud" bonus experience. This minimum payout has EV contribution:

EV_total(pick) = EV_calculated(picks) + P(payout < minimum) × (minimum - E[low payout])

If you don't include the minimum guarantee in your calculation, you'll understate EV by its contribution — which could be 1–3% of total RTP for games with low-paying Pick Bonus outcomes.

Mistake 6: Not Modelling the Variance of the Bonus

EV alone is insufficient for a complete bonus design. The variance of the bonus payout determines how much the bonus contributes to total game variance (and therefore volatility).

// EV tells you the average. Variance tells you the spread.
// Both are required for complete PAR Sheet documentation.

// Total game variance with bonus:
double varianceTotal = varianceBase + P_trigger × (varianceBonus + ev_bonus^2)
                     - (P_trigger × ev_bonus)^2;
// [Derived from total variance formula: Var(X+Y) for rare event Y]

A bonus with EV = 50× and low variance (most bonuses pay 40–60×) contributes much less to total volatility than a bonus with EV = 50× but high variance (some bonuses pay 5×, others pay 500×). Both have the same RTP contribution but produce radically different volatility profiles.


Part IX. Simulation Verification Strategy

9.1 The Three-Level Verification Approach

Level 1: Unit test each bonus component
  ├── Free Spins EV: compare DP result vs analytical formula
  ├── Retrigger probability: chi-square test over 1M simulations
  ├── Pick bonus EV: compare simulation vs hypergeometric formula
  └── Multiplier distribution: compare simulated mean vs expected

Level 2: Integration test the complete bonus
  ├── Simulate 1M bonus rounds
  ├── Verify mean payout within ±1% of DP/analytical result
  ├── Verify percentile distribution is plausible (no impossible outcomes)
  └── Verify retrigger rate matches p_r^n formula

Level 3: End-to-end full game simulation
  ├── Simulate 10M complete spins (base + bonus)
  ├── Verify total RTP within ±0.1% of target
  ├── Verify bonus trigger frequency matches analytical
  └── Verify bonus contribution to total RTP

9.2 Verification Code

[TestClass]
public class BonusMathVerificationTests
{
    [TestMethod]
    public void FreeSpisEV_DPMatchesSimulation_Within1Percent()
    {
        double dpEV = ComputeFreeSpisEV(
            initialSpins: 10, retriggerSpins: 10,
            rtpPerSpin: 0.72, multiplier: 2.5,
            retriggerProbPerSpin: 0.006893);

        double simEV = SimulateFreeSpinsEV(
            initialSpins: 10, retriggerSpins: 10,
            rtpPerSpin: 0.72, multiplier: 2.5,
            retriggerProbPerSpin: 0.006893,
            simulations: 1_000_000);

        double relativeDiff = Math.Abs(dpEV - simEV) / dpEV;
        Assert.IsTrue(relativeDiff < 0.01,
            $"DP EV={dpEV:F3} vs Sim EV={simEV:F3}: relative diff {relativeDiff:P2} > 1%");
    }

    [TestMethod]
    public void PickBonusEV_WithReplacement_MatchesFormula()
    {
        // E[N picks] = N × avg_value (proved analytically)
        var prizes = new[] { 5.0, 10.0, 20.0, 50.0, 10.0, 5.0, 15.0, 25.0, 30.0 };
        double expectedAvg = prizes.Average();
        int    picks       = 3;
        double formulaEV   = picks * expectedAvg;

        double simEV = SimulatePickBonusEV(prizes, picks, withReplacement: false, 1_000_000);

        Assert.IsTrue(
            Math.Abs(formulaEV - simEV) / formulaEV < 0.005,
            $"Formula EV={formulaEV:F3}, Sim EV={simEV:F3}");
    }

    [TestMethod]
    public void TotalRTP_BonusContribution_WithinCertificationTolerance()
    {
        // Run 10M full game simulations
        long spins    = 10_000_000;
        long bonuses  = 0;
        decimal totalWin = 0;
        decimal totalBet = 0;

        for (long i = 0; i < spins; i++)
        {
            decimal spinResult = SimulateFullSpin(out bool bonusTriggered);
            totalWin += spinResult;
            totalBet += 1.0m;
            if (bonusTriggered) bonuses++;
        }

        double rtp          = (double)(totalWin / totalBet);
        double triggerRate  = (double)bonuses / spins;

        // RTP within ±0.1% of 96%
        Assert.IsTrue(Math.Abs(rtp - 0.96) <= 0.001,
            $"Simulated RTP {rtp:P4} outside ±0.1% of 96%");

        // Trigger rate within ±5% of 1/145
        Assert.IsTrue(Math.Abs(triggerRate - 1.0/145) / (1.0/145) <= 0.05,
            $"Trigger rate {1.0/triggerRate:F0} outside ±5% of 145");
    }
}

Summary

Bonus round mathematics is the discipline where a slot's mathematical model most requires rigorous engineering. The interaction of retrigger probabilities, enhanced reel configurations, progressive multipliers, and conditional mechanics creates a complex analytical landscape that cannot be navigated by approximation alone.

The key principles from this article:

EV is the master metric for bonus design. RTP_bonus = P(trigger) × EV(bonus) is the equation that governs every decision: how many Free Spins, what multiplier, how likely the retrigger, what the enhanced reels look like. Start with this equation and work backwards from your target RTP_bonus.

Retriggers compound exponentially. Even a 5% per-block retrigger probability adds 8–10% to total bonus EV. Never omit retrigger calculations from your PAR Sheet.

Free Spins EV requires FS-specific reel analysis. The FS reel strips produce a different (typically 20–50% higher) RTP per spin than base game reels. Calculate this separately.

Pick Bonus EV has elegant closed forms. For N picks without replacement: EV = N × average(all prizes). This result — independent of selection order and strategy — simplifies Pick Bonus design substantially.

Bonus variance matters as much as EV. Two bonuses with identical EV can produce radically different game volatility profiles. Document and verify both.

Three-level verification is non-negotiable. Unit test each component, integration test the complete bonus, end-to-end test the full game. Certification labs will run their own simulations — yours need to agree.

Neon Royale use cookies for technical purposes, functionality and measurement.

Policy