📄 What's Inside
This report walks through the computer opponent's decision engine from start to finish. It is the same code that runs in your copy of Just Cribbage. Each section shows the actual code responsible for a behavior, lightly condensed for reading, with a plain-language explanation next to it. You can skip every code block and still follow the whole story. The only thing we hold back is the exact tuning numbers behind the newer Academy-lesson tactics (a game needs a little secret sauce). Everything structural, from the fairness guarantees to the lookahead to the benchmark, is shown in full and independently verifiable.
It's a companion to the deal verifier: that page proves the cards were dealt honestly; this page shows exactly how the AI thinks, and why it is built in a way that makes peeking at your hand impossible.
You’ll see the opponent referred to as the Alley all through this page:
AlleyBrain, AlleyObservation, and friends. That’s a bit of
Five Cats Game Studios lore: the name carried over from a different project of ours that
may or may not ever see the light of day. It stuck, we grew fond of it, and renaming a
shipping engine to satisfy a footnote felt like the wrong use of everyone’s time. So
wherever you read “the Alley,” it just means the computer opponent.
🏗 1 System Architecture
The computer opponent is built as an assembly line of small, separate parts. Each part only sees what it is handed; nothing reaches around the side for extra information. The hand-off between parts is a fixed list of facts, described in the next section.
The AI is a pure function, which is programmer-speak for: same input, same answer, every single time. That is what makes the game verifiable. You can test it yourself by swapping your cards for any others and confirming the AI's move doesn't change.
🔒 2 The Data Contract: AlleyObservation
This is the most important design decision in the whole AI. Before the brain can
make any move, it is handed an AlleyObservation: a fixed list of facts,
like a form with a set number of boxes. There is no box on the form for your
hidden cards.
var own_hand: Array = [] # the Alley's OWN cards (its 6 to discard, or its play hand) var played: Array = [] # the public pegging pile, in order var running_count: int = 0 # public running count in the current pegging sub-round var opp_card_count: int = 0 # how many cards the opponent HOLDS (a COUNT, never the cards) var starter = null # the cut/starter card -- public once cut (null before) var crib_is_alley: bool = false var dealer: String = "" var scores: Dictionary = {} # public {"player": int, "alley": int}
There is no opp_hand field. Your cards aren't hidden from the AI or
encrypted away from it; the box for them simply does not exist on the form, so the
brain has nothing to read. A promise says the AI won't look. This design means
it can't.
The one thing that might look like cheating: opp_card_count. But how many
cards you hold is public information at any real cribbage table;
anyone can count the cards fanned in your hand. The brain uses that number only to
judge how likely you are to hold a given rank. Which cards they actually are is
never here.
The discard observation is built by AlleyObservation.for_discard(own_six, crib_is_alley)
and the pegging observation by AlleyObservation.for_play(own_hand, played, count, opp.size(), starter).
In both cases the player's hand is never handed over, only how many cards it holds
(its .size()). The test suite in tests/test_no_peek.gd swaps the
player's hidden cards for arbitrary others and confirms the brain's output is identical
down to the last byte.
The two factory constructors enforce consistent construction everywhere:
static func for_discard(own_six: Array, crib_is_alley_in: bool, ...) -> AlleyObservation: var o := AlleyObservation.new() o.own_hand = own_six o.crib_is_alley = crib_is_alley_in # ...dealer, scores -- but NEVER the opponent's cards return o static func for_play(own_hand_in: Array, played_in: Array, running_count_in: int, opp_card_count_in: int, starter_in = null, ...) -> AlleyObservation: var o := AlleyObservation.new() o.own_hand = own_hand_in o.played = played_in o.running_count = running_count_in o.opp_card_count = opp_card_count_in # a COUNT (int), not the actual cards o.starter = starter_in return o
🃏 3 Discard: Expected Value Analysis
The discard is where the most math happens. The AI tries every possible 2-card throw from its 6-card hand, and for each one asks: on average, how many points would this leave me with, across all 46 cards that could be cut as the starter? That average is called the expected value, or EV, and you'll see the term all over this page. It just means “the average result if you could replay the moment under every possible cut.”
The Top-Level Entry Point
func choose_discard(obs: AlleyObservation, skill: float, decision_seed: int) -> Array: _o = obs var hand: Array = obs.own_hand if hand.size() <= 2: return hand.duplicate() var ranked := _eval_discards(hand, obs.crib_is_alley) # rank all 15 possible throws if ranked.is_empty(): return hand.slice(0, 2) var t := _temperature(skill) # 0 = expert (exact argmax), >0 = weighted random if t <= 0.0: return (ranked[0]["thrown"] as Array).duplicate() # expert: always the best EV throw var roll := RandomNumberGenerator.new() roll.seed = decision_seed # build a probability distribution over ALL ranked options, weighted by EV var vals: Array = [] for r in ranked: vals.append(float(r["score"])) var pick := _softmax_pick(vals, t, roll) return (ranked[pick]["thrown"] as Array).duplicate()
Evaluating Every Possible Throw
With 6 cards and 2 to throw, there are exactly C(6,2) = 15 possible throws. The AI evaluates all of them. For each candidate throw it keeps 4 cards and calculates the expected score across all 46 possible starter cards.
func _eval_discards(hand: Array, my_crib: bool, ranked_order := false) -> Array: var results: Array = [] var cuts := _unseen_cuts(hand) # the 46 cards not in the AI's hand var inv := 1.0 / float(maxi(1, cuts.size())) for i in range(n): for j in range(i + 1, n): var thrown: Array = [hand[i], hand[j]] var kept: Array = [] # the 4 cards the AI would keep for k in range(n): if k != i and k != j: kept.append(hand[k]) var avg_hand := _avg_hand(kept, cuts) * inv # E[hand points | cut] var own_only := _avg_own_crib(thrown, cuts) * inv # E[these 2 cards in crib | cut] var avg_crib := StatsScript.crib_value(thrown[0].rank, thrown[1].rank, my_crib) var expected := avg_hand + avg_crib var net := avg_hand - avg_crib # dealer wants to maximise; pone wants to minimise crib var safety := avg_hand + (own_only if my_crib else -own_only) results.append({ "thrown": thrown, "avg_hand": avg_hand, "avg_crib": avg_crib, "expected": expected, "net": net, "safety": safety, "score": expected if my_crib else net, })
The scoring formula is crib-seat aware:
Pone score = avg_hand − avg_crib
When you're the dealer, the crib is yours, so high-crib throws are good. When you're the pone, the crib goes to your opponent, so the AI penalises dangerous throws (5s, pairs into the opponent's crib) even if they'd score well on their own.
The 46-Card Starter Pool
func _unseen_cuts(hand: Array) -> Array: var seen := {} for c in hand: seen[c.rank * 4 + c.suit] = true var cuts: Array = [] for su in range(4): for rk in range(1, 14): if not seen.has(rk * 4 + su): cuts.append(Card.new(rk, su)) return cuts
The AI averages over all 46 cards it can't see: the 52-card deck minus its own 6. It does not exclude the player's hand from the pool, because it doesn't know what's in the player's hand. This is exactly what a fair human player would do. If it excluded the player's cards, that would be a form of peeking.
The Tiebreaker: Safety
func _cmp_disc(a: Dictionary, b: Dictionary) -> bool: if absf(float(a["score"]) - float(b["score"])) > 0.001: return float(a["score"]) > float(b["score"]) return float(a["safety"]) > float(b["safety"]) # within 0.001 EV: prefer safer throw
When two throws are within 0.001 points of each other in expected value, the AI prefers the safer throw. "Safety" here measures how much the thrown cards would help a crib; high safety means the thrown cards don't help a future crib much either way.
🎶 4 Difficulty Tiers
Four named difficulty levels. Each tier differs on two independent axes, the way real players do:
StrategyProfile.gd,
the same table the in-game Academy chips read from.| Tier | Skill Value | Temperature (T) | Expert Win Rate Target | Character |
|---|---|---|---|---|
| Easy | 0.12 | 4.5 | 95–98% | Chases points. Hasn't learned counting or board play yet; frequent real mistakes |
| Normal | 0.35 | 1.6 | 75–85% | Balks bad cribs and leads safer, but still ignores the board. Regular slips a steady player will punish |
| Hard | 0.85 | 0.65 | 55–65% | Plays the board when ahead or behind, and thinks two moves ahead on the peg. Occasional slips |
| Expert | 1.0 | 0.0 | n/a | Everything Hard knows, plus traps and endgame lines. Always the highest-EV option, no randomness. |
The Academy Curriculum: What Each Tier Knows
The in-game Advanced Academy teaches 17 lessons, from “Every Discard Is a Bet” to “The Complete Player.” That same curriculum is the difficulty ladder: every lesson maps to a named heuristic in the brain, switched on tier by tier. Study a lesson in the Academy, then feel your opponent using it against you.
| Tier | Lessons it plays |
|---|---|
| Easy | Prices every throw against the crib (Every Discard Is a Bet, Feeding Your Own Crib), and that's it. No card counting, no board sense. |
| Normal | + Balking Their Crib, Lead Like a Card Sharp (safe, fifteen-proof leads), and light card counting. |
| Hard | + The Rule of Twenty-Six & Ahead or Behind (board position), Hold or Break, The Magic Eleven, Reading the Table, The Second Wind, and both Endgame lessons (Sprint & Wall), plus two-move lookahead on the peg. |
| Expert | All seventeen: + The Four Streets, Whose Count Comes First, Bait and Trap, and The Last Deal, with deeper lookahead and zero slips. |
The lesson-to-tier map lives in one place (StrategyProfile.gd) and drives both
the AI's decisions and the tier chips shown on each Academy lesson in-game,
so the UI can never claim a lesson the tier doesn't actually play. The exact numbers each
tactic uses are the one thing we keep to ourselves; the benchmark below is how we prove
the result honestly.
static func temperature_for_skill(skill: float) -> float: var s := clampf(skill, 0.0, 1.0) var anchors := [ [0.0, 6.0], # skill 0.0 -> T 6.0 (essentially random) [0.12, 4.5], # skill 0.12 -> T 4.5 (Easy) [0.35, 1.6], # skill 0.35 -> T 1.6 (Normal) [0.85, 0.65], # skill 0.85 -> T 0.65 (Hard) [1.0, 0.0] # skill 1.0 -> T 0 (Expert: exact argmax) ] for i in range(1, anchors.size()): if s <= float(anchors[i][0]): var a: Array = anchors[i - 1] var b: Array = anchors[i] var f := (s - float(a[0])) / maxf(0.000001, float(b[0]) - float(a[0])) return lerpf(float(a[1]), float(b[1]), f) return 0.0
The function slides T smoothly between those anchor points. At Expert (skill 1.0), T = 0 exactly, and the code skips the dice roll entirely: it always takes the top-ranked option, no randomness at all. At lower skills, T > 0 feeds into the mistake model in the next section.
The previous difficulty model had a "worst-of-15 throw" branch where Easy would pick the worst possible discard roughly 77% of the time, tossing 5-5 into your own crib while keeping garbage. That reads as broken rather than weak. The current model instead uses the weighted dice roll: every tier picks from the same ranking with a calibrated temperature, producing believable near-misses and the occasional real blunder rather than deliberately self-destructive play.
📈 5 The Mistake Model
This is how the game generates realistic mistakes at lower difficulty levels without simply playing randomly. Think of it as a raffle: every option gets tickets, the best option always gets the most, and worse options get fewer and fewer the more points they give up. The temperature T from the last section controls how steep that drop-off is. (The technical name for this is a softmax, which is why you'll see that word in the code.)
When T is large (Easy), the tickets spread out widely; even options that give up 2+ points get picked sometimes. As T shrinks toward 0 (Expert), nearly every ticket belongs to the best option.
static func _softmax_pick(vals: Array, t: float, roll: RandomNumberGenerator) -> int: var best := -1.0e20 for v in vals: best = maxf(best, float(v)) var weights: Array = [] var total := 0.0 for v in vals: var w := exp(-(best - float(v)) / t) # best option always gets weight 1.0 weights.append(w) # worse options get fractional weight total += w var x := roll.randf() * total # uniform draw scaled to total weight for i in range(weights.size()): x -= float(weights[i]) if x <= 0.0: return i # this is the chosen option return 0
The key property: options more than about 8×T points below the best almost never win the raffle. At Easy (T=4.5) that cutoff is ~36 points, larger than any real discard spread, so effectively every option is live and the pick is close to random. That is the point of the calibration: Easy loses to Expert about 97% of the time, near the pure-random baseline, which is how a real beginner plays. As T drops toward Expert, the tickets pile back onto the single best play.
In ranked sessions the game's random number generator is replaced by
FairDeal.hash_roll(), a draw derived from SHA-256 fingerprints that any
computer can reproduce exactly. This means the server can double-check every AI
decision without trusting the game on your machine. The raffle logic is
identical; only the source of randomness changes. See choose_discard_ranked()
and choose_play_rolled().
♥ 6 Pegging Decisions
Pegging is harder than discarding: decisions happen in sequence, depend on the public pile, and have to balance immediate points against what the reply might score. Easy and Normal value each card directly with a card-counted net-value model; Hard and Expert go a step further and search two moves ahead.
Entry Point: choose_play()
func choose_play(obs: AlleyObservation, profile: StrategyProfile, rng, sample_seed := 0): _o = obs var legal: Array = _legal_plays(obs.own_hand, obs.running_count) if legal.is_empty(): return null var t := profile.temperature if profile.peg_depth >= 2: # Hard & Expert: 2-ply expectimax var vals2 := _play_line_values(legal, profile, sample_seed) if not vals2.is_empty(): if t <= 0.0: return legal[_argmax(vals2)] # Expert: best full line, exactly return legal[_softmax_pick(vals2, t, rng)] # Hard: softmax over line values if t <= 0.0: return _best_play(...) # exact argmax over 1-ply values var vals: Array = [] for card in legal: vals.append(_net_value(card, profile)) # tier knowledge gates apply inside return legal[_softmax_pick(vals, t, rng)]
Legal plays are cards that won't push the running count over 31. Easy and Normal
score each legal card on its own with _net_value() and feed the results
through the same raffle used for discards. Hard and Expert instead score whole
pegging exchanges, several cards deep; see the two-moves-ahead section below.
The Net Value Function: What Makes a Peg Card Good
func _net_value(card, profile: StrategyProfile) -> float: var new_count: int = _o.running_count + card_value var score: float = 0.0 if new_count == 15: score += 2.0 # fifteen-for-two if new_count == 31: score += 2.0 # thirty-one score += pairs_and_runs(pile + card) # pairs, pairs royal, runs # Minus the card-counted reply threat. Board position (Rule of 26), the 121-endgame, # fourth street, and whose-count-comes-first each re-weight it -- IF this tier has # learned that lesson (profile.knows_* gates). if profile.threat_weight > 0.0 and new_count < 31: score -= threat_w * _opponent_threat(new_count, ...) # Classic heuristics (all tiers): if new_count == 5 or new_count == 21: score -= 1.0 # leaves easy 15 or 31 if _o.running_count == 0 and card.rank == 5: score -= 1.2 # don't lead a 5 # Plus the Academy-lesson terms this tier knows: safe fifteen-proof leads, # holding a magic eleven, saving closers for after the Go, taking a peg-out # that wins on the spot. (Exact weights: our secret sauce.) return score
The signals, in plain language:
| Signal | Tiers | Explanation |
|---|---|---|
| Immediate points (15 / 31 / pairs / runs) | All | The points this card scores right now |
| Opponent reply threat | Normal+ | Card-counted expected reply, subtracted (Easy hasn't learned counting) |
| Leaving count 5 or 21, leading a 5 | All | The classic beginner traps, penalised for everyone |
| Safe, fifteen-proof leads | Normal+ | Lead Like a Card Sharp (L9) |
| Board position re-weights the threat | Hard+ | Rule of 26 / Ahead-or-Behind: defend a lead, gamble when behind |
| Magic eleven, closer budgeting | Hard+ | Hold the 11-pair; save aces and deuces for after the Go |
| Streets & whose-count-first arithmetic | Expert | Fourth-street denial; the show order decides photo finishes |
| Peg-out awareness | Expert | A play whose points reach 121 ends the game; nothing outweighs it |
The threat weight comes from the tier's profile: Easy's is zero. It truly doesn't count cards, rather than counting them badly. Normal counts at reduced weight. Hard and Expert apply the full threat model and then adjust it for board position and the endgame, with each adjustment unlocked by the Academy lesson that teaches it.
Thinking Two Moves Ahead (Hard & Expert)
Judging one card at a time can't see a trap: a play that scores nothing now but wins the exchange two plays later. So Hard and Expert don't judge single cards; they play the exchange out in their head, the way a person does:
For every card it could play, the AI deals itself a set of plausible opponent hands drawn from the cards it hasn't seen, the same public information any card counter has (its own cards, the pile, and the starter are ruled out; yours are unknown, so every possibility is weighted fairly). It then plays the rest of the pegging exchange out against each imagined hand, with both sides playing well, and averages how the points came out. The card with the best average wins. Expert imagines more hands and never deviates from the best line; Hard searches the same way but still slips at its usual rate.
The imagined hands are generated from public information plus a fixed random
seed, never from your actual cards. Swap your real hand for any other cards and
the imagined hands (and therefore the decision) are identical; tests/test_no_peek.gd
proves this for the lookahead too. In ranked mode the seed comes from its own SHA-256
hash stream, so the server re-creates the exact same imagined hands and verifies every
lookahead decision after the match.
This is what finally makes the trap lessons real. Holding 4-6-6, one-move greed happily leads the safe king; the two-move search sees that leading a 6 invites a reply it can punish and sets the five-card trap the Academy teaches. Bait-and-trap, the second wind, and last-deal peg-outs all emerge from the same search.
⚠ 7 Opponent Threat Model
At higher skill levels, the AI counts more than its own immediate points: it also estimates what the opponent is likely to score in reply. This is the card-counting part of the AI.
The Unseen Rank Pool
The AI keeps a tally of how many copies of each rank are still unaccounted for: not in its hand, not in the public pile, not the starter card. Exactly the tally a human card counter keeps in their head.
func _unseen_rank_counts() -> Dictionary: if _rc_for == _o and _o != null: return _rc_cached # memoized per observation -- computed once per decision var counts: Dictionary = {} for r in range(1, 14): counts[r] = 4 # start: 4 of every rank for c in _o.own_hand: counts[c.rank] = int(counts[c.rank]) - 1 # subtract own cards for c in _o.played: counts[c.rank] = int(counts[c.rank]) - 1 # subtract public pile if _o.starter != null: counts[_o.starter.rank] = maxi(0, int(counts[_o.starter.rank]) - 1) # subtract starter _rc_for = _o _rc_cached = counts return counts
The starter subtraction (added in AI-8) is an important fairness detail: the starter card is public knowledge at a real table, and including it in the card count gives the AI a small but legitimate advantage over naive play. Not subtracting it would be leaving information on the table a skilled human would use.
Threat Calculation
func _opponent_threat(new_count: int, played_rank: int, expected := false) -> float: var rank_remaining := _unseen_rank_counts() var opp_cards: int = _o.opp_card_count if opp_cards <= 0 or unseen_total <= 0: return 0.0 var threat: float = 0.0 for r in range(1, 14): var copies: int = rank_remaining[r] if copies <= 0: continue var pts: int = reply_points(r, new_count) # points if opp plays this rank if pts <= 0: continue var weighted: float = float(pts) * _prob_opponent_holds(copies, unseen_total, opp_cards) if expected: threat += weighted # Hard & Expert: read the WHOLE table elif weighted > threat: threat = weighted # Easy & Normal: fear the single worst reply return threat
Lower tiers track only the single scariest reply, the one obvious danger a casual player notices. Hard and Expert have graduated the “Reading the Table” lesson: they add up the weighted threats across every rank, because a count with three different ways to get burned is more dangerous than one with a single pothole of the same size. That's how a club player reads a table: the whole picture rather than just the nightmare card.
Probability of Holding a Rank
func _prob_opponent_holds(copies: int, pool: int, hand_size: int) -> float: if copies <= 0 or hand_size <= 0: return 0.0 if copies >= pool: return 1.0 var p_none: float = 1.0 for k in range(hand_size): p_none *= float(pool - copies - k) / float(pool - k) if p_none <= 0.0: return 1.0 return 1.0 - p_none # P(holds at least one copy) = 1 - P(holds zero copies)
This is the textbook formula for drawing cards without putting them back: given a pool of unseen cards, some copies of which are the target rank, and an opponent holding a few of them, what's the chance they have at least one? The code answers it backwards, by working out the chance they hold zero copies and subtracting from 1, which is both exact and fast.
Board Position & the Endgame (Hard+)
Tiers that have graduated the position lessons read the board the same way the in-game coach strip does, using par pace (the Rule of Twenty-Six) plus the head-to-head race, and adjust the reply threat accordingly: defend a lead (replies count for more), sprint when behind (discount them and chase points). Down the stretch the reads sharpen: when the opponent is within striking distance of 121 the AI defends harder, and when the AI itself is nearly home it grabs points to close the game out. Expert adds The Four Streets and Whose Count Comes First on top: on the last deal, the show order decides photo finishes, so whoever counts first plays it differently.
Every one of these reads runs off the scores, the hand number, and the deal order, all public information sitting on the board for everyone to see, so it stays a no-peek decision.
🔢 8 ScoreEngine: The Math
All scoring lives in core/ScoreEngine.gd: plain math functions with no
memory and no display code. There are two versions of each scorer: a detailed one
that produces the play-by-play events the Show screen animates, and a stripped-down
fast one for the millions of what-if checks inside the discard math.
Finding Fifteens
static func _find_fifteens(cards: Array) -> Array: var events: Array = [] var n: int = cards.size() for mask in range(1, 1 << n): # enumerate all 2^n - 1 non-empty subsets var sum: int = 0 var combo: Array = [] for i in range(n): if mask & (1 << i): # bit i set = card i is in this subset sum += cards[i].get_cribbage_value() combo.append(cards[i]) if sum == 15 and combo.size() >= 2: events.append({"type": "fifteen", "points": 2, ...}) return events
For 5 cards, this checks 31 subsets (2&sup5; − 1). Face cards count as 10, aces as 1. Every combination that totals 15 with at least 2 cards scores 2 points.
Fast Scorer (EV Inner Loop)
The fast version avoids allocating arrays for every subset by using a PackedInt32Array and scanning runs directly:
# Runs: consecutive-rank streaks; each streak of length L>=3 scores # L * (product of counts) -- identical to enumerating the cartesian combos. var streak_len: int = 0 var streak_mult: int = 1 for r in range(1, 15): if r <= 13 and rc[r] > 0: streak_len += 1 streak_mult *= rc[r] # multiply by copies of this rank else: if streak_len >= 3: pts += streak_len * streak_mult # double runs, triple runs etc. arise naturally streak_len = 0 streak_mult = 1
The run formula, streak_length * product_of_rank_counts, correctly
handles double runs (a run of 4 where one rank appears twice) and triple runs
without having to list every combination one by one.
The Perfect 29
static func is_perfect_29(hand: Array, starter) -> bool: if starter.rank != 5: return false var has_jack := false var five_count := 0 var jack_suit := -1 for c in hand: if c.rank == 11: # Jack has_jack = true jack_suit = c.suit elif c.rank == 5: five_count += 1 return has_jack and five_count == 3 and jack_suit == starter.suit
The rarest hand in cribbage: J♦ + 5♠5♣5♥ + 5♦ starter (Jack matching the cut's suit). Exactly one combination out of the ~12 billion possible cribbage deals.
📈 9 Crib Odds Tables
Rather than simulating the full crib completion at runtime for every discard candidate, the AI reads from pre-computed tables generated offline by exact enumeration.
# Computed by tools/gen_crib_table.py via exact enumeration of all 58,800 crib completions const AVG_CRIB := [ # A 2 3 4 5 6 7 8 9 10 J Q K [5.53, 4.45, 4.57, 5.47, 5.74, 4.26, 4.09, 4.13, 4.04, 3.96, 4.20, 3.86, 3.75], # A ... [5.74, 5.77, 6.43, 7.00, 8.99, 7.10, 6.42, 5.76, 5.74, 7.03, 7.26, 6.93, 6.82], # 5 ]
The 5-5 entry (row 5, col 5): 8.99 average crib points. That's why throwing a pair of 5s into your own crib is so powerful, and why the AI is reluctant to let a pair of 5s go into the opponent's crib as pone.
The basic AVG_CRIB table assumes the other two crib cards arrive at random.
But real cribbage has seat effects: the dealer feeds their own crib its best
cards, while the pone defensively throws their most harmless cards into the
dealer's crib. So the AI uses two additional tables, AVG_CRIB_DEALER and
AVG_CRIB_PONE, built by having the AI play against itself 3,000 times per
rank pair. The 5-5 entry in the dealer table is 8.85; in the pone table it is 9.28,
higher because the dealer completes their own crib aggressively.
At decision time the lookup is a single read from the table. No math, no simulation:
static func crib_value(rank_a: int, rank_b: int, my_crib: bool) -> float: var a := clampi(rank_a, 1, 13) - 1 var b := clampi(rank_b, 1, 13) - 1 if my_crib: return AVG_CRIB_DEALER[a][b] # seat-conditioned: opponent completes defensively else: return AVG_CRIB_PONE[a][b] # seat-conditioned: opponent completes greedily
🎓 10 Coach & Hints (CoachEval)
The in-game hint system and post-game coaching grade the player's decisions using the exact same model the AI uses for itself. One source of truth; the game never holds the player to a higher standard than it holds the computer.
Live Grade: Blunder Detection
func grade_play(own_hand: Array, pile: Array, count: int, opp_count: int, chosen, starter = null) -> Dictionary: var obs := AlleyObservation.for_play(own_hand, pile, count, opp_count, starter) var best_val: float = _brain.peg_best_value(obs) var chosen_val: float = _brain.peg_play_value(obs, chosen) var ev_lost: float = maxf(0.0, best_val - chosen_val) # Named trap detection var led_five: bool = (count == 0 and chosen.rank == 5) var left_danger: bool = new_count in [5, 10, 11, 21, 22] return { "best": ev_lost <= PEG_BEST, # within 0.05 of optimal "ok": ev_lost < PEG_SLACK, # within 0.35 -- no nagging "ev_lost": ev_lost, "best_card": best_card, "reason": ..., "tags": tags, }
The slack constants matter. PEG_SLACK = 0.35 means if you give up less than
0.35 expected peg points, the coach says nothing. Pegging differences are small and
noisy, and the coach is designed not to nag over coin-flip decisions.
Named Traps: Positive Coaching
The coach also recognises specific tactical formations in the player's kept hand and calls them out positively, teaching technique as well as flagging blunders.
| Trap Name | Formation | How it works |
|---|---|---|
| Five-card trap | 4-6-6 | Lead a 6; they can't safely answer with a 5 (6-5-4 run threat); you force 31 |
| Pair-royal bait | Low pair (A-4) | Lead one; if they pair it, drop the third for pairs royal (6 points) |
| Magic eleven | Two cards summing to 11 | Any ten-card in the reply lets you seize 31-for-2 |
| Run bait | Three consecutive ranks | Offer one end; hold the extension to re-trap into a longer run |
Deep Analysis: Post-Game Expectimax
The opt-in deep analysis after the game replays your leads against a spread of hands the opponent could have held, and averages the results. That surfaces leads which score nothing immediately but quietly win the exchange: the traps the quick in-game grader can't see.
func analyze_lead(player_cards: Array, unseen_pool: Array, opp_count: int, chosen, max_samples: int = 120) -> Dictionary: var samples: Array = _sample_hands(unseen_pool, opp_count, max_samples) for oc in samples: for c in player_cards: totals[c] += _play_recurse(player_cards, oc, [], 0, P, c) # player maximises # opponent inside _sim() plays fair greedy -- AlleyBrain.peg_best_card(obs)
The simulation tries up to 120 possible opponent hands from the unseen pool (or every single one, when there are 120 or fewer). For each it plays the rest of the pegging out with best play on both sides and records the point margin. A "trap" is flagged when the best lead scores nothing immediately yet averages 1.5 points or more of margin, and the lead you chose underperformed it by a point or more.
The Hand Analyzer
The same discard evaluator is also open for you to play with directly. The Hand Analyzer (Learn → Hand Analyzer) lets you build any six-card hand, pick whose crib it is, and see all 15 possible throws ranked by the same expected-value model the AI uses for its own discards. The Count Summary's best-throw recap links straight into it with an "Analyze this hand" button, so you can dig into the hand you just played.
🔐 11 FairDeal: Cryptographic Fairness
FairDeal is the anti-cheat backbone: it proves that neither the game nor the AI could have "re-rolled" the deal after seeing the cards, and that every hand was predetermined before a single card was shown.
Commit-Reveal Protocol
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) # this goes to the player BEFORE dealing return commit_hex 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 # 63-bit positive seed
At match start you see commit = SHA256(secret), a fingerprint of a secret
number. SHA-256 is a one-way function: nobody can work backwards from the fingerprint
to the secret. So the entire match (every hand, every shuffle, every AI decision in
ranked mode) was locked in before any card was dealt. At match end the secret is
revealed and you, or anyone, can replay every hand from scratch using
verify_hand_seed() and confirm the cards match.
Ranked Mode: Hash Rolls for AI Decisions
static func hash_roll(seed_val: int, tag: String, n: int) -> float: # roll(seed, "aid", 0) for discard; roll(seed, "aip", n) for nth pegging play # Returns a uniform float in [0, 1) derived from SHA-256 var ctx := HashingContext.new() ctx.start(HashingContext.HASH_SHA256) ctx.update(b) # seed as big-endian 8 bytes ctx.update(("|%s|%d" % [tag, n]).to_utf8_buffer()) var v: int = 0 for i in range(7): v = (v << 8) | int(d[i]) v = v >> 3 # 53 bits return float(v) / 9007199254740992.0 # / 2^53
In ranked sessions, the AI's raffle draws come from this function instead of
the game's normal random number generator. The server's replay verifier runs the same
formula (mirrored in functions/src/ai.ts) and gets identical results,
proving the AI played at the claimed skill level and didn't get lucky with
unusual rolls.
📊 12 Strength Benchmark
The AI's strength is measured, not asserted. The benchmark runner
in tests/bench_ai.gd plays the shipped AlleyBrain against itself at
different skill levels over thousands of matches and checks the results against
hard win-rate targets.
# AI-5 spacing targets -- the weaker tier's win rate vs Expert should land near: # Easy 2-5% (expert wins 95-98%) # Normal 15-25% (expert wins 75-85%) # Hard 35-45% (expert wins 55-65%) # Outside those bands => retune the _temperature() anchors in core/AlleyBrain.gd. const N_MAIN := 10000 # expert vs random -- headline sanity check const N_LADDER := 3000 # expert vs each weaker tier
The benchmark alternates which seat each player sits in and which one deals first, so neither seat advantage nor dealer advantage skews the result. It runs 10,000 matches for the headline number and 3,000 for each ladder rung.
The latest full 19,000-match run, the same numbers the in-game "AI Strength" panel reads, lands every tier inside its target band:
| Expert vs | Win rate | 95% CI | Target band |
|---|---|---|---|
| Random | 99.5% | 99.4–99.6 | >85% |
| Easy | 97.0% | 96.3–97.6 | 95–98% |
| Normal | 82.7% | 81.3–84.0 | 75–85% |
| Hard | 59.2% | 57.4–60.9 | 55–65% |
One detail worth noticing: after the knowledge upgrade, Expert's edge over Hard tightened (62.3% → 59.2% in the previous vs. current run) while the lower rungs barely moved. The club-player lessons strengthened Hard more than they strengthened Expert, which is just what a knowledge-based ladder should do.
func _wilson(k: int, n: int) -> Array: var z := 1.96 # z* for 95% CI var p := float(k) / float(n) var denom := 1.0 + z * z / float(n) var centre := (p + z * z / (2.0 * float(n))) / denom var margin := (z * sqrt(p * (1.0 - p) / float(n) + z * z / (4.0 * float(n) * float(n)))) / denom return [(centre - margin) * 100.0, (centre + margin) * 100.0]
The results are saved to data/ai_benchmark.json, which the in-game "AI Strength"
panel reads, so the numbers displayed in-game always come from the latest actual
benchmark run rather than hardcoded estimates.
⚡ Key Takeaways
The design enforces this, not a policy. AlleyObservation
has no field for the opponent's hidden cards, and AlleyBrain can only act
on what an AlleyObservation contains. The test suite confirms it.
The AI plays the mathematically best hand almost every time: averaged discard math over all 46 possible cuts, seat-aware crib tables, card-counted pegging, and at Hard and Expert a genuine two-move lookahead that sets and springs the traps the Academy teaches, plus board-position play from the Rule of Twenty-Six through the last-deal arithmetic. These are the same concepts a strong human player uses (each difficulty has graduated more of the game's own 17-lesson curriculum), so think of it as a strong, card-counting club player rather than an unbeatable solver.
The raffle model produces believable errors at lower difficulties: near-misses and occasional real blunders, weighted by how many points they give up. No tier ever plays the single worst option unless the hand is truly close. The difficulty targets are validated by benchmarks of thousands of matches.
Every deal is committed before any card is shown via SHA-256 commit-reveal. You can verify every hand at match end. In ranked mode, every AI decision is a deterministic SHA-256 draw the server can replay independently.
| File | Role | Lines |
|---|---|---|
| core/AlleyBrain.gd | Complete decision engine (discard + peg) | ~830 |
| core/StrategyProfile.gd | Per-tier knowledge & execution profile (the curriculum table) | ~260 |
| core/PeggingSim.gd | Forward pegging simulator (two-move search + coach analysis) | ~190 |
| core/AlleyObservation.gd | Data contract (no-peek enforcement) | ~50 |
| core/ScoreEngine.gd | Pure scoring, fast + full-event variants | ~240 |
| core/PeggingController.gd | Pegging state & AlleyBrain bridge | ~155 |
| core/CoachEval.gd | Hint/grading system & deep analysis | ~310 |
| core/FairDeal.gd | Commit-reveal fairness & ranked hash-rolls | ~160 |
| scenes/stats.gd | Crib tables, par benchmarks, stat tracking | ~400 |
| tests/bench_ai.gd | 19k-match strength benchmark | ~265 |
Generated from live source code for Just Cribbage. Updated July 20, 2026. Code quotations are lightly condensed from the shipped GDScript files; the tuned numbers behind the Academy-lesson tactics are left out (that part is our secret sauce). Everything else, including the benchmark, you can verify.