Gambling Fundamentals: House Edge, RTP & Variance
Every gambling game is built on one idea: the price the player pays to play is, on average, slightly more than what the game pays back. That gap is the house edge. Its mirror image is RTP (Return To Player): a game with 96% RTP returns 96 cents of every dollar wagered over the long run and keeps 4 cents. The edge is guaranteed by math, not by cheating — it just needs enough spins to show up.
| Term | What it means |
|---|---|
| RTP | Long-run % of wagers paid back to players |
| House edge | 100% − RTP; the operator keeps this on average |
| Variance / volatility | How wildly results swing around the average |
| Hit frequency | How often a spin returns anything at all |
| Max win | Largest multiplier the paytable can produce |
Anatomy of a Slot
A modern video slot is a grid of reels (columns) and rows. Each reel is really a long strip of symbols; a spin picks a random stop on each strip and shows a window of symbols. Paylines are fixed paths across the grid; matching symbols along a line pays out per the paytable.
| Symbol type | Role |
|---|---|
| Regular | Pays when matched along a payline |
| Wild | Substitutes for regular symbols to complete a line |
| Scatter | Pays anywhere on screen, often triggers free spins |
| Bonus | Launches a mini-game or feature round |
The RNG — Where Every Outcome Begins
The outcome of a spin is decided by a random number generator the instant the player presses spin — the spinning animation is pure theatre. For real-money play the RNG must be a CSPRNG (cryptographically secure), never Math.random(), and it must live on the server where the client can't tamper with or predict it.
// Server-side ONLY — a cryptographically secure RNG, never Math.random().
import { randomInt } from "node:crypto";
// Pick a stop on a reel strip of `stripLength` positions.
// randomInt is uniform over [0, stripLength) and unpredictable.
function pickStop(stripLength: number): number {
return randomInt(stripLength);
}
// Math.random() is a fast PRNG seeded from a small state — an attacker who
// observes enough outputs can predict the next spin. Real-money engines use a
// CSPRNG (crypto.randomInt / getRandomValues) or a certified hardware RNG.Virtual Reels — Tuning Odds Invisibly
The trick that lets designers hit an exact RTP is the virtual reel: the strip has far more stops than visible symbols, and each symbol occupies a different number of stops. Rare symbols get few stops, blanks get many — so you control the odds precisely while the visible reel looks unchanged.
// A "virtual reel" maps many strip positions onto a few visible symbols.
// The player sees 22 symbols, but the strip has 64 stops — so rare symbols
// occupy few stops and common ones occupy many. This is how you tune odds
// WITHOUT changing what the player sees on the physical/rendered reel.
type Symbol = "SEVEN" | "BAR" | "BELL" | "CHERRY" | "BLANK";
// stops per symbol on ONE reel — sums to the strip length (64)
const reelStrip: Record<Symbol, number> = {
SEVEN: 2, // very rare
BAR: 6,
BELL: 10,
CHERRY: 14,
BLANK: 32, // most stops are "nothing"
};
// Probability of landing SEVEN on this reel = 2 / 64 = 3.125%
// Probability of three SEVENs across 3 independent reels = (2/64)^3 ≈ 0.003%Weighted Selection — The Core Algorithm
Picking a stop is a weighted random selection. Build a cumulative-weight table once, draw one secure random number, and binary-search the bucket it falls into. This is O(log n) per pick and is the same primitive used for loot tables, gacha, and card draws.
// Weighted random selection — the core of every slot outcome.
// Build a cumulative table once, then binary-search a single random draw.
import { randomInt } from "node:crypto";
interface Weighted<T> { value: T; weight: number; }
function buildCumulative<T>(items: Weighted<T>[]) {
const cum: number[] = [];
let total = 0;
for (const it of items) {
total += it.weight;
cum.push(total);
}
return { cum, total };
}
function pickWeighted<T>(items: Weighted<T>[]): T {
const { cum, total } = buildCumulative(items);
const roll = randomInt(total); // [0, total)
// first cumulative bucket strictly greater than roll
let lo = 0, hi = cum.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (roll < cum[mid]!) hi = mid;
else lo = mid + 1;
}
return items[lo]!.value;
}Payline Evaluation & the Paytable
Once the grid is fixed, the engine walks each active payline from the leftmost reel, counts consecutive matching symbols, and looks up the multiplier in the paytable. Wilds substitute; scatters are checked separately because they pay regardless of position.
// Evaluate a 3x5 grid against a set of paylines.
// A payline is a fixed path of one cell per column (left → right).
// A win = the same symbol on consecutive columns starting from column 0.
type Grid = Symbol[][]; // grid[col][row]
// each payline lists the ROW index to read in each of the 5 columns
const paylines: number[][] = [
[1, 1, 1, 1, 1], // middle row
[0, 0, 0, 0, 0], // top row
[2, 2, 2, 2, 2], // bottom row
[0, 1, 2, 1, 0], // V shape
];
// payout multiplier by symbol and match-length (3, 4, or 5 in a row)
const payTable: Partial<Record<Symbol, Record<number, number>>> = {
SEVEN: { 3: 50, 4: 200, 5: 1000 },
BAR: { 3: 20, 4: 60, 5: 250 },
BELL: { 3: 10, 4: 30, 5: 100 },
CHERRY: { 3: 4, 4: 10, 5: 40 },
};
function evaluate(grid: Grid, betPerLine: number) {
let totalWin = 0;
for (const line of paylines) {
const first = grid[0]![line[0]!]!;
let run = 1;
for (let col = 1; col < line.length; col++) {
if (grid[col]![line[col]!] === first) run++;
else break; // must be consecutive from the leftmost reel
}
const mult = payTable[first]?.[run];
if (mult) totalWin += mult * betPerLine;
}
return totalWin;
}RTP by Design — Math, Then Simulation
RTP isn't discovered, it's designed. Summing (probability × payout) over every outcome gives the exact theoretical RTP; a Monte-Carlo simulation of millions of spins confirms the strips and paytable actually deliver it. Certification labs re-run this before a game goes live.
// RTP (Return To Player) is a LONG-RUN average, computed by summing
// (probability × payout) over every possible outcome — or estimated by
// simulating millions of spins. House edge = 100% − RTP.
function estimateRtp(spins: number, bet: number): number {
let wagered = 0;
let returned = 0;
for (let i = 0; i < spins; i++) {
const grid = spinReels(); // uses the weighted CSPRNG picks
returned += evaluate(grid, bet / paylines.length);
wagered += bet;
}
return (returned / wagered) * 100; // → converges to the design RTP
}
// A machine designed for 96% RTP keeps ~4% over millions of spins.
// Any SINGLE session can win big or lose everything — that's variance,
// not a change in the edge.| Volatility | Player experience |
|---|---|
| Low | Frequent small wins, shallow swings |
| Medium | Balanced hit rate and payout size |
| High | Rare but large wins, deep bankroll swings |
How the Server Works — Authoritative Flow
A trustworthy engine is server-authoritative: the server validates the bet, debits the balance, generates the outcome, computes the win, persists an audit record, then returns the result for the client to animate. The client never decides anything that touches money.
Provably Fair & Auditing
Regulated engines log every spin's seed, nonce, bet, and result so an auditor can replay it. Crypto-style casinos go further with provably fair: the server commits to a hashed seed before the bet and reveals it after, letting players verify the outcome couldn't have been altered post-hoc.
// Provably fair: prove the outcome was fixed BEFORE the player saw it,
// without revealing the server seed up front (commit–reveal scheme).
import { createHash, randomBytes } from "node:crypto";
// 1. Server generates a secret seed and publishes only its hash (the commit).
const serverSeed = randomBytes(32).toString("hex");
const commit = createHash("sha256").update(serverSeed).digest("hex");
// → send `commit` to the client; keep `serverSeed` secret for now.
// 2. Client contributes its own seed + a per-bet incrementing nonce.
function outcome(serverSeed: string, clientSeed: string, nonce: number) {
const h = createHash("sha256")
.update(`${serverSeed}:${clientSeed}:${nonce}`)
.digest("hex");
// map the hash to reel stops deterministically
return parseInt(h.slice(0, 8), 16);
}
// 3. After the round, server reveals `serverSeed`. The client re-hashes it,
// checks it matches `commit`, and recomputes the same outcome — proving
// the server could not have altered the result after seeing the bet.