Frontend
One rounding, not many: why a provably-fair verifier must match the server bit for bit
Betkyo Research DEV Community
5 views
Originally published on the Betkyo Journal, where every figure is read from the game engine's source.
Every provably fair round on this site turns a 256-bit hash into a number between 0 and 1 and then into an outcome. The server does that in Kotlin; the browser verifier does it in JavaScript. Both languages hold numbers as 64-bit doubles with 53 bits of precision, and a hash has more bits than that, so somewhere a rounding must happen. If the server rounds once and the verifier rounds several times, the two numbers can differ in the last bit, and a game that multiplies by 37 and rounds down can land in a different pocket. The engine avoids this by constructing the number as two exact pieces added in one operation: the first four bytes divided by 2³², plus the next bytes divided by 2⁵⁶ or 2⁶⁴, so the only rounding is the single one that Kotlin’s conversion also performs. Crash goes further and never touches a double at all: its crash point is computed with exact integer arithmetic on the leading 52 bits, because a floating-point approximation “would disagree with integer division at floor boundaries and make the verifier reject good rounds”. The verifier is not an approximation of the server. It is the same arithmetic, and that is what makes a rejection mean something.
The trap
A hash is thirty-two bytes. A game needs a number between 0 and 1. The obvious way to get one is to read some of the bytes as an integer and divide by the largest possible value, and every provably fair explainer, including ours, describes it that way. The obvious way has a problem that only shows up when two different programs do it.
Both the server and the browser store ordinary numbers as IEEE 754 doubles: 64 bits, of which 53 carry precision. Seven bytes of hash are 56 bits, eight bytes are 64, and neither fits. So the conversion must lose bits, and the question is not whether it rounds but how many times. A loop that accumulates bytes one at a time, multiplying the running total by 256 and adding the next byte, rounds at every step once the total passes 2⁵³. Kotlin’s conversion of a 64-bit integer to a double rounds exactly once. Two roundings of the same value do not always land on the same double as one.
Most of the time nobody notices, because the difference is in the last bit of a number with sixteen significant digits. Then a game multiplies the number by 37, or 52, or 1,000,000, and rounds down to an integer, and the last bit is exactly the bit that decides whether 36.9999999999999 becomes 36 or 37. A verifier built the obvious way would reject a small fraction of perfectly honest rounds, and a player who saw a rejection would have no way to tell an honest rounding error from a dishonest server.
How the engine steps around it
The fix is to make the browser round exactly as often as the server does, which is once. The engine reads the first four bytes as an integer, which fits in a double exactly, and the next three or four bytes as another integer, which also fits exactly. Each is divided by a power of two, an operation that is exact for doubles. Then the two are added. That single addition is the only place precision is lost, and it loses it the same way Kotlin’s single conversion does.
Verified in source. _shared/rng.ts u56: “first 7 bytes → uniform [0,1), matching the server’s Long→Double rounding bit-for-bit: hi/2^32 and lo/2^56 are both exact dyadic doubles, so the one IEEE addition rounds the 56-bit value exactly once — the same single rounding Kotlin’s toDouble() performs. (A naive 56-bit accumulate in a double would round repeatedly past 2^53 and can differ at floor edges.)” u64 uses the same construction over eight bytes, “matching the server’s ULong→Double conversion in LimboService.outcome100”, and is the function every seed-pair original imports.
How the engine turns bytes into a number, and what each choice protects
FUNCTION
BYTES READ
CONSTRUCTION
MATCHES
u56
first 7
hi ÷ 2³² + lo ÷ 2⁵⁶, one addition
the server’s Long → Double conversion
u64
first 8
hi ÷ 2³² + lo ÷ 2⁶⁴, one addition
the server’s ULong → Double conversion (Limbo)
h52 / bust100
first 6.5 (13 hex digits)
exact BigInt integer arithmetic, no double at all
the server’s CrashDerivation.crashPoint100
Every seed-pair original (bingo, blackjack, hold’em, koban, omikuji, sic bo, video poker, hanabi, fukubukuro, roulette) imports u64 from rng.ts; the generator and the verifier are the same import.
Crash takes the argument to its conclusion. Its crash point follows the bustabit formula, floor((100 × 2⁵² − h) ÷ (2⁵² − h)) where h is the leading 52 bits of the hash, and that division sits on exactly the kind of boundary where a double can be off by one. So the engine does not use a double. It computes the formula with arbitrary-precision integers, in the browser, the way the server does, and the comment in the source says why in one line: a float approximation would disagree with integer division at floor boundaries and make the verifier reject good rounds.
Verified in source. _shared/rng.ts bust100: h = h52(digest), the first 13 hex characters as a BigInt; p = (100·2⁵² − h) ÷ (2⁵² − h) in BigInt division, clamped to [100, 1,000,000]; the comment reads “Exact BigInt math to match the server (NewWhiteBack CrashDerivation.crashPoint100) bit-for-bit — a float approximation would disagree with integer division at floor boundaries and make the verifier reject good rounds.” The BigInt values are built with BigInt() calls rather than literals so the file compiles under the project’s es5 target.
One implementation, two jobs
There is a second, quieter decision in the same file. The functions that turn a hash into a number are not written twice, once for the game and once for the checker. They are written once, in a module the header describes as shared by the generator and the verifier, and the demo engine that plays signed-out rounds in your browser produces its outcomes by calling the same functions the fairness panel calls to check them.
Verified in source. _shared/rng.ts header: “Crypto primitives shared by the provably-fair GENERATOR (demoLocal.ts, the per-game derive modules) and the VERIFIER (fairness.tsx). Extracted from fairness.tsx so a game’s derivation can be imported by both sides without a circular import.” hmacSha256Utf8 is described as consuming key and message “exactly as RandomUtils.generateHash consumes them on the server … one implementation, so the demo can never drift from what the verifier accepts.”
That design has a consequence that is easy to state and worth stating. When the verifier says a round checks out, it is not saying that its own approximation of the server agrees with the server to within some tolerance. It is saying that the same function, given the same inputs, produced the same output, and there is no tolerance because there is nothing to tolerate. When it says a round does not check out, that is not noise. It means the inputs differ, which is the one thing a verifier exists to detect.
A verifier that rounds differently from the server is a verifier that sometimes cries wolf. After the first false alarm, nobody listens to the real one.
— why the rounding matters
What to take from it
“Provably fair” is a claim about arithmetic, and arithmetic has edges. The commitment scheme is the headline; the bit-for-bit derivation is what makes the headline enforceable.
A mismatch should be rare enough to be alarming. On this engine an honest round cannot fail verification because of rounding, so a failure is information rather than an artefact.
You can read the function. The construction is a few lines, and the verification walkthrough shows where each one is used on a live round.
Note. This article describes the client derivation module and quotes its comments about the server it mirrors; the server itself is not in the public repository, and the house service is the paying authority. It is an engineering note, not a certification.
FAQ
Why would a verifier and a server disagree if they use the same hash?
Because turning a hash into a number between 0 and 1 requires rounding to 53 bits of precision, and rounding once does not always give the same result as rounding several times. A verifier that accumulates bytes in a loop can differ from a server that converts once, in the last bit.
Does one bit really matter?
When the number is multiplied by 37 or 52 or a million and rounded down, the last bit can decide which integer results. That is a different pocket, card or item.
How does the engine avoid it?
It builds the number from two exactly representable pieces, the first four bytes over 2³² and the next bytes over 2⁵⁶ or 2⁶⁴, and adds them once, matching the single rounding of the server’s integer-to-double conversion. Crash uses exact integer arithmetic and no doubles at all.
Is the verifier a separate program from the game?
No. The hash and number functions live in one shared module that both the demo engine and the fairness panel import, so the generator cannot drift from the verifier.
What does a failed verification mean here?
That the inputs differ from what the server used, because rounding cannot cause a false failure on this engine. It is the signal the verifier exists to give.
Sources
Betkyo engine source: _shared/rng.ts (u56, u64, h52, bust100, hmacSha256Utf8 and their comments)
IEEE 754-2019 — Standard for Floating-Point Arithmetic (binary64: 53 bits of significand precision)
Bustabit provably fair crash-point formula, the construction bust100 mirrors
This article first appeared on the Betkyo Journal: https://betkyo.com/en/blog/one-rounding-not-many-why-the-verifier-matches-the-server-bit-for-bit/?utm_source=devto&utm_medium=repost&utm_campaign=journal. Betkyo is a crypto casino with provably fair original games. 18+. Educational content, not betting advice.
Read original: https://dev.to/betkyo/one-rounding-not-many-why-a-provably-fair-verifier-must-match-the-server-bit-for-bit-2l4m
← Previous
Attestly: Generate EU AI Act Annex IV docs from your agents’ operational traces
Next →
Stop Slamming Downstream Services: Singleflight Request Coalescing with Java Virtual Threads
Related
[Showoff Saturday] A new portfolio page!
Frontend
0
Reddit r/webdev
How I Built a Fast, Clean Wiki & Database for Steal an Egg
Frontend
0
Dev.to (EN Zone)
Got tired of writing READMEs, so I built a tool that does it for me
Frontend
2
Reddit r/webdev
How to catch the frozen tabs and jank your monitoring misses
Frontend
4
Dev.to (EN Zone)
Comments0
No comments yet — be the first