I’m building Original Table Games, four original casino table games in which the player rolls two dice and the dealer deals cards. It’s a portfolio piece for game aggregators and live-dealer studios, and for that audience the code that matters most is the code nobody sees: the math.

Every bet in a casino game declares its RTP, its return to player. A bet with an RTP of 97.3% hands back, in the long run, 97.3 cents of every euro staked; the house edge is the 2.7% it keeps. Operators publish these numbers, regulators audit them, and a game whose real return differs from its declared one is a broken game, however good it looks. So in this project every declared figure is proved twice, by two methods that fail in different ways. This article shows both on a small example you can run, and the numbers they produce in the real games. (These tests are the developer’s own evidence. In regulated markets the game’s math and its random number generator are also certified by an accredited test lab under that jurisdiction’s technical standards, and nothing here replaces that.)

A bet is a function from outcomes to payouts

Take the smallest possible game: the player rolls two dice. It has two bets:

  • Over 7 pays even money when the total is 8 or more.
  • Doubles pays 9 to 2 when both dice show the same face.

Write each bet as what it pays back per unit staked, stake included: 2 for an even-money win, 1 + 9/2 = 11/2 for a 9-to-2 win, and 0 for a loss.

type Bet = (d1: number, d2: number) => Fraction;

const over7: Bet = (a, b) => (a + b >= 8 ? frac(2n, 1n) : frac(0n, 1n));
const doubles: Bet = (a, b) => (a === b ? frac(11n, 2n) : frac(0n, 1n));

The RTP is the average payout over all outcomes, weighted by their probability. Two dice have 36 equally likely outcomes, so for Over 7 it’s 15 winning rolls × 2 ÷ 36 = 30/36 = 5/6, and for Doubles 6 × 11/2 ÷ 36 = 11/12. Easy by hand here; impossible by hand for a real game with dice, several cards and a decision in the middle.

Exact: run the game on every outcome

The exact method doesn’t model the rules in a spreadsheet. It runs the real game code on every possible sequence of draws and weighs each result by its probability:

function exactRtp(bet: Bet): Fraction {
  let rtp = frac(0n, 1n);
  const each = frac(1n, 36n);
  for (let a = 1; a <= 6; a++)
    for (let b = 1; b <= 6; b++) rtp = add(rtp, mul(each, bet(a, b)));
  return rtp;
}

Two choices make this a proof rather than an estimate.

Fractions of BigInts, not floats. 0.1 + 0.2 is not 0.3 in floating point, and errors add up over millions of outcomes. A fraction of two bigints is exact at any size: the test asserts the string '5/6', and the declared RTP in the bet definition, written as the literal 5 / 6, must equal the fraction converted to a number bit for bit. (Number(n) / Number(d) is correctly rounded only while both are below 2⁵³; a game with larger denominators would compare at a stated precision instead.)

The shipped code, not a copy of the rules. In the real engine the game asks a random number generator for draws. For enumeration, it gets a generator that replays a script; when the game asks for a draw past the end of the script, the enumerator branches on every possible value of that draw, each with its share of the parent’s probability, and replays the round. Every complete path yields a settlement and an exact probability. Because the production game runs on every branch, the result checks the implementation itself: a payout bug in the game shows up as a wrong fraction.

The cost grows with the product of the draw ranges: 36 paths for two dice, 2,704 for one card each to the player and the dealer (52 × 52), and 36 × 52³ for two dice and three cards. Cards are drawn from an infinite shoe for this purpose, every card independent and uniform. That’s a common simplification that keeps every game enumerable, but it isn’t the dealt shoe: many published figures for card games assume a real deck dealt without replacement, so the finite-shoe effect has to be checked on its own.

Advertisement

Monte Carlo: play millions of rounds

The second method plays the game, many times, with real draws, and compares the average return with the declared one:

function simulate(bet: Bet, rounds: number, seed: number) {
  const next = mulberry32(seed);
  let paid = 0;
  for (let i = 0; i < rounds; i++) {
    const p = bet(1 + randomInt(next, 6), 1 + randomInt(next, 6));
    paid += Number(p.n) / Number(p.d);
  }
  return paid / rounds;
}

It catches what enumeration can’t: the production draw path, and state leaking between rounds, because one game object plays millions of them. Two details keep it honest.

An unbiased die. next() % 6 is subtly wrong: 2³² isn’t a multiple of 6, so the lowest four faces come up a tiny bit more often. randomInt rejects the incomplete top bucket and draws again, so every face has probability exactly 1/6, given uniform 32-bit draws:

// next() must return an integer in [0, 2³²). The common mulberry32 snippet
// divides by 2³² to return a float in [0, 1); that version won't work here.
function mulberry32(a: number): () => number {
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return (t ^ (t >>> 14)) >>> 0;
  };
}

function randomInt(next: () => number, n: number): number {
  const limit = 2 ** 32 - (2 ** 32 % n); // reject the incomplete top bucket
  for (;;) {
    const x = next();
    if (x < limit) return x % n;
  }
}

A seed. The simulation uses a seeded generator, so a test is deterministic: it passes or fails the same way on every run and every machine, and never flakes. (Real play uses crypto.getRandomValues; the seeded generator is only for tests and replays. The demo here uses mulberry32 for brevity; the engine uses xoshiro128**, checked against its reference C implementation.)

How many rounds is enough?

This is where most simulations go wrong. “Run two million rounds and check it’s within 0.15 pp” sounds rigorous, but a fixed round count doesn’t give a fixed confidence. The standard error of a simulated RTP is σ/√n, where σ is the standard deviation of a round’s result per unit staked. For an even-money bet σ is about 1; over 2,000,000 rounds the standard error is 0.071 percentage points, so a ±0.15 pp check reaches only 2.1 standard errors either side, and a correct game would fail it on about 3.4% of seeds. For a volatile side bet with σ = 5 the same check would fail a correct game about two times in three.

So the project sizes each run so that the tolerance is 3.29 standard errors, about 99.9% two-sided confidence:

const roundsFor = (sigma: number, tolerance = 0.0015, z = 3.29) =>
  Math.ceil(((z * sigma) / tolerance) ** 2);

σ comes from the exact report, so each suite sizes itself. Running both methods on the two dice bets prints:

Over 7: exact 5/6 = 83.3333% · sigma 0.986 · rounds 4,677,081 · simulated 83.3206% · off by -0.0128 pp
Doubles: exact 11/12 = 91.6667% · sigma 2.050 · rounds 20,211,669 · simulated 91.7271% · off by 0.0604 pp

Both land well inside ±0.15 pp. Notice how the more volatile Doubles bet needs more than four times the rounds of Over 7 for the same confidence: rounds grow with σ².

The numbers in the real games

The same sizing, applied to the four games, explains why a bet’s volatility drives the whole test suite:

Bet σ Rounds for ±0.15 pp at 99.9%
Any even-money bet (upper bound) 1.000 4,810,712
Alvo Móvel, Primeira Carta (9 to 1) 2.886 40,055,852
Entre Dados, Triplo (30 to 1) 5.094 124,852,059
Espelho, Par vs Par (30 to 1) 5.094 124,852,059

Some bets are too volatile for this at any practical size: Espelho’s 200-to-1 bet would need about 0.9 billion rounds. Those are held to 3.29 of their own standard errors in the simulation, and their exact test is the proof. In all, the Monte Carlo suites play about 535 million seeded rounds, in parallel, in roughly six minutes on a CI runner. For bets sized to ±0.15 pp, a declared RTP wrong by 0.3 pp or more fails with a probability of at least 99.95%. The volatile bets held to their own standard errors only catch proportionally larger errors, and smaller errors are the exact test’s job in every case.

What else the math has to cover

A real game adds wrinkles the two dice don’t have:

  • Decisions. When the player can raise, a bet made only in good spots can return more than 100% on its own. The project quotes the game’s RTP and both house-edge conventions usual for table games, the loss per initial wager and the “element of risk” per total stake, all derived from the exact report.
  • Finite shoes. Tables deal from six decks with a cut card, so the odds shift slightly from round to round. Each game sheet says whether that changes the long-run figures, simulates the real shoe when it does, and checks whether counting cards could gain an edge.
  • Money in integer cents. Payouts round down to the cent, and the fraction stays with the house, so paytables use odds that pay exactly at every chip size. Breakage can only lower the realized RTP, never raise it.
  • Progressive jackpots. A meter fed by a share of every stake has its own long-run formula, and a conservation identity checks the pool to the millionth of a cent.

Takeaways

  • Write payouts as exact fractions and assert the fraction, not a rounded percentage.
  • Enumerate with the production code wherever the outcome space allows; it proves the implementation, not a model of it.
  • Simulate with a seed, an unbiased integer draw and a round count sized from σ. A fixed count gives a test that is either flaky or toothless.
  • Use both. Enumeration is the proof; the simulation exercises the code paths enumeration skips.

None of this is specific to casinos. Any code whose output is a probability, a price or a score that people will rely on deserves a test that proves the number, not just one that runs it.