DocsPorting
Porting Guide
Every detail a GFS 1.0 implementation in another language has to reproduce exactly, why each one is the way it is, and what you see when it's wrong.
Anyone can recompute a GFS result in any language that has SHA-256 and HMAC. That's the point of the scheme, and it's why a player doesn't have to trust our JavaScript. This page is for the person writing that second implementation. It lists the places where two honest programs come out different, and none of them is cryptography. They're all encoding, byte order and arithmetic.
There's no such thing as nearly conforming. A port agrees with the reference on every input or it's a different scheme that shares some results.
Order of Work
Build in the order the vectors file is laid out, and don't start a stage until the one before it passes in full.
| Stage | Section of vectors/gfs-1.0.json | Entries | A failure here means |
|---|---|---|---|
| Commitment | commitments | 4 | You hashed the wrong bytes |
| Digest | digests | 24 | Wrong key bytes or wrong message text |
| Floats | floats | 12, each with 16 floats | Byte order, grouping, the divisor, or float width |
| Games | games | 2,240 | A mapper |
After those, the crash profile has its own file, vectors/gfs-1.0-crash.json, and record hashes have test values on the canonical JSON page. Test vectors describes the files, and the Python walkthrough goes through this order with running code.
The floats stage looks skippable, since the games would catch a bad float. They don't always. We divided by 2³² − 1 where the spec divides by 2³², and separately pushed every float through single precision. Each version passed all 560 Dice, Roulette and Wheel vectors. Against the floats section the wrong divisor matched 0 of 192 values and single precision matched 8.
Seed Text, Not Seed Bytes
The server seed is 64 lowercase hex characters. Both places it's used take those characters as UTF-8 text, 64 bytes of it: the commitment is SHA-256 of the text, and the HMAC key is the text. Nothing in the single-player profile ever decodes the seed to 32 bytes.
The reason is history and not cryptography. The source says it in a comment: the key is the hex string "so that existing industry implementations remain conformant". The spec describes what was already running.
Get it wrong and nothing survives: 0 of 4 commitments, 0 of 24 digests, and 52.21 for the public Dice inputs where the answer is 56.12. Languages whose HMAC takes a byte array invite the mistake, because the developer has to convert the string somehow and decoding hex feels like the careful choice.
mac := hmac.New(sha256.New, []byte(serverSeed)) // GFS: the characters themselves
// key, _ := hex.DecodeString(serverSeed) // a different scheme, 52.21
Those two lines of Go are not run by our example checker, which only has Node and Python. The Python equivalent is executed on the Dice page.
Case is part of the text. The reference refuses a seed with capital A to F outright, and it has to, since the uppercase spelling of the public seed rolls 30.37.
The Message
clientSeed:nonce:cursor, joined by colons and encoded as UTF-8. The client seed goes in exactly as the player typed it, with no trimming, case folding or Unicode normalisation. The nonce and cursor are decimal integers with no sign, padding, fraction or exponent.
The cursor exists because a digest only holds eight floats and a shuffled deck needs 51. It starts at 0 and goes up by one for each further digest the round reads. A record stores the highest cursor read, not the number of digests, so Mines, which always reads 24 floats however many mines were asked for, records 2. Keno records 4, a one-deck shuffle 6, an eight-deck shoe 51. The games vectors carry the expected cursor for every round.
Each slip has its own Dice roll for the public seed pair. galabet:42 without the cursor rolls 80.91. galabet:042:0 rolls 0.57 and galabet:42.0:0 rolls 51.55, which is what a language that holds the nonce as a float will print if you let it format the number. The troubleshooting page keeps the full table.
One limit is inherited from JavaScript. The reference formats the nonce the way a JavaScript number prints, which stops being plain digits at 10²¹ and stops telling neighbours apart at 2⁵³. A port with true integers will disagree with it up there. Cap the nonce well below 2⁵³, as Nonce and cursor recommends, and the question never comes up.
Four Bytes, Big-Endian, Over 2³²
Cut the 32-byte digest into eight groups of four, in order, without overlap. Read each group as an unsigned 32-bit integer with the first byte most significant, and divide by 4,294,967,296. The source writes it as b0/256 + b1/256² + b2/256³ + b3/256⁴, which is the same number: every partial sum fits in a double exactly, so there's no rounding to reproduce. The float is in [0, 1) and never reaches 1, and the mappers rely on that.
Read the groups little-endian and the public roll becomes 87.86.
Use a 64-bit double, or don't use floating point at all. The second option is sound and is covered under Integer Forms below.
Floor, Never Round
Every mapper ends in a floor. Rounding agrees with flooring whenever the fraction is below a half, so a rounding port looks half right: 62 of 112 Dice vectors pass, and the public roll is still 56.12. Roulette on the same seeds gives 21 where the pocket is 20, which is the quicker test. The page for existing systems has that run.
Watch for languages where integer conversion truncates toward zero. It's the same as floor here only because nothing is negative.
What Each Game Reads and Returns
| Game | Floats read | Mapping | Result |
|---|---|---|---|
| dice | 1 | floor(f × 10001) / 100 | number, 0 to 100 |
| limbo | 1 | see Limbo | number, 1 or more |
| roulette | 1 | floor(f × 37) | integer, 0 to 36 |
| wheel | 1 | floor(f × segments) | integer, 0 to segments − 1 |
| plinko | rows | each float below 0.5 is 0 (left), otherwise 1 | path array, and bucket, the sum of the path |
| mines | 24 | shuffle 25, keep the first mines | tile numbers 0 to 24, ascending |
| keno | 39 | shuffle 40, keep the first draws, add 1 to each | numbers 1 to 40, ascending |
| blackjack, hilo | 52 × decks − 1 | shuffle 52 × decks | card labels in dealing order |
Two things in that table get missed. Mines counts from 0 and Keno from 1, so a port that settles on one convention is wrong for the other game. And Mines and Keno sort before returning while the card games must not. An unsorted port still passes 131 of 336 Mines vectors and 112 of 336 Keno, because a one-item result is already in order. The other 19 Mines passes are three-mine boards that came out ascending by chance.
Defaults are houseEdge 0.01, segments 10, rows 16, mines 3, draws 10, decks 1. Every vector for a game that takes a parameter spells it out, so the vectors won't test your defaults.
The Shuffle
One routine serves Mines, Keno and the cards. Start with the list 0, 1, 2 and so on up to size − 1. Walk i from size − 1 down to 1. At each step take the next unused float, compute j = floor(f × (i + 1)), and swap positions i and j. That's size − 1 swaps and size − 1 floats, taken in stream order, none reused, none skipped.
The direction is a convention, and it has to be this one. Walking i upward is an equally fair Fisher-Yates and produces other boards: we ran it and it passed 0 of 336 Mines vectors. Drawing from a shrinking pool failed them all too. Taking the 32-bit integer modulo i + 1 passed 10 of 336 by luck.
A card index becomes a label like this: reduce it modulo 52, which only matters in a multi-deck shoe. The rank is the index modulo 13 into A23456789TJQK. The suit is the index divided by 13, floored, into CDHS. Rank first, so index 22 is TD. With another numbering every card vector fails, and what comes out is still a plausible-looking deck, which is what makes it slow to spot.
Limbo Operation Order
Limbo is the one mapper with real floating-point work in it. In doubles: multiply the float by 1e8, add 1, divide 1e8 by that, multiply by 1 − houseEdge, multiply by 100, floor, divide by 100, and return 1 if the result is below 1.
The edge comes off before the floor. Flooring to cents first and applying the edge afterwards passes 226 of the 336 Limbo vectors, and 112 of those only because a third of the vectors have no edge. The + 1 is part of the formula. Leaving it out passes all 336 vectors and still disagrees on roughly one float in five hundred, as measured on the page for existing systems, so that one has to be checked by reading.
Merging the constant multiplications turned out not to matter. Over a million random floats, applying the edge to the numerator first, or multiplying by (1 − houseEdge) × 100 in one step, matched the reference every time. We'd still keep the reference order. It costs nothing, and "matched a million" is not "proved".
Integer Forms
A float here is a 32-bit integer u over 2³², so floor(f × n) is u × n shifted right by 32 bits. For languages without IEEE doubles, or where you'd rather keep floating point out of money code, every mapper has an integer form:
| Float form | Integer form |
|---|---|
floor(f × 10001), Dice in hundredths | (u × 10001) >> 32 |
floor(f × 37), floor(f × segments) | (u × 37) >> 32, (u × segments) >> 32 |
f < 0.5, Plinko | top bit of u: u >> 31 is the path entry |
floor(f × (i + 1)), the shuffle | (u × (i + 1)) >> 32 |
| Limbo, in hundredths | 10⁸ × 2³² × (10000 − e) ÷ ((u × 10⁸ + 2³²) × 100) with integer division, where e is the edge in hundredths of a percent, then at least 100 |
The products need 64-bit unsigned arithmetic, and Limbo's numerator needs 72 bits, so big integers or a 128-bit type. This program computes every number in every game vector both ways and counts agreement.
import hashlib
import hmac
import json
import math
from collections import Counter
def uint32s(server_seed, client_seed, nonce, count):
out, cursor = [], 0
while len(out) < count:
digest = hmac.new(server_seed.encode(), f"{client_seed}:{nonce}:{cursor}".encode(), hashlib.sha256).digest()
out += [int.from_bytes(digest[i:i + 4], "big") for i in range(0, 32, 4)]
cursor += 1
return out[:count]
def both_forms(v):
"""(float form, integer form) for every number the round computes."""
game, p = v["game"], v["params"]
size = {"mines": 25, "keno": 40, "blackjack": 52 * p.get("decks", 1), "hilo": 52 * p.get("decks", 1)}.get(game)
count = size - 1 if size else p.get("rows", 1)
for n, u in enumerate(uint32s(v["serverSeed"], v["clientSeed"], v["nonce"], count)):
f = u / 2**32
if game == "dice":
yield math.floor(f * 10001), u * 10001 >> 32
elif game == "roulette":
yield math.floor(f * 37), u * 37 >> 32
elif game == "wheel":
yield math.floor(f * p["segments"]), u * p["segments"] >> 32
elif game == "plinko":
yield (0 if f < 0.5 else 1), u >> 31
elif game == "limbo":
e = round(p["houseEdge"] * 10000)
yield (max(100, math.floor(1e8 / (f * 1e8 + 1) * (1 - p["houseEdge"]) * 100)),
max(100, 10**8 * 2**32 * (10000 - e) // ((u * 10**8 + 2**32) * 100)))
else:
i = size - 1 - n
yield math.floor(f * (i + 1)), u * (i + 1) >> 32
with open("vectors/gfs-1.0.json") as handle:
vectors = json.load(handle)["games"]
compared, equal = Counter(), Counter()
for v in vectors:
for float_form, integer_form in both_forms(v):
compared[v["game"]] += 1
equal[v["game"]] += float_form == integer_form
for game in compared:
print(f"{game:10} {equal[game]:6} of {compared[game]}")
print(f"{'all':10} {sum(equal.values()):6} of {sum(compared.values())}")
dice 112 of 112
limbo 336 of 336
roulette 112 of 112
wheel 336 of 336
plinko 4032 of 4032
mines 8064 of 8064
keno 13104 of 13104
blackjack 52192 of 52192
hilo 5712 of 5712
all 84000 of 84000
For everything except Limbo this isn't luck, and you don't need the count to believe it. u × n stays below 2⁵³ for any n under 2²¹, so the double holds the product exactly and both floors see the same value. The largest multiplier in GFS is 10001.
Limbo has no such argument behind it. The float form rounds at each step and the integer form doesn't, so they could part on some input. They didn't on the 336 vectors, and they didn't when we ran 2,997,186 evenly spaced values of u at edges of 0, 0.01 and 0.04. That's evidence, and we're calling it that. If a player's money rides on a Limbo port, the double version in the reference order is the one the vectors were generated by.
Crash Uses 52 Bits
The crash profile has no client seed, nonce or cursor. A chain is built by repeated SHA-256, each step hashing the previous hash as hex text, the same text-not-bytes convention as the commitment. A game's digest is HMAC-SHA256 with the game hash as the key, again as text, and the salt string as the message. The Crash page covers how the chain is published and checked.
The number h is the first 52 bits of that digest, which is its first 13 hex characters. Not the first 8 bytes, and not a float. The crash point in hundredths is floor(100 × 2⁵² ÷ (h + 1) × (1 − houseEdge)), never below 100.
A double can hold h. It can't hold the quotient well enough, and the reference doesn't try: it divides in big integers, scaled by a million so that six decimals of a cent survive, and only then converts to a double to apply the edge. That numerator runs to 79 bits. A port that wants every round to match has to follow those steps, and the difference from the tidy all-integer formula is measurable.
import hashlib
import hmac
import json
import math
def reference_order(h, edge):
raw_cents = float(100 * 2**52 * 1_000_000 // (h + 1)) / 1_000_000
return max(100, math.floor(raw_cents * (1 - edge)))
def all_integer(h, edge):
e = round(edge * 10000)
return max(100, 100 * 2**52 * (10000 - e) // ((h + 1) * 10000))
with open("vectors/gfs-1.0-crash.json") as handle:
games = json.load(handle)["games"]
for form in (reference_order, all_integer):
ok = 0
for g in games:
h = int(hmac.new(g["gameHash"].encode(), g["salt"].encode(), hashlib.sha256).hexdigest()[:13], 16)
ok += form(h, g["houseEdge"]) / 100 == g["result"]
print(f"{form.__name__:16} {ok} of {len(games)} vectors")
sample = range(0, 2**52, 1_500_000_001)
for edge in (0, 0.01, 0.04):
apart = [(h, reference_order(h, edge), all_integer(h, edge)) for h in sample if reference_order(h, edge) != all_integer(h, edge)]
print(f"edge {edge}: {len(apart)} of {len(sample)} differ {apart}")
reference_order 48 of 48 vectors
all_integer 48 of 48 vectors
edge 0: 0 of 3002400 differ []
edge 0.01: 1 of 3002400 differ [(3945631502630421, 112, 113)]
edge 0.04: 1 of 3002400 differ [(3458764502305843, 124, 125)]
Both pass all 48 vectors. Over three million evenly spaced values of h they part once at each non-zero edge, and both times the reference is one hundredth lower. The cause is the six decimals. In the first case the exact answer is 113.0000008 hundredths. The truncated quotient, 114.141414, times 0.99 comes to 112.9999999, and the floor does the rest. One round in three million, one cent, in the house's favour. We'd call it a wart in the reference. But the reference is what verifiers run, so until a spec revision says otherwise a port should reproduce it, and an all-integer port will one day show a player 1.13 where the verifier says 1.12.
Client Seed Length Counts UTF-16 Code Units
clover = "\U0001F340"
for seed in (clover, clover * 32, clover * 33):
print(len(seed), len(seed.encode("utf-16-le")) // 2, len(seed.encode("utf-8")))
1 2 4
32 64 128
33 66 132
Code points, UTF-16 code units, UTF-8 bytes. The reference accepts a client seed of 1 to 64 and measures with JavaScript's length, which is the middle column. So 32 clovers are allowed and 33 are refused, and a port counting code points would accept a seed the reference rejects, producing a record no GFS verifier will take. Go's len counts bytes, Python's counts code points, Java's and C#'s count UTF-16 like JavaScript. The message that goes into the HMAC is still UTF-8. One encoding for the limit, another for the hash, and a port copies both. The only other rule is that the seed contains no colon.
Record Hashes
A record hash is SHA-256 over canonical JSON, and two ports that agree on every result can still disagree here. Members are sorted by key, compared as UTF-16 code units, with no whitespace anywhere. Record keys are all ASCII, so any byte-wise sort gets the record itself right, and the distinction only bites on keys outside the Basic Multilingual Plane.
Numbers are where ports fall down. They're written in the shortest form that reads back as the same double, the way JavaScript prints them: 50, not 50.0, and 54.7, not 54.70. Languages that keep integers and floats apart print a Dice roll of exactly 50 as 50.0, and then about one Dice record in a hundred hashes differently while every result verifies. Canonical JSON and record hash has a Python serialiser that matches and the test values to check yours against. The signing vectors in vectors/gfs-1.0-sign.json depend on it too.
