The Math Behind “The House Always Wins”
Last Updated on June 25, 2026 by Editorial Team
Author(s): Fern
Originally published on Towards AI.
The Math Behind “The House Always Wins”

Casino probabilities are not guesses. They are calculated by listing every possible outcome, assigning each outcome its correct probability, and then checking which outcomes lead to a win, loss, or tie.
Every single game on the floor is built around a concept called Expected Value (EV). In simple terms, EV is the mathematical average of what you can expect to win (or lose) per bet over the long run. The general formula behind every casino game is:
In plain English, calculating your Expected Value looks like this: (Probability of Winning x Win Amount) + (Probability of Losing x Loss Amount)
Because the player’s expected value is almost always negative, the casino holds a mathematical advantage known as the House Edge. Let’s break down the probabilities of the most popular games to see where your math actually stands a fighting chance, and where you are just paying a tax on statistical ignorance.
The Illusion of 50/50: European vs. American Roulette
For simple games like roulette, calculating probability can be done with basic fractions. When you bet on Red or Black, it feels like a 50/50 coin flip. You win double your money or lose your bet. The mathematical genius of Roulette, however, lies in the green zero.
European Roulette has 37 equally likely pockets: the numbers 1 to 36, and one green zero.
There are 18 winning red pockets out of 37 total pockets. So, your true probability of hitting red is 18 divided by 37, or 48.65%.
The losing outcomes are the 18 black pockets plus the green zero, meaning your probability of losing is 19 divided by 37, or 51.35%.
If you wager $100 on red, you win $100 exactly 48.65% of the time, and you lose your $100 exactly 51.35% of the time. The difference between those two numbers is a net loss of $2.70. That is your 2.70% house edge.
Simulating European Roulette

Each faint blue line represents one player repeatedly betting ₹25 on red in European roulette. Individual players may temporarily win, but the average bankroll declines because the casino has a built-in mathematical advantage. The red dashed line shows the unconstrained expected bankroll, while the solid blue line shows what actually happens in the simulation after bankrupt players stop betting. Over more spins, an increasing number of players reach the ₹0 bankruptcy boundary.
# Average bankroll across all simulated players
player_columns = [
column for column in df_sim.columns
if column != "Theoretical EV"
]
df_sim["Average Simulated Bankroll"] = df_sim[player_columns].mean(axis=1)
fig = px.line(
df_sim,
x=df_sim.index,
y=player_columns,
title="Simulating Gambler's Ruin: 100 Players vs. European Roulette",
labels={
"value": "Bankroll (₹)",
"Spin Number": "Number of Spins",
"variable": "Simulation"
},
template="plotly_white"
)
# Style and hide individual players from the legend
for trace in fig.data:
trace.line.color = "rgba(30, 144, 255, 0.12)"
trace.line.width = 1
trace.showlegend = False
trace.hoverinfo = "skip"
# Add average simulated bankroll
fig.add_scatter(
x=df_sim.index,
y=df_sim["Average Simulated Bankroll"],
mode="lines",
name="Average Simulated Bankroll",
line=dict(
color="blue",
width=4
)
)
# Add unconstrained theoretical EV
fig.add_scatter(
x=df_sim.index,
y=df_sim["Theoretical EV"],
mode="lines",
name="Unconstrained Theoretical EV",
line=dict(
color="red",
width=4,
dash="dash"
)
)
# Add the bankruptcy boundary
fig.add_hline(
y=0,
line_width=2,
line_dash="dot",
line_color="black",
annotation_text="Bankruptcy boundary",
annotation_position="bottom right"
)
fig.update_layout(
plot_bgcolor="white",
paper_bgcolor="white",
hovermode="x unified",
legend=dict(
title="",
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
),
xaxis=dict(
showgrid=False,
title="Number of Spins"
),
yaxis=dict(
showgrid=True,
gridcolor="rgba(0, 0, 0, 0.08)",
title="Bankroll (₹)"
)
)
fig.show()
Is American Roulette any different?
American Roulette fundamentally changes the math by adding an extra pocket: the double zero. Now there are 38 pockets, but still only 18 red ones. Your chances of winning drop to 18 out of 38 (47.37%). There are now 20 losing outcomes, bumping the loss probability to 52.63%. Running the same Expected Value calculation for a $100 wager yields an average loss of $5.26. That single extra double-zero pocket nearly doubles the house edge to 5.26%. You are getting paid 1-to-1 on a bet that has significantly worse than 1-to-1 odds.
Craps: Combining Probabilities
For games such as craps, calculating the edge means we must combine probabilities across several rolls. Craps uses two six-sided dice, creating 36 possible ordered combinations (6 x 6).
However, the totals are not equally likely. There is only one way to roll a 2 (a 1 and a 1), but there are six ways to roll a 7 (1+6, 2+5, 3+4, 4+3, 5+2, 6+1).
If you place a basic Pass Line bet, you win immediately on a 7 or 11 (which accounts for 8 of the 36 combinations). You lose immediately on a 2, 3, or 12 (4 of the 36 combinations).
The remaining totals establish a “point.” If a point is established — say, a 4 — you must roll a 4 again before you roll a 7 to win. Because there are three ways to roll a 4 and six ways to roll a 7, your probability of making a point of 4 is 3 chances out of 9 total deciding rolls (or roughly 33%).
To find the true probability of winning craps, a data scientist adds up the immediate win probability with the probability of hitting every single possible point. When you add all those fractions together, your true probability of winning a Pass Line bet sits at about 49.29%. Because the bet pays even money, your long-term average is roughly a 1.41% loss, making it one of the mathematically fairest bets on the floor.
Simulating Craps

The chart starts with substantial random variation because only a small number of bets have been simulated. As the number of bets increases, the simulated win rate stabilises near the theoretical Pass Line probability of 49.29%. Because wins pay even money while losses occur slightly more often, the casino retains a long-run house edge of approximately 1.41%.
import numpy as np
import pandas as pd
import plotly.graph_objects as go
def roll_two_dice(rng, size=None):
"""
Roll two fair six-sided dice and return their totals.
"""
die_1 = rng.integers(1, 7, size=size)
die_2 = rng.integers(1, 7, size=size)
return die_1 + die_2
def simulate_pass_line_bets(
num_bets=1_000_000,
random_seed=42
):
"""
Simulate independent Craps Pass Line bets.
Pass Line rules:
- Come-out roll:
7 or 11 -> immediate win
2, 3 or 12 -> immediate loss
4, 5, 6, 8, 9 or 10 -> point established
- Point stage:
Roll the point again before 7 -> win
Roll 7 before the point -> loss
Returns
-------
numpy.ndarray
Boolean array where True represents a win
and False represents a loss.
"""
rng = np.random.default_rng(random_seed)
# First roll of every Pass Line bet
come_out_rolls = roll_two_dice(
rng=rng,
size=num_bets
)
# Immediate outcomes
wins = np.isin(
come_out_rolls,
[7, 11]
)
losses = np.isin(
come_out_rolls,
[2, 3, 12]
)
# Bets where a point was established
unresolved = ~(wins | losses)
points = come_out_rolls[unresolved]
# Store the result of each point-stage bet
point_results = np.zeros(
len(points),
dtype=bool
)
# Track bets that have not yet rolled
# either their point or a 7
active = np.ones(
len(points),
dtype=bool
)
while active.any():
active_indices = np.flatnonzero(active)
active_points = points[active]
new_rolls = roll_two_dice(
rng=rng,
size=active.sum()
)
point_hit = new_rolls == active_points
seven_hit = new_rolls == 7
decided = point_hit | seven_hit
# Bets hitting their point are wins
winning_indices = active_indices[point_hit]
point_results[winning_indices] = True
# Remove all decided bets
decided_indices = active_indices[decided]
active[decided_indices] = False
# Insert point-stage outcomes into the main result
wins[unresolved] = point_results
return wins
def create_convergence_chart(
results,
theoretical_win_probability
):
"""
Plot the running simulated win probability.
"""
num_bets = len(results)
# Use logarithmically spaced checkpoints so the chart
# shows both early volatility and long-run convergence
checkpoints = np.unique(
np.logspace(
1,
np.log10(num_bets),
500
).astype(int)
)
cumulative_wins = np.cumsum(results)
running_win_probability = (
cumulative_wins[checkpoints - 1]
/ checkpoints
)
figure = go.Figure()
figure.add_trace(
go.Scatter(
x=checkpoints,
y=running_win_probability,
mode="lines",
name="Simulated win probability",
line=dict(width=3)
)
)
figure.add_hline(
y=theoretical_win_probability,
line_dash="dash",
line_width=3,
annotation_text=(
"Theoretical probability: "
f"{theoretical_win_probability:.2%}"
),
annotation_position="top right"
)
figure.update_layout(
title=(
"Craps Pass Line: "
"Simulated Win Probability"
),
template="plotly_white",
plot_bgcolor="white",
paper_bgcolor="white",
hovermode="x unified",
xaxis=dict(
title="Number of Simulated Bets",
type="log",
showgrid=False
),
yaxis=dict(
title="Running Win Probability",
tickformat=".2%",
gridcolor="rgba(0, 0, 0, 0.08)"
),
legend=dict(
title="",
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="left",
x=0
)
)
return figure
# ---------------------------------------------------------
# Run the simulation
# ---------------------------------------------------------
NUM_BETS = 1_000_000
RANDOM_SEED = 42
results = simulate_pass_line_bets(
num_bets=NUM_BETS,
random_seed=RANDOM_SEED
)
# ---------------------------------------------------------
# Calculate simulation results
# ---------------------------------------------------------
simulated_wins = results.sum()
simulated_losses = NUM_BETS - simulated_wins
simulated_win_probability = results.mean()
simulated_loss_probability = 1 - simulated_win_probability
# An even-money bet earns +1 for a win and -1 for a loss.
# House edge = loss probability - win probability.
simulated_house_edge = (
simulated_loss_probability
- simulated_win_probability
)
# ---------------------------------------------------------
# Exact theoretical results
# ---------------------------------------------------------
theoretical_win_probability = 244 / 495
theoretical_loss_probability = 251 / 495
theoretical_house_edge = 7 / 495
# ---------------------------------------------------------
# Display summary
# ---------------------------------------------------------
summary = pd.DataFrame(
{
"Metric": [
"Number of simulated bets",
"Simulated wins",
"Simulated losses",
"Simulated win probability",
"Theoretical win probability",
"Simulated house edge",
"Theoretical house edge"
],
"Value": [
f"{NUM_BETS:,}",
f"{simulated_wins:,}",
f"{simulated_losses:,}",
f"{simulated_win_probability:.4%}",
f"{theoretical_win_probability:.4%}",
f"{simulated_house_edge:.4%}",
f"{theoretical_house_edge:.4%}"
]
}
)
print("\nCRAPS PASS LINE SIMULATION")
print("=" * 55)
print(summary.to_string(index=False))
# ---------------------------------------------------------
# Create and display chart
# ---------------------------------------------------------
figure = create_convergence_chart(
results=results,
theoretical_win_probability=theoretical_win_probability
)
figure.show()
Baccarat: Conditional Probability
A standard Baccarat shoe often contains eight decks (416 cards). Calculating the probability of an initial hand means tracking fractions that change with every draw. For example, if the first card is an Ace, there is one less Ace available for the next card, changing the denominator from 416 to 415.
Furthermore, drawing rules apply depending on player totals. Because the “Banker” hand acts after the “Player” hand, its drawing rules can respond to whether the Player drew and what card they received. This structural advantage means the Banker wins more often (about 45.86% of the time).
Simulating Baccarat

The simulated Banker win probability fluctuates heavily over the first few hundred rounds because the sample is still small. As more rounds are played, the running probability steadily converges toward the theoretical value of approximately 45.86%. This demonstrates the Law of Large Numbers: short-term outcomes are noisy, but long-run results become increasingly predictable.
import random
import numpy as np
import pandas as pd
import plotly.graph_objects as go
# ============================================================
# SETTINGS
# ============================================================
NUM_DECKS = 8
NUM_ROUNDS = 1_000_000
BANKER_COMMISSION = 0.05
RANDOM_SEED = 42
# ============================================================
# CARD UTILITIES
# ============================================================
def create_shoe(num_decks=8):
"""
Create a Baccarat shoe.
Baccarat card values:
- Ace = 1
- 2 to 9 = face value
- 10, Jack, Queen, King = 0
"""
single_deck = (
[1] * 4
+ [2] * 4
+ [3] * 4
+ [4] * 4
+ [5] * 4
+ [6] * 4
+ [7] * 4
+ [8] * 4
+ [9] * 4
+ [0] * 16
)
shoe = single_deck * num_decks
random.shuffle(shoe)
return shoe
def baccarat_total(cards):
"""
Baccarat totals use only the final digit.
Example:
7 + 8 = 15 -> total of 5
"""
return sum(cards) % 10
# ============================================================
# DRAWING RULES
# ============================================================
def player_draws(player_total):
"""
Player draws on totals 0 to 5.
Player stands on totals 6 and 7.
Naturals 8 and 9 end the round immediately.
"""
return player_total <= 5
def banker_draws(
banker_total,
player_drew,
player_third_card=None
):
"""
Apply the standard Baccarat Banker drawing rules.
"""
# If Player stood, Banker draws on 0 to 5
if not player_drew:
return banker_total <= 5
# If Player drew a third card, Banker uses the table below
if banker_total <= 2:
return True
if banker_total == 3:
return player_third_card != 8
if banker_total == 4:
return player_third_card in [2, 3, 4, 5, 6, 7]
if banker_total == 5:
return player_third_card in [4, 5, 6, 7]
if banker_total == 6:
return player_third_card in [6, 7]
return False
# ============================================================
# SIMULATE ONE ROUND
# ============================================================
def simulate_round(shoe):
"""
Simulate one Baccarat round.
"""
# Deal order:
# Player, Banker, Player, Banker
player_cards = [
shoe.pop(),
shoe.pop()
]
banker_cards = [
shoe.pop(),
shoe.pop()
]
player_total = baccarat_total(player_cards)
banker_total = baccarat_total(banker_cards)
player_third_card = None
player_drew = False
# Natural 8 or 9: no more cards are drawn
if player_total not in [8, 9] and banker_total not in [8, 9]:
if player_draws(player_total):
player_third_card = shoe.pop()
player_cards.append(player_third_card)
player_drew = True
player_total = baccarat_total(player_cards)
if banker_draws(
banker_total=banker_total,
player_drew=player_drew,
player_third_card=player_third_card
):
banker_cards.append(shoe.pop())
banker_total = baccarat_total(banker_cards)
if player_total > banker_total:
outcome = "Player"
elif banker_total > player_total:
outcome = "Banker"
else:
outcome = "Tie"
return {
"outcome": outcome,
"player_total": player_total,
"banker_total": banker_total,
"player_cards": len(player_cards),
"banker_cards": len(banker_cards)
}
# ============================================================
# RUN SIMULATION
# ============================================================
def simulate_baccarat(
num_rounds=1_000_000,
num_decks=8,
random_seed=42
):
"""
Simulate many Baccarat rounds.
A fresh shoe is created whenever too few cards remain.
"""
random.seed(random_seed)
np.random.seed(random_seed)
shoe = create_shoe(num_decks)
records = []
for round_number in range(1, num_rounds + 1):
# A Baccarat round can use at most six cards.
# Reshuffle early for safety.
if len(shoe) < 20:
shoe = create_shoe(num_decks)
result = simulate_round(shoe)
result["round"] = round_number
records.append(result)
return pd.DataFrame(records)
df = simulate_baccarat(
num_rounds=NUM_ROUNDS,
num_decks=NUM_DECKS,
random_seed=RANDOM_SEED
)
# ============================================================
# OUTCOME PROBABILITIES
# ============================================================
outcome_counts = df["outcome"].value_counts()
banker_probability = (
outcome_counts.get("Banker", 0) / NUM_ROUNDS
)
player_probability = (
outcome_counts.get("Player", 0) / NUM_ROUNDS
)
tie_probability = (
outcome_counts.get("Tie", 0) / NUM_ROUNDS
)
# ============================================================
# BET RETURNS
# ============================================================
# Banker bet:
# +0.95 on Banker win
# -1 on Player win
# 0 on Tie
df["banker_bet_profit"] = np.select(
[
df["outcome"] == "Banker",
df["outcome"] == "Player"
],
[
1 - BANKER_COMMISSION,
-1
],
default=0
)
# Player bet:
# +1 on Player win
# -1 on Banker win
# 0 on Tie
df["player_bet_profit"] = np.select(
[
df["outcome"] == "Player",
df["outcome"] == "Banker"
],
[
1,
-1
],
default=0
)
# Tie bet at 8:1:
# +8 on Tie
# -1 otherwise
df["tie_bet_profit"] = np.where(
df["outcome"] == "Tie",
8,
-1
)
# ============================================================
# HOUSE EDGE
# ============================================================
banker_return = df["banker_bet_profit"].mean()
player_return = df["player_bet_profit"].mean()
tie_return = df["tie_bet_profit"].mean()
banker_house_edge = -banker_return
player_house_edge = -player_return
tie_house_edge = -tie_return
# ============================================================
# SUMMARY
# ============================================================
summary = pd.DataFrame({
"Metric": [
"Rounds simulated",
"Banker win probability",
"Player win probability",
"Tie probability",
"Banker bet house edge",
"Player bet house edge",
"Tie bet house edge"
],
"Value": [
f"{NUM_ROUNDS:,}",
f"{banker_probability:.4%}",
f"{player_probability:.4%}",
f"{tie_probability:.4%}",
f"{banker_house_edge:.4%}",
f"{player_house_edge:.4%}",
f"{tie_house_edge:.4%}"
]
})
print("\nBACCARAT MONTE CARLO SIMULATION")
print("=" * 55)
print(summary.to_string(index=False))
# ============================================================
# CONVERGENCE CHART
# ============================================================
df["banker_win"] = (
df["outcome"] == "Banker"
).astype(int)
df["running_banker_probability"] = (
df["banker_win"].cumsum()
/ df["round"]
)
checkpoints = np.unique(
np.logspace(
2,
np.log10(NUM_ROUNDS),
500
).astype(int)
)
plot_data = df.iloc[checkpoints - 1]
figure = go.Figure()
figure.add_trace(
go.Scatter(
x=plot_data["round"],
y=plot_data["running_banker_probability"],
mode="lines",
name="Simulated Banker probability",
line=dict(width=3)
)
)
figure.add_hline(
y=0.4586,
line_dash="dash",
line_width=3,
annotation_text="Expected Banker probability: ~45.86%",
annotation_position="top right"
)
figure.update_layout(
title="Baccarat: Banker Win Probability Convergence",
template="plotly_white",
plot_bgcolor="white",
paper_bgcolor="white",
hovermode="x unified",
xaxis=dict(
title="Number of Simulated Rounds",
type="log",
showgrid=False
),
yaxis=dict(
title="Running Banker Win Probability",
tickformat=".2%",
gridcolor="rgba(0, 0, 0, 0.08)"
)
)
figure.show()
Conclusion: The Law of Large Numbers
Whether you are calculating simple fractions on a roulette wheel or building complex decision trees for blackjack, the underlying idea never changes. The casino identifies every possible outcome, calculates how likely it is, assigns its payout, and combines everything into one massive weighted average.
So, if the math is rigged, why do people win? Short-term variance. If you flip a coin 10 times, getting 8 heads and 2 tails is entirely plausible. If you play 10 hands of Blackjack, you might win 8 of them and walk away feeling like a mathematical genius.
However, casinos do not care about your 10 hands. They care about the 10 million hands played across their floor over a year.
Thanks to a statistical rule called the Law of Large Numbers, as the sample size increases, the actual results will relentlessly converge on the theoretical expected value. The casino is playing the long game; and you are playing the short game. Over a long enough timeline, the math always wins.
Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.
Published via Towards AI
Towards AI Academy
We Build Enterprise-Grade AI. We'll Teach You to Master It Too.
15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.
Start free — no commitment:
→ 6-Day Agentic AI Engineering Email Guide — one practical lesson per day
→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages
Our courses:
→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.
→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.
→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.
Note: Article content contains the views of the contributing authors and not Towards AI.