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.
Every hand comes from a seed the game commits to before you play and reveals after. Re-derive your exact cards from it.
A million-deal audit shows no rank, suit, or seat is favored, and you can run the script yourself.
The opponent is never handed your cards. An automated no-peek test proves its move can't depend on your hidden hand.
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.
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.
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.
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.
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:
#!/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()
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
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 iOne 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.