Somewhere in the help screens of every slot machine, usually three taps deep, there's a line that says something like RTP 94.60%. Most people read that as "put in 100, get back 94.60." That's true in roughly the same way that the average family has 2.3 kids. Nobody has ever walked away from a machine with exactly 94.60.
I wanted to know where that number comes from. Not the definition, which fits on one line, but the construction. A modern video slot isn't really one game. It's a base game with a couple of smaller games bolted onto it, and each of those pieces quietly pays its own share of the 94.60. So I built a toy slot from scratch: a base game, free games with retriggers, and a hold and spin bonus. I worked out its RTP by hand wherever the maths let me, then ran 20 million simulated spins to check I hadn't fooled myself.
Short version: the RTP turned out to be the easy part. The more interesting number is the one they don't print.
§ 1 RTP Is Just an Expected Value
Take one paid spin. You stake B, and the machine pays back some random amount W, which is zero most of the time. RTP is the ratio of what you expect back to what you put in:
ω runs over every possible outcome of a spin: the reel stops, any feature it triggers, and everything that happens inside that feature.
To keep things clean I'll measure every win as a multiple of the total bet, so B = 1 and RTP is simply E[W]. The house edge is 1 − RTP.
So how can a casino print that number with a straight face when no single player ever gets it? The law of large numbers. Over N spins the average payback converges to the RTP, and the central limit theorem tells you how quickly:
σ is the standard deviation of the win on a single spin.
Hold on to that σ. Two games can both return 94.6% and feel nothing alike. One pays you a little, often. The other pays you nothing for forty minutes and then drops 300x on you. RTP can't tell those two apart. σ, which the industry calls volatility, can. The other number you'll see quoted is hit frequency, P(W > 0), which is just how often a spin pays anything at all.
§ 2 The Toy Game
I wanted something small enough to reason about but close enough to a real cabinet that the lessons carry over. Five reels, three rows, and 243 ways instead of paylines.
In a ways game, a win is any run of the same symbol on adjacent reels starting from reel 1, and the symbol can sit in any row. If it shows up more than once on a reel, the combinations multiply: the number of ways is the product of the counts on each reel. So if reel 1 shows one A, reel 2 shows two, and reel 3 shows one A plus a wild, that's three As paying on 1 × 2 × 2 = 4 ways. Five reels each showing three matches gives 35 = 243, which is where the name comes from.
There are two picture symbols (P1 and P2), four royals (A, K, Q, J), a wild W that appears only on reels 2 to 4 and stands in for anything that pays on ways, a scatter S that lives on reels 1, 3 and 5, and a coin C that can land anywhere. Here's the paytable, per way, in multiples of total bet. The last column jumps ahead a little: it's how much of the base game RTP each symbol ends up paying, which we'll work out in § 4.
| Symbol | 3 of a kind | 4 of a kind | 5 of a kind | Share of RTP |
|---|---|---|---|---|
| P1 | 1.00 | 2.50 | 8.00 | 5.89% |
| P2 | 0.60 | 1.60 | 5.00 | 8.54% |
| A | 0.30 | 0.70 | 1.60 | 12.03% |
| K | 0.20 | 0.60 | 1.40 | 9.97% |
| Q | 0.20 | 0.40 | 1.00 | 12.70% |
| J | 0.15 | 0.30 | 0.90 | 10.36% |
| Base game | 59.48% |
Funny detail already: the humble Q pays more of the RTP than the top picture symbol. Royals are all over the reels, so they hit constantly. P1 is the one that looks exciting on the paytable, but it's rare, and rare things don't carry much expected value unless they pay a lot.
Then the two features.
Free games. Scatters on reels 1, 3 and 5 at the same time award 8 free games. These are played on a different set of reel strips with more pictures and three wilds on each middle reel, and every win is multiplied by 3. Land the three scatters again during free games and you get another 8.
Hold and spin. Six or more coins anywhere on screen start the bonus. The coins lock in place and you get 3 respins. On each respin every empty cell has a 7% chance of landing a new coin, and any new coin resets the counter to 3. When the respins run out, you're paid the sum of all the coin values. Fill all 15 cells and you also win the Grand, worth 1,000x. The coin values are drawn from this table, which has a mean of 4.05x:
| Coin value | 1x | 2x | 3x | 5x | 10x | 20x Minor | 100x Major |
|---|---|---|---|---|---|---|---|
| Weight | 40 | 25 | 15 | 10 | 6 | 3 | 1 |
The base reels are 40 to 43 symbols long and the free game reels 36 to 38. Full strip layouts are in the appendix code.
§ 3 The One Equation That Runs the Whole Job
Every spin's win splits cleanly into three parts, W = Wbase + WFG + WHS. Expectation is linear, so the RTP splits the same way:
And each feature's share is its trigger rate times its average award:
This looks almost too obvious to write down, but it's the backbone of how these games get built. Linearity doesn't care that the features are rare, or correlated with each other, or that both could trigger on the same spin. You can design and tune each bucket on its own and add them up at the end. If the free games are paying too much, you fix the free games. The base game doesn't need to know.
§ 4 The Base Game, Computed Exactly
Here's the thing that makes ways games pleasant to work with: the five reels stop independently of each other.
Fix a symbol X and let ci be the number of X-or-wild symbols showing on reel i. A run of exactly k pays payX(k) × c1c2…ck, and it needs reel k+1 to show none, otherwise it would be a longer run. Because the reels are independent, the expectation of that product breaks into a product of expectations:
For k = 5 there's no sixth reel, so the last factor drops out.
E[ci] is easy. Each of the three visible positions shows X-or-wild with probability (nX + nW) / Li, so E[ci] = 3(nX + nW) / Li no matter how the strip is arranged. P(ck+1 = 0) is different, because it depends on how the symbols are stacked, so I just count it by walking every stop. Reel 1 has no wilds, which means a pure run of wilds can never happen and I don't need a special rule for it.
A worked example. P1 appears 3 times on reels 1 and 5 (length 40). On reels 2 and 4 (length 41) the 2 wilds bring the X-or-wild count to 5, and the same on reel 3 (length 43). So:
That one line of the paytable is worth 1.89% RTP. Do the same for six symbols and three run lengths, add it all up, and you get RTPbase = 59.48%.
The brute force alternative is a full cycle: every combination of stops, which here is 40 × 41 × 43 × 41 × 40 = 115,652,800 screens. A computer can grind through that, but it's 115 million screens to get a number that equation (5) gets from about sixty averages. Here's the code:
def reel_stats(strip, sym):
"""Average number of sym-or-wild in the window, and P(none at all)."""
L = len(strip)
counts = [sum(x in (sym, "W") for x in window(strip, s)) for s in range(L)]
return sum(counts) / L, sum(c == 0 for c in counts) / L
def exact_ways_ev(reels, mult=1.0):
"""Expected ways win per spin.
E[win for sym, exactly k] = pay * E[c1]...E[ck] * P(c_{k+1} = 0)."""
total = 0.0
for sym, pay in PAYS.items():
stats = [reel_stats(r, sym) for r in reels]
for k in (3, 4, 5):
ev = math.prod(stats[i][0] for i in range(k))
if k < 5:
ev *= stats[k][1] # the run has to stop at reel k
total += pay[k] * ev
return total * mult
§ 5 Free Games, and a Geometric Series Hiding in the Retrigger
Start with the trigger. On each scatter reel, the scatters are spaced out so two of them can never show in the same window. That makes the chance of seeing one simply 3s/L, where s is the number of scatters on the strip:
The retrigger is where it gets fun. On the free game reels, the three scatters show up together with probability q = 0.25 × 0.1579 × 0.25 = 0.00987. Every free spin has that chance of adding 8 more. So how many free spins do you actually get on average?
Let S be the total number of spins in one feature, and n = 8. Each of the first n spins retriggers with probability q, and a retrigger kicks off what is basically a fresh feature with the same expected length. So:
Written as a series it's n(1 + nq + (nq)2 + …), which only converges if nq < 1.
Plugging in, E[S] = 8 / (1 − 0.0789) = 8.686. Retriggers add about two thirds of a spin on average. Small, but that nq < 1 condition is exactly why real games cap retriggers. Push the retrigger rate high enough and the expected length of the feature is infinite, which is not a sentence any regulator wants to read.
Next, the average win per free spin. That's just equation (5) again, run on the free game reels and multiplied by 3, which gives 4.679x. And since the number of spins is decided by what happens on the spins themselves (a stopping time, in probability language), Wald's identity lets us multiply the two expectations directly:
p_fg = p_free_trigger(BASE_REELS) # 1 in 212.3
q = p_free_trigger(FREE_REELS) # retrigger chance per free spin
exp_spins = FREE_GAMES_AWARD / (1 - FREE_GAMES_AWARD * q) # equation (7)
per_spin = exact_ways_ev(FREE_REELS, FREE_GAMES_MULT) # equation (5), x3
fg_rtp = p_fg * exp_spins * per_spin # equations (4) and (8)
Worth a pause
A feature the player sees on less than half a percent of spins pays out a fifth of everything the game returns. That ratio is a design choice, and it has more to do with how a game feels than the headline RTP does.
§ 6 Hold and Spin, Where I Started Reaching for the Simulator
The trigger still gives in to exact maths. Coins can stack, so a reel might show 0, 1 or 2 of them. I counted that distribution for each reel by walking the strip, then convolved the five distributions to get the distribution of the total number of coins on screen. That gives P(6 or more) = 1 / 305.5. Given a trigger, you start with exactly 6 coins 92.2% of the time, 7 coins 7.6% of the time, and 8 coins the rest.
The feature itself is a Markov chain. The state is (n, r): n cells filled, r respins left. With e = 15 − n empty cells, each landing a coin with probability p = 0.07, the expected number of coins at the end satisfies
Boundary conditions: V(n, 0) = n when the respins run out, and V(15, r) = 15 when the screen is full. The first term is "nothing landed, lose a respin", the sum is "j coins landed, counter back to 3".
Counting coins is enough here, because each coin's value is drawn independently of how the board fills up. So the average award is
Starting from 6 coins, the chain gives 9.45 coins on average and a 1.03% chance of filling the screen. With a mean coin value of 4.05x that's 48.6x. Averaging over the possible starting counts, E[HS win] = 48.84x, and RTPHS = 48.84 / 305.5 = 15.99%.
Now, to be honest, I only solved the chain as a check on the simulator. Real hold and spin games
have collector symbols, multipliers, extra rows that unlock, coins that upgrade other coins.
Each of those breaks the independence that made equation (10) work, and the state space blows
up fast. What you do then is write the feature as a plain function, run it a few hundred
thousand times on its own, and plug the average into equation (4). That's what
play_hold_and_spin in the appendix does. 400,000 isolated features gave 48.91x,
comfortably inside the noise around the exact 48.84x.
Simulate features on their own
In a full-game run of 20 million spins, hold and spin only triggers about 65,000 times, and its estimated RTP also carries the randomness of whether it triggered at all. Running the feature by itself 400,000 times costs a small fraction of the compute and pins its RTP down about 2.7 times more tightly.
§ 7 Simulating the Whole Thing
The full-game simulator is almost boring, which is kind of the point. One paid spin: stop the base reels, pay the ways, check for scatters, check for coins, play whatever triggered. Keep running sums of the win and the squared win so you get σ for free.
def play_one(rng):
"""One paid spin. Returns wins split by where they came from."""
grid = spin_grid(BASE_REELS, rng)
base = ways_win(grid)
fg = play_free_games(rng)[0] if scatters(grid) == 3 else 0.0
n = coins(grid)
hs = play_hold_and_spin(n, rng) if n >= HS_TRIGGER else 0.0
return base, fg, hs
def simulate(n_spins, seed=7):
rng = random.Random(seed)
tot = {"base": 0.0, "free": 0.0, "hs": 0.0}
s1 = s2 = 0.0 # running sum and sum of squares of total win
hits = 0
big = 0
for _ in range(n_spins):
b, f, h = play_one(rng)
w = b + f + h
tot["base"] += b; tot["free"] += f; tot["hs"] += h
s1 += w; s2 += w * w
hits += w > 0
big += w >= 100
mean = s1 / n_spins
sd = math.sqrt(s2 / n_spins - mean ** 2)
return {
"rtp": mean,
"parts": {k: v / n_spins for k, v in tot.items()},
"sd": sd,
"ci95": 1.96 * sd / math.sqrt(n_spins),
"hit_freq": hits / n_spins,
"p_100x": big / n_spins,
}
I ran 20 million paid spins. Here's how they line up against the exact numbers:
| Component | Exact | Simulated (20M) | Trigger rate |
|---|---|---|---|
| Base game | 59.48% | 59.44% | every spin |
| Free games | 19.14% | 19.14% | 1 in 212.3 |
| Hold and spin | 15.99% | 15.98% | 1 in 305.5 |
| Total | 94.60% | 94.56% |
Close. But is it close enough? Equation (2) answers that. The simulated standard deviation per spin came out at σ = 7.87, so the 95% band around a 20 million spin average is
The simulated 94.56% sits 0.04 points away from the exact 94.60%, well inside the band. The code and the maths agree, which is really all a full-game run is for.
Flip equation (11) around and you get the question every game studio runs into sooner or later: how many spins do I need to pin the RTP down to within ε?
For ε = 0.1% that's (1.96 × 7.87 / 0.001)2 ≈ 238 million spins. For 0.01% it's about 24 billion. And this is a fairly tame game. A high volatility game with σ = 20 needs about six and a half times as many. That's the practical reason you compute RTP exactly wherever you can, and use simulation to confirm the code matches the maths rather than to discover the RTP in the first place.
§ 8 The Number That Isn't on the Glass
A standard deviation of 7.87 on a game whose average win is under 1x. Where does all that spread come from?
For a rare feature that triggers with probability p and pays X when it does, its contribution to the second moment of a spin is roughly
μ and σX are the mean and standard deviation of the feature award. Features rarely overlap, so the pieces roughly add up to the whole.
| Piece | Share of RTP | Adds to E[W²] | Share of E[W²] |
|---|---|---|---|
| Base game | 59.5% | 2.7 | 4% |
| Free games | 19.1% | 13.6 | 22% |
| Hold and spin | 16.0% | 46.9 | 74% |
| of which the Grand | 3.4% | ≈ 38.5 | ≈ 61% |
| Total | 94.6% | 63.2 | sim: 62.8 |
The base game pays out almost 60% of the money and causes 4% of the variance. The Grand pays 3.4% of the money, lands about once every 29,300 spins, and is responsible for around 60% of it. That table is basically the personality of the game.
You can see the skew inside the features too. The average hold and spin pays 48.8x, but more than half of them pay under 30x. The mean is being dragged up by the rare full screens. Free games are gentler: about 11% pay under 10x and about 6% pay 100x or more.
Equation (13) also shows the dial designers actually turn. Halve a feature's trigger rate and double its award. pμ stays the same, so the RTP doesn't move at all. But p·E[X2] becomes (p/2) · 4E[X2] = 2p·E[X2]. Same RTP, twice the variance from that bucket. RTP and volatility are separate knobs, and the features are where the second one lives.
A couple of other numbers from the run. Hit frequency was 42.8%, so a spin pays something a bit less than half the time, but 58% of those "wins" pay back less than the bet. That's the classic slot win that's actually a loss. And a spin paid 100x or more about once every 1,600 spins.
§ 9 What I'm Taking Away
Three things stuck with me. First, RTP is a sum, and nearly every piece of the work maps onto one of its terms. Second, independence is a gift. Ways pays, scatter triggers and coin counts all break down into per-reel calculations, and those are exact and basically instant. Third, the simulator isn't there to find the RTP. It's there to prove the code does what the maths says, and to answer the questions an expected value can't: how often a feature pays under 20x, how long a player might wait between big hits, what share of sessions go completely dry.
The RTP on the glass is a promise about averages. The σ nobody prints is what the player actually feels.
Appendix The Full Code
Everything above comes out of this one file. It uses only the Python standard library. Running
it prints the exact breakdown and then a 2 million spin check, which takes about 20 seconds on
a laptop. I ran 20 million for the post. Change BASE_COUNTS, the paytable or the
feature settings and both the exact numbers and the simulation update together, which makes it
a nice little sandbox for tuning.
"""
A toy 5x3, 243-ways video slot with three pieces:
1. base game (ways pays, wild on reels 2 to 4)
2. free games (3 scatters on reels 1, 3, 5 -> 8 free games at 3x, retriggerable)
3. hold and spin (6+ coins anywhere -> respin feature, 3 respins that reset)
Everything is measured in multiples of the total bet, so a win of 2.0 means
twice what you staked on that spin.
"""
import math
import random
ROWS = 3
N_CELLS = 15
# Pays per way, for 3, 4 and 5 of a kind, in multiples of total bet.
PAYS = {
"P1": {3: 1.00, 4: 2.50, 5: 8.00},
"P2": {3: 0.60, 4: 1.60, 5: 5.00},
"A": {3: 0.30, 4: 0.70, 5: 1.60},
"K": {3: 0.20, 4: 0.60, 5: 1.40},
"Q": {3: 0.20, 4: 0.40, 5: 1.00},
"J": {3: 0.15, 4: 0.30, 5: 0.90},
}
# How many of each symbol sit on each reel strip.
BASE_COUNTS = [
dict(P1=3, P2=4, A=6, K=6, Q=7, J=7, S=2, C=5),
dict(P1=3, P2=4, A=6, K=6, Q=7, J=7, W=2, C=6),
dict(P1=3, P2=4, A=6, K=6, Q=7, J=7, W=2, S=3, C=5),
dict(P1=3, P2=4, A=6, K=6, Q=7, J=7, W=2, C=6),
dict(P1=3, P2=4, A=6, K=6, Q=7, J=7, S=2, C=5),
]
FREE_COUNTS = [
dict(P1=4, P2=5, A=6, K=6, Q=6, J=6, S=3),
dict(P1=4, P2=5, A=6, K=6, Q=6, J=6, W=3),
dict(P1=4, P2=5, A=6, K=6, Q=6, J=6, W=3, S=2),
dict(P1=4, P2=5, A=6, K=6, Q=6, J=6, W=3),
dict(P1=4, P2=5, A=6, K=6, Q=6, J=6, S=3),
]
FREE_GAMES_AWARD = 8
FREE_GAMES_MULT = 3.0
HS_TRIGGER = 6 # coins needed to start hold and spin
HS_RESPINS = 3 # respins, reset whenever a new coin lands
HS_P_COIN = 0.07 # chance an empty cell lands a coin on a respin
HS_GRAND = 1000.0 # extra prize for filling all 15 cells
# Coin values (x bet) and their weights. 20 and 100 are the Minor and Major.
COIN_VALUES = [1, 2, 3, 5, 10, 20, 100]
COIN_WEIGHTS = [40, 25, 15, 10, 6, 3, 1]
def build_strip(counts, seed):
"""Shuffle the symbols into a strip, retrying until no two scatters
are close enough to show up in the same 3-row window."""
rng = random.Random(seed)
strip = [sym for sym, n in counts.items() for _ in range(n)]
L = len(strip)
while True:
rng.shuffle(strip)
pos = [i for i, s in enumerate(strip) if s == "S"]
if all(min((a - b) % L, (b - a) % L) >= ROWS
for a in pos for b in pos if a != b):
return strip
BASE_REELS = [build_strip(c, 100 + i) for i, c in enumerate(BASE_COUNTS)]
FREE_REELS = [build_strip(c, 200 + i) for i, c in enumerate(FREE_COUNTS)]
def window(strip, stop):
"""The 3 symbols you see on one reel when it stops at position `stop`."""
L = len(strip)
return [strip[(stop + r) % L] for r in range(ROWS)]
# ==================================================================
# Part 1: exact maths. Reels stop independently, which is the whole trick.
# ==================================================================
def reel_stats(strip, sym):
"""Average number of sym-or-wild in the window, and P(none at all)."""
L = len(strip)
counts = [sum(x in (sym, "W") for x in window(strip, s)) for s in range(L)]
return sum(counts) / L, sum(c == 0 for c in counts) / L
def exact_ways_ev(reels, mult=1.0):
"""Expected ways win per spin.
E[win for sym, exactly k] = pay * E[c1]...E[ck] * P(c_{k+1} = 0)."""
total = 0.0
for sym, pay in PAYS.items():
stats = [reel_stats(r, sym) for r in reels]
for k in (3, 4, 5):
ev = math.prod(stats[i][0] for i in range(k))
if k < 5:
ev *= stats[k][1] # the run has to stop at reel k
total += pay[k] * ev
return total * mult
def p_scatter(strip):
L = len(strip)
return sum("S" in window(strip, s) for s in range(L)) / L
def p_free_trigger(reels):
"""Scatters live on reels 1, 3, 5 and we need all three."""
return p_scatter(reels[0]) * p_scatter(reels[2]) * p_scatter(reels[4])
def coin_count_dist(reels):
"""Exact distribution of total coins on screen, by convolving reels."""
dist = [1.0]
for strip in reels:
L = len(strip)
per_reel = [0.0] * (ROWS + 1)
for s in range(L):
per_reel[sum(x == "C" for x in window(strip, s))] += 1 / L
new = [0.0] * (len(dist) + ROWS)
for i, a in enumerate(dist):
for j, b in enumerate(per_reel):
new[i + j] += a * b
dist = new
return dist
def hs_exact(start, p=HS_P_COIN):
"""Solve hold and spin as a Markov chain on (cells filled, respins left).
Returns E[coins at the end] and P(all 15 filled). Works only because
coin values are drawn independently of how the board fills up."""
R = HS_RESPINS
Ec, Pf = {}, {}
for n in range(N_CELLS, start - 1, -1):
for r in range(R + 1):
if n == N_CELLS:
Ec[n, r], Pf[n, r] = N_CELLS, 1.0
continue
if r == 0:
Ec[n, r], Pf[n, r] = n, 0.0
continue
e = N_CELLS - n
stay = (1 - p) ** e # nothing lands this respin
ec, pf = stay * Ec[n, r - 1], stay * Pf[n, r - 1]
for j in range(1, e + 1): # j new coins, counter resets
pj = math.comb(e, j) * p ** j * (1 - p) ** (e - j)
ec += pj * Ec[n + j, R]
pf += pj * Pf[n + j, R]
Ec[n, r], Pf[n, r] = ec, pf
return Ec[start, R], Pf[start, R]
# ==================================================================
# Part 2: one spin of each piece of the game.
# ==================================================================
def spin_grid(reels, rng):
return [window(r, rng.randrange(len(r))) for r in reels]
def ways_win(grid, mult=1.0):
"""Walk left to right; ways = product of matching counts per reel."""
win = 0.0
for sym, pay in PAYS.items():
ways, k = 1, 0
for col in grid:
c = sum(x in (sym, "W") for x in col)
if c == 0:
break
ways *= c
k += 1
if k >= 3:
win += pay[k] * ways
return win * mult
def scatters(grid):
return sum("S" in grid[i] for i in (0, 2, 4))
def coins(grid):
return sum(x == "C" for col in grid for x in col)
def coin_value(rng):
return rng.choices(COIN_VALUES, COIN_WEIGHTS)[0]
def play_free_games(rng):
"""Returns (total win, number of free spins played)."""
left, played, win = FREE_GAMES_AWARD, 0, 0.0
while left > 0:
grid = spin_grid(FREE_REELS, rng)
win += ways_win(grid, FREE_GAMES_MULT)
if scatters(grid) == 3:
left += FREE_GAMES_AWARD # retrigger
left -= 1
played += 1
return win, played
def play_hold_and_spin(start_coins, rng):
"""Held coins stay put. Each respin, every empty cell has HS_P_COIN
to land a coin. Any new coin resets the respin counter."""
values = [coin_value(rng) for _ in range(start_coins)]
respins = HS_RESPINS
while respins > 0 and len(values) < N_CELLS:
respins -= 1
empty = N_CELLS - len(values)
new = sum(rng.random() < HS_P_COIN for _ in range(empty))
if new:
values += [coin_value(rng) for _ in range(new)]
respins = HS_RESPINS
win = sum(values)
if len(values) == N_CELLS:
win += HS_GRAND
return win
def play_one(rng):
"""One paid spin. Returns wins split by where they came from."""
grid = spin_grid(BASE_REELS, rng)
base = ways_win(grid)
fg = play_free_games(rng)[0] if scatters(grid) == 3 else 0.0
n = coins(grid)
hs = play_hold_and_spin(n, rng) if n >= HS_TRIGGER else 0.0
return base, fg, hs
# ==================================================================
# Part 3: run it.
# ==================================================================
def simulate(n_spins, seed=7):
rng = random.Random(seed)
tot = {"base": 0.0, "free": 0.0, "hs": 0.0}
s1 = s2 = 0.0 # running sum and sum of squares of total win
hits = 0
big = 0
for _ in range(n_spins):
b, f, h = play_one(rng)
w = b + f + h
tot["base"] += b; tot["free"] += f; tot["hs"] += h
s1 += w; s2 += w * w
hits += w > 0
big += w >= 100
mean = s1 / n_spins
sd = math.sqrt(s2 / n_spins - mean ** 2)
return {
"rtp": mean,
"parts": {k: v / n_spins for k, v in tot.items()},
"sd": sd,
"ci95": 1.96 * sd / math.sqrt(n_spins),
"hit_freq": hits / n_spins,
"p_100x": big / n_spins,
}
if __name__ == "__main__":
# Exact pieces
base = exact_ways_ev(BASE_REELS)
p_fg = p_free_trigger(BASE_REELS)
q = p_free_trigger(FREE_REELS)
exp_spins = FREE_GAMES_AWARD / (1 - FREE_GAMES_AWARD * q)
per_spin = exact_ways_ev(FREE_REELS, FREE_GAMES_MULT)
fg = p_fg * exp_spins * per_spin # Wald: E[S] * E[win per spin]
dist = coin_count_dist(BASE_REELS)
p_hs = sum(dist[HS_TRIGGER:])
mean_coin = sum(v * w for v, w in zip(COIN_VALUES, COIN_WEIGHTS)) / sum(COIN_WEIGHTS)
hs_award = 0.0
for k in range(HS_TRIGGER, len(dist)):
if dist[k] > 0:
e_coins, p_full = hs_exact(k)
hs_award += dist[k] / p_hs * (e_coins * mean_coin + p_full * HS_GRAND)
hs = p_hs * hs_award
print("EXACT")
print(f" base game {base:8.4%}")
print(f" free games {fg:8.4%} 1 in {1/p_fg:.1f}, "
f"E[spins] {exp_spins:.3f}, E[award] {exp_spins*per_spin:.2f}x")
print(f" hold and spin {hs:8.4%} 1 in {1/p_hs:.1f}, E[award] {hs_award:.2f}x")
print(f" total {base+fg+hs:8.4%}")
# Monte Carlo check (I ran 20 million for the post; 2 million takes ~20s)
r = simulate(2_000_000)
print("SIMULATED")
for k, v in r["parts"].items():
print(f" {k:<14} {v:8.4%}")
print(f" total {r['rtp']:8.4%} +/- {r['ci95']:.4%} (95%)")
print(f" sd per spin {r['sd']:.3f}, hit freq {r['hit_freq']:.4f}, "
f"P(win >= 100x) {r['p_100x']:.6f}")