Just Cribbage · Technical Analysis

AI Deep Dive:
How the Computer Thinks

A guided walk through the actual source code powering the computer opponent, from the first shuffle to the last peg. Every decision explained in plain language, next to the code that makes it. No programming background needed.

No-peek guarantee Deterministic
AlleyBrain.gd AlleyObservation.gd StrategyProfile.gd PeggingSim.gd ScoreEngine.gd FairDeal.gd
Academy-taught tiers Two-move lookahead

📄 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.

Why do we call it “the Alley”?

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.

CribbageMatch
Game state & phase
AlleyObservation
Public facts only
AlleyBrain
Pure decision logic
Move
Card(s) returned
AlleyBrain.gd
The entire decision engine. Pure function of an AlleyObservation. No game state, no UI, no randomness except via a seeded RNG passed in. Handles both discard EV and pegging.
AlleyObservation.gd
The data contract. Contains exactly what a fair opponent at a real cribbage table would know: their own cards, the public pile, the running count, and the count (not identity) of the opponent's hand.
ScoreEngine.gd
Pure, stateless scoring. Fast allocation-free variants for EV inner loops, full event-producing variants for the UI. All static functions.
PeggingController.gd
Manages pegging state and calls AlleyBrain with an appropriately constructed observation. The bridge between live game state and the pure brain.
Design principle

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.

core/AlleyObservation.gd The complete field list (no opponent hand field exists)
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.

Structural no-peek guarantee

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:

core/AlleyObservation.gd Factory constructors
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

core/AlleyBrain.gd · choose_discard() The only place the Alley's discard is decided
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.

core/AlleyBrain.gd · _eval_discards() Scores all 15 possible throws
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:

Dealer score = avg_hand + avg_crib
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

core/AlleyBrain.gd · _unseen_cuts() What the AI averages over
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
Fair averaging

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

core/AlleyBrain.gd · _cmp_disc() How near-ties are broken
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:

Knowledge: which concepts it plays
Each tier has "graduated" more of the game's own 17-lesson Advanced Academy. Easy prices its throws but hasn't learned card counting; Hard plays board position; Expert knows every lesson. Gated per tier in StrategyProfile.gd, the same table the in-game Academy chips read from.
Execution: how often it slips
A weighted dice roll over the ranked options (next section). Easy misses a lot; Hard slips occasionally; Expert never does. Calibrated against explicit win-rate targets validated by the benchmark runner.
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.

TierLessons 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.
One table, brain and UI

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.

core/StrategyProfile.gd · temperature_for_skill() Piecewise-linear interpolation through difficulty anchors
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.

Design change from the old system

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.)

P(option k) ∝ exp(−(EV_best − EV_k) / T)

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.

core/AlleyBrain.gd · _softmax_pick() The weighted random selection that produces human-plausible errors
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.

T = 4.5
Easy feel
A throw 2 points worse than the best still holds about 64% as many raffle tickets, and 4 points worse about 41%. Nearly every discard is live, so the play is close to random.
T = 0.65
Hard feel
A throw 2 points worse holds only about 5% as many tickets, and 4 points worse about 0.2%. The AI almost always finds the best play, with occasional small slips.
Ranked / Leaderboard mode

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()

core/AlleyBrain.gd · choose_play() (condensed) One-move scoring for Easy/Normal; two-move search for Hard/Expert
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

core/AlleyBrain.gd · _net_value() (condensed) The one-move scoring core (lesson tactics elided)
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:

SignalTiersExplanation
Immediate points (15 / 31 / pairs / runs)AllThe points this card scores right now
Opponent reply threatNormal+Card-counted expected reply, subtracted (Easy hasn't learned counting)
Leaving count 5 or 21, leading a 5AllThe classic beginner traps, penalised for everyone
Safe, fifteen-proof leadsNormal+Lead Like a Card Sharp (L9)
Board position re-weights the threatHard+Rule of 26 / Ahead-or-Behind: defend a lead, gamble when behind
Magic eleven, closer budgetingHard+Hold the 11-pair; save aces and deuces for after the Go
Streets & whose-count-first arithmeticExpertFourth-street denial; the show order decides photo finishes
Peg-out awarenessExpertA play whose points reach 121 ends the game; nothing outweighs it
Knowledge gating in action

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:

Candidate card
each legal play
Sample opponent hands
from the public unseen pool
Simulate the exchange
to the end of the count
Average the margin
pick the best line

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.

Lookahead without peeking

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.

core/AlleyBrain.gd · _unseen_rank_counts() Card counting: what ranks could the opponent hold?
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

core/AlleyBrain.gd · _opponent_threat() (condensed) Reply score per rank, weighted by probability of holding it
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
Two ways to read the table (L12: Reading the Table)

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

core/AlleyBrain.gd · _prob_opponent_holds() Hypergeometric without-replacement probability
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

core/ScoreEngine.gd · _find_fifteens() Bitmask enumeration of all subsets
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:

core/ScoreEngine.gd · _fast_combo_points() Allocation-free hot-loop scorer
# 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

core/ScoreEngine.gd · is_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.

scenes/stats.gd · AVG_CRIB (excerpt) AVG_CRIB[a-1][b-1] = expected crib value for tossing ranks a and b
# 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.

Two seat-conditioned tables (AI-6)

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:

scenes/stats.gd · crib_value()
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

core/CoachEval.gd · grade_play() Returns a verdict dict for the player's most recent peg
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 NameFormationHow 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.

core/CoachEval.gd · analyze_lead() Plays out up to 120 possible opponent hands
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

Match Start
Commit = SHA256(secret) shown to player
Each Hand
Seed = SHA256(secret + hand_index)
Match End
Secret revealed; player verifies every hand
core/FairDeal.gd · begin_match() and hand_seed()
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
What this proves

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

core/FairDeal.gd · hash_roll() Deterministic uniform draw any language can reproduce
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.

tests/bench_ai.gd Published AI strength 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.

Measured results (July 10, 2026, after the curriculum & lookahead upgrade)

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 vsWin rate95% CITarget band
Random99.5%99.4–99.6>85%
Easy97.0%96.3–97.695–98%
Normal82.7%81.3–84.075–85%
Hard59.2%57.4–60.955–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.

>85%
Expert vs Random
Floor sanity check: Expert must beat a random-card-selection opponent more than 85% of the time. This validates the scoring and EV model are working at all.
Wilson CI
Confidence interval
Every reported win rate comes with a 95% confidence interval (the Wilson method), so the margin of error from sampling is stated up front. At 10,000 matches it's roughly ±0.4%.
tests/bench_ai.gd · _wilson() Wilson score interval for the published win rate
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.

🌟 13 Coaching Menu: Code to UI

The Options → Coaching menu exposes six toggles/settings. Each one is a direct wire into specific functions in the codebase. Here's exactly what each option activates, the code that runs, and what the player sees.

One standard, both directions

Every coaching surface uses AlleyBrain as its evaluator, the exact same brain the AI plays with. The coach never holds the player to a higher standard than the AI holds itself. If the AI wouldn't be penalised for a play, the coach won't flag it either.

Option 1: Discard Evaluator (Off / Flags Only / Full)

scenes/Main.gd Setting key: "coach_discard" (level 0/1/2)
# After the player locks in their throw, the discard evaluator runs.
# It calls AlleyBrain.eval_discards() -- the same function the AI used for its own throw.
var ranked: Array = _ranked_discards(six, dealer == _seat_me)
# Level 1 (Flags only): speaks up only when EV lost >= threshold, or a named bad throw
# Level 2 (Full): always shows the best keep + EV delta for the player's actual throw
# Grades the TWO cards you TOSSED, after the throw is locked. Feedback only.
# Never marks the game "assisted" (the throw cannot be changed).

The discard evaluator runs after your throw is locked in; it cannot change your decision. It calls _ranked_discards() which internally calls AlleyBrain.eval_discards() (the public PERF-4 wrapper), ranks all 15 possible throws from your starting six, and shows you where yours fell in the ranking plus the EV difference from the best keep.

LevelWhen it speaksWhat you see
OffNeverNothing
Flags OnlyOnly on significant blunders (high EV loss or named bad throw like 5-5 to opponent crib)Brief flag in the game log
FullEvery discardFull line: best keep, your throw's rank, EV delta

Option 2: Pegging Evaluator (Off / Flags Only / Full)

scenes/Main.gd · _coach_grade_peg() Called before each player peg play is submitted
func _coach_grade_peg(card) -> void:
    var obs := AlleyObservation.for_play(own, pile, count, opp_count, _match.starter)
    var g: Dictionary = _coach_eval().grade_play(own, pile, count, opp_count, card, _match.starter)
    # Accumulates pegging accuracy stats regardless of display level:
    _deal["peg_plays"] = int(_deal.get("peg_plays", 0)) + 1
    if bool(g.get("best", false)):
        _deal["peg_best"] = int(_deal.get("peg_best", 0)) + 1
    _deal["sum_peg_ev_lost"] = float(_deal.get("sum_peg_ev_lost", 0.0)) + float(g.get("ev_lost", 0.0))
    # Level-gated display:
    var p_lvl := _coach_level("coach_pegging", 0)
    if p_lvl <= 0 or not _coaching_allowed(): return
    if p_lvl == 1:          # flags only: led 5, danger count, or lost >= 1.0 EV
        if bool(g.get("led_five", false)) or bool(g.get("danger_count", false)) \
                or float(g.get("ev_lost", 0.0)) >= 1.0:
            _log("  [color=#e6b34a]Peg analysis: %s[/color]" % g.get("reason", ""))
    else:                   # full: every play, green if best, amber if not
        var col := "#4ade80" if g.get("ok", false) else "#ffd166"
        _log("  [color=%s]Peg analysis: %s[/color]" % [col, g.get("reason", "")])

Critically, _coach_grade_peg() accumulates accuracy stats (peg_plays, peg_best, sum_peg_ev_lost) regardless of the display level. Even with the evaluator off, your pegging accuracy is tracked silently and feeds the lifetime stats screen. It doesn't mark the game as assisted; pegging feedback can't un-play a card.

Option 3: Best-Throw Recap at Show

scenes/Main.gd · _compute_show_recap() Setting key: "coach_show_recap"
func _compute_show_recap() -> Dictionary:
    var ranked: Array = _ranked_discards(six, dealer == _seat_me)
    var best_thrown: Array = ranked[0]["thrown"]
    var best_keep: Array = ...   # the 4 cards that would have been kept
    var best_pts: int = ScoreEngine.get_total_points(
        ScoreEngine.score_show(best_keep, starter, false))
    return {"your": player_show_total, "best": best_pts, "keep": best_keep, "thrown": best_thrown}

At the Show, this surface displays what the EV-optimal four-card keep would have scored on the actual cut. This is an important teaching moment: the best keep before the cut can differ from the best keep in hindsight. If the best-EV keep scores 2 points and you kept a hand that scores 8, you made a good-outcome bad-decision. The coach's what-if view makes that distinction visible.

Option 4: Deep Analysis After Game (vs. the Computer Only)

scenes/Main.gd · _show_deep_analysis() Setting key: "coach_deep_analysis" (vs-computer games only)
# Each time the player makes a fresh LEAD (count 0, pile empty) during PvE:
if count == 0 and pile.is_empty() and coach_deep_analysis and _pve_context():
    _coach_moves.append({"hand": own.duplicate(), "chosen": card,
        "opp_count": opp_count, "unseen": _unseen_for_player(own, pile)})

# After the game ends, runs CoachEval.analyze_lead() on each recorded lead
# on a background WorkerThreadPool task (non-blocking UI):
var work := func() -> void:
    for m in moves:
        var r: Dictionary = ce.analyze_lead(m["hand"], m["unseen"], m["opp_count"], m["chosen"], 60)
        if float(r.get("gain", 0.0)) >= 1.0:
            findings.append({"m": m, "r": r})   # only real misses (>=1 expected peg gained)
WorkerThreadPool.add_task(work)     # runs async, modal stays responsive

Deep analysis runs the play-it-out simulation from Section 10 on every lead you made. It samples up to 60 opponent hands per lead, simulates the full pegging continuation for both players, and surfaces leads where a different card would have averaged ≥1 expected peg more over those samples. It explicitly flags trap leads: ones that score nothing immediately but build an advantage that only shows up two or three plays later, which is just what the live grader misses.

The analysis runs on a background thread (WorkerThreadPool) so the modal opens instantly and results fill in as they finish. If you close the window mid-analysis, the work is discarded cleanly, with nothing left hanging.

Option 5: Pre-Throw Hint / Best Keep (vs. the Computer Only)

scenes/Main.gd · _coach_hint_on() Setting key: "coach_discard_hint" (vs-computer only, marks game assisted)
func _coach_hint_on() -> bool:
    return bool(settings.get("coach_discard_hint", false)) and _pve_context()
    # Never active in daily / online / LAN matches, regardless of the toggle.

# When ON: before the player commits, the UI highlights the EV-optimal 4-card keep.
# This is REAL assistance -- it changes the decision the player hasn't made yet.
# Consequence:
_coach_assisted = true   # set the moment a hint is shown; cannot be unset this game
# Assisted games: no achievements, no skill-stat accumulation, no streak credit.

This is the only coaching option that counts as real assistance: it changes a decision before it's made. The game tracks this with the _coach_assisted flag, which once set cannot be cleared. Assisted games:

  • Do not count toward achievements
  • Do not accumulate skill-stat data
  • Do not grant streak credit
  • Are clearly labelled as practice games

The hint itself calls the same eval_discards() ranking the AI used for its own throw. You're seeing exactly the hand the AI would have kept.

Option 6: Hide Coaching in Ranked

scenes/Main.gd · _coaching_allowed() Setting key: "coach_ranked_off"
func _coaching_allowed() -> bool:
    if bool(settings.get("coach_ranked_off", true)) and not _pve_context():
        return false    # silences discard log, pegging log, show recap in daily/online/LAN
    return true

When enabled (the default), all display coaching surfaces go silent in daily, online, and LAN matches, but the accuracy stats still accumulate silently. The pre-throw hint (coach_discard_hint) is additionally gated by _pve_context() and cannot be shown in ranked contexts regardless of this toggle. This is a tone choice: post-commit feedback can't change a locked decision, but displaying it in competitive matches could affect the feel of the experience.

Coaching Data Flow Summary

Player Action
Discard or peg
AlleyBrain
eval_discards() / grade_play()
coach_level check
0/1/2 & ranked gate
Game Log
Coloured line or silence
Each Lead
Fresh count, vs. computer
_coach_moves[]
Recorded if deep_analysis on
analyze_lead()
Expectimax, background thread
Review Modal
Traps & misses listed

Key Takeaways

The AI cannot cheat

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.

Strong, but not perfect

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.

Mistakes are calibrated, not random

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.

The deals are provably fair

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.


FileRoleLines
core/AlleyBrain.gdComplete decision engine (discard + peg)~830
core/StrategyProfile.gdPer-tier knowledge & execution profile (the curriculum table)~260
core/PeggingSim.gdForward pegging simulator (two-move search + coach analysis)~190
core/AlleyObservation.gdData contract (no-peek enforcement)~50
core/ScoreEngine.gdPure scoring, fast + full-event variants~240
core/PeggingController.gdPegging state & AlleyBrain bridge~155
core/CoachEval.gdHint/grading system & deep analysis~310
core/FairDeal.gdCommit-reveal fairness & ranked hash-rolls~160
scenes/stats.gdCrib tables, par benchmarks, stat tracking~400
tests/bench_ai.gd19k-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.