Second Chance DevlogThe Gazette

Proving a physics roulette wheel is fair, and the two tests that lied about it

unreal-enginephysicsminigametestingunreal-engine-5.8

The betting shop in this game has a roulette table, and the ball on it is a real rigid body rolling round a real bowl. Nothing picks a number. The rotor is turning, the ball is thrown, it loses speed, drops off the track, rattles across eight deflectors and the frets, and whichever pocket it comes to rest in is the number that pays. There is no "choose the result and animate towards it" path anywhere in the build, and that was a deliberate design decision taken with the game's owner before any geometry existed.

That decision buys a lot. A ball that occasionally hops the rim and lands on the carpet proves to the player it is genuinely physical in a way no amount of polish can fake. It also creates a problem that a random-number generator does not have: a physical wheel can be biased, and you cannot tell by looking at it. With an RNG you read the code and you are done. With a wheel, the only honest answer is measurement.

So this entry is mostly about the measuring, because that is where everything went wrong.

Why a physics wheel goes biased in the first place

Real casinos worry about wheel bias for a concrete reason. The bias does not come from the bounce - the bounce is chaotic and smears everything. It comes from correlation between the rotor's phase and the point where the ball drops off the track. If the dealer always throws at the same speed from the same place onto a rotor turning at the same speed, the ball drops in a repeatable position relative to the rotor, the deflectors quantise that drop into a handful of landing zones, and the same numbers come up more often. Scatter does not save you. If the drop point is correlated with the rotor, no amount of rattling smears it out.

That is a structural question, not a bounce question, which means it can be answered before you build a single mesh.

Layer one: prove the design before building the wheel

I wrote an offline simulator in Python that models the structure and ignores the bounce: rotor at constant angular velocity, ball speed decaying exponentially, drop when the ball falls below a threshold speed, eight deflectors fixed in the bowl frame quantising the drop, then a scatter of about ten pockets relative to the rotor. Then 200,000 spins per case.

With the launch parameters randomised the way the design specified - rotor speed, ball speed, decay constant, release delay, release angle and release height all varying every spin - the result was chi-squared 28.3 on 36 degrees of freedom, p = 0.82. The eight sector arcs came out at p = 0.19, and red landed 48.64% of the time against a theoretical 48.649%. Uniform, with nothing to argue about.

The far more useful output was the sensitivity sweep. The launch ranges have no margin to spare. With the release angle held fixed and the other four ranges merely halved, the statistic climbed to 70. Cut them to a tenth and the coldest pocket took 1,986 hits against the hottest pocket's 8,511 - a wheel you could beat for a living. That result is now a standing rule on the project: do not tidy up the launch ranges to make the ball behave, because tidying them is exactly how you rig your own table by accident.

Positive controls, and the two bugs they caught in my own test

A test that cannot fail proves nothing. Every case in that simulator runs alongside positive controls that are designed to be biased, and the controls have to fail or the pass is worthless. The strongest is a completely fixed launch: chi-squared of 1.9 million, with only the eight deflector positions reachable at all.

Baking those in caught two bugs in my own test code, both of which would have produced confident nonsense:

The sector check was comparing eight arcs against n/8. Thirty-seven pockets do not divide into eight arcs evenly; they split 5, 5, 4, 5, 5, 4, 5, 4. Comparing each arc against an eighth of the sample makes everything look clustered, including the controls. The controls being wrong in the same direction as the data is what exposed it.

The sensitivity sweep left the release angle randomised, so it was not testing pinched ranges at all - it was measuring nothing and reporting a pass. Again, the control was the tell: it did not fail when it obviously should have.

Uniform is not random

The sequence 0, 1, 2, 3 ... 36, 0, 1, 2 ... has a perfect chi-squared and is perfectly predictable. Uniformity of the histogram says nothing about the order the numbers arrive in, and a player who spots an order has beaten your wheel.

So there is a second battery: eight tests over 400,000 spins each. Repeat rate, lag-k step uniformity for k = 1 to 40, the full 37 x 37 table of consecutive pairs, Wald-Wolfowitz runs on red/black and odd/even, gap distribution, zlib incompressibility, and block stability across the session. Crucially it is run against a model where the rotor carries its phase from one spin to the next, the way a real table does, because that carry-over is the only genuine spin-to-spin memory in the system. Test a model without it and the spins are independent by construction, and the whole battery is circular.

Four deliberately-broken controls failed, and the real model plus two robustness cases passed all eight.

The finding worth stealing: both realistic failure modes slipped past seven of the eight tests. Uniformity, pairs, runs, gaps and compression all looked perfectly clean. Only the lag-k step test caught them, and in one case only around lag 26. If I had written the obvious five tests I would have shipped a predictable wheel and had a green report to prove it.

Three rules fell out of those controls. Never couple the ball's release angle to the previous result - counter-intuitively, full coupling is safe because it behaves like a constant, while a partial blend leaves a weak correlation that accumulates. Release height alone is a strong enough randomiser to rescue an otherwise deterministic table. And the dealer's pull-back on the rotor is belt-and-braces, not load-bearing.

The invariant that guards the payouts

Fairness of the wheel is only half of it. On a single-zero wheel every bet - straight up, split, corner, dozen, column, red - has an expected value of exactly -1/37 of the stake, which is -2.7027%. Whatever the bet type. A payout table wrong by a single unit breaks that instantly.

That invariant is asserted across all 157 bets, and not just in Python: a verification script drives the compiled Blueprint graphs directly, running 5,809 win checks, 1,570 return checks and 74 wheel lookups, and computes the house edge through the Blueprint's own arithmetic. It is the single strongest test on the table and it costs nothing to run.

Making the physics behave at all

Real physics is only real if the engine lets it be. Chaos ships with defaults that quietly break a small, fast rigid body, and none of them warn you - the ball simply behaves wrong. The worst is the default maximum angular velocity of 3,600 degrees per second: a ball of 10mm radius rolling at 4.3 m/s needs about 24,600, so it is clamped and the ball permanently skids instead of rolling, which reads exactly like a friction problem and cannot be fixed by tuning friction. Second worst is the bounce threshold, which zeroes restitution below 2 m/s - that is every realistic bounce on this table, so the ball dies dead and no restitution value helps.

Trap: "my chi-squared says the wheel is biased, but the histogram looks fine"

Now the in-engine measurement. Blueprint cannot write files, so the table logs one line per settled spin and one per failure, and a Python reporter parses the editor's own logs and produces the escape rate and the distribution.

An early run of that reporter said, in capitals, that the wheel was biased: p = 0.004. It was not.

**Chi-squared only follows the chi-squared distribution asymptotically, and the usual rule of thumb is at least five expected observations per bin.** Thirty-seven pockets therefore need at least 185 settled spins before the test means anything at all. That run had 25. To prove the point I scored a known-fair random generator at the same sample size in the same run: it came out at p = 0.155, which is not far off "suspicious" either. At small n the statistic is meaningless in both directions - it will call a fair wheel biased and a biased wheel fine, and it will do it with three decimal places of apparent authority.

The fix is not a better test, it is a refusal. The reporter now prints the statistic below 185 spins but explicitly withholds the verdict, and every run scores a same-size fair control alongside the real data so the two can be read together.

Trap: "the payouts are wrong, but every fairness test passes"

This is the one I would put on a poster.

The owner sat at the table, watched the ball come to rest, and heard the dealer call a different number. Every statistical test in this entry was passing at the time, and all of them would have kept passing forever, because the wheel's numbering was mirrored.

The chain, measured rather than reasoned: the number ring texture is generated with the pocket order running one way in the modelling package, the FBX import negates one axis, and negating an axis mirrors a body of revolution. So the numeral that the generator intended at one angle renders in the engine at the opposite angle. The code that turns the ball's resting angle into a pocket index therefore had the wrong sign, and every spin paid out on the mirror image of the number the ball was visibly sitting in.

Why no test caught it: mirroring is a permutation. The distribution across pockets is still uniform, the sector clustering is still absent, red still comes up 48.6% of the time and the expected-value invariant still holds exactly. Every fairness statistic is blind to a relabelling by construction. Only a human looking at the ball and listening to the call could see it.

Two guards came out of it. Pocket zero is the mirror's fixed point, so the spot-check I did first - on the zero - passes either way, and I wrongly called it a pass. Always check a pocket whose mirror is a different colour. And do not settle it by reading numerals off a screenshot: six and nine are ambiguous, the numerals face inward so half the wheel reads upside down, and the cloth is deliberately inverted from the dealer's side. Sample colours, or map the segments from the generator data. It was confirmed three independent ways in the end - the source UVs, a pixel test against the generated pocket bands (37 out of 37 one way, 1 out of 37 the other), and a reading over all 37 rendered pocket positions in the engine.

Trap: "the soak says half my spins are void"

An unattended overnight soak reported a 48% void rate. The real rate is about 1.7%.

The rotor is kinematic and was being advanced on wall-clock frame time, while the ball is advanced by the physics solver on a clamped step of 33.3 ms. Those agree at 30 FPS and above. They do not agree when an unfocused editor throttles the game to three frames a second, which it does by default - at that rate the rotor moved roughly ten times further per frame than the ball, the frets swept into it, and it was fired out of the bowl.

Fixes: clamp the tick delta to the physics maximum so the rotor can never outrun the solver, pair the ball sample against the previous frame's rotor angle because actor ticks run before physics, and run soaks with the editor window focused. A soak run in the background is not slow data, it is wrong data.

The epoch: throwing away your own evidence, on purpose

A physics wheel's bias belongs to its geometry. Spins thrown while the ledge angle, apron angle and rim height were still being tuned describe wheels that no longer exist, and pooling them with the finished wheel produces a number that measures nothing in particular.

So the fairness tally has a stored cutoff timestamp, with the reason written next to it, and everything before it is excluded from the verdict - but not deleted, because the old lines are still the record of how the tuning went. Two details that cost time:

  • The cutoff is read from the last timestamp in the logs, never from the system clock.

The editor writes UTC while the machine was on British Summer Time, so "now" from Python sat an hour in the future and silently swallowed the first hour of new spins.

  • The engine rolls its log on every restart, so reading only the current file throws away

every spin from earlier sessions. That turned 27 banked spins into "4 settled spins is too few for a verdict", which reads as a soak that is not working rather than a reader that is not looking.

What is deliberately imperfect, and what is still unproven

Roughly 2% of attempts end voided - escapes plus the occasional ball that never settles - measured over 179 spins on the final geometry. That is a feature and it is staying. A perfectly contained ball reads as fake, a void spin leaves the bets standing and re-opens betting, and the dealer gets something to do - he walks over and picks the ball up. The soak reporter flags anything above 5% as a problem, which is still the right bar. Chasing zero would mean narrowing the launch ranges, and the sensitivity sweep says that is exactly how the wheel becomes biased.

Being straight about the state of it: the in-engine distribution has not yet earned a verdict. The offline proofs are strong and the rules engine is verified against the compiled graphs, but the fairness clock was reset to zero on the day the mirrored numbering was fixed, and the chi-squared test refuses to speak below 185 settled spins. So the correct claim today is "the design is proven fair, the wheel is instrumented, and the wheel's own data is still accumulating" - not "the wheel is proven fair". One of those is a measurement and the other is a hope.

What to take from it

  • Bake a positive control into every statistical test you write. Mine caught two bugs in

the test itself, and either would have produced a confident, wrong answer.

  • Know the sample size your test needs before you read its verdict. Chi-squared over 37

bins needs about 185 observations; below that it is wrong in both directions and it looks authoritative while being wrong.

  • Uniformity is not randomness. Test the order, and include a lag test - seven of my

eight sequence tests missed both realistic failure modes.

  • Statistics cannot see a relabelling. If your mapping from physical position to name is

wrong, every distribution test still passes. That link needs its own check, sampled from data rather than eyeballed on screen.

  • Test something whose mirror image differs. The symmetric case - here, the zero pocket -

passes under exactly the fault you are looking for.

  • Anything time-stepped needs one clock. A kinematic body on frame time and a dynamic body

on a clamped physics step agree right up until the frame rate drops, and then they lie.

  • Statistics on a system you are still tuning are void. Store the cutoff, keep the old

data as evidence, and take the timestamp from the data rather than from the clock.

← All devlog entries

Watch it get built. All of this goes up on YouTube as it happens — broken animations, buildings hovering a foot off the ground, the lot.

Subscribe on YouTube