Updated: Mar 29, 2026
| 7 min

A Python-Based Statistical Audit of Gates of Olympus

Using Python, statistics, and 40,000 spins to analyze RTP, variance, bonus frequency, and volatility in Gates of Olympus through hypothesis testing and simulation.

Cover image for the blog series "Expected Riches and Expensive Lessons"

Why volatility feels brutal, bonuses feel magical, and the house always wins.

Introduction

What’s more exciting than data science? Gambling. At least emotionally.

Gates of Olympus advertises a 96.5% RTP, massive multipliers up to 500×, and the tantalising promise that the Ante Bet doubles your chance of a bonus.

But anyone who has played more than 15 minutes knows:

  • Dry spells feel endless
  • Bonuses feel mythical
  • And your balance rarely trends upward

So instead of trusting feelings, we collected 40,000 spins and audited the game using Python, probability theory, and statistical inference.

We test three main questions:

  1. Does the Ante Bet really double the bonus frequency?
  2. Is short-term RTP deviation normal or suspicious?
  3. Are multiplier mechanics consistent across modes?

This is not a strategy guide. It is a reality check.

How Does Gates Of Olympus Work

Precision matters when you’re auditing math.

Base Game

  • 6×5 grid
  • 8+ symbols pay, position irrelevant
  • Tumble mechanic chains wins

Multiplier Orbs

  • 2×–500×
  • Add together
  • Apply only if a win occurs in the same tumble

Bonus Round

  • 4+ scatters → 15 spins
  • Multipliers persist and accumulate
  • Retriggers add +5 spins

Ante Bet

  • Costs +25% per spin
  • Advertised: “Double chance to win the feature”

Data Collection & Model Assumptions

Data Source

Spin data was collected from the demo version of Gates of Olympus provided by Pragmatic Play.

  • Collection method: Python + browser automation (Selenium-based)
  • Scope: Multiple sessions of 1000 spins
  • Bet size: Constant per session of 0.20 base bet
  • Environment: Demo mode (providers state demo and live operate under the same RNG model)
ColumnDescription
round_idUnique spin identifier
betCost of the spin
winTotal payout
multiplierSum of multipliers landed
is_anteAnte Bet active
is_bonusSpin belongs to free spins

The Statistical Model

Spins are modelled as realisations of a random process, where each XiX_i is the payout from a single spin:

X1,X2,...,XnX_1,X_2,...,X_n

Assumptions

These are the assumptions that we use when analysing the game:

  • Independence Each spin is treated as independent of previous outcomes and drawn from the same probability distribution. The system holds no memory of past wins or losses, meaning there are no “hot” or “cold” streak mechanics.
  • Stationarity The probability distribution governing spin outcomes is assumed to remain constant during the data collection period. In practical terms, the RTP structure and volatility profile do not change mid-session.
  • High Variance, Slow Convergence Slot payout distributions are known to be heavy-tailed, consisting of many small or zero outcomes and rare extreme wins. As a result, short-term RTP can deviate substantially from theoretical values without implying bias or malfunction.

Limitations

  • 40k spins are small relative to the volatility scale
  • Demo environment assumed equivalent to live
  • Bonus detection relies on observed state transitions
  • Bootstrap assumes stationarity

Double Chance

Claim: The Ante Bet Doubles Bonus Probability

If the marketing is accurate, Ante mode should roughly double the bonus trigger rate.

Let:

  • pRp_R = bonus probability (Regular)
  • pAp_A = bonus probability (Ante)

In Python:

def get_bonus_stats(df: pd.DataFrame) -> float:
    is_bonus = df['is_bonus']
    # Create unique IDs for each bonus session
    bonus_ids = (is_bonus != is_bonus.shift()).cumsum()
    bonus_sessions = df[is_bonus].groupby(bonus_ids)

    total_bonuses = bonus_sessions.ngroups
    base_spins = len(df[~df['is_bonus']])

    return (total_bonuses / base_spins) * 100

reg_hit_rate = get_bonus_stats(df[~df['is_ante']])
ante_hit_rate = get_bonus_stats(df[df['is_ante']])
observed_lift = ante_hit_rate / reg_hit_rate

Results:

Regular Hit Rate: 0.19
Ante Hit Rate: 0.56
Observed Lift: 2.87

That’s nearly 3×. But is it statistically meaningful? To test this, we construct a contingency table comparing:

  • Bonus hits
  • Non-bonus spins

In Python:

def get_bonus_hits(df: pd.DataFrame) -> int:
    is_bonus = df['is_bonus']
    bonus_ids = (is_bonus != is_bonus.shift()).cumsum()
    bonus_sessions = df[is_bonus].groupby(bonus_ids)

    return bonus_sessions.ngroups

reg_bonus_hits = get_bonus_hits(df[~df['is_ante']])
ant_bonus_hits = get_bonus_hits(df[df['is_ante']])

reg_total_spins = len(df[(~df['is_ante']) & (~df['is_bonus'])])
ant_total_spins = len(df[(df['is_ante']) & (~df['is_bonus'])])

# Contingency Table: [[Bonus_Hits, Non_Bonus_Spins]]
observed = [
    [reg_bonus_hits, reg_total_spins - reg_bonus_hits],
    [ant_bonus_hits, ant_total_spins - ant_bonus_hits]
]

chi2, p_value, _, _ = stats.chi2_contingency(observed)

Results:

P-value: 0.0000000043

Interpretation:

The difference is statistically significant. However, observing 2.87× instead of 2× does not imply hidden manipulation. With high-variance events and 20,000 spins, sampling noise can inflate ratios. So, Ante materially increases bonus frequency, consistent with its design. It does not guarantee profitability.

Expected Spins Until Bonus: The “Dry Spell” Simulation

Even if bonuses are more frequent, the wait time follows a Geometric Distribution.

Expected spins until bonus:

E(X)=1pE(X) = \frac{1}{p}

To see the harsh reality, we will simulate 1000 players on the dataset.

Python:

rng = np.random.default_rng(seed=42)

# Simulating 1,000 players' wait times based on our observed rates
dry_spells_reg = rng.geometric(p=reg_hit_rate/100, size=1000)
dry_spells_ante = rng.geometric(p=ante_hit_rate/100, size=1000)

plt.figure(figsize=(12, 6))
plt.margins(x=0, y=0)

# Plotting the distributions
plt.hist(dry_spells_reg, bins=50, alpha=0.5, label='Regular', color='royalblue', edgecolor='white')
plt.hist(dry_spells_ante, bins=50, alpha=0.5, label='Ante Bet', color='darkorange', edgecolor='white')

# Theoretical Expectations
plt.axvline(1/(reg_hit_rate/100), color='blue', linestyle='--', linewidth=2, label='Mean (Regular)')
plt.axvline(1/(ante_hit_rate/100), color='red', linestyle='--', linewidth=2, label='Mean (Ante)')

plt.title("The 'Dry Spell' Distribution: Regular vs. Ante Bet", fontsize=14)
plt.xlabel("Number of Spins until Bonus Trigger")
plt.ylabel("Frequency")
plt.legend()
plt.grid(axis='y', alpha=0.2)
plt.show()

Results:

Dry Spell distribution: ante vs regular bets

Interpretation:

Most bonuses happen within the first 100-300 spins, creating the “illusion” of a frequent hit rate. However, there are long tails that stretch far to the right. In the simulation, even with Ante Bet, some players went far over 1000 spins without hitting a bonus. Even though Ante Bets reduces the average wait time, it does not eliminate the possibility of extreme dry spells. If your bankroll cannot survive a 4x deviation from the mean (e.g., 700+ spins without a bonus), you are mathematically destined for ruin

Calculating RTP

The game advertises RTP=96.5RTP = 96.5%. The RTP can be calculated as follows:

RTP=winbetRTP = \frac{\sum{win}}{\sum{bet}}

To avoid double-counting bonus payouts, we retain only the final state of each free-spin session.

Because payout distributions are heavy-tailed and non-normal, parametric confidence intervals based on normality assumptions would be inappropriate. Bootstrap resampling provides a distribution-free estimate of uncertainty.

Python:

def extract_final_bonus_spins(df: pd.DataFrame) -> pd.DataFrame:
    is_bonus = df['is_bonus']
    bonus_ids = (is_bonus != is_bonus.shift()).cumsum()
    bonus_sessions = df[is_bonus].groupby(bonus_ids)

    return bonus_sessions.tail(1)

def get_rtp(df: pd.DataFrame) -> float:
    return df['win'].sum() / df['bet'].sum() * 100

def bootstrap_rtp(df: pd.DataFrame, n_simulations=10_000) -> np.array:
    rtps = []
    for _ in range(n_simulations):
        sample = df.sample(frac=1, replace=True)
        rtp = get_rtp(sample)
        rtps.append(rtp)
    return np.array(rtps)

df_r_fil = pd.concat([df[~df['is_bonus'] & ~df['is_ante']], extract_final_bonus_spins(df[~df['is_ante']])])
df_a_fil = pd.concat([df[~df['is_bonus'] & df['is_ante']], extract_final_bonus_spins(df[df['is_ante']])])

rtp_r = get_rtp(df_r_fil)
rtp_a = get_rtp(df_a_fil)

ci_low_r, ci_high_r = np.percentile(bootstrap_rtp(df_r_fil), [2.5, 97.5])
ci_low_a, ci_high_a = np.percentile(bootstrap_rtp(df_a_fil), [2.5, 97.5])

Results:

Observed RTP Regular: 89.26%
95% bootstrap CI for Regular RTP: (78.37, 100.74)
Observed RTP Ante: 101.80%
95% bootstrap CI for Ante RTP: (87.58, 117.60)

Interpretation:

The observed RTP of both sessions deviate from the advertised long-run RTP of 96.50%. Both realised RTP values fall inside their respective 95% bootstrap confidence intervals, indicating that the observed session outcomes are statistically consistent with a 96.5% long-run RTP under high variance.

High-volatility games have:

SE(Xˉ)=σnSE(\bar{X}) = \frac{\sigma}{\sqrt{n}}

When variance σ\sigma is large, convergence is slow. 20,000 spins sounds like a lot, but mathematically it isn’t. The house edge operates as a slow bleed, not an instant drain.

Multiplier Strength Does Not Change Across Modes

A common claim amongst players is that increasing bonus frequency via the Ante Bet may be balanced by reducing multiplier strength. To evaluate this, we compare the distribution of realised multipliers from base-game wins across modes.

We restrict the dataset to:

  • Winning spins only
  • Spins with multiplier contribution
  • Base game only (bonus rounds excluded)
H0:Fregular(x)=Fante(x)H1:Fregular(x)Fante(x)H_0: F_{regular}(x) = F_{ante}(x) \\ H_1: F_{regular}(x) \neq F_{ante}(x)

Because multiplier outcomes are heavy-tailed (many small values, rare extreme values), we use the Kolmogorov–Smirnov (KS) test with α=0.05\alpha =0.05, a nonparametric method sensitive to differences in entire distributions.

Python:

def get_multiplier_hit_rate(df: pd.DataFrame) -> float:
    multiplier_spins = df[(df['win'] > 0) & (df['multiplier'] > 0)]
    return (len(multiplier_spins) / len(df)) * 100

hit_rate_r = get_multiplier_hit_rate(df[~df['is_ante'] & ~df['is_bonus']])
hit_rate_a = get_multiplier_hit_rate(df[df['is_ante'] & ~df['is_bonus']])

df_multi = df[(df['win'] > 0) & (df['multiplier'] > 0) & (~df['is_bonus'])]

stat, p_ks = stats.ks_2samp(
    df_multi[~df_multi['is_ante']]['multiplier'],
    df_multi[df_multi['is_ante']]['multiplier']
)

Results:

Regular hit rate: 1.47
Ante hit rate: 1.73
KS Test P-value: 0.0737

Interpretation:

Because the p-value is greater than our alpha, we fail to reject the null hypothesis. This indicates:

  • No statistically detectable shift in multiplier magnitude
  • Ante mode increases feature frequency, not multiplier strength
  • Observed visual differences in plots are attributable to sampling variability and normalisation effects ****

Final Conclusion

After 40,000 spins and multiple statistical tests:

  1. The Ante Bet significantly increases the frequency of bonuses.
  2. Multiplier strength remains statistically consistent across modes.
  3. RTP deviations over 20k spins are entirely plausible.
  4. Volatility, not manipulation, explains most player experience.

The game does not need to cheat.

It simply relies on:

  • Rare, high-impact events
  • Heavy-tailed distributions
  • Slow mathematical convergence

Short-term outcomes are chaos.

Long-term expectation is relentless.

And Zeus always collects his tribute.

Disclaimer: This series is for educational and mathematical purposes only. It is not financial advice. The only winning move is to understand the math and play for entertainment, not profit.

Series: Expected Riches and Expensive Lessons

2 Chapters