Provably fair cribbage

Don't trust us. Check us.

Just Cribbage is classic two-handed cribbage where every deal is just math, and this page re-runs that math in your browser, offline. Paste a receipt to prove a deal was honest, or a match replay to step through a whole game with both hands face up.

Verify a deal Watch a replay

Reproducible deals

Every hand comes from a seed the game commits to before you play and reveals after. Re-derive your exact cards from it.

Audited shuffle

A million-deal audit shows no rank, suit, or seat is favored, and you can run the script yourself.

Blind AI

The opponent is never handed your cards. An automated no-peek test proves its move can't depend on your hidden hand.

01Verify a deal

Download this checker (works offline) See the shuffle audit See how the AI thinks

Works fully offline. The download is a single self-contained HTML file: no external code, no tracking, nothing that phones home. Save it, pull your network cable, open it in any browser, and it still checks your seeds. That's the proof there's no sleight of hand hidden behind a server.

02Watch a replay

Any finished match can be copied out of the game as a small block of text (end screen → Copy match replay). Paste it here and this page re-plays the entire game with the same rules engine, every deal, discard, peg and count, with both hands face up. Because the cards are recomputed from the committed seed (not read from the file), a replay is also a proof: if it plays out to the recorded score, that's the game that happened.

03How it's fair

1 · The AI can't see your cards.
Your hand is hidden from the opponent. It is never handed your cards, so it cannot know what you're holding. It decides using only the public information any real opponent would have: the shared pile, the running count, the cut card, and its own hand. Difficulty changes how well it plays, never what it can see. A stronger opponent simply keeps higher-scoring hands of its own, which can look like luck but is only better decisions. An automated test in the game's suite proves the AI's move doesn't change even when your hidden cards are swapped for different ones, because it never looked at them.
2 · The deal is locked in before you play, and revealed after.
At the start of every match the game commits to a secret and shows you its fingerprint (a SHA-256 hash). Every hand's cards come from that secret. When the match ends the secret is revealed, so no board could have been re-rolled or stacked mid-game. Paste the revealed secret into the checker above and reproduce the exact cards you were dealt.
2½ · Online, the SERVER deals, and neither player can see the other's hand.
In online matches the shuffle happens on our server under a commitment published before either player sits down, with both players' public nonces mixed into every hand. That way the host can't grind a friendly seed, your opponent's client never holds your cards, and even we can't stack the deck without it showing up as a hash mismatch on your own screen. As each hand ends, its deal-secret is published and your game re-checks the whole hand live, including that every card your opponent played was really theirs. At match end (even on a rage-quit) the server seed itself is revealed, and this page can re-derive the entire match from that one number.
3 · The shuffle isn't rigged: every card is equally likely.
We dealt a million hands and counted where each card ended up. Every card turned up in every spot about the same number of times. No rank, suit, or seat gets favored, and there's no "the deck hates you" thumb on the scale. The count uses a standard fairness test (chi-square), and you can run it yourself with our script and get the same result.

04How your rank is calculated

This one is an explanation, not a cryptographic proof like the deal above; your rank lives on our server, so it isn't something you can re-derive from a receipt. But the formula is public, it's the same for everyone, and the number you see can only move the way you'd expect.

We use Glicko-2 under the hood.
Ranked play is rated with Glicko-2, the same family Lichess and CS2 Premier use, because it tracks how confident we are in your rank as well as the rank itself. New players calibrate fast; if you take a break, your next games count for more.
You never see the raw number, on purpose.
Raw Glicko-2 can move counterintuitively: because playing shrinks the system's uncertainty about you, a raw rating can dip after a win. That's a notorious way to make players distrust a ladder. So we don't show it. Instead you see Rank Points, a smoothed number with one hard rule: it rises on a win and falls on a loss, always. The counterintuitive movement stays entirely inside a number you never have to look at.
Placing, then tiers.
Until we're confident in your rank you'll see Placing instead of a number. After that, Rank Points map to eight tiers (Wood, Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster of the Crib), each split into three divisions.
The math is open.
The exact update lives in core/Ratings.gd, with a byte-identical server port in functions/src/glicko.ts (so online matches are rated the same on the server as in the game) and test fixtures that pin both to the same numbers. Nothing about how your rank moves is hidden; only the raw value is kept off-screen, and that's a design choice for your benefit, not a secret.

05The openly available code

Nothing is hidden. The deal is a pure function of a committed seed, and the same ~30-line algorithm runs in the game, in this page, and in the command-line verifier, so all three agree byte for byte. Read it here:

The checker on this page & the CLI verify.py
#!/usr/bin/env python3
"""Just Cribbage — independent deal verifier (CLI).

Reproduces any deal from the seed the game commits to, using nothing but SHA-256. If the
cards this prints match the cards you were dealt, the deal was honest and nothing was
re-rolled or stacked. The whole algorithm is the ~30 lines below — audit it yourself.

Algorithm (identical in the game's core/DealController.gd and verify/verify.html):
  * A uint32 stream: block_k = SHA256( seed_be8 || counter_be4 ), read 4 bytes big-endian
    at a time; refill with the next counter block when a block is exhausted.
  * Start from the ordered deck (index = suit*13 + rank-1; suits H,D,C,S; ranks A..K).
  * Fisher-Yates shuffle: for i = 51..1, j = next_uint() % (i+1), swap.
  * ai_seed = next_uint()  (consumed so the starter draw lands in the right place).
  * starter index = next_uint() % 40, into the 40-card stock (deck[12:]).
  * player = deck[0:6], opponent = deck[6:12], starter = deck[12 + starter_index].

Per-hand seed (when you pass a revealed secret instead of a raw seed):
  hand_seed = first 8 bytes (big-endian) of SHA256( secret || "|" client_seed "|" index ),
  masked to 63 bits — matches the game's core/FairDeal.gd.

Usage:
  verify.py --seed 12345
  verify.py --secret <hex> --client <str> --index 0
  verify.py --commit <hex> --secret <hex>        # confirm reveal matches the commitment
  verify.py --selftest
"""
import hashlib, argparse, sys

def _be(v, n): return int(v).to_bytes(n, "big")

class Stream:
    def __init__(self, seed_value):
        self.seed8 = _be(seed_value, 8); self.counter = 0; self.buf = b""; self.pos = 0
    def next_uint(self):
        if self.pos + 4 > len(self.buf):
            self.buf = hashlib.sha256(self.seed8 + _be(self.counter, 4)).digest()
            self.pos = 0; self.counter += 1
        v = int.from_bytes(self.buf[self.pos:self.pos + 4], "big"); self.pos += 4
        return v

RANK = {1: "A", 11: "J", 12: "Q", 13: "K"}
SUIT = ["♥", "♦", "♣", "♠"]   # H D C S
def _name(c): s, r = c; return RANK.get(r, str(r)) + SUIT[s]

def deal_from_seed(seed_value):
    st = Stream(seed_value)
    deck = [(s, r) for s in range(4) for r in range(1, 14)]   # index = s*13 + (r-1)
    for i in range(len(deck) - 1, 0, -1):
        j = st.next_uint() % (i + 1)
        deck[i], deck[j] = deck[j], deck[i]
    _ai = st.next_uint()
    starter_idx = st.next_uint() % 40
    return {"player": deck[0:6], "opponent": deck[6:12],
            "starter": deck[12 + starter_idx], "ai_seed": _ai, "starter_index": starter_idx}

def hand_seed(secret_hex, client_seed, index):
    h = hashlib.sha256(bytes.fromhex(secret_hex) + ("|%s|%d" % (client_seed, index)).encode()).digest()
    return int.from_bytes(h[:8], "big") & 0x7FFFFFFFFFFFFFFF

def commit_of(secret_hex):
    return hashlib.sha256(bytes.fromhex(secret_hex)).hexdigest()

def _print_deal(d):
    print("  You:      ", " ".join(_name(c) for c in d["player"]))
    print("  Opponent: ", " ".join(_name(c) for c in d["opponent"]))
    print("  Starter:  ", _name(d["starter"]))

def selftest():
    d = deal_from_seed(12345)
    ok = ([_name(c) for c in d["player"]] == ["5♦", "7♠", "2♣", "9♣", "9♥", "6♠"]
          and d["ai_seed"] == 897033589 and _name(d["starter"]) == "6♥")
    hs = hand_seed("aa" * 32, "player-xyz", 0)
    d2 = deal_from_seed(hs)
    ok2 = (hs == 1583252095113163573
           and [_name(c) for c in d2["player"]] == ["3♥", "5♥", "5♣", "5♠", "A♠", "K♠"]
           and _name(d2["starter"]) == "8♣")
    print("selftest:", "PASS" if (ok and ok2) else "FAIL")
    return 0 if (ok and ok2) else 1

def main():
    ap = argparse.ArgumentParser(description="Just Cribbage deal verifier")
    ap.add_argument("--seed", type=int)
    ap.add_argument("--secret"); ap.add_argument("--client", default="")
    ap.add_argument("--index", type=int, default=0)
    ap.add_argument("--commit"); ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()
    if a.selftest:
        sys.exit(selftest())
    if a.commit and a.secret:
        good = commit_of(a.secret) == a.commit.lower()
        print("commit matches revealed secret:", good)
        if not good: sys.exit(1)
    if a.seed is not None:
        print("Hand from seed %d:" % a.seed); _print_deal(deal_from_seed(a.seed))
    elif a.secret:
        hs = hand_seed(a.secret, a.client, a.index)
        print("Hand %d (seed %d):" % (a.index, hs)); _print_deal(deal_from_seed(hs))
    elif not a.commit:
        ap.print_help()

if __name__ == "__main__":
    main()
The commit-reveal seed core/FairDeal.gd
class_name FairDeal
extends RefCounted
# Commit-reveal seed provider (build-order step 2). Makes every deal a pure, published
# function of a secret the game COMMITS to (as a SHA-256 hash) before a single card is
# dealt, and REVEALS at match end. A player — or an out-of-binary verifier (step 7) — can
# then confirm: (a) the revealed secret hashes to the pre-play commit, and (b) every hand
# seed, and therefore every deal, is reproducible from it. There is no room to "re-roll a
# bad board" or "deal the AI better cards": the whole match is fixed the moment the commit
# is shown.
#
# Determinism is preserved. The secret is derived deterministically from the match's
# master_seed (+ optional client_seed), so "same master_seed => identical match" still
# holds — required for netcode sync, replays, and the existing tests. The player just
# doesn't learn the secret until reveal(). For online ranked, pass a crypto-random
# master_seed (and let the client contribute client_seed) so neither side can bias it.

var server_secret: PackedByteArray = PackedByteArray()   # 32 bytes; hidden until reveal()
var client_seed: String = ""                             # optional player-supplied entropy
var commit_hex: String = ""                              # sha256(server_secret), shown up front

# Call once at match start. Returns the commit to display/emit BEFORE dealing.
func begin_match(master_seed: int, client_seed_in: String = "") -> String:
	client_seed = client_seed_in
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(_seed_bytes(master_seed))
	ctx.update(("|%s|server" % client_seed).to_utf8_buffer())
	server_secret = ctx.finish()
	commit_hex = _sha256_hex(server_secret)
	return commit_hex

# Deterministic per-hand seed: first 63 bits of sha256(secret | client_seed | hand_index).
# SHA-256 (not the engine RNG) so any language can reproduce it in the public verifier.
func hand_seed(hand_index: int) -> int:
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(server_secret)
	ctx.update(("|%s|%d" % [client_seed, hand_index]).to_utf8_buffer())
	var d: PackedByteArray = ctx.finish()
	var s: int = 0
	for i in range(8):
		s = (s << 8) | int(d[i])
	return s & 0x7FFFFFFFFFFFFFFF

# Call at match end. The payload a player/verifier needs to check every deal.
func reveal() -> Dictionary:
	return {
		"server_secret": server_secret.hex_encode(),
		"client_seed": client_seed,
		"commit": commit_hex,
	}

# --- helpers ---
func _seed_bytes(n: int) -> PackedByteArray:
	var b := PackedByteArray()
	b.resize(8)
	b.encode_s64(0, n)
	return b

func _sha256_hex(bytes: PackedByteArray) -> String:
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(bytes)
	return ctx.finish().hex_encode()

# Static re-derivation used by the in-game verifier / tests: given the revealed secret and
# client seed (as strings), reproduce a hand seed. Mirrors hand_seed() exactly.
static func verify_hand_seed(server_secret_hex: String, client_seed_in: String, hand_index: int) -> int:
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(_hex_to_bytes(server_secret_hex))
	ctx.update(("|%s|%d" % [client_seed_in, hand_index]).to_utf8_buffer())
	var d: PackedByteArray = ctx.finish()
	var s: int = 0
	for i in range(8):
		s = (s << 8) | int(d[i])
	return s & 0x7FFFFFFFFFFFFFFF

static func verify_commit(server_secret_hex: String, commit_hex_in: String) -> bool:
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(_hex_to_bytes(server_secret_hex))
	return ctx.finish().hex_encode() == commit_hex_in

static func _hex_to_bytes(h: String) -> PackedByteArray:
	var out := PackedByteArray()
	var i: int = 0
	while i + 1 < h.length():
		out.append(("0x" + h.substr(i, 2)).hex_to_int())
		i += 2
	return out
The game's deal & shuffle core/DealController.gd
class_name DealController
extends RefCounted
# Deal/shuffle + the Alley's discard delegate. DETERMINISTIC and, as of build-order step 7,
# VERIFIABLE OUTSIDE GODOT: the shuffle no longer uses the engine RNG (whose PCG output is
# hard to reproduce in another language). Instead every random draw comes from a plain
# SHA-256 counter stream keyed by the per-hand seed. That makes the whole deal a pure
# function of the seed that a ~40-line script in ANY language can reproduce — see
# verify/verify.html and verify/verify.py. Same seed => identical deck, ai_seed and starter,
# so replay / netcode sync are unchanged.
#
# Draw order for one hand (all uint32, big-endian, from the stream below):
#   51 Fisher-Yates draws (deck of 52)  ->  1 ai_seed draw  ->  (later) 1 starter draw.
# Deck order before shuffle is Card.create_deck(): index = suit*13 + (rank-1),
# suits 0..3 = H,D,C,S, ranks 1..13. player = deck[0:6], alley = deck[6:12], stock = deck[12:].

var _ai_seed: int = 0
# The Alley's discard is chosen by the single canonical, pure AlleyBrain (never sees the
# opponent's hand). Kept here so any deal-side/authoritative caller gets the same strong,
# provably-EV discard the live game uses. See core/AlleyBrain.gd.
var _brain := AlleyBrain.new()

# --- SHA-256 counter-stream RNG state ---
var _seed8: PackedByteArray = PackedByteArray()
var _counter: int = 0
var _buf: PackedByteArray = PackedByteArray()
var _buf_pos: int = 0

func begin() -> void:
	_ai_seed = 0

# Shuffle `deck` in place using `seed_value`. Deterministic AND reproducible outside Godot.
func shuffle(deck: Array, seed_value: int) -> void:
	_stream_init(seed_value)
	for i in range(deck.size() - 1, 0, -1):
		var j: int = int(_next_uint() % (i + 1))
		var tmp = deck[i]
		deck[i] = deck[j]
		deck[j] = tmp
	_ai_seed = int(_next_uint())

func ai_seed() -> int:
	return _ai_seed

# Index of the cut starter within the remaining stock — continues the same stream begun by
# shuffle(), so the starter is reproducible from the hand seed (it is the draw right after
# ai_seed).
func cut_starter_index(stock_size: int) -> int:
	if stock_size <= 0:
		return 0
	return int(_next_uint() % stock_size)

func alley_discard(alley_hand: Array, crib_owner_is_alley: bool, skill: float) -> Array:
	if alley_hand.size() < 2:
		return alley_hand.duplicate()
	# Delegate to the canonical brain: exact expected hand over the 46 unseen cuts + the
	# exact crib-EV table. It i

One honest note: a truly fair shuffle feels streakier than a kitchen-table game, because real decks are usually under-shuffled. Big hands and cold streaks are the mark of honest randomness, not bias. This is the same fairness shown in-game under Menu → Provably Fair.