From 7c9294d46ccc8ea4e6bd5e9c9b590ba531df2218 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sat, 4 Jul 2026 23:08:06 +1000 Subject: [PATCH 01/14] blackjack-challenge: fork bcook/blackjack verbatim Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack-challenge/.gitignore | 2 + games/bcook/blackjack-challenge/LICENSE | 21 + games/bcook/blackjack-challenge/anim.go | 131 ++ games/bcook/blackjack-challenge/cards.go | 221 +++ games/bcook/blackjack-challenge/cards_test.go | 130 ++ games/bcook/blackjack-challenge/exports.go | 33 + games/bcook/blackjack-challenge/game.go | 81 + games/bcook/blackjack-challenge/go.mod | 12 + games/bcook/blackjack-challenge/go.sum | 12 + games/bcook/blackjack-challenge/layout.go | 928 ++++++++++ games/bcook/blackjack-challenge/main.go | 29 + .../blackjack-challenge/preview/01-deal.frame | 7 + .../preview/02-stand.frame | 7 + .../blackjack-challenge/preview/03-bust.frame | 7 + .../preview/04-payout.frame | 7 + .../blackjack-challenge/preview/preview.yaml | 9 + games/bcook/blackjack-challenge/room.go | 1373 +++++++++++++++ games/bcook/blackjack-challenge/room_test.go | 1530 +++++++++++++++++ games/bcook/blackjack-challenge/round.go | 118 ++ games/bcook/blackjack-challenge/round_test.go | 152 ++ games/bcook/blackjack-challenge/smoke.yaml | 74 + 21 files changed, 4884 insertions(+) create mode 100644 games/bcook/blackjack-challenge/.gitignore create mode 100644 games/bcook/blackjack-challenge/LICENSE create mode 100644 games/bcook/blackjack-challenge/anim.go create mode 100644 games/bcook/blackjack-challenge/cards.go create mode 100644 games/bcook/blackjack-challenge/cards_test.go create mode 100644 games/bcook/blackjack-challenge/exports.go create mode 100644 games/bcook/blackjack-challenge/game.go create mode 100644 games/bcook/blackjack-challenge/go.mod create mode 100644 games/bcook/blackjack-challenge/go.sum create mode 100644 games/bcook/blackjack-challenge/layout.go create mode 100644 games/bcook/blackjack-challenge/main.go create mode 100644 games/bcook/blackjack-challenge/preview/01-deal.frame create mode 100644 games/bcook/blackjack-challenge/preview/02-stand.frame create mode 100644 games/bcook/blackjack-challenge/preview/03-bust.frame create mode 100644 games/bcook/blackjack-challenge/preview/04-payout.frame create mode 100644 games/bcook/blackjack-challenge/preview/preview.yaml create mode 100644 games/bcook/blackjack-challenge/room.go create mode 100644 games/bcook/blackjack-challenge/room_test.go create mode 100644 games/bcook/blackjack-challenge/round.go create mode 100644 games/bcook/blackjack-challenge/round_test.go create mode 100644 games/bcook/blackjack-challenge/smoke.yaml diff --git a/games/bcook/blackjack-challenge/.gitignore b/games/bcook/blackjack-challenge/.gitignore new file mode 100644 index 0000000..efeb4da --- /dev/null +++ b/games/bcook/blackjack-challenge/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/blackjack-challenge diff --git a/games/bcook/blackjack-challenge/LICENSE b/games/bcook/blackjack-challenge/LICENSE new file mode 100644 index 0000000..fc7e2e0 --- /dev/null +++ b/games/bcook/blackjack-challenge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brandon Cook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/games/bcook/blackjack-challenge/anim.go b/games/bcook/blackjack-challenge/anim.go new file mode 100644 index 0000000..d691f3c --- /dev/null +++ b/games/bcook/blackjack-challenge/anim.go @@ -0,0 +1,131 @@ +package main + +import ( + "time" + + kit "github.com/shellcade/kit/v2" +) + +// Card dealing/reveal animation. Animations are purely cosmetic: every card's +// rank, suit and landing slot are fixed up front from the seeded shoe before any +// animation starts, and a `cardAnim` only carries room-clock timings that the +// compose pass interpolates from r.Now(). Nothing here ever consults the room +// RNG, so a seeded room reproduces every deal regardless of frame timing, a +// frame composed after an animation's end renders exactly the settled card, and +// a hibernation freeze/thaw replays identically (the schedule and the clock both +// live in guest memory / CallContext). +const ( + // dealStagger is the delay between successive cards starting their slide, so + // the initial deal reads as a sweep around the table rather than a flash. + dealStagger = 120 * time.Millisecond + // slideDur is how long a card takes to glide from the right felt edge to its + // slot, and flipDur is the back -> edge -> face reveal that follows. + slideDur = 260 * time.Millisecond + flipDur = 240 * time.Millisecond + + // The dealer's reveal is paced far more deliberately than the initial deal + // sweep: players need to watch the hole card turn over and read each hit as + // it lands, one card at a time, rather than the cards flashing in back to + // back at dealStagger. + // + // - holeRevealDelay is the lead-in beat after the dealer's turn begins and + // before the hole card starts to turn, so the reveal reads as the dealer + // pausing then flipping the card rather than it snapping over the instant + // play ends. The card sits face down through this beat, then flips. + // - holeRevealHold is the beat the table holds on the just-turned hole card + // (the dealer's two-card total) before the first hit slides in. + // - dealerDrawGap is the pause between one hit fully landing and the next + // beginning to slide — the dealer reading the table and deciding to draw + // again. Far longer than dealStagger so hits never overlap. + // - dealerDoneHold is the final beat on the completed dealer hand (its made + // total, blackjack, or bust) before the round settles to results. + holeRevealDelay = 500 * time.Millisecond + holeRevealHold = 700 * time.Millisecond + dealerDrawGap = 450 * time.Millisecond + dealerDoneHold = 800 * time.Millisecond +) + +// animKind distinguishes where an animated card lands. +type animKind uint8 + +const ( + animSeat animKind = iota // a player's hand card + animDealer // a dealer row card +) + +// cardAnim is one card's cosmetic schedule. The card itself already sits in the +// authoritative hand; this record only says when and from where it animates in. +// +// - slide: slideStart .. slideStart+slideDur (right edge -> slot) +// - flip: flipStart .. flipStart+flipDur (back -> edge -> face); zero +// flipStart means "no reveal flip" (the concealed dealer hole card until it +// is turned over, which schedules its own flip). +type cardAnim struct { + kind animKind + player kit.Player // seat owner (zero for the dealer) + handIdx int // which split hand (0 for the dealer / unsplit) + cardIdx int // index within that hand + + slideStart time.Time + flipStart time.Time +} + +// slideProgress returns how far the slide has advanced in [0,1] at now; 1 means +// the card has reached its slot. +func (a cardAnim) slideProgress(now time.Time) float64 { + if a.slideStart.IsZero() { + return 1 + } + d := now.Sub(a.slideStart) + if d <= 0 { + return 0 + } + if d >= slideDur { + return 1 + } + return float64(d) / float64(slideDur) +} + +// flipFrame returns the reveal frame at now: 0 = back, 1 = edge, 2 = face, and +// reports whether a flip is still in progress (a settled card returns 2,false). +func (a cardAnim) flipFrame(now time.Time) (frame int, flipping bool) { + if a.flipStart.IsZero() { + return 2, false // no reveal scheduled (e.g. concealed hole card) + } + d := now.Sub(a.flipStart) + switch { + case d < 0: + return 0, true // card has landed but not yet begun to turn + case d < flipDur/3: + return 0, true + case d < 2*flipDur/3: + return 1, true + case d < flipDur: + return 2, true + default: + return 2, false + } +} + +// settled reports whether the whole animation (slide + any flip) has finished by +// now, so a compose pass can draw the card exactly as the static layout would. +func (a cardAnim) settled(now time.Time) bool { + if a.slideProgress(now) < 1 { + return false + } + if _, flipping := a.flipFrame(now); flipping { + return false + } + return true +} + +// endsAt is the room-clock instant this card is fully settled. +func (a cardAnim) endsAt() time.Time { + end := a.slideStart.Add(slideDur) + if !a.flipStart.IsZero() { + if fe := a.flipStart.Add(flipDur); fe.After(end) { + end = fe + } + } + return end +} diff --git a/games/bcook/blackjack-challenge/cards.go b/games/bcook/blackjack-challenge/cards.go new file mode 100644 index 0000000..77f5c4d --- /dev/null +++ b/games/bcook/blackjack-challenge/cards.go @@ -0,0 +1,221 @@ +package main + +import "math/rand" + +// rank is 1..13 with 1 = Ace, 11 = Jack, 12 = Queen, 13 = King. +type rank uint8 + +const ( + rankAce rank = 1 + rankJack rank = 11 + rankQueen rank = 12 + rankKing rank = 13 +) + +// points is the blackjack value of a rank; an Ace counts 11 here and is reduced +// to 1 by hand.value when needed. Faces are worth 10. +func (r rank) points() int { + switch { + case r == rankAce: + return 11 + case r >= 10: + return 10 + default: + return int(r) + } +} + +// label is the short display token: A, 2..10, J, Q, K. +func (r rank) label() string { + switch r { + case rankAce: + return "A" + case rankJack: + return "J" + case rankQueen: + return "Q" + case rankKing: + return "K" + default: + // A small switch (not a map literal) keeps rendering free of + // map-iteration-order surprises and allocation in the steady state. + switch r { + case 2: + return "2" + case 3: + return "3" + case 4: + return "4" + case 5: + return "5" + case 6: + return "6" + case 7: + return "7" + case 8: + return "8" + case 9: + return "9" + case 10: + return "10" + } + return "?" + } +} + +// boxLabel is the one-character rank for a fixed-width card box (ten is "T", so +// every card face is exactly two cells: rank + suit pip). +func (r rank) boxLabel() string { + if r == 10 { + return "T" + } + return r.label() +} + +// suit is one of the four suits. The pip is a single-width Unicode glyph. +type suit uint8 + +const ( + suitSpade suit = iota + suitHeart + suitDiamond + suitClub +) + +func (s suit) pip() rune { return [...]rune{'♠', '♥', '♦', '♣'}[s] } + +// red reports whether the suit is drawn red (hearts/diamonds). +func (s suit) red() bool { return s == suitHeart || s == suitDiamond } + +// card is a single playing card. +type card struct { + r rank + s suit +} + +// hand is a set of cards held by a player or the dealer. +type hand []card + +// value returns the best total and whether the hand is soft (an Ace still +// counts as 11). +func (h hand) value() (total int, soft bool) { + aces := 0 + for _, c := range h { + total += c.r.points() + if c.r == rankAce { + aces++ + } + } + for total > 21 && aces > 0 { + total -= 10 // count one Ace as 1 instead of 11 + aces-- + } + return total, aces > 0 +} + +// total is the hand value without the soft flag. +func (h hand) total() int { t, _ := h.value(); return t } + +// isBlackjack reports a two-card 21. +func (h hand) isBlackjack() bool { return len(h) == 2 && h.total() == 21 } + +// isBust reports a value over 21. +func (h hand) isBust() bool { return h.total() > 21 } + +// --- shoe ------------------------------------------------------------------ + +const ( + numDecks = 6 + cutPenetration = 0.75 // reshuffle once this fraction of the shoe is dealt +) + +// shoe is a multi-deck stack dealt from front to back, reshuffled past a cut +// card. It draws from the room-seeded RNG so a seeded room reproduces every +// deal (and survives hibernation: the shoe AND the RNG's consumed state live +// in guest memory, restored byte-for-byte by the snapshot — the RNG is never +// re-seeded mid-room, which would diverge the post-thaw stream). +type shoe struct { + cards []card + pos int + cut int + roundStart int // pos when the current round began; cards before it are settled-round discards + recycled bool // drained mid-round and refilled from discards; reshuffle at the next round boundary +} + +func newShoe(rng *rand.Rand) *shoe { + cards := make([]card, 0, numDecks*52) + for d := 0; d < numDecks; d++ { + for s := suit(0); s < 4; s++ { + for r := rank(1); r <= 13; r++ { + cards = append(cards, card{r, s}) + } + } + } + s := &shoe{cards: cards, cut: int(float64(len(cards)) * cutPenetration)} + s.shuffle(rng) + return s +} + +// shuffleCards Fisher–Yates shuffles cards in place — the one shuffle +// implementation, shared by the full-shoe reshuffle and the discard recycle. +func shuffleCards(rng *rand.Rand, cards []card) { + for i := len(cards) - 1; i > 0; i-- { + j := rng.Intn(i + 1) + cards[i], cards[j] = cards[j], cards[i] + } +} + +// shuffle reshuffles the whole shoe and resets the draw cursor. +func (s *shoe) shuffle(rng *rand.Rand) { + shuffleCards(rng, s.cards) + s.pos = 0 + s.roundStart = 0 + s.recycled = false +} + +// beginRound marks the draw cursor at a round boundary: everything before it +// is a settled round's discards, available to recycle if this round drains +// the shoe. +func (s *shoe) beginRound() { s.roundStart = s.pos } + +// draw deals the next card. A shoe drained MID-round (an extreme run of splits +// and hits at a full table can outrun the ~78 cards behind the cut) recycles +// the settled rounds' discards rather than repeating the last card — the +// casino procedure, so a card already in play this round is never duplicated. +func (s *shoe) draw(rng *rand.Rand) card { + if s.pos >= len(s.cards) { + s.recycle(rng) + } + c := s.cards[s.pos] + s.pos++ + return c +} + +// recycle shuffles the settled-round discards (everything before roundStart) +// back behind the cards already dealt this round and continues from them. The +// whole shoe then reshuffles at the next round boundary. With nothing to +// recycle (one round cannot hold all 312 cards) the old defensive clamp +// remains. +func (s *shoe) recycle(rng *rand.Rand) { + if s.roundStart == 0 { + // Unreachable invariant breach (a round consumed the whole shoe): + // clamp, and force a full reshuffle at the next round boundary so the + // repetition cannot outlive the round. + s.pos = len(s.cards) - 1 + s.recycled = true + return + } + discards := s.cards[:s.roundStart] + shuffleCards(rng, discards) + rebuilt := make([]card, 0, len(s.cards)) + rebuilt = append(rebuilt, s.cards[s.roundStart:]...) // this round's dealt cards + rebuilt = append(rebuilt, discards...) // then the recycled discards + s.pos = len(s.cards) - s.roundStart + s.cards = rebuilt + s.roundStart = 0 + s.recycled = true +} + +// needsReshuffle reports whether the shoe must be reshuffled before the next +// round: the cut card has been reached, or a drained round recycled discards. +func (s *shoe) needsReshuffle() bool { return s.pos >= s.cut || s.recycled } diff --git a/games/bcook/blackjack-challenge/cards_test.go b/games/bcook/blackjack-challenge/cards_test.go new file mode 100644 index 0000000..0e6730a --- /dev/null +++ b/games/bcook/blackjack-challenge/cards_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "math/rand" + "testing" +) + +func TestHandValue(t *testing.T) { + cases := []struct { + name string + h hand + total int + soft bool + }{ + {"soft 18", hand{{rankAce, suitSpade}, {7, suitHeart}}, 18, true}, + {"hard 13 after a third card", hand{{rankAce, suitSpade}, {7, suitHeart}, {5, suitClub}}, 13, false}, + {"pair of aces is soft 12", hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, 12, true}, + {"two faces", hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, 20, false}, + {"three sevens is a hard 21", hand{{7, suitSpade}, {7, suitHeart}, {7, suitClub}}, 21, false}, + {"ten and ace is 21 soft", hand{{10, suitSpade}, {rankAce, suitHeart}}, 21, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + total, soft := c.h.value() + if total != c.total || soft != c.soft { + t.Fatalf("value() = (%d, soft=%v), want (%d, soft=%v)", total, soft, c.total, c.soft) + } + }) + } +} + +func TestBlackjack(t *testing.T) { + if !(hand{{rankAce, suitSpade}, {rankKing, suitHeart}}).isBlackjack() { + t.Error("A,K should be a blackjack") + } + if (hand{{7, suitSpade}, {7, suitHeart}, {7, suitClub}}).isBlackjack() { + t.Error("7,7,7 (21 in three cards) is not a blackjack") + } + if (hand{{10, suitSpade}, {9, suitHeart}}).isBlackjack() { + t.Error("a two-card 19 is not a blackjack") + } +} + +func TestBust(t *testing.T) { + if !(hand{{rankKing, suitSpade}, {rankQueen, suitHeart}, {5, suitClub}}).isBust() { + t.Error("K,Q,5 (25) should bust") + } + if (hand{{rankAce, suitSpade}, {rankKing, suitHeart}, {5, suitClub}}).isBust() { + t.Error("A,K,5 reduces to 16 and must not bust") + } +} + +func TestShoeIsSixDecks(t *testing.T) { + s := newShoe(rand.New(rand.NewSource(1))) + if len(s.cards) != numDecks*52 { + t.Fatalf("shoe has %d cards, want %d", len(s.cards), numDecks*52) + } + // every (rank,suit) appears exactly numDecks times + counts := map[card]int{} + for _, c := range s.cards { + counts[c]++ + } + if len(counts) != 52 { + t.Fatalf("shoe has %d distinct cards, want 52", len(counts)) + } + for c, n := range counts { + if n != numDecks { + t.Fatalf("card %v appears %d times, want %d", c, n, numDecks) + } + } +} + +func TestSeededShoeReproducesOrder(t *testing.T) { + ra := rand.New(rand.NewSource(42)) + rb := rand.New(rand.NewSource(42)) + a := newShoe(ra) + b := newShoe(rb) + for i := 0; i < 100; i++ { + if a.draw(ra) != b.draw(rb) { + t.Fatalf("same-seed shoes diverged at draw %d", i) + } + } +} + +func TestShoeReshufflePastCut(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + s := newShoe(rng) + if s.needsReshuffle() { + t.Fatal("a fresh shoe should not need a reshuffle") + } + for !s.needsReshuffle() { + s.draw(rng) + } + if s.pos < s.cut { + t.Fatalf("needsReshuffle true at pos %d but cut is %d", s.pos, s.cut) + } +} + +func TestShoeDrainMidRoundRecyclesDiscards(t *testing.T) { + rng := rand.New(rand.NewSource(9)) + s := newShoe(rng) + // Burn most of the shoe as settled rounds, then start a round with only a + // few cards left: the round must outrun the shoe. + s.pos = len(s.cards) - 5 + s.beginRound() + seen := map[card]int{} + // 40 draws in one round: a clamping shoe would repeat its last card ~36 + // times; a recycling shoe deals from the reshuffled discards instead. + for i := 0; i < 40; i++ { + seen[s.draw(rng)]++ + } + for c, n := range seen { + if n > numDecks { + t.Fatalf("card %v dealt %d times within one round; only %d exist in the shoe", c, n, numDecks) + } + } + if !s.recycled { + t.Fatal("draining the round should have recycled the discards") + } + if !s.needsReshuffle() { + t.Fatal("a recycled shoe must reshuffle at the next round boundary") + } + s.shuffle(rng) + if s.needsReshuffle() || s.recycled || s.roundStart != 0 { + t.Fatal("shuffle must clear the recycle state") + } + if len(s.cards) != numDecks*52 { + t.Fatalf("recycle changed the shoe size to %d, want %d", len(s.cards), numDecks*52) + } +} diff --git a/games/bcook/blackjack-challenge/exports.go b/games/bcook/blackjack-challenge/exports.go new file mode 100644 index 0000000..faed7b4 --- /dev/null +++ b/games/bcook/blackjack-challenge/exports.go @@ -0,0 +1,33 @@ +//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight ABI exports, trampolined to the kit SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } diff --git a/games/bcook/blackjack-challenge/game.go b/games/bcook/blackjack-challenge/game.go new file mode 100644 index 0000000..49c2660 --- /dev/null +++ b/games/bcook/blackjack-challenge/game.go @@ -0,0 +1,81 @@ +package main + +import kit "github.com/shellcade/kit/v2" + +// Game is the blackjack registry entry: static metadata plus the per-room +// factory. The catalog slug is composed by the platform from the directory path +// (games/bcook/blackjack -> "bcook/blackjack"); Meta carries the bare name. +type Game struct{} + +// Meta returns the static game metadata (mirrors the native blackjack meta). +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "blackjack", + Name: "Blackjack", + ShortDescription: "Take a seat at a shared dealer table: bet, hit, stand, and chase your high score.", + MinPlayers: 1, + MaxPlayers: 5, + Tags: []string{"cards", "casino"}, + + // A casual social room: when everyone leaves, the room closes — + // no hibernation snapshot, no Resume-menu entry (kit v2.7.0). + Lifecycle: kit.LifecycleEphemeral, + + // A casino-kind game (kit v2.16.0): players gamble their account-wide + // platform Credits through the room's svc.Credits service — the host + // owns every balance. MaxPayoutMultiplier is the settlement ceiling: + // the top single-stake outcome is a Perfect Pairs "perfect" pair at + // 25:1, which returns stake×(25+1) = 26× on that side stake; the + // mandatory main bet only dilutes the per-seat aggregate, so 26 covers + // the largest honest payout. CtxFeatCredits is declared alongside so a + // credits-capable front end negotiates the encoding. + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: 26, + + // Per-member arcade characters (kit v2.9.0): every roster member + // arrives with Player.Character populated, rendered as a one-cell + // tile right before the player's name (seat rail + turn waits). + CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatCredits, + + QuickModeLabel: "Join a table", + SoloModeLabel: "Heads-up vs dealer", + PrivateInviteLine: "Friends take a seat when they enter the code.", + + // The board is a peak account-wide-credits metric: after each settle the + // seat's fresh platform balance (svc.Credits.Balance) is compared to its + // high-water mark and Posted on a new personal best (see room.go + // postPeak). BestResult keeps each account's highest observed balance. + Leaderboard: &kit.LeaderboardSpec{ + MetricLabel: "Credits", + Direction: kit.HigherBetter, + Aggregation: kit.BestResult, + Format: kit.Integer, + }, + + // Touch deck chips (kit v2.10.0): every input beyond the canonical + // vocabulary (arrows/Confirm/Back, which the deck always provides) needs + // a chip so it is reachable on touch. The turn actions, the insurance + // answers, and the betting side-bet keys are all letter commands. Betting + // itself drives stake (Up/Down), the backed seat (Left/Right), and place + // (Confirm) off the canonical arrows; only P/B need declaring. + Controls: []kit.ControlDecl{ + kit.RuneControl('h', "HIT"), + kit.RuneControl('s', "STAND"), + kit.RuneControl('d', "DOUBLE"), + // P splits a pair on a turn AND loops the Perfect Pairs side bet + // during betting — one rune, so the chip carries both meanings. + kit.RuneControl('p', "SPLIT/PAIRS"), + kit.RuneControl('r', "SURRENDER"), + kit.RuneControl('y', "YES"), + kit.RuneControl('n', "NO"), + // Betting only: B loops the behind bet on the focused seat (Left/Right + // pick the seat); P (above) loops that seat's Perfect Pairs. + kit.RuneControl('b', "BEHIND"), + }, + } +} + +// NewRoom returns the per-room behavior. +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return newRoom(cfg, svc) +} diff --git a/games/bcook/blackjack-challenge/go.mod b/games/bcook/blackjack-challenge/go.mod new file mode 100644 index 0000000..e0f11b9 --- /dev/null +++ b/games/bcook/blackjack-challenge/go.mod @@ -0,0 +1,12 @@ +module shellcade.games/bcook/blackjack-challenge + +go 1.25.11 + +require github.com/shellcade/kit/v2 v2.17.0 + +require ( + github.com/extism/go-pdk v1.1.3 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/games/bcook/blackjack-challenge/go.sum b/games/bcook/blackjack-challenge/go.sum new file mode 100644 index 0000000..d059d93 --- /dev/null +++ b/games/bcook/blackjack-challenge/go.sum @@ -0,0 +1,12 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/shellcade/kit/v2 v2.17.0 h1:offS5LwLfvCLGSCfdA2ch//mDfJvnpn4+53ZPPYWwnc= +github.com/shellcade/kit/v2 v2.17.0/go.mod h1:sPELZYSFkvOkV+zM17SRVeTHbcTHJAR7KVXlFaL1QoY= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go new file mode 100644 index 0000000..7f6ca0a --- /dev/null +++ b/games/bcook/blackjack-challenge/layout.go @@ -0,0 +1,928 @@ +package main + +import ( + "fmt" + "strings" + "time" + + kit "github.com/shellcade/kit/v2" +) + +// Felt-table geometry (a casino table): a rounded felt frame with the dealer +// centred up top and up to five player seats along the rail. +const ( + feltTop = 1 + feltBottom = 19 + dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2 + dealerValRow = 7 // dealer total / verdict, centred just below the cards + + // The seat block sits one row higher than the dealer-only layout used to + // allow: relocating the rules tagline (it now flanks the dealer) freed rows + // 8-9, so the block shifts up to open a dedicated backers line at the bottom. + seatNameRow = 10 + seatCardRow = 11 // seat card group occupies seatCardRow..seatCardRow+2 + seatValRow = 14 + seatChipRow = 15 + seatPairRow = 16 // Perfect Pairs side-bet line (stake while betting, result once dealt) + seatBackRow = 17 // backers line: who is backing this seat, with their tiles + actionRow = 18 + slotW = 15 + maxSeats = 5 + + // slideEdgeCol is the right-felt-edge column a dealt card slides in from. + slideEdgeCol = kit.Cols - 6 +) + +var ( + stTitle = kit.Style{FG: kit.White, Attr: kit.AttrBold} + stDim = kit.Style{FG: kit.DimGray} + stPhase = kit.Style{FG: kit.Yellow, Attr: kit.AttrBold} + stFelt = kit.Style{FG: kit.Green} + stOwn = kit.Style{FG: kit.Cyan, Attr: kit.AttrBold} + stActive = kit.Style{FG: kit.Yellow, Attr: kit.AttrBold} + stCard = kit.Style{FG: kit.White} + stRed = kit.Style{FG: kit.Red, Attr: kit.AttrBold} + stWin = kit.Style{FG: kit.Green, Attr: kit.AttrBold} + stLose = kit.Style{FG: kit.Red, Attr: kit.AttrBold} + stBack = kit.Style{FG: kit.Yellow} // card back while sliding/flipping + stPrompt = kit.Style{FG: kit.RGB(0, 0, 0), BG: kit.Yellow, Attr: kit.AttrBold} // bright wait-state prompt +) + +// render composes and sends a per-viewer frame to every member, reusing one +// long-lived frame (Send copies immediately, so the steady state is +// allocation-free). +func (rm *room) render(r kit.Room) { + rm.lastNow = r.Now() + for _, v := range r.Members() { + rm.frame.Clear() + rm.compose(rm.frame, v) + r.Send(v, rm.frame) + } +} + +func (rm *room) compose(f *kit.Frame, v kit.Player) { + // A casino game with no host economy renders out-of-service rather than + // trapping: the table cannot take a bet without the Credits service. + if rm.economyOff() { + f.Text(0, 1, "♠♥♦♣ BLACKJACK", stTitle) + drawFelt(f, feltTop, feltBottom) + center(f, (feltTop+feltBottom)/2, "table closed — credits unavailable", stDim) + return + } + + active, _ := rm.firstUnresolved() + + // Title + phase/time above the felt. + f.Text(0, 1, "♠♥♦♣ BLACKJACK", stTitle) + phase := rm.phase + if secs := rm.remaining(); secs > 0 { + phase = fmt.Sprintf("%s · %s", rm.phase, clock(secs)) + } + f.TextRight(0, kit.Cols-1, phase, stPhase) + + drawFelt(f, feltTop, feltBottom) + center(f, feltTop, " B L A C K J A C K ", stFelt) + + // Dealer, centred near the top of the felt, with the table rules as subtle + // signage flanking the DEALER label (left + right) rather than a banner + // across the middle of the felt — keeping the centre clear for the cards. + f.Text(dealerRow-1, 2, "blackjack pays 3:2", stDim) + f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim) + center(f, dealerRow-1, "D E A L E R", stTitle) + rm.drawDealer(f) + + // Seats along the rail, centred as a group. + n := len(rm.order) + if n > maxSeats { + n = maxSeats + } + groupLeft := (kit.Cols - n*slotW) / 2 + for i := 0; i < n; i++ { + s := rm.seats[rm.order[i]] + if s == nil { + continue + } + own := s.p.AccountID == v.AccountID + isActive := active != nil && active.p.AccountID == s.p.AccountID + rm.drawSeat(f, groupLeft+i*slotW, s, v, own, isActive) + } + + rm.drawActionBar(f, v, active) + + f.Text(kit.Rows-1, 1, "Esc leave", stDim) + if s := rm.seats[v.AccountID]; s != nil { + f.TextRight(kit.Rows-1, kit.Cols-1, fmt.Sprintf("credits %d HI %d", s.bal, s.highScore), stDim) + } +} + +func (rm *room) drawDealer(f *kit.Frame) { + if len(rm.dealer) == 0 { + center(f, dealerRow+1, "(waiting for bets)", stDim) + return + } + hide := -1 + if rm.dealerHole { + hide = 1 + } + // Size and centre the row to the cards actually on the table (dealt cards + // plus any hit already arriving), and draw only those — so a pending hit's + // slot never appears before the hole card is turned over and that hit begins + // to slide in. + lc := rm.dealerLayoutCount() + w := cardsWidth(lc) + col := (kit.Cols - w) / 2 + drawCardsAnim(f, dealerRow, col, rm.dealer[:lc], hide, rm.dealerResolver()) + // The total and the verdict read off only the cards shown face up so far, + // never the authoritative hand — so the number ticks up and BUST/BLACKJACK + // appear as each card lands, not the instant the hand is dealt behind the + // scenes. Centred just below the cards (like each seat's value line) and + // coloured by outcome so the dealer's result reads at a glance. + shown := rm.dealer[:rm.dealerShownCount()] + label, st := fmt.Sprintf("(%d)", shown.total()), stDim + switch { + case len(shown) < 2: + // Only the up card is face up — while the hole is concealed, mid lead-in, + // or mid-flip — so report what the dealer is showing, not a phantom total. + label = fmt.Sprintf("shows %d", hand{rm.dealer[0]}.total()) + case shown.isBlackjack(): + label, st = "BLACKJACK", stWin + case shown.isBust(): + label, st = "BUST", stLose + } + center(f, dealerValRow, label, st) +} + +func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, active bool) { + if s == nil { + return + } + defer rm.drawBackersLine(f, slot, s, v) // who is backing this seat (its own dedicated line) + nameSt := stCard + switch { + case active: + nameSt = stActive + case own: + nameSt = stOwn + } + // Seat label: [►►] , centred in the slot. The + // arcade character tile (kit v2.9.0) rides immediately before the name; + // the 2 extra columns (tile + space) come out of the name budget so the + // slot never overflows. + marker, markerW := "", 0 + if active { + // A doubled, highlighted marker so the active seat is unmissable; the + // glyph degrades to ">>" on non-UTF-8 sessions (► -> >). + marker, markerW = "►►", 2 + } + name := s.p.Handle + if max := slotW - markerW - 2; len(name) > max { + name = name[:max] + } + labelW := markerW + 2 + len([]rune(name)) + nameCol := f.Text(seatNameRow, slot+(slotW-labelW)/2, marker, nameSt) + f.Set(seatNameRow, nameCol, kit.CharacterCell(s.p.Character)) + f.Text(seatNameRow, nameCol+2, name, nameSt) + + rm.drawPairsLine(f, slot, s, own) + + if rm.phase == phBetting { + status := "--" + if s.placed { + status = fmt.Sprintf("bet %d", s.bet) + } else if own { + status = fmt.Sprintf("bet %d?", s.bet) + } + centerSlot(f, seatCardRow+1, slot, status, stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) + return + } + // A seat with no hand sat the round out (it never placed a bet). This keys off + // the hand, not s.placed: settle() clears s.placed on every seat that played, + // so a busted/played seat keeps its cards on the felt through results while a + // genuine sat-out seat (never dealt in) says why rather than blanking. + if len(s.hands) == 0 { + centerSlot(f, seatCardRow+1, slot, "no bet", stDim) + centerSlot(f, seatValRow, slot, "sat out", stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) + return + } + + // A split seat (2+ hands) renders one compact line per hand, stacked down the + // seat rows, so every hand's cards stay visible and the hand on turn is + // marked — the 15-col slot cannot fit multiple card boxes side by side, which + // is why the old layout collapsed later hands to a bare "+". A single hand + // keeps the full card box below — UNLESS a hit-heavy hand has grown too wide + // to fit (a 5+ card box is 16+ cols but the slot is 15), in which case it also + // falls back to the compact line so its cards never spill into the next seat. + tooWide := len(s.hands) == 1 && cardsWidth(len(s.hands[0].cards)) > slotW-1 + if len(s.hands) >= 2 || tooWide { + _, ah := rm.firstUnresolved() + for hi, h := range s.hands { + line, st := compactHandLine(h, active && ah == h) + centerSlot(f, seatCardRow+hi, slot, line, st) + } + // Split aces take exactly one card each and stand — so both hands lock the + // moment they're split and the turn passes on. Name the rule beneath them, + // so a locked "can't hit, turn moved on" reads as intended, not broken. + if splitAces(s) { + centerSlot(f, seatValRow, slot, "aces: 1 card", stDim) + } + if rm.phase == phResults && s.result != "" { + centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) + } else { + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) + } + return + } + + // Draw the seat's hand(s) as joined card groups, left to right within the slot. + col := slot + 1 + limit := slot + slotW + var vals []string + for hi, h := range s.hands { + w := cardsWidth(len(h.cards)) + if col+w > limit && hi > 0 { + vals = append(vals, "+") + break + } + drawCardsAnim(f, seatCardRow, col, h.cards, -1, rm.seatResolver(s.p, hi, h)) + col += w + 1 + vals = append(vals, valueLabel(h.cards, h.fromSplit)+dblTag(h)) + } + // During results the value line doubles as the ready indicator: a readied + // seat shows READY where its hand total was, so who's holding up the table + // reads at a glance. + valStr, valSt := strings.Join(vals, " "), valueStyle(s) + if rm.phase == phResults && s.ready { + valStr, valSt = "READY", stWin + } + centerSlot(f, seatValRow, slot, valStr, valSt) + // The chip line carries the settlement summary during results, drawn instead + // of the stack (not over it) — a shorter result like "PUSH" centred over the + // wider "$1000" would otherwise leave a stray digit peeking out beside it. + if rm.phase == phResults && s.result != "" { + centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) + } else { + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) + } +} + +// pairsMult maps a Perfect Pairs result kind to its payout multiplier (X:1), +// for the result label; 0 for no pair. +func pairsMult(kind string) int { + switch kind { + case "perfect": + return 25 + case "colored": + return 12 + case "mixed": + return 6 + } + return 0 +} + +// drawPairsLine renders the seat's Perfect Pairs side-bet line. While betting it +// sits directly beneath that seat's main bet (seatCardRow+2), so each seat's +// bet+pairs read as one contiguous block and whose side bet is whose is never +// ambiguous; it shows only once placed, or for the seat's owner, matching how +// the main bet stays private until placed. Once the cards are dealt it moves to +// seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 12:1") +// or a quiet "pairs lost". +func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { + ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet + if rm.phase == phBetting { + if s.pairsBet > 0 && (s.placed || own) { + // Match the seat's bet line (stDim) rather than the bright own-seat + // cyan — the side stake reads as part of the same quiet bet block, + // not a highlight competing with the active-seat and prompt colours. + centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stDim) + } + return + } + if s.pairsBet <= 0 { + return + } + // Hold the Perfect Pairs verdict until BOTH of the seat's first two cards are + // face up. The result is fixed at the deal, but revealing "pairs lost" (or a + // win) while the second card is still sliding/flipping in would spoil it — the + // side bet reads off exactly those two cards, so wait for them to land. + if len(s.hands) == 0 || !rm.seatCardFaceUp(s.p, 0, 0) || !rm.seatCardFaceUp(s.p, 0, 1) { + return + } + if s.pairsKind != "" { + centerSlotChar(f, seatPairRow, slot, ch, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin) + return + } + centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim) +} + +// drawBackersLine renders, on a seat's dedicated backers row, a token per player +// backing it: their character tile then a compact stake (betting: "25p10") or +// net (results: "+25"). The viewer's own back is shown even at zero while they +// are focused on this seat (so "you're editing this" reads) and highlighted. +// Tokens are drawn left-to-right and truncated with an ellipsis past the slot. +func (rm *room) drawBackersLine(f *kit.Frame, slot int, target *seat, v kit.Player) { + col, limit := slot, slot+slotW + for _, id := range rm.order { + s := rm.seats[id] + if s == nil || s.p.AccountID == target.p.AccountID { + continue + } + b := s.backs[target.p.AccountID] + focused := s.p.AccountID == v.AccountID && s.focus == target.p.AccountID + if (b == nil || (b.behind == 0 && b.pairs == 0)) && !focused { + continue + } + if b == nil { + b = &backBet{} + } + text, st := backerToken(rm.phase, b), stDim + if focused { + st = stActive + } + if col+1+len([]rune(text)) > limit { // no room for tile + token + if col < limit { + f.SetRune(seatBackRow, col, '…', stDim) + } + return + } + f.Set(seatBackRow, col, kit.CharacterCell(s.p.Character)) + col = f.Text(seatBackRow, col+1, text, st) + 1 // tile, token, then a gap + } +} + +// backerToken is one backer's compact cell content on the backers line: the +// stakes while betting (e.g. "25p10", "p10"), else the round net (e.g. "+25"). +func backerToken(phase string, b *backBet) string { + if phase == phBetting { + s := "" + if b.behind > 0 { + s += fmt.Sprintf("%d", b.behind) + } + if b.pairs > 0 { + s += fmt.Sprintf("p%d", b.pairs) + } + return s + } + return fmt.Sprintf("%+d", (b.behindWin-b.behind)+(b.pairsWin-b.pairs)) +} + +func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { + s := rm.seats[v.AccountID] + if s == nil { + return + } + var msg string + st := stActive + switch rm.phase { + case phBetting: + // When the viewer is focused on another seat, the bar spells out their + // back on that seat (the nav axes are editing it) — drawn in parts so the + // backed player's character tile rides before their name. + if t := rm.seats[s.focus]; s.focus != "" && t != nil { + b := s.backOn(s.focus) + post := fmt.Sprintf("%s B behind %d P pairs %d Left/Right seat", t.p.Handle, b.behind, b.pairs) + centerWithChar(f, actionRow, "BACKING ", kit.CharacterCell(t.p.Character), post, stPrompt) + return + } + if !s.placed && s.bal < betTiers[0] { + // Broke: can't cover the minimum stake, so the bet controls are dead. + // Offer the platform broke-relief re-buy explicitly (the auto-rebuy + // fires post-hand, but a seat that lands here broke — a fresh low + // balance, or an auto-rebuy that was momentarily unavailable — would + // otherwise be stuck with no affordance). ASCII-only. + msg, st = "BROKE - press R to re-buy credits", stPrompt + } else if !s.placed { + // Prominent, highlighted call to bet for a viewer who hasn't yet. + // ASCII-only so it reads identically on non-UTF-8 sessions. + msg, st = "BET: Up/Down stake P pairs Left/Right back a seat SPACE bet", stPrompt + } else if n := rm.unplacedCount(); n > 0 { + noun := "player" + if n != 1 { + noun = "players" + } + msg, st = fmt.Sprintf("waiting on %d %s to bet - deals in %s", n, noun, clock(rm.remaining())), stDim + } else { + msg, st = "all bets in - dealing...", stPhase + } + case phInsurance: + switch n := rm.insuranceUndecidedCount(); { + case s.placed && !s.insuranceDecided: + // Spell out the side bet rather than just naming it: a stake of half the + // main bet that the dealer's hole card completes a blackjack, paying 2:1 + // (so it exactly offsets the main-bet loss if the dealer does have it). + msg = fmt.Sprintf("Insurance? Stake %d that dealer has blackjack - pays 2:1 [Y]es [N]o", s.bet/2) + case n > 0: + noun := "player" + if n != 1 { + noun = "players" + } + msg, st = fmt.Sprintf("waiting on %d %s for insurance - %s", n, noun, clock(rm.remaining())), stDim + default: + msg, st = "resolving insurance...", stDim + } + case phTurns: + switch { + case active != nil && active.p.AccountID == v.AccountID: + _, h := rm.firstUnresolved() + // Highlighted YOUR TURN so the viewer can't miss that it's on them. + msg, st = "YOUR TURN - "+legalActions(s, h), stPrompt + case active != nil: + // The active player's character tile rides immediately before + // their name, so the wait line is drawn in parts (text–tile–text) + // rather than through the centered-string path. + centerWithChar(f, actionRow, "waiting on ", kit.CharacterCell(active.p.Character), active.p.Handle+"...", stDim) + return + default: + // No hand left on turn: every player has resolved and the dealer is + // turning its hole card and drawing. Name the moment so the slow + // reveal reads as the dealer acting rather than a frozen table. + msg, st = "dealer plays...", stDim + } + case phResults: + switch n := rm.unreadyCount(); { + case !s.ready: + // Prominent call to ready up for the viewer who hasn't yet. + msg, st = "round over - SPACE to ready up for the next hand", stPrompt + case n > 0: + noun := "player" + if n != 1 { + noun = "players" + } + msg, st = fmt.Sprintf("ready - waiting on %d %s (next hand in %s)", n, noun, clock(rm.remaining())), stDim + default: + msg, st = "all ready - next hand starting...", stPhase + } + } + if msg != "" { + center(f, actionRow, msg, st) + } +} + +// unplacedCount is how many seated players have not yet placed a bet. +func (rm *room) unplacedCount() int { + n := 0 + for _, s := range rm.seats { + if !s.placed { + n++ + } + } + return n +} + +// splitAces reports whether a seat's hands are a split pair of aces — two or more +// hands, all formed by splitting, each led by an ace. Such hands take one card +// each and cannot be played on, so the felt names the rule beside them. +func splitAces(s *seat) bool { + if len(s.hands) < 2 { + return false + } + for _, h := range s.hands { + if !h.fromSplit || len(h.cards) == 0 || h.cards[0].r != rankAce { + return false + } + } + return true +} + +// legalActions lists the action prompts available for hand h. +func legalActions(s *seat, h *phand) string { + if h == nil { + return "" + } + parts := []string{"[H]it", "[S]tand"} + first := len(h.cards) == 2 && !h.doubled + if first && s.bal >= h.bet { + parts = append(parts, "[D]ouble") + } + if first && h.cards[0].r == h.cards[1].r && s.bal >= h.bet && len(s.hands) < maxHands { + parts = append(parts, "[P]split") + } + if first && len(s.hands) == 1 { + parts = append(parts, "[R]surrender") + } + return strings.Join(parts, " ") +} + +// --- animation-aware card drawing ------------------------------------------ + +// animFor returns the active animation for the addressed card, if the schedule +// holds one that has not yet settled at the latest composed instant. It reads +// only the recorded schedule and the frozen clock — never the RNG. +func (rm *room) animFor(kind animKind, p kit.Player, handIdx, cardIdx int) (cardAnim, bool) { + if len(rm.sched) == 0 || rm.lastNow.IsZero() { + return cardAnim{}, false + } + for _, a := range rm.sched { + if a.kind != kind || a.handIdx != handIdx || a.cardIdx != cardIdx { + continue + } + if kind == animSeat && a.player.AccountID != p.AccountID { + continue + } + if a.settled(rm.lastNow) { + return cardAnim{}, false + } + return a, true + } + return cardAnim{}, false +} + +// faceFromAnim turns a card's recorded animation into this frame's draw aspect. +func (rm *room) faceFromAnim(a cardAnim) cardFace { + now := rm.lastNow + if p := a.slideProgress(now); p < 1 { + return cardFace{sliding: true, slideFrac: p} + } + frame, flipping := a.flipFrame(now) + return cardFace{flip: frame, flipping: flipping} +} + +// seatResolver returns a per-card animation resolver for one of a seat's hands. +// It also highlights the busting (final) card of a busted hand through results +// so a bust stays visible and explained rather than the seat blanking. +func (rm *room) seatResolver(p kit.Player, handIdx int, h *phand) func(i int) cardFace { + bustIdx := -1 + if h.cards.isBust() { + bustIdx = len(h.cards) - 1 + } + return func(i int) cardFace { + var face cardFace + if a, ok := rm.animFor(animSeat, p, handIdx, i); ok { + face = rm.faceFromAnim(a) + } + if i == bustIdx && !face.sliding { + face.highlight = stLose + face.hasHL = true + } + return face + } +} + +// seatCardFaceUp reports whether a seat's card currently shows its face at the +// latest composed instant: a settled (or unscheduled) card always does, and an +// animating card only once its slide has landed and its reveal flip has turned +// far enough to expose the face. It mirrors dealerCardFaceUp for seat hands. +func (rm *room) seatCardFaceUp(p kit.Player, handIdx, cardIdx int) bool { + a, ok := rm.animFor(animSeat, p, handIdx, cardIdx) + if !ok { + return true // settled or no animation -> face up + } + if a.slideProgress(rm.lastNow) < 1 { + return false // still gliding in + } + frame, _ := a.flipFrame(rm.lastNow) + return frame == 2 // face exposed only on the final flip frame +} + +// dealerCardFaceUp reports whether dealer card i currently shows its face at the +// latest composed instant: the concealed hole card never does, a settled (or +// unscheduled) card always does, and an animating card only once its slide has +// landed and its reveal flip has turned far enough to expose the face. It reads +// only the recorded schedule and the frozen clock. +func (rm *room) dealerCardFaceUp(i int) bool { + if rm.dealerHole && i == 1 { + return false // hole card still concealed + } + a, ok := rm.animFor(animDealer, kit.Player{}, 0, i) + if !ok { + return true // settled or no animation -> face up + } + if a.slideProgress(rm.lastNow) < 1 { + return false // still gliding in + } + frame, _ := a.flipFrame(rm.lastNow) + return frame == 2 // face exposed only on the final flip frame +} + +// dealerLayoutCount is how many dealer cards occupy the table right now: the two +// dealt cards plus any hit that has actually begun sliding in. A hit that is +// scheduled but has not yet started arriving is excluded, so the row is only as +// wide as the cards on show and the next hit's arrival is announced by its slide +// — never by a slot reserved before the hole card has even been turned over. +func (rm *room) dealerLayoutCount() int { + n := 0 + for i := range rm.dealer { + if i >= 2 { + if a, ok := rm.animFor(animDealer, kit.Player{}, 0, i); ok && rm.lastNow.Before(a.slideStart) { + break // this hit has not begun arriving yet, nor have any after it + } + } + n++ + } + return n +} + +// dealerShownCount is how many leading dealer cards are face up right now. The +// dealer reveals strictly in order — up card, then the hole flip, then each hit +// in turn — so the shown set is always this contiguous prefix, and the displayed +// total/verdict slice off it without allocating. +func (rm *room) dealerShownCount() int { + n := 0 + for i := range rm.dealer { + if !rm.dealerCardFaceUp(i) { + break + } + n++ + } + return n +} + +// dealerResolver returns the dealer row's per-card animation resolver. +func (rm *room) dealerResolver() func(i int) cardFace { + bustIdx := -1 + if !rm.dealerHole && rm.dealer.isBust() { + bustIdx = len(rm.dealer) - 1 + } + return func(i int) cardFace { + var face cardFace + if a, ok := rm.animFor(animDealer, kit.Player{}, 0, i); ok { + face = rm.faceFromAnim(a) + } + if i == bustIdx && !face.sliding { + face.highlight = stLose + face.hasHL = true + } + return face + } +} + +// drawCardBack renders a face-down card box (4 wide) at (row, col). It degrades +// to ASCII on non-UTF-8 sessions via the renderer's box-drawing fallback. +func drawCardBack(f *kit.Frame, row, col int) { + f.SetRune(row, col, '┌', stBack) + f.SetRune(row, col+1, '─', stBack) + f.SetRune(row, col+2, '─', stBack) + f.SetRune(row, col+3, '┐', stBack) + f.SetRune(row+1, col, '│', stBack) + f.SetRune(row+1, col+1, '●', stBack) + f.SetRune(row+1, col+2, '●', stBack) + f.SetRune(row+1, col+3, '│', stBack) + f.SetRune(row+2, col, '└', stBack) + f.SetRune(row+2, col+1, '─', stBack) + f.SetRune(row+2, col+2, '─', stBack) + f.SetRune(row+2, col+3, '┘', stBack) +} + +// --- drawing helpers ------------------------------------------------------- + +func cardsWidth(n int) int { + if n == 0 { + return 3 + } + return 3*n + 1 +} + +// faceFor is the per-card draw decision returned by an animation resolver: how a +// card should render this frame given the schedule and the frozen clock. +type cardFace struct { + sliding bool // card is gliding in; render it as a floating back box + slideFrac float64 // [0,1] slide progress (only when sliding) + flip int // 0=back,1=edge,2=face (only meaningful when !sliding) + flipping bool // mid-flip back/edge in place + hasHL bool // highlight overrides the face style (e.g. the busting card) + highlight kit.Style // the override style (valid when hasHL) +} + +// drawCardsAnim renders the joined card group, consulting resolve(i) for each +// card's animation aspect. resolve may be nil (the static layout). hideIdx +// conceals a card as "??" (the dealer hole card). +func drawCardsAnim(f *kit.Frame, row, col int, cards hand, hideIdx int, resolve func(i int) cardFace) { + if len(cards) == 0 { + f.Text(row+1, col, "( )", stDim) + return + } + // The group spans only the contiguous prefix of cards that have arrived + // (slide complete). A still-sliding tail card floats in separately so the + // joined box never has to render a gap. + arrived := len(cards) + if resolve != nil { + for i := range cards { + if resolve(i).sliding { + arrived = i + break + } + } + } + if arrived > 0 { + f.SetRune(row, col, '┌', stFelt) + f.SetRune(row+1, col, '│', stFelt) + f.SetRune(row+2, col, '└', stFelt) + } + c := col + 1 + for i := 0; i < arrived; i++ { + cd := cards[i] + f.SetRune(row, c, '─', stFelt) + f.SetRune(row, c+1, '─', stFelt) + f.SetRune(row+2, c, '─', stFelt) + f.SetRune(row+2, c+1, '─', stFelt) + var face cardFace + if resolve != nil { + face = resolve(i) + } + switch { + case i == hideIdx: + f.SetRune(row+1, c, '?', stDim) + f.SetRune(row+1, c+1, '?', stDim) + case face.flipping && face.flip == 0: + f.SetRune(row+1, c, '●', stBack) + f.SetRune(row+1, c+1, '●', stBack) + case face.flipping && face.flip == 1: + f.SetRune(row+1, c, ' ', stBack) + f.SetRune(row+1, c+1, '│', stBack) + default: + st := stCard + if face.hasHL { + st = face.highlight + } + f.Text(row+1, c, cd.r.boxLabel(), st) + pipSt := st + if !face.hasHL && cd.s.red() { + pipSt = stRed + } + f.SetRune(row+1, c+1, cd.s.pip(), pipSt) + } + c += 2 + top, mid, bot := '┬', '│', '┴' + if i == arrived-1 { + top, mid, bot = '┐', '│', '┘' + } + f.SetRune(row, c, top, stFelt) + f.SetRune(row+1, c, mid, stFelt) + f.SetRune(row+2, c, bot, stFelt) + c++ + } + // Float any still-sliding cards in from the right felt edge toward the slot + // they will occupy once the group grows to include them. + if resolve != nil { + for i := arrived; i < len(cards); i++ { + face := resolve(i) + if !face.sliding { + continue + } + // Settled left-border column of card i within the eventual group. + target := col + 1 + i*3 - 1 + cur := lerpCol(slideEdgeCol, target, face.slideFrac) + drawCardBack(f, row, cur) + } + } +} + +// lerpCol linearly interpolates a column between from and to at fraction t. +func lerpCol(from, to int, t float64) int { + return from + int(float64(to-from)*t+0.5) +} + +func drawFelt(f *kit.Frame, top, bot int) { + f.SetRune(top, 0, '╭', stFelt) + f.SetRune(top, kit.Cols-1, '╮', stFelt) + f.SetRune(bot, 0, '╰', stFelt) + f.SetRune(bot, kit.Cols-1, '╯', stFelt) + for c := 1; c < kit.Cols-1; c++ { + f.SetRune(top, c, '─', stFelt) + f.SetRune(bot, c, '─', stFelt) + } + for r := top + 1; r < bot; r++ { + f.SetRune(r, 0, '│', stFelt) + f.SetRune(r, kit.Cols-1, '│', stFelt) + } +} + +func center(f *kit.Frame, row int, s string, st kit.Style) { + f.Text(row, (kit.Cols-len([]rune(s)))/2, s, st) +} + +// centerWithChar centres "
 " on a row: the styled
+// tile cell (width 1, kit v2.9.0) plus its trailing space sit between the two
+// text parts, so a player name in post carries its character right before it.
+func centerWithChar(f *kit.Frame, row int, pre string, ch kit.Cell, post string, st kit.Style) {
+	w := len([]rune(pre)) + 2 + len([]rune(post))
+	col := (kit.Cols - w) / 2
+	if col < 0 {
+		col = 0
+	}
+	col = f.Text(row, col, pre, st)
+	f.Set(row, col, ch)
+	f.Text(row, col+2, post, st)
+}
+
+// centerSlot centres s within a slotW-wide column starting at slot.
+func centerSlot(f *kit.Frame, row, slot int, s string, st kit.Style) {
+	n := len([]rune(s))
+	if n > slotW {
+		s = string([]rune(s)[:slotW])
+		n = slotW
+	}
+	f.Text(row, slot+(slotW-n)/2, s, st)
+}
+
+// centerSlotChar centres " " within a slotW-wide column:
+// the styled character cell (width 1) plus a space precede the text, tying the
+// line to a specific player by face. The text is clamped so the tile + text
+// never overflow the slot.
+func centerSlotChar(f *kit.Frame, row, slot int, ch kit.Cell, text string, st kit.Style) {
+	tr := []rune(text)
+	if len(tr) > slotW-2 {
+		tr = tr[:slotW-2]
+	}
+	w := 2 + len(tr)
+	col := slot + (slotW-w)/2
+	f.Set(row, col, ch)
+	f.Text(row, col+2, string(tr), st)
+}
+
+func (rm *room) remaining() int {
+	if rm.deadline.IsZero() || rm.lastNow.IsZero() {
+		return 0
+	}
+	d := rm.deadline.Sub(rm.lastNow)
+	if d <= 0 {
+		return 0
+	}
+	return int((d + time.Second - 1) / time.Second)
+}
+
+func clock(secs int) string { return fmt.Sprintf("0:%02d", secs) }
+
+// compactHandLine formats one split hand as a single slot-wide line —
+// " ", e.g. "►8♠3♥ 11" — for the stacked split layout.
+// The on-turn hand carries a ► marker (degrades to > on non-UTF-8) and the
+// active style; a busted hand reads red. The card tokens are truncated with an
+// ellipsis if a long (hit-heavy) hand would otherwise overflow the slot, so the
+// total always stays visible.
+func compactHandLine(h *phand, active bool) (string, kit.Style) {
+	var cards strings.Builder
+	for _, c := range h.cards {
+		cards.WriteString(c.r.boxLabel())
+		cards.WriteRune(c.s.pip())
+	}
+	total := valueLabel(h.cards, h.fromSplit) + dblTag(h)
+	marker, st := " ", stCard
+	if h.cards.isBust() {
+		st = stLose
+	}
+	if active {
+		marker, st = "►", stActive
+	}
+	budget := slotW - len([]rune(marker)) - 1 - len([]rune(total)) // marker + space + total
+	if budget < 1 {
+		budget = 1
+	}
+	cr := []rune(cards.String())
+	if len(cr) > budget {
+		cr = append(cr[:budget-1:budget-1], '…')
+	}
+	return marker + string(cr) + " " + total, st
+}
+
+// dblTag is the " DBL" flag appended to a doubled hand's total, so a hand that
+// doubled down reads as such on the felt (its stake was doubled at the deal).
+func dblTag(h *phand) string {
+	if h.doubled {
+		return " DBL"
+	}
+	return ""
+}
+
+// valueLabel formats a hand's total for the felt. Only a NATURAL two-card 21
+// (not one formed by splitting) reads as "BJ"; a split two-card 21 — the kind a
+// split ace hitting a ten makes — reads as a plain "21", since it is a plain 21
+// (even money, not a 3:2 blackjack) and labelling it "BJ" would mislead.
+func valueLabel(h hand, fromSplit bool) string {
+	total, soft := h.value()
+	switch {
+	case total > 21:
+		return "BUST"
+	case total == 21 && len(h) == 2 && !fromSplit:
+		return "BJ"
+	case total == 21:
+		return "21" // any other 21 (split-ace 21, multi-card 21) reads plainly
+	case soft:
+		return fmt.Sprintf("s%d", total)
+	default:
+		return fmt.Sprintf("%d", total)
+	}
+}
+
+func valueStyle(s *seat) kit.Style {
+	for _, h := range s.hands {
+		if h.cards.isBust() {
+			return stLose
+		}
+		if h.cards.isBlackjack() {
+			return stWin
+		}
+	}
+	return stCard
+}
+
+func resultStyle(result string) kit.Style {
+	switch {
+	case strings.HasPrefix(result, "WIN"):
+		return stWin
+	case strings.HasPrefix(result, "LOSE"), strings.HasPrefix(result, "BUST"):
+		return stLose
+	default:
+		return stDim
+	}
+}
diff --git a/games/bcook/blackjack-challenge/main.go b/games/bcook/blackjack-challenge/main.go
new file mode 100644
index 0000000..5a29a88
--- /dev/null
+++ b/games/bcook/blackjack-challenge/main.go
@@ -0,0 +1,29 @@
+// Blackjack — a no-winner, social multiplayer blackjack table on the 80x24
+// shellcade canvas: one shared auto-dealer, up to five seats, and rounds that
+// loop while anyone is seated. Bet, hit, stand, double, split, surrender, and
+// take insurance; the dealer stands on all 17 (S17) and blackjack pays 3:2.
+// Chips are integers: an odd bet's half-chip on the 3:2 payout and on a
+// surrender return rounds UP to the player; the insurance stake is half the
+// bet rounded down and pays exactly 2:1 on what was staked. Leaving with a
+// live hand forfeits its stake. Stakes are the platform's account-wide Credits
+// (kit v2.16.0 casino ABI): every bet Wagers onto the seat's single open stake
+// and the round Settles that stake exactly once with the gross payout; the host
+// owns every balance and the board ranks your peak credits.
+//
+// The wasm ABI has no timers, ticks, or phases: every "later…" here is a
+// deadline held in guest memory and checked against r.Now() inside OnWake (the
+// host heartbeat). Card-dealing animation is a cosmetic schedule of timestamps
+// the renderer interpolates from r.Now(); the authoritative cards are fixed up
+// front from the room-seeded shoe, so a hibernation freeze/thaw and a -seed run
+// both reproduce every deal.
+//
+// This is the native entry point; the wasm exports live in exports.go. The game
+// logic shares this package so `go run .` plays it.
+//
+//	Build (artifact): tinygo build -opt=1 -no-debug -gc=conservative \
+//	  -o game.wasm -target wasip1 -buildmode=c-shared .
+package main
+
+import kit "github.com/shellcade/kit/v2"
+
+func main() { kit.Main(Game{}) }
diff --git a/games/bcook/blackjack-challenge/preview/01-deal.frame b/games/bcook/blackjack-challenge/preview/01-deal.frame
new file mode 100644
index 0000000..38cf677
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/01-deal.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK
+
+   DEALER   [6♣] [??]         shows 6
+
+   YOU      [T♣] [T♦]         20
+
+   ► hit · stand · double        chips 950
diff --git a/games/bcook/blackjack-challenge/preview/02-stand.frame b/games/bcook/blackjack-challenge/preview/02-stand.frame
new file mode 100644
index 0000000..2fe86e8
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/02-stand.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK
+
+   DEALER   [6♣] [K♠]         16
+
+   YOU      [T♣] [T♦]         20
+
+   standing…                     chips 950
diff --git a/games/bcook/blackjack-challenge/preview/03-bust.frame b/games/bcook/blackjack-challenge/preview/03-bust.frame
new file mode 100644
index 0000000..104c4bf
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/03-bust.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK
+
+   DEALER   [6♣] [K♠] [9♥]    25 BUST
+
+   YOU      [T♣] [T♦]         20
+
+   dealer busts!                 chips 950
diff --git a/games/bcook/blackjack-challenge/preview/04-payout.frame b/games/bcook/blackjack-challenge/preview/04-payout.frame
new file mode 100644
index 0000000..d11fad6
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/04-payout.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK
+
+        ★   Y O U   W I N   ★
+
+   YOU      [T♣] [T♦]         20
+
+   +100                          chips 1050
diff --git a/games/bcook/blackjack-challenge/preview/preview.yaml b/games/bcook/blackjack-challenge/preview/preview.yaml
new file mode 100644
index 0000000..d395eeb
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/preview.yaml
@@ -0,0 +1,9 @@
+frames:
+  - file: 01-deal.frame
+    ms: 1100
+  - file: 02-stand.frame
+    ms: 700
+  - file: 03-bust.frame
+    ms: 1000
+  - file: 04-payout.frame
+    ms: 1400
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
new file mode 100644
index 0000000..7561dcf
--- /dev/null
+++ b/games/bcook/blackjack-challenge/room.go
@@ -0,0 +1,1373 @@
+package main
+
+import (
+	"sort"
+	"strconv"
+	"time"
+
+	kit "github.com/shellcade/kit/v2"
+)
+
+// phases of one round. These are INTERNAL state-machine markers held in guest
+// memory — the lean ABI has no phase surface (SetPhase is gone; joinability is
+// host-derived), so a phase only drives this game's own logic and rendering.
+const (
+	phBetting   = "betting"
+	phInsurance = "insurance"
+	phTurns     = "player turns"
+	phResults   = "results"
+)
+
+const (
+	maxHands = 4 // a seat may split up to four hands
+
+	bettingDur   = 15 * time.Second
+	insuranceDur = 10 * time.Second
+	turnDur      = 20 * time.Second
+	resultsDur   = 6 * time.Second
+
+	// gracePeriod is the short beat between the last seated player placing a bet
+	// and dealing, so an all-bets-in table deals early without feeling abrupt.
+	gracePeriod = time.Second
+
+	// maxPayoutMult mirrors Meta().MaxPayoutMultiplier: the host clamps every
+	// Settle to the seat's open stake × this multiplier, so the game clamps its
+	// own displayed/settled gross to the same ceiling before Settle. 26 covers a
+	// Perfect Pairs "perfect" pair (25:1 -> stake×26); the mandatory main bet
+	// only dilutes the per-seat aggregate, so it is never reached in practice.
+	maxPayoutMult = 26
+)
+
+// betTiers are the selectable stakes, lowest first. The ×2.5/×2/×2 climb repeats
+// each decade (10→100, 100→1000, 1000→10000) so the ladder runs from a 10-chip
+// minimum up to a 10k high roller.
+var betTiers = []int{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
+
+// pairsTiers are the Perfect Pairs / behind side-bet stakes, lowest first; index
+// 0 is "off" (no side bet). Adjusted on the Left/Right axis during betting, and
+// mirrors betTiers' upper reaches so side action can scale with the main bet.
+var pairsTiers = []int{0, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
+
+// phand is one hand a seat plays (a seat holds more than one after a split).
+type phand struct {
+	cards       hand
+	bet         int
+	resolved    bool // stood / busted / blackjack / doubled / surrendered
+	doubled     bool
+	surrendered bool
+	fromSplit   bool // a split hand: a two-card 21 is a plain 21, not a blackjack
+}
+
+// backBet is one seat's wager ON ANOTHER seat: a "behind" bet that rides the
+// backed player's first hand vs the dealer, and/or a Perfect Pairs bet on the
+// backed player's first two cards. Stakes are chosen during betting; the result
+// fields are filled at settlement (their-pairs at the deal, behind at settle).
+type backBet struct {
+	behind    int    // behind-bet stake (0 = none)
+	pairs     int    // their-Perfect-Pairs stake (0 = none)
+	pairsKind string // resolved at deal: "" | "mixed" | "colored" | "perfect"
+	pairsWin  int    // their-pairs chips credited at deal (0 = lost/none)
+	behindWin int    // behind chips credited at settle (0 = lost)
+}
+
+// seat is one player's place at the table. Keyed by account id in the room map
+// so it survives a hibernation freeze/thaw (connections change; accounts don't).
+type seat struct {
+	p kit.Player
+	// bal is the cached account-wide credits balance (svc.Credits.Balance),
+	// refreshed at join, after each Wager (decremented), and after each Settle /
+	// Buyback (re-read). It drives the HUD and every betting-affordability check;
+	// the platform owns the authoritative balance — this is only a per-tick cache
+	// so the render/clamp paths never call Balance in a hot loop.
+	bal        int
+	highScore  int // peak balance observed this session (the leaderboard metric)
+	postedPeak int // last peak Posted to the board (post only on increase)
+	// grossThisRound accumulates the seat's single open stake's GROSS payout
+	// (stake-inclusive): side-bet wins fold in as they resolve, hand credits at
+	// settle, and the whole thing Settles exactly once. roundStake is the total
+	// escrowed onto that open stake (sum of every Wager this round); >0 marks an
+	// open, unsettled stake and bounds the payout ceiling (roundStake×maxPayoutMult).
+	grossThisRound   int64
+	roundStake       int64
+	bet              int // currently selected/placed stake
+	placed           bool
+	pairsBet         int                 // Perfect Pairs side stake (0 = off), carried between rounds like bet
+	pairsKind        string              // this round's pairs result: "" | "mixed" | "colored" | "perfect"
+	pairsWin         int                 // chips credited on the pairs side bet this round (0 = lost/none)
+	focus            string              // betting UI: "" edits own bet, else the account id whose backs are being edited
+	backs            map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order)
+	insurance        int
+	insuranceWin     int // chips credited on the insurance side bet this round (0 = lost/none)
+	insuranceDecided bool
+	hands            []*phand
+	joinOrder        int
+	result           string // settlement summary for the results phase
+	ready            bool   // readied up during results to skip the wait
+}
+
+// pending names the deferred one-shot the room is waiting on, replacing the
+// native engine timers: each is a deadline held in guest memory and landed in
+// OnWake when r.Now() passes it (the wake idiom — no host timer survives a thaw).
+type pending uint8
+
+const (
+	pendNone         pending = iota
+	pendBettingClose         // betting window closed (or grace beat elapsed)
+	pendInsurance            // insurance window closed -> resolve
+	pendTurn                 // active turn timed out -> auto-stand
+	pendResults              // results flash elapsed -> next round
+	pendSettle               // dealer reveal/draw animation done -> settle
+)
+
+type room struct {
+	kit.Base
+	cfg kit.RoomConfig
+	svc kit.Services
+
+	sh         *shoe
+	phase      string
+	seats      map[string]*seat // keyed by account id (hibernation-safe)
+	order      []string         // join order of account ids
+	dealer     hand
+	dealerHole bool // hole card concealed
+	joinSeq    int
+
+	// deadline is the current phase deadline (rendered as the countdown) and
+	// what is the active pending one-shot. pendAt is the instant `what` fires;
+	// for most phases it equals deadline, but pendSettle/pendBettingClose-grace
+	// carry their own instant. A single (what, pendAt) is enough because the
+	// round is strictly sequential: at most one one-shot is armed at a time.
+	deadline       time.Time
+	what           pending
+	pendAt         time.Time
+	bettingClosing bool // grace timer armed after an all-bets-in early close
+
+	lastNow time.Time
+
+	// sched is the in-flight card animation schedule; schedEnd is the room-clock
+	// instant the last card settles. A frame composed at or after schedEnd renders
+	// every card settled, so the schedule is read-only cosmetic state derived from
+	// authoritative hands. Cleared once schedEnd passes (in OnWake).
+	sched    []cardAnim
+	schedEnd time.Time
+
+	frame *kit.Frame // reused render scratch (allocation-light steady state)
+}
+
+func newRoom(cfg kit.RoomConfig, svc kit.Services) *room {
+	return &room{cfg: cfg, svc: svc, seats: map[string]*seat{}, frame: kit.NewFrame()}
+}
+
+// economyOff reports whether the host has no credits economy (svc.Credits is
+// nil). A casino game must render out-of-service rather than trap when the
+// economy is disabled — every money path checks this first.
+func (rm *room) economyOff() bool { return rm.svc.Credits == nil }
+
+func (rm *room) OnStart(r kit.Room) {
+	rm.sh = newShoe(r.Rand())
+	if rm.economyOff() {
+		rm.render(r) // out-of-service: no economy, no betting
+		return
+	}
+	rm.enterBetting(r)
+	rm.render(r)
+}
+
+func (rm *room) OnClose(r kit.Room) {
+	// Book every seat's open stake on the way out so no escrow leaks: a seat with
+	// an unsettled stake at close Settles its accumulated gross (forfeiting any
+	// still-unresolved hand as a loss). Synchronous — the wasm sandbox has no
+	// goroutines and the host bounds the call.
+	for _, id := range rm.order {
+		if s := rm.seats[id]; s != nil {
+			rm.settleOpenStake(s)
+		}
+	}
+}
+
+// --- platform credits (the account-wide casino bankroll) -------------------
+
+// wager escrows amt onto the seat's SINGLE open stake via svc.Credits, updating
+// the cached balance and the round's total stake. A non-positive amt is a no-op
+// success (nothing to escrow). Returns false — the caller MUST reject the action
+// and never proceed — when the economy is off or the balance can't cover it
+// (mirrors the old `chips < bet` guards). Wager accumulates: repeated calls
+// before one Settle all land on the same open stake.
+func (rm *room) wager(s *seat, amt int) bool {
+	if amt <= 0 {
+		return true
+	}
+	if rm.economyOff() {
+		return false
+	}
+	if err := rm.svc.Credits.Wager(s.p, int64(amt)); err != nil {
+		return false // ErrInsufficientCredits (or disabled): reject cleanly
+	}
+	s.roundStake += int64(amt)
+	s.bal -= amt
+	return true
+}
+
+// refreshBal re-reads the seat's authoritative account-wide balance into the
+// cache (after a Settle or Buyback changes it). A disabled/erroring economy
+// leaves the cache untouched.
+func (rm *room) refreshBal(s *seat) {
+	if rm.economyOff() {
+		return
+	}
+	if b, err := rm.svc.Credits.Balance(s.p); err == nil {
+		s.bal = int(b)
+	}
+}
+
+// settleOpenStake closes the seat's single open stake EXACTLY ONCE: it clamps
+// the accumulated gross to the payout ceiling (roundStake×maxPayoutMult) and
+// Settles it, then clears the round's stake/gross and re-reads the balance.
+// Returns the round's net chip change (gross-stake) for the results summary. A
+// no-op with no open stake (roundStake==0), so it is safe on the mid-round exit
+// paths (OnLeave/OnClose) — a committed-but-unresolved bet Settles its resolved
+// gross (0 for a bare main bet), never leaking escrow and never double-settling.
+func (rm *room) settleOpenStake(s *seat) int {
+	if s.roundStake <= 0 {
+		s.grossThisRound = 0
+		return 0
+	}
+	gross := s.grossThisRound
+	if lim := s.roundStake * maxPayoutMult; gross > lim {
+		gross = lim // never settle (or show) more than the host will pay
+	}
+	net := int(gross - s.roundStake)
+	if !rm.economyOff() {
+		_ = rm.svc.Credits.Settle(s.p, gross)
+	}
+	s.grossThisRound = 0
+	s.roundStake = 0
+	rm.refreshBal(s)
+	return net
+}
+
+// buyback triggers the platform broke-relief rebuy for a busted seat, updating
+// the cached balance on success. Returns false when the host refuses (solvent or
+// the daily rebuy limit reached — render it, do not retry) or the economy is off.
+func (rm *room) buyback(s *seat) bool {
+	if rm.economyOff() {
+		return false
+	}
+	nb, err := rm.svc.Credits.Buyback(s.p)
+	if err != nil {
+		return false
+	}
+	s.bal = int(nb)
+	if s.bal > s.highScore {
+		s.highScore = s.bal
+	}
+	return true
+}
+
+// postPeak feeds the declared leaderboard with a new personal peak (the board
+// keeps each account's best). highScore tracks the peak account-wide credits
+// balance observed this session (sourced from svc.Credits.Balance after each
+// Settle); Post is what reaches the board, only on a new personal best.
+func (rm *room) postPeak(r kit.Room, s *seat) {
+	if s.highScore <= s.postedPeak {
+		return
+	}
+	s.postedPeak = s.highScore
+	r.Post(kit.Result{Rankings: []kit.PlayerResult{{
+		Player: s.p, Metric: s.highScore, Status: kit.StatusFinished,
+	}}})
+}
+
+// --- roster ----------------------------------------------------------------
+
+func (rm *room) OnJoin(r kit.Room, p kit.Player) {
+	if s, ok := rm.seats[p.AccountID]; ok {
+		// A re-delivered join is a fresh kit.Player (new connection): adopt
+		// it so the seat renders the current handle and character tile.
+		s.p = p
+		rm.render(r)
+		return
+	}
+	s := &seat{p: p, bet: betTiers[1], joinOrder: rm.joinSeq}
+	rm.refreshBal(s) // seed the cached balance from the platform bankroll
+	s.highScore = s.bal
+	s.postedPeak = s.bal
+	rm.seats[p.AccountID] = s
+	rm.joinSeq++
+	rm.order = append(rm.order, p.AccountID)
+	rm.render(r)
+}
+
+// OnLeave books any open stake and frees the seat. Leaving mid-round Settles the
+// seat's single open stake exactly once with its accumulated gross (0 for a bare,
+// still-unresolved main bet) — abandoning a dealt hand forfeits it as a loss, as
+// at a real table, and the escrow is released rather than leaked.
+func (rm *room) OnLeave(r kit.Room, p kit.Player) {
+	active, _ := rm.firstUnresolved()
+	if s := rm.seats[p.AccountID]; s != nil {
+		rm.settleOpenStake(s)
+	}
+	delete(rm.seats, p.AccountID)
+	for i, id := range rm.order {
+		if id == p.AccountID {
+			rm.order = append(rm.order[:i], rm.order[i+1:]...)
+			break
+		}
+	}
+	// If the player who just left was the one on turn, advance the table.
+	if rm.phase == phTurns && active != nil && active.p.AccountID == p.AccountID {
+		rm.beginTurn(r)
+	}
+	// If the leaver was the last seat the table was waiting on to ready up, the
+	// remaining players are all ready — deal the next hand now.
+	if rm.phase == phResults && rm.allSeatedReady() {
+		rm.enterBetting(r)
+	}
+	rm.render(r)
+}
+
+// --- the wake heartbeat ----------------------------------------------------
+
+// OnWake advances every time-driven element against CallContext time, then
+// renders. It clears a finished animation, lands the active phase one-shot when
+// its deadline has passed, and (for settlement) waits on the reveal schedule —
+// all the native engine timers, re-expressed as deadline comparisons.
+func (rm *room) OnWake(r kit.Room) {
+	now := r.Now()
+	rm.lastNow = now
+
+	// Drop a fully-played animation schedule so the renderer stops consulting it.
+	if len(rm.sched) > 0 && !rm.schedEnd.IsZero() && !now.Before(rm.schedEnd) {
+		rm.sched = nil
+		rm.schedEnd = time.Time{}
+	}
+
+	// Land the armed one-shot if its deadline has passed. Each branch may re-arm
+	// `what` (e.g. beginTurn after an auto-stand), so re-read after handling.
+	if rm.what != pendNone && now.After(rm.pendAt) {
+		switch rm.what {
+		case pendBettingClose:
+			rm.what = pendNone
+			rm.onBettingClose(r)
+		case pendInsurance:
+			rm.what = pendNone
+			rm.resolveInsurance(r)
+		case pendTurn:
+			rm.what = pendNone
+			rm.autoStand(r)
+		case pendResults:
+			rm.what = pendNone
+			rm.enterBetting(r)
+		case pendSettle:
+			rm.what = pendNone
+			rm.settle(r)
+		}
+	}
+	rm.render(r)
+}
+
+// arm sets the active deferred one-shot (deadline checked in OnWake).
+func (rm *room) arm(what pending, at time.Time) {
+	rm.what = what
+	rm.pendAt = at
+}
+
+// --- betting ---------------------------------------------------------------
+
+func (rm *room) enterBetting(r kit.Room) {
+	rm.phase = phBetting
+	rm.dealer = nil
+	rm.dealerHole = false
+	rm.bettingClosing = false
+	rm.clearSchedule()
+	for _, s := range rm.seats {
+		s.hands = nil
+		s.placed = false
+		s.insurance = 0
+		s.insuranceWin = 0
+		s.insuranceDecided = false
+		s.result = ""
+		s.pairsKind = ""
+		s.pairsWin = 0
+		s.focus = ""         // re-open editing on the seat's own bet
+		s.grossThisRound = 0 // no open stake carries into a fresh betting window
+		s.roundStake = 0     //
+		rm.refreshBal(s)     // re-sync the cached balance before affordability clamps
+		rm.carryBacks(s)     // behind/their-pairs stakes are sticky between rounds
+		if s.bet > s.bal {
+			s.bet = clampBet(s.bal)
+		}
+		rm.clampPairs(s) // a thinned stack may no longer afford the carried side bet
+		rm.clampBacks(s) // nor its carried backs
+	}
+	rm.deadline = r.Now().Add(bettingDur)
+	r.SetInputContext(kit.CtxNav) // bet up/down + confirm
+	rm.arm(pendBettingClose, rm.deadline)
+}
+
+func (rm *room) onBettingClose(r kit.Room) {
+	if rm.anyPlaced() {
+		rm.deal(r)
+		return
+	}
+	rm.enterBetting(r) // nobody bet — reopen
+}
+
+func (rm *room) anyPlaced() bool {
+	for _, s := range rm.seats {
+		if s.placed {
+			return true
+		}
+	}
+	return false
+}
+
+// allSeatedPlaced reports whether at least one seat is taken and every seated
+// player has placed a bet — the trigger to deal early after a short grace beat.
+func (rm *room) allSeatedPlaced() bool {
+	seated := false
+	for _, s := range rm.seats {
+		seated = true
+		if !s.placed {
+			return false
+		}
+	}
+	return seated
+}
+
+// maybeCloseEarly arms the grace timer once every seated player has placed. It
+// re-points the betting-close one-shot at a short grace deadline; a guard
+// (bettingClosing) keeps a second confirm during the grace beat from re-arming
+// and pushing the deal out. The empty-betting reopen path in onBettingClose is
+// untouched (it always re-checks anyPlaced).
+func (rm *room) maybeCloseEarly(r kit.Room) {
+	if rm.bettingClosing || !rm.allSeatedPlaced() {
+		return
+	}
+	rm.bettingClosing = true
+	rm.deadline = r.Now().Add(gracePeriod)
+	rm.arm(pendBettingClose, rm.deadline)
+}
+
+// clampBet returns the highest tier the chips can cover (at least the lowest).
+func clampBet(chips int) int {
+	best := betTiers[0]
+	for _, t := range betTiers {
+		if t <= chips {
+			best = t
+		}
+	}
+	return best
+}
+
+func (rm *room) adjustBet(s *seat, dir int) {
+	i := tierIndex(s.bet) + dir
+	if i < 0 {
+		i = 0
+	}
+	if i >= len(betTiers) {
+		i = len(betTiers) - 1
+	}
+	s.bet = betTiers[i]
+	if s.bet > s.bal {
+		s.bet = clampBet(s.bal)
+	}
+}
+
+func tierIndex(bet int) int {
+	for i, t := range betTiers {
+		if t == bet {
+			return i
+		}
+	}
+	return 0
+}
+
+// cycleOwnPairs advances the seat's own Perfect Pairs stake one tier, wrapping
+// back to 0 past the top (and resetting to 0 if the next tier is unaffordable).
+func (rm *room) cycleOwnPairs(s *seat) {
+	s.pairsBet = loopTier(pairsTiers, s.pairsBet, s.bal-(s.committed()-s.pairsBet))
+}
+
+// clampPairs lowers the side bet to the highest tier the seat can still afford
+// alongside its main bet (down to off), so a raised main bet or a thin stack can
+// never leave an unaffordable side bet placed.
+func (rm *room) clampPairs(s *seat) {
+	for s.pairsBet > 0 && s.bet+s.pairsBet > s.bal {
+		s.pairsBet = pairsTiers[pairsTierIndex(s.pairsBet)-1]
+	}
+}
+
+// carryBacks makes a seat's backs sticky between rounds: it prunes any back on a
+// target that has left the table (keying by account id, so a still-seated target
+// is kept even if it sits the round out — resolveBackPairs voids that round's
+// stakes) and clears last round's per-back result fields so they re-settle fresh.
+func (rm *room) carryBacks(s *seat) {
+	for tid, b := range s.backs {
+		if rm.seats[tid] == nil {
+			delete(s.backs, tid)
+			continue
+		}
+		b.pairsKind, b.pairsWin, b.behindWin = "", 0, 0
+	}
+}
+
+// clampBacks lowers each carried back's stakes to the highest tiers the seat can
+// still afford alongside its main bet, own pairs, and the other backs (down to
+// off). Visits backs in a deterministic order so the clamp never depends on Go's
+// map iteration order.
+func (rm *room) clampBacks(s *seat) {
+	for _, tid := range sortedBackIDs(s) {
+		b := s.backs[tid]
+		b.pairs = affordTier(pairsTiers, b.pairs, s.bal-(s.committed()-b.pairs))
+		b.behind = affordTier(pairsTiers, b.behind, s.bal-(s.committed()-b.behind))
+	}
+}
+
+// affordTier returns the highest tier not exceeding `want` that still fits
+// `budget` (the chips left for this stake after the seat's other commitments).
+// tiers must be ascending and start at 0, so a zero/negative budget yields the
+// "off" tier rather than panicking.
+func affordTier(tiers []int, want, budget int) int {
+	best := 0
+	for _, t := range tiers {
+		if t > want || t > budget {
+			break
+		}
+		best = t
+	}
+	return best
+}
+
+func pairsTierIndex(bet int) int {
+	for i, t := range pairsTiers {
+		if t == bet {
+			return i
+		}
+	}
+	return 0
+}
+
+// committed totals every chip the seat has wagered this betting window: its main
+// bet, its own Perfect Pairs, and every behind/their-pairs stake across backs.
+func (s *seat) committed() int {
+	total := s.bet + s.pairsBet
+	for _, b := range s.backs {
+		total += b.behind + b.pairs
+	}
+	return total
+}
+
+// backTargets is the account ids a seat may back: every OTHER occupied seat, in
+// join order (never map order).
+func (rm *room) backTargets(self *seat) []string {
+	var ids []string
+	for _, id := range rm.order {
+		if rm.seats[id] != nil && id != self.p.AccountID {
+			ids = append(ids, id)
+		}
+	}
+	return ids
+}
+
+// cycleFocus moves the seat's betting focus `dir` steps (Right = +1, Left = -1)
+// around ["" (self), t1, t2, …] where t1… are the other occupied seats, wrapping
+// at both ends. With no other seats it stays on self.
+func (rm *room) cycleFocus(s *seat, dir int) {
+	targets := rm.backTargets(s)
+	if len(targets) == 0 {
+		s.focus = ""
+		return
+	}
+	list := append([]string{""}, targets...) // index 0 = self
+	cur := 0
+	for i, id := range list {
+		if id == s.focus {
+			cur = i
+			break
+		}
+	}
+	n := len(list)
+	s.focus = list[((cur+dir)%n+n)%n]
+}
+
+// backOn returns the seat's backBet on target (creating it on first use).
+func (s *seat) backOn(target string) *backBet {
+	if s.backs == nil {
+		s.backs = map[string]*backBet{}
+	}
+	b := s.backs[target]
+	if b == nil {
+		b = &backBet{}
+		s.backs[target] = b
+	}
+	return b
+}
+
+// cycleBackPairs / cycleBackBehind advance the focused back's pairs / behind
+// stake one tier, wrapping to 0 past the top, budget-clamped against the seat's
+// other commitments (an unaffordable next tier also resets to 0).
+func (rm *room) cycleBackPairs(s *seat) {
+	b := s.backOn(s.focus)
+	b.pairs = loopTier(pairsTiers, b.pairs, s.bal-(s.committed()-b.pairs))
+}
+
+func (rm *room) cycleBackBehind(s *seat) {
+	b := s.backOn(s.focus)
+	b.behind = loopTier(pairsTiers, b.behind, s.bal-(s.committed()-b.behind))
+}
+
+// loopTier returns the tier one step above cur, wrapping back to 0 (the "off"
+// tier) past the top — so a side bet cycles 0 -> 10 -> … -> top -> 0. A next tier
+// the budget can't cover also resets to 0 (higher tiers are unaffordable too).
+func loopTier(tiers []int, cur, budget int) int {
+	next := tiers[(pairsTierIndex(cur)+1)%len(tiers)]
+	if next > budget {
+		return 0
+	}
+	return next
+}
+
+// --- dealing ---------------------------------------------------------------
+
+func (rm *room) deal(r kit.Room) {
+	if rm.sh.needsReshuffle() {
+		rm.sh.shuffle(r.Rand())
+	}
+	rm.sh.beginRound() // everything dealt before this point is recyclable discards
+	rng := r.Rand()
+	rm.dealer = hand{rm.sh.draw(rng), rm.sh.draw(rng)} // [up, hole]
+	rm.dealerHole = true
+	// Range the join-ordered slice (not the map) so dealing order is
+	// deterministic — never depends on Go's map iteration order.
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil || !s.placed {
+			continue
+		}
+		// Open the seat's single stake with the main bet. A failed Wager
+		// (balance can't cover it — should not happen after the betting clamps)
+		// sits the seat out this round rather than dealing an unbacked hand.
+		if !rm.wager(s, s.bet) {
+			s.placed = false
+			continue
+		}
+		h := &phand{cards: hand{rm.sh.draw(rng), rm.sh.draw(rng)}, bet: s.bet}
+		if h.cards.isBlackjack() {
+			h.resolved = true
+		}
+		s.hands = []*phand{h}
+		rm.resolvePairs(s, h.cards)
+	}
+
+	rm.resolveBackPairs() // every hand is now dealt; settle the their-pairs side of each back
+
+	rm.recordDeal(r)
+
+	up := rm.dealer[0]
+	switch {
+	case up.r == rankAce:
+		rm.enterInsurance(r)
+	case up.r.points() == 10:
+		if rm.dealer.isBlackjack() {
+			rm.revealAndSettle(r)
+		} else {
+			rm.enterTurns(r)
+		}
+	default:
+		rm.enterTurns(r)
+	}
+}
+
+// resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards:
+// the stake is Wagered onto the seat's open stake and any winning pair folds its
+// gross into grossThisRound immediately — the casino way, where the side bet
+// stands apart from how the hand goes on to play out. A stake the balance can't
+// cover is dropped (no side bet this round) rather than proceeding unpaid.
+func (rm *room) resolvePairs(s *seat, dealt hand) {
+	if s.pairsBet <= 0 || len(dealt) < 2 {
+		return
+	}
+	if !rm.wager(s, s.pairsBet) {
+		s.pairsBet = 0
+		return
+	}
+	kind, mult := perfectPairsOutcome(dealt[0], dealt[1])
+	s.pairsKind = kind
+	s.pairsWin = pairsCreditFor(mult, s.pairsBet)
+	s.grossThisRound += int64(s.pairsWin)
+}
+
+// sortedBackIDs returns a seat's back target ids in a deterministic (sorted)
+// order. Backs are keyed by account id in a map; the targets are independent
+// (settlement is purely additive), so a stable sort is enough to avoid relying
+// on Go's map iteration order — and it still visits a target that has since left
+// the table (which join order no longer lists).
+func sortedBackIDs(s *seat) []string {
+	if len(s.backs) == 0 {
+		return nil
+	}
+	ids := make([]string, 0, len(s.backs))
+	for id := range s.backs {
+		ids = append(ids, id)
+	}
+	sort.Strings(ids)
+	return ids
+}
+
+// resolveBackPairs settles the their-Perfect-Pairs side of every seat's backs,
+// once all hands are dealt: a back on a seat that didn't get dealt in is voided
+// (never charged); otherwise each stake is Wagered onto the backer's open stake
+// and any winning pair on the target's first two cards folds its gross into the
+// backer's grossThisRound immediately. The behind bet is Wagered here too
+// (committed, held) and settled later against the dealer. A stake the balance
+// can't cover is dropped (voided) rather than proceeding unpaid.
+func (rm *room) resolveBackPairs() {
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil || !s.placed {
+			continue
+		}
+		for _, tid := range sortedBackIDs(s) {
+			b := s.backs[tid]
+			t := rm.seats[tid]
+			if t == nil || !t.placed || len(t.hands) == 0 {
+				b.behind, b.pairs = 0, 0 // target sat out / gone -> void the back
+				continue
+			}
+			if b.pairs > 0 {
+				if rm.wager(s, b.pairs) {
+					kind, mult := perfectPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1])
+					b.pairsKind = kind
+					b.pairsWin = pairsCreditFor(mult, b.pairs)
+					s.grossThisRound += int64(b.pairsWin)
+				} else {
+					b.pairs = 0
+				}
+			}
+			if b.behind > 0 && !rm.wager(s, b.behind) {
+				b.behind = 0 // committed now; the dealer comparison happens at settle
+			}
+		}
+	}
+}
+
+// --- animation schedule ----------------------------------------------------
+
+// clearSchedule drops any pending card animation. Safe to call when none is
+// active. (No frame-rate control exists in the ABI: the host heartbeat drives
+// wakes; the schedule is read off room-clock timestamps either way.)
+func (rm *room) clearSchedule() {
+	rm.sched = nil
+	rm.schedEnd = time.Time{}
+}
+
+// dealingActive reports whether a dealing/reveal animation is still in flight at
+// the latest composed instant, so hand-action input can be ignored until it ends
+// (betting-phase input is unaffected). It reads only authoritative timestamps.
+func (rm *room) dealingActive() bool {
+	if len(rm.sched) == 0 || rm.schedEnd.IsZero() || rm.lastNow.IsZero() {
+		return false
+	}
+	return rm.lastNow.Before(rm.schedEnd)
+}
+
+// computeSchedEnd recomputes schedEnd as the latest settle instant across the
+// recorded schedule.
+func (rm *room) computeSchedEnd() {
+	if len(rm.sched) == 0 {
+		rm.schedEnd = time.Time{}
+		return
+	}
+	rm.schedEnd = rm.sched[0].endsAt()
+	for _, a := range rm.sched[1:] {
+		if e := a.endsAt(); e.After(rm.schedEnd) {
+			rm.schedEnd = e
+		}
+	}
+}
+
+// recordDeal lays out the initial two-card deal as a staggered slide-and-flip
+// sweep: each card slides from the right felt edge to its slot and then flips
+// face up, except the dealer hole card, which slides in but stays concealed
+// until the reveal turns it over. Card identities are already fixed; this only
+// records cosmetic timings.
+func (rm *room) recordDeal(r kit.Room) {
+	now := r.Now()
+	rm.sched = nil
+	step := 0
+	add := func(a cardAnim) {
+		a.slideStart = now.Add(time.Duration(step) * dealStagger)
+		if !a.flipStart.IsZero() { // flip begins as the slide lands
+			a.flipStart = a.slideStart.Add(slideDur)
+		}
+		rm.sched = append(rm.sched, a)
+		step++
+	}
+	// Two passes around the table mirror a real deal: first card to every seat
+	// then the dealer up card; second card to every seat then the dealer hole.
+	for round := 0; round < 2; round++ {
+		for _, id := range rm.order {
+			s := rm.seats[id]
+			if s == nil || !s.placed || len(s.hands) == 0 {
+				continue
+			}
+			add(cardAnim{kind: animSeat, player: s.p, cardIdx: round, flipStart: now})
+		}
+		if round == 0 {
+			add(cardAnim{kind: animDealer, cardIdx: 0, flipStart: now}) // up card flips
+		} else {
+			add(cardAnim{kind: animDealer, cardIdx: 1}) // hole card stays face down
+		}
+	}
+	rm.computeSchedEnd()
+}
+
+// recordDraw schedules a single drawn card (hit/double/split) for the given seat
+// hand: it slides in from the right edge and flips face up.
+func (rm *room) recordDraw(r kit.Room, p kit.Player, handIdx, cardIdx int) {
+	now := r.Now()
+	rm.sched = []cardAnim{{
+		kind:       animSeat,
+		player:     p,
+		handIdx:    handIdx,
+		cardIdx:    cardIdx,
+		slideStart: now,
+		flipStart:  now.Add(slideDur),
+	}}
+	rm.computeSchedEnd()
+}
+
+// recordHoleReveal schedules the dealer hole card flipping over in place (no
+// slide) starting at `at`, and returns the room-clock instant the flip
+// completes so dealer play can pace off it. Callers lead in with a short beat
+// before `at` so the card sits face down a moment, then turns over, rather than
+// snapping up the instant the dealer's turn begins.
+func (rm *room) recordHoleReveal(at time.Time) time.Time {
+	rm.sched = []cardAnim{{
+		kind:      animDealer,
+		cardIdx:   1,
+		flipStart: at,
+	}}
+	rm.computeSchedEnd()
+	return at.Add(flipDur)
+}
+
+// recordDealerDraw appends a dealer hit card sliding in and flipping face up.
+func (rm *room) recordDealerDraw(start time.Time, cardIdx int) {
+	rm.sched = append(rm.sched, cardAnim{
+		kind:       animDealer,
+		cardIdx:    cardIdx,
+		slideStart: start,
+		flipStart:  start.Add(slideDur),
+	})
+}
+
+// --- insurance -------------------------------------------------------------
+
+func (rm *room) enterInsurance(r kit.Room) {
+	rm.phase = phInsurance
+	rm.deadline = r.Now().Add(insuranceDur)
+	r.SetInputContext(kit.CtxCommand) // y/n are domain commands
+	rm.arm(pendInsurance, rm.deadline)
+}
+
+func (rm *room) takeInsurance(s *seat, yes bool) {
+	if s == nil || !s.placed || s.insuranceDecided {
+		return
+	}
+	s.insuranceDecided = true
+	if yes {
+		ins := s.bet / 2
+		if ins <= 0 {
+			return
+		}
+		if !rm.wager(s, ins) {
+			return // can't cover the insurance stake: decline it
+		}
+		s.insurance = ins
+	}
+}
+
+// insuranceUndecidedCount is how many placed seats have not yet answered the
+// insurance offer (the seats the table is still waiting on).
+func (rm *room) insuranceUndecidedCount() int {
+	n := 0
+	for _, s := range rm.seats {
+		if s.placed && !s.insuranceDecided {
+			n++
+		}
+	}
+	return n
+}
+
+// maybeResolveInsurance resolves the insurance window early once every placed
+// seat has answered, instead of waiting out the timer (mirrors the all-bets-in
+// early deal and the all-ready results skip).
+func (rm *room) maybeResolveInsurance(r kit.Room) {
+	placed := false
+	for _, s := range rm.seats {
+		if !s.placed {
+			continue
+		}
+		placed = true
+		if !s.insuranceDecided {
+			return // still waiting on this seat
+		}
+	}
+	if !placed {
+		return
+	}
+	rm.what = pendNone // cancel the armed timer; resolving now
+	rm.resolveInsurance(r)
+}
+
+func (rm *room) resolveInsurance(r kit.Room) {
+	dbj := rm.dealer.isBlackjack()
+	// Order the credit loop by join order for determinism (chips are
+	// per-seat so order is harmless, but range-over-map is avoided on principle).
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil || s.insurance <= 0 {
+			continue
+		}
+		s.insuranceWin = insuranceCredit(dbj, s.insurance)
+		s.grossThisRound += int64(s.insuranceWin)
+	}
+	if dbj {
+		rm.revealAndSettle(r)
+		return
+	}
+	rm.enterTurns(r)
+}
+
+// --- player turns ----------------------------------------------------------
+
+func (rm *room) enterTurns(r kit.Room) {
+	rm.phase = phTurns
+	rm.beginTurn(r)
+}
+
+// firstUnresolved returns the seat and hand currently on turn (the first
+// unresolved hand of the first placed seat, in join order), or nil/nil.
+func (rm *room) firstUnresolved() (*seat, *phand) {
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil || !s.placed {
+			continue
+		}
+		for _, h := range s.hands {
+			if !h.resolved {
+				return s, h
+			}
+		}
+	}
+	return nil, nil
+}
+
+func (rm *room) beginTurn(r kit.Room) {
+	s, _ := rm.firstUnresolved()
+	if s == nil {
+		rm.enterDealer(r)
+		return
+	}
+	rm.deadline = r.Now().Add(turnDur)
+	r.SetInputContext(kit.CtxCommand) // h/s/d/p/r are domain commands
+	rm.arm(pendTurn, rm.deadline)
+}
+
+func (rm *room) autoStand(r kit.Room) {
+	if rm.phase != phTurns {
+		return
+	}
+	if _, h := rm.firstUnresolved(); h != nil {
+		h.resolved = true
+	}
+	rm.beginTurn(r)
+}
+
+func (rm *room) act(r kit.Room, p kit.Player, a rune) {
+	s, h := rm.firstUnresolved()
+	if s == nil || s.p.AccountID != p.AccountID {
+		return // not this player's turn
+	}
+	hi := rm.handIndex(s, h)
+	first := len(h.cards) == 2 && !h.doubled // first decision on this hand
+	// A REJECTED action returns without beginTurn: re-arming there would reset
+	// the turn deadline, letting a player stall their own clock with no-ops.
+	switch a {
+	case 'h':
+		h.cards = append(h.cards, rm.sh.draw(r.Rand()))
+		rm.recordDraw(r, p, hi, len(h.cards)-1)
+		if h.cards.isBust() || h.cards.total() == 21 {
+			h.resolved = true
+		}
+		rm.beginTurn(r)
+	case 's':
+		h.resolved = true
+		rm.beginTurn(r)
+	case 'd':
+		// The double's extra stake Wagers onto the seat's open stake; a failed
+		// Wager (can't cover it) rejects the action, exactly as the old
+		// `chips < bet` guard did — never proceed on a failed Wager.
+		if !first {
+			return
+		}
+		if !rm.wager(s, h.bet) {
+			return
+		}
+		h.bet *= 2
+		h.doubled = true
+		h.cards = append(h.cards, rm.sh.draw(r.Rand()))
+		rm.recordDraw(r, p, hi, len(h.cards)-1)
+		h.resolved = true
+		rm.beginTurn(r)
+	case 'p':
+		if !rm.split(r, s, h) {
+			return
+		}
+		rm.beginTurn(r)
+	case 'r':
+		if !first || len(s.hands) != 1 {
+			return
+		}
+		h.surrendered = true
+		h.resolved = true
+		// Half the stake is returned at settle: settle() folds halfUp(h.bet) into
+		// the seat's gross for a surrendered hand (the full bet is already escrowed
+		// on the open stake). An odd bet's half-chip rounds UP to the player
+		// (halfUp owns the policy, shared with the 3:2 payout in creditFor).
+		rm.beginTurn(r)
+	}
+}
+
+// split turns a two-card equal-rank pair into two hands, each taking a new card,
+// reporting whether the split happened. Split aces take one card and stand.
+func (rm *room) split(r kit.Room, s *seat, h *phand) bool {
+	if len(h.cards) != 2 || h.cards[0].r != h.cards[1].r || len(s.hands) >= maxHands {
+		return false
+	}
+	// The new hand's stake Wagers onto the seat's open stake; a failed Wager
+	// (can't cover it) rejects the split, as the old `chips < bet` guard did.
+	if !rm.wager(s, h.bet) {
+		return false
+	}
+	c0, c1 := h.cards[0], h.cards[1]
+	rng := r.Rand()
+	nh := &phand{cards: hand{c1, rm.sh.draw(rng)}, bet: h.bet, fromSplit: true}
+	h.cards = hand{c0, rm.sh.draw(rng)}
+	h.fromSplit = true
+	if c0.r == rankAce {
+		h.resolved = true
+		nh.resolved = true
+	}
+	// insert nh directly after h
+	idx := 0
+	for i, x := range s.hands {
+		if x == h {
+			idx = i
+			break
+		}
+	}
+	s.hands = append(s.hands[:idx+1], append([]*phand{nh}, s.hands[idx+1:]...)...)
+	// Animate the freshly drawn card of each split hand sliding in.
+	rm.sched = nil
+	now := r.Now()
+	for i := 0; i < 2; i++ {
+		rm.sched = append(rm.sched, cardAnim{
+			kind:       animSeat,
+			player:     s.p,
+			handIdx:    idx + i,
+			cardIdx:    1,
+			slideStart: now.Add(time.Duration(i) * dealStagger),
+			flipStart:  now.Add(time.Duration(i)*dealStagger + slideDur),
+		})
+	}
+	rm.computeSchedEnd()
+	return true
+}
+
+// handIndex returns h's position within s.hands (0 if not found).
+func (rm *room) handIndex(s *seat, h *phand) int {
+	for i, x := range s.hands {
+		if x == h {
+			return i
+		}
+	}
+	return 0
+}
+
+// --- dealer & settlement ---------------------------------------------------
+
+func (rm *room) revealAndSettle(r kit.Room) {
+	rm.dealerHole = false
+	// A beat with the card still face down, then turn it over, then hold a beat
+	// on the revealed blackjack before settling — so the reveal animates and
+	// registers instead of snapping straight to results.
+	flipAt := r.Now().Add(holeRevealDelay)
+	done := rm.recordHoleReveal(flipAt)
+	rm.settleAt(r, done.Add(dealerDoneHold))
+}
+
+func (rm *room) enterDealer(r kit.Room) {
+	rm.dealerHole = false
+	// The outcome is fixed up front from the seeded shoe; the schedule only paces
+	// the reveal. Hold a beat with the hole card still face down, turn it over,
+	// hold a beat on the dealer's two-card total, then slide in each hit one
+	// unhurried card at a time, and settle a final beat after the last card
+	// lands — never the flurry the initial deal uses.
+	flipAt := r.Now().Add(holeRevealDelay)
+	done := rm.recordHoleReveal(flipAt).Add(holeRevealHold)
+	if rm.anyLive() {
+		before := len(rm.dealer)
+		rm.dealer = dealerPlay(rm.dealer, rm.sh, r.Rand())
+		start := done
+		for i := before; i < len(rm.dealer); i++ {
+			rm.recordDealerDraw(start, i)
+			// The next hit waits for this card to fully land, plus a read beat.
+			start = start.Add(slideDur + flipDur + dealerDrawGap)
+		}
+		rm.computeSchedEnd() // across the appended dealer cards
+		if len(rm.dealer) > before {
+			done = rm.schedEnd
+		}
+	}
+	rm.settleAt(r, done.Add(dealerDoneHold))
+}
+
+// settleAt defers settlement until the dealer reveal/draw schedule has played
+// out (settlement timing keys off the schedule, never the renderer). A deadline
+// already in the past settles immediately.
+func (rm *room) settleAt(r kit.Room, at time.Time) {
+	if !at.After(r.Now()) {
+		rm.settle(r)
+		return
+	}
+	rm.deadline = at
+	rm.arm(pendSettle, at)
+}
+
+// anyLive reports whether any player hand can still beat the dealer.
+func (rm *room) anyLive() bool {
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil {
+			continue
+		}
+		for _, h := range s.hands {
+			if !h.surrendered && !h.cards.isBust() {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func (rm *room) settle(r kit.Room) {
+	dbj := rm.dealer.isBlackjack()
+	for _, id := range rm.order {
+		s := rm.seats[id]
+		if s == nil || !s.placed {
+			continue // skip seats that never opened a stake this round
+		}
+		// Fold every resolved credit into the seat's single open-stake gross.
+		for _, h := range s.hands {
+			if h.surrendered {
+				s.grossThisRound += int64(halfUp(h.bet)) // half the stake returned
+				continue
+			}
+			pbj := h.cards.isBlackjack() && !h.fromSplit
+			o := settleHandEx(h.cards, pbj, rm.dealer, dbj)
+			s.grossThisRound += int64(creditFor(o, h.bet))
+		}
+		// The Perfect Pairs and insurance side-bet wins already folded into gross
+		// as they resolved (deal / offer-close); settle the behind bets now.
+		rm.settleBacks(s, dbj)
+		// Close the seat's open stake with ONE Settle of the accumulated gross
+		// (clamped to the payout ceiling), then feed the board on a new peak.
+		net := rm.settleOpenStake(s)
+		s.result = resultText(net)
+		if s.bal > s.highScore {
+			s.highScore = s.bal
+		}
+		rm.postPeak(r, s)
+		// A seat that can no longer cover the minimum stake would be soft-locked in
+		// betting (Confirm requires bal >= betTiers[0]); trigger the platform
+		// broke-relief rebuy. A refusal (solvent/daily limit) leaves the LOSE
+		// summary and lets the seat sit out until it recovers.
+		if s.bal < betTiers[0] && rm.buyback(s) {
+			s.result = "BUST - re-buy"
+		}
+		s.placed = false
+	}
+	rm.enterResults(r)
+}
+
+// settleBacks settles seat s's behind bets, folding each behind win into the
+// seat's open-stake gross. Their-pairs already folded in at the deal; each behind
+// bet is judged now against the target's first hand vs the dealer — even money on
+// a win, 3:2 on a natural blackjack, push returned — refunded if the target has
+// left (their hand can't be judged) or lost if that hand surrendered.
+func (rm *room) settleBacks(s *seat, dealerBJ bool) {
+	for _, tid := range sortedBackIDs(s) {
+		b := s.backs[tid]
+		if b.behind <= 0 {
+			continue
+		}
+		t := rm.seats[tid]
+		switch {
+		case t == nil || len(t.hands) == 0:
+			b.behindWin = b.behind // target left: refund the behind stake (push)
+		case t.hands[0].surrendered:
+			b.behindWin = 0 // backed a hand that bailed out
+		default:
+			h0 := t.hands[0]
+			o := settleHandEx(h0.cards, h0.cards.isBlackjack() && !h0.fromSplit, rm.dealer, dealerBJ)
+			b.behindWin = creditFor(o, b.behind)
+		}
+		s.grossThisRound += int64(b.behindWin)
+	}
+}
+
+func (rm *room) enterResults(r kit.Room) {
+	rm.phase = phResults
+	for _, s := range rm.seats {
+		s.ready = false // a fresh round of ready-ups
+	}
+	rm.deadline = r.Now().Add(resultsDur)
+	r.SetInputContext(kit.CtxNav) // results screen: Up/Down idle, Confirm readies up
+	rm.arm(pendResults, rm.deadline)
+}
+
+// allSeatedReady reports whether at least one seat is taken and every seated
+// player has readied up — the trigger to start the next round without waiting
+// out the results flash.
+func (rm *room) allSeatedReady() bool {
+	seated := false
+	for _, s := range rm.seats {
+		seated = true
+		if !s.ready {
+			return false
+		}
+	}
+	return seated
+}
+
+// unreadyCount is how many seated players have not yet readied up.
+func (rm *room) unreadyCount() int {
+	n := 0
+	for _, s := range rm.seats {
+		if !s.ready {
+			n++
+		}
+	}
+	return n
+}
+
+// --- input -----------------------------------------------------------------
+
+func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) {
+	s := rm.seats[p.AccountID]
+	if s == nil {
+		return
+	}
+	switch rm.phase {
+	case phBetting:
+		// Up/Down set your own main stake; Left/Right change which seat you're
+		// betting on (self, then each other seat). P loops the pairs side bet and
+		// B loops the behind bet for the focused seat — each cycles up a tier and
+		// resets to 0 past the top. Both runes are unmapped in CtxNav, read raw.
+		switch kit.Resolve(in, kit.CtxNav) {
+		case kit.ActUp:
+			rm.adjustBet(s, +1)
+			rm.clampPairs(s) // a raised main bet may crowd out the side bet
+		case kit.ActDown:
+			rm.adjustBet(s, -1)
+		case kit.ActLeft:
+			rm.cycleFocus(s, -1)
+		case kit.ActRight:
+			rm.cycleFocus(s, +1)
+		case kit.ActConfirm:
+			if s.bal >= betTiers[0] {
+				if s.bet > s.bal {
+					s.bet = clampBet(s.bal)
+				}
+				rm.clampPairs(s)
+				s.placed = true
+				rm.maybeCloseEarly(r) // deal early once every seat has bet
+			}
+		}
+		if in.Kind == kit.InputRune {
+			switch in.Rune {
+			case 'p', 'P': // loop the focused seat's pairs side bet (yours, or theirs)
+				if s.focus == "" {
+					rm.cycleOwnPairs(s)
+				} else {
+					rm.cycleBackPairs(s)
+				}
+			case 'b', 'B': // loop the behind bet on the focused seat (none on yourself)
+				if s.focus != "" {
+					rm.cycleBackBehind(s)
+				}
+			case 'r', 'R': // broke-relief re-buy when you can't cover the min bet
+				if s.bal < betTiers[0] {
+					rm.buyback(s) // on success s.bal is topped up; the bet controls light back up next render
+				}
+			}
+		}
+	case phInsurance:
+		if in.Kind == kit.InputRune {
+			switch in.Rune {
+			case 'y', 'Y':
+				rm.takeInsurance(s, true)
+				rm.maybeResolveInsurance(r) // all answered -> resolve, skip the timer
+			case 'n', 'N':
+				rm.takeInsurance(s, false)
+				rm.maybeResolveInsurance(r)
+			}
+		}
+	case phResults:
+		// Confirm (Enter/Space) readies up for the next hand; once every seated
+		// player is ready the table deals straight away instead of waiting out
+		// the results flash.
+		if kit.Resolve(in, kit.CtxNav) == kit.ActConfirm {
+			s.ready = true
+			if rm.allSeatedReady() {
+				rm.enterBetting(r)
+			}
+		}
+	case phTurns:
+		// A hand-action key is ignored while a dealing/draw animation is in
+		// flight, so a card can't be acted on before it has landed (the
+		// betting-phase keys above are unaffected).
+		if rm.dealingActive() {
+			return
+		}
+		if in.Kind == kit.InputRune {
+			switch in.Rune {
+			case 'h', 'H':
+				rm.act(r, p, 'h')
+			case 's', 'S':
+				rm.act(r, p, 's')
+			case 'd', 'D':
+				rm.act(r, p, 'd')
+			case 'p', 'P':
+				rm.act(r, p, 'p')
+			case 'r', 'R':
+				rm.act(r, p, 'r')
+			}
+		}
+	}
+	rm.render(r)
+}
+
+func resultText(net int) string {
+	switch {
+	case net > 0:
+		return "WIN +" + strconv.Itoa(net)
+	case net < 0:
+		return "LOSE " + strconv.Itoa(net)
+	default:
+		return "PUSH"
+	}
+}
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
new file mode 100644
index 0000000..68de93a
--- /dev/null
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -0,0 +1,1530 @@
+package main
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	kit "github.com/shellcade/kit/v2"
+	"github.com/shellcade/kit/v2/kittest"
+)
+
+func mkPlayer(h string) kit.Player {
+	return kit.Player{AccountID: h, Handle: h, Kind: kit.KindMember, Conn: "conn-" + h}
+}
+
+// newGame returns a started room driven by an in-memory kittest.Room. The
+// credits double is set to clamp at the game's declared MaxPayoutMultiplier so
+// the settlement ceiling is exercised exactly as the host would apply it.
+func newGame(t *testing.T, players ...kit.Player) (*room, *kittest.Room) {
+	t.Helper()
+	tr := kittest.NewRoom(players...)
+	tr.CreditsMaxPayoutMultiplier = maxPayoutMult
+	rm := newRoom(tr.Config(), tr.Services())
+	rm.OnStart(tr)
+	return rm, tr
+}
+
+// fund sets the seat's account-wide credits balance in the double AND the seat's
+// cached balance to n, so a subsequent Wager (deal/double/split/insurance) draws
+// against a known bankroll.
+func fund(tr *kittest.Room, s *seat, n int) {
+	if tr.Credits == nil {
+		tr.Credits = map[string]int64{}
+	}
+	tr.Credits[s.p.AccountID] = int64(n)
+	s.bal = n
+}
+
+// staked models a seat that has already opened its stake this round: `bal` is the
+// bankroll left after `stake` credits were escrowed. It seeds both the double
+// (balance + open stake) and the seat's cache (bal + roundStake) so a direct
+// settle() call reproduces a post-deal position without replaying the deal.
+func staked(tr *kittest.Room, s *seat, bal, stake int) {
+	if tr.Credits == nil {
+		tr.Credits = map[string]int64{}
+	}
+	if tr.CreditsStakes == nil {
+		tr.CreditsStakes = map[string]int64{}
+	}
+	tr.Credits[s.p.AccountID] = int64(bal)
+	tr.CreditsStakes[s.p.AccountID] = int64(stake)
+	s.bal = bal
+	s.roundStake = int64(stake)
+}
+
+// pump advances the virtual clock by d in heartbeat-sized steps, waking on each,
+// so deadlines land exactly as they would under the host heartbeat.
+func pump(rm *room, tr *kittest.Room, d time.Duration) {
+	const beat = 50 * time.Millisecond
+	for elapsed := time.Duration(0); elapsed < d; elapsed += beat {
+		tr.Advance(beat)
+		rm.OnWake(tr)
+	}
+}
+
+func runeInput(r rune) kit.Input { return kit.Input{Kind: kit.InputRune, Rune: r} }
+
+func keyInput(k kit.Key) kit.Input { return kit.Input{Kind: kit.InputKey, Key: k} }
+
+func TestPairsSideBetLoopsOnP(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	if s.pairsBet != 0 {
+		t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet)
+	}
+	s.bal = 100000                  // deep enough to afford every tier, so the loop wraps only at the top
+	rm.OnInput(tr, a, runeInput('p')) // P advances one tier
+	if s.pairsBet != pairsTiers[1] {
+		t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1])
+	}
+	for i := 0; i < len(pairsTiers)-1; i++ { // loop the rest of the way round
+		rm.OnInput(tr, a, runeInput('p'))
+	}
+	if s.pairsBet != 0 {
+		t.Fatalf("after a full loop, pairsBet = %d, want reset to 0 at the end", s.pairsBet)
+	}
+	// B is the behind bet, not pairs — it must not touch your own pairs.
+	rm.OnInput(tr, a, runeInput('p')) // -> 10
+	rm.OnInput(tr, a, runeInput('b'))
+	if s.pairsBet != pairsTiers[1] {
+		t.Fatalf("B changed own pairs (B should be behind-only): %d", s.pairsBet)
+	}
+}
+
+func TestPairsSideBetClampedToChips(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.bet = 100
+	s.bal = 105 // can afford the 100 main bet + at most a 5-chip side bet, so only "off"
+	for i := 0; i < len(pairsTiers); i++ {
+		rm.OnInput(tr, a, runeInput('p'))
+	}
+	if s.bet+s.pairsBet > s.bal {
+		t.Fatalf("main %d + pairs %d exceeds chips %d (clamp failed)", s.bet, s.pairsBet, s.bal)
+	}
+}
+
+func TestBackFocusCyclesSeatsOnLeftRight(t *testing.T) {
+	a, b, c := mkPlayer("a"), mkPlayer("b"), mkPlayer("c")
+	rm, tr := newGame(t, a, b, c)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	rm.OnJoin(tr, c)
+	sa := rm.seats[a.AccountID]
+	if sa.focus != "" {
+		t.Fatalf("focus starts %q, want self (empty)", sa.focus)
+	}
+	rm.OnInput(tr, a, keyInput(kit.KeyRight))
+	if sa.focus != b.AccountID {
+		t.Fatalf("after Right, focus = %q, want b", sa.focus)
+	}
+	rm.OnInput(tr, a, keyInput(kit.KeyRight))
+	if sa.focus != c.AccountID {
+		t.Fatalf("after Right Right, focus = %q, want c", sa.focus)
+	}
+	rm.OnInput(tr, a, keyInput(kit.KeyRight))
+	if sa.focus != "" {
+		t.Fatalf("after cycling through all seats, focus = %q, want back to self", sa.focus)
+	}
+	// Left walks the other way: from self, wrap to the last seat.
+	rm.OnInput(tr, a, keyInput(kit.KeyLeft))
+	if sa.focus != c.AccountID {
+		t.Fatalf("after Left from self, focus = %q, want c (wrap backward)", sa.focus)
+	}
+}
+
+func TestBackFocusSoloIsNoOp(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	rm.OnInput(tr, a, keyInput(kit.KeyRight))
+	if rm.seats[a.AccountID].focus != "" {
+		t.Fatal("with no other seats, Left/Right must stay on self")
+	}
+}
+
+func TestBackBetAdjustsWhenFocused(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.bet = 25
+	sa.bal = 1000
+	rm.OnInput(tr, a, keyInput(kit.KeyRight)) // focus seat b
+	rm.OnInput(tr, a, runeInput('b'))         // behind loops 0 -> 10
+	rm.OnInput(tr, a, runeInput('p'))         // their-pairs loops 0 -> 10
+	bb := sa.backs[b.AccountID]
+	if bb == nil || bb.behind != 10 || bb.pairs != 10 {
+		t.Fatalf("back on b = %+v, want behind 10 / pairs 10", bb)
+	}
+	// The viewer's own bet must be untouched while editing a back.
+	if sa.bet != 25 || sa.pairsBet != 0 {
+		t.Fatalf("own bet changed while editing a back: bet=%d pairs=%d", sa.bet, sa.pairsBet)
+	}
+}
+
+func TestBackBetBudgetClamped(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.bet = 100
+	sa.bal = 105 // only 5 chips beyond the main bet — no back tier fits
+	rm.OnInput(tr, a, keyInput(kit.KeyRight))
+	for i := 0; i < len(pairsTiers); i++ {
+		rm.OnInput(tr, a, runeInput('b')) // try to raise the behind stake
+		rm.OnInput(tr, a, runeInput('p')) // and the their-pairs stake
+	}
+	committed := sa.bet + sa.pairsBet
+	if bb := sa.backs[b.AccountID]; bb != nil {
+		committed += bb.behind + bb.pairs
+	}
+	if committed > sa.bal {
+		t.Fatalf("total commitment %d exceeds chips %d (budget clamp failed)", committed, sa.bal)
+	}
+}
+
+func TestDealResolvesPerfectPairsSideBet(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.bet = 50
+	s.placed = true
+	s.pairsBet = 10
+	fund(tr, s, 1000)
+	// Stack the shoe: dealer up + hole, then the seat's two cards — a mixed pair.
+	rm.sh.cards = hand{
+		{10, suitClub}, {9, suitDiamond}, // dealer 19
+		{8, suitSpade}, {8, suitHeart}, // seat: mixed pair of 8s
+		{2, suitClub}, {3, suitClub}, {4, suitClub}, // filler draws
+	}
+	rm.sh.pos = 0
+	rm.sh.roundStart = 0
+	rm.deal(tr)
+
+	if s.pairsKind != "mixed" {
+		t.Fatalf("pairsKind = %q, want mixed", s.pairsKind)
+	}
+	if s.pairsWin != 70 { // mixed 6:1 on 10 -> 10 + 60
+		t.Fatalf("pairsWin = %d, want 70", s.pairsWin)
+	}
+	// The deal Wagers the main bet (50) + the pairs stake (10) onto the seat's
+	// open stake: bankroll 1000 -> 940, roundStake 60. The mixed pair's 70 gross
+	// folds into grossThisRound (paid only at the single settle), NOT the balance.
+	if s.bal != 940 {
+		t.Fatalf("bal = %d, want 940 (bet + pairs Wagered at deal)", s.bal)
+	}
+	if s.roundStake != 60 {
+		t.Fatalf("roundStake = %d, want 60 (main 50 + pairs 10)", s.roundStake)
+	}
+	if s.grossThisRound != 70 {
+		t.Fatalf("grossThisRound = %d, want 70 (mixed pair folded into the open stake)", s.grossThisRound)
+	}
+}
+
+func TestDealResolvesBackPairs(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
+	sa.bet, sa.placed = 25, true
+	sb.bet, sb.placed = 25, true
+	fund(tr, sa, 1000)
+	fund(tr, sb, 1000)
+	sa.backs = map[string]*backBet{b.AccountID: {pairs: 10}} // a backs b's pairs
+	// dealer up+hole, then seat a's two cards, then seat b's two cards (a mixed pair).
+	rm.sh.cards = hand{
+		{10, suitClub}, {9, suitDiamond}, // dealer
+		{2, suitSpade}, {7, suitHeart}, // seat a (no pair)
+		{8, suitSpade}, {8, suitHeart}, // seat b: mixed pair of 8s
+		{3, suitClub}, {4, suitClub}, {5, suitClub}, // filler
+	}
+	rm.sh.pos, rm.sh.roundStart = 0, 0
+	rm.deal(tr)
+
+	bb := sa.backs[b.AccountID]
+	if bb.pairsKind != "mixed" || bb.pairsWin != 70 {
+		t.Fatalf("back-pairs on b = kind %q win %d, want mixed/70", bb.pairsKind, bb.pairsWin)
+	}
+	// a Wagered main 25 + back-pairs 10 (bal 1000 -> 965, roundStake 35); the 70
+	// back-pairs gross folds into a's open stake, paid at settle not now.
+	if sa.bal != 965 {
+		t.Fatalf("a bal = %d, want 965 (main 25 + back-pairs 10 Wagered)", sa.bal)
+	}
+	if sa.grossThisRound != 70 {
+		t.Fatalf("a grossThisRound = %d, want 70 (back-pairs win folded in)", sa.grossThisRound)
+	}
+}
+
+func TestDealVoidsBackOnSatOutTarget(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
+	sa.bet, sa.placed = 25, true
+	fund(tr, sa, 1000)
+	sb.placed = false // b sits this round out
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}}
+	rm.sh.cards = hand{{10, suitClub}, {9, suitDiamond}, {2, suitSpade}, {7, suitHeart}, {3, suitClub}, {4, suitClub}}
+	rm.sh.pos, rm.sh.roundStart = 0, 0
+	rm.deal(tr)
+
+	if sa.bal != 975 { // only a's own 25 bet Wagered; the back on a sat-out seat is voided
+		t.Fatalf("a bal = %d, want 975 (back on sat-out target voided, not Wagered)", sa.bal)
+	}
+	if sa.roundStake != 25 {
+		t.Fatalf("a roundStake = %d, want 25 (only the main bet opened)", sa.roundStake)
+	}
+	if bb := sa.backs[b.AccountID]; bb != nil && (bb.behind != 0 || bb.pairs != 0) {
+		t.Fatalf("back on sat-out target not voided: %+v", bb)
+	}
+}
+
+func TestSettleBehindBetWinFolds(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
+	sa.placed, sa.bet = true, 25
+	staked(tr, sa, 925, 75) // a's open stake: main 25 + behind 50 escrowed
+	sa.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}}, bet: 25}} // a: 5, loses
+	sb.placed, sb.bet = true, 25
+	sb.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // b: 19, beats dealer
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}                    // a backs b's hand
+	rm.dealer = hand{{10, suitClub}, {7, suitDiamond}}                           // 17
+
+	rm.settle(tr)
+
+	bb := sa.backs[b.AccountID]
+	if bb.behindWin != 100 { // even money: 50 stake + 50
+		t.Fatalf("behindWin = %d, want 100 (behind paid even money on b's win)", bb.behindWin)
+	}
+	// a's own hand loses (gross 0); the behind grosses 100 on a 75 stake: net +25.
+	if sa.result != "WIN +25" {
+		t.Fatalf("a result = %q, want WIN +25 (behind win folded into net)", sa.result)
+	}
+}
+
+func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.placed, sa.bet = true, 25
+	staked(tr, sa, 900, 75) // main 25 + behind 50 escrowed; 900 bankroll left
+	sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
+	delete(rm.seats, b.AccountID) // b left mid-round
+	rm.order = []string{a.AccountID}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19
+
+	rm.settle(tr)
+
+	if bb := sa.backs[b.AccountID]; bb.behindWin != 50 {
+		t.Fatalf("behindWin = %d, want 50 (behind refunded when target left)", bb.behindWin)
+	}
+	// own hand pushes (gross 25), behind refunded (gross 50): Settle(75) on a 75
+	// stake tops the 900 bankroll back to 975.
+	if sa.bal != 975 {
+		t.Fatalf("a bal = %d, want 975 (push + behind refund settled once)", sa.bal)
+	}
+}
+
+func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 50
+	s.pairsBet = 10
+	staked(tr, s, 940, 60)   // main 50 + pairs 10 escrowed
+	s.pairsWin = 70          // a mixed pair already resolved at deal...
+	s.grossThisRound = 70    // ...and folded into the open stake's gross
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 -> hand pushes
+	rm.settle(tr)
+	// Hand pushes (gross 50) atop the pairs gross 70 = 120 on a 60 stake: net +60.
+	if s.result != "WIN +60" {
+		t.Fatalf("result = %q, want WIN +60 (pairs win folded into the round net)", s.result)
+	}
+}
+
+func TestJoinSeatsPlayer(t *testing.T) {
+	p := mkPlayer("alice")
+	rm, tr := newGame(t, p)
+	rm.OnJoin(tr, p)
+	s := rm.seats[p.AccountID]
+	// A fresh account seeds the credits double's default balance (1000); the seat
+	// caches it and seeds its peak from it.
+	if s == nil || s.bal != 1000 || s.highScore != 1000 || s.bet != betTiers[1] {
+		t.Fatalf("bad seat after join: %+v", s)
+	}
+}
+
+func TestBettingDeductsAndSitsOut(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	rm.seats[a.AccountID].bet = 50
+	rm.OnInput(tr, a, runeInput(' ')) // a places a bet; b does not
+
+	pump(rm, tr, bettingDur+time.Second) // betting closes -> deal
+
+	if len(rm.seats[a.AccountID].hands) == 0 {
+		t.Fatal("a placed a bet but was not dealt a hand")
+	}
+	if rm.seats[a.AccountID].bal != 1000-50 {
+		t.Errorf("a bal = %d, want %d (bet Wagered at deal)", rm.seats[a.AccountID].bal, 1000-50)
+	}
+	if len(rm.seats[b.AccountID].hands) != 0 {
+		t.Error("b placed no bet but was dealt in")
+	}
+}
+
+func TestEmptyBettingReopensWithoutDealing(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a) // nobody places a bet
+
+	pump(rm, tr, bettingDur+time.Second)
+
+	if rm.phase != phBetting {
+		t.Errorf("phase = %q, want betting (no bet should not deal)", rm.phase)
+	}
+	if len(rm.dealer) != 0 {
+		t.Error("no cards should have been dealt")
+	}
+}
+
+func TestNoWinnerLifecycle(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	rm.OnInput(tr, a, runeInput(' ')) // place a bet
+	pump(rm, tr, 150*time.Second)     // a full round via auto-stands, loop back to betting
+
+	if tr.Ended != nil {
+		t.Error("a no-winner table must never settle via End()")
+	}
+	rm.OnLeave(tr, a)
+	if tr.Ended != nil {
+		t.Error("leaving must not settle a ranked result")
+	}
+}
+
+// turnsSetup joins two players, both placed with the given hands, and puts the
+// table into the player-turns phase with the turn pointer at the first seat.
+func turnsSetup(t *testing.T, ah, bh hand) (*room, *kittest.Room, kit.Player, kit.Player) {
+	t.Helper()
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.what = pendNone // drop the pending betting one-shot; we drive turns directly
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	rm.seats[a.AccountID].placed = true
+	rm.seats[b.AccountID].placed = true
+	rm.seats[a.AccountID].hands = []*phand{{cards: ah, bet: 50}}
+	rm.seats[b.AccountID].hands = []*phand{{cards: bh, bet: 50}}
+	rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
+	rm.phase = phTurns
+	return rm, tr, a, b
+}
+
+func TestPublishesContextPerPhase(t *testing.T) {
+	// Betting is a navigation screen.
+	_, tr := newGame(t, mkPlayer("a"))
+	if tr.InputCtx != kit.CtxNav {
+		t.Errorf("betting InputCtx = %v, want CtxNav", tr.InputCtx)
+	}
+	// Player turns bind h/s/d/p/r as domain commands.
+	rm, tr2, _, _ := turnsSetup(t, hand{{10, suitSpade}, {5, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	rm.beginTurn(tr2)
+	if tr2.InputCtx != kit.CtxCommand {
+		t.Errorf("turns InputCtx = %v, want CtxCommand", tr2.InputCtx)
+	}
+}
+
+func TestOnlyActiveSeatActs(t *testing.T) {
+	rm, tr, _, b := turnsSetup(t, hand{{10, suitSpade}, {5, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	rm.OnInput(tr, b, runeInput('s')) // b is not on turn
+	if rm.seats[b.AccountID].hands[0].resolved {
+		t.Error("a non-active seat must not be able to act")
+	}
+}
+
+func TestDoubleDrawsOneCardAndResolves(t *testing.T) {
+	rm, tr, a, _ := turnsSetup(t, hand{{5, suitSpade}, {6, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	fund(tr, rm.seats[a.AccountID], 950)
+	rm.OnInput(tr, a, runeInput('d'))
+
+	h := rm.seats[a.AccountID].hands[0]
+	if rm.seats[a.AccountID].bal != 900 {
+		t.Errorf("bal = %d, want 900 (double Wagered the second bet)", rm.seats[a.AccountID].bal)
+	}
+	if h.bet != 100 || len(h.cards) != 3 || !h.resolved {
+		t.Errorf("after double: bet=%d cards=%d resolved=%v, want 100/3/true", h.bet, len(h.cards), h.resolved)
+	}
+}
+
+func TestSplitFormsTwoHands(t *testing.T) {
+	rm, tr, a, _ := turnsSetup(t, hand{{8, suitSpade}, {8, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	fund(tr, rm.seats[a.AccountID], 950)
+	rm.OnInput(tr, a, runeInput('p'))
+
+	s := rm.seats[a.AccountID]
+	if len(s.hands) != 2 {
+		t.Fatalf("after split: %d hands, want 2", len(s.hands))
+	}
+	if s.bal != 900 {
+		t.Errorf("bal = %d, want 900 (split Wagered the second bet)", s.bal)
+	}
+	for i, h := range s.hands {
+		if len(h.cards) != 2 {
+			t.Errorf("split hand %d has %d cards, want 2", i, len(h.cards))
+		}
+	}
+}
+
+func TestSplitAcesTakeOneCardEach(t *testing.T) {
+	rm, tr, a, _ := turnsSetup(t, hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	fund(tr, rm.seats[a.AccountID], 950)
+	rm.OnInput(tr, a, runeInput('p'))
+
+	s := rm.seats[a.AccountID]
+	if len(s.hands) != 2 {
+		t.Fatalf("split aces: %d hands, want 2", len(s.hands))
+	}
+	for i, h := range s.hands {
+		if !h.resolved || len(h.cards) != 2 {
+			t.Errorf("split-ace hand %d: resolved=%v cards=%d, want resolved/2", i, h.resolved, len(h.cards))
+		}
+	}
+}
+
+func TestTurnTimeoutAutoStands(t *testing.T) {
+	rm, tr, a, _ := turnsSetup(t, hand{{10, suitSpade}, {5, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
+	rm.beginTurn(tr) // arm the per-turn deadline
+
+	pump(rm, tr, turnDur+time.Second)
+
+	if !rm.seats[a.AccountID].hands[0].resolved {
+		t.Error("a timed-out turn should auto-stand the active hand")
+	}
+}
+
+func TestBustRebuysAndKeepsHighScore(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	staked(tr, s, 0, 50) // bet 50 already escrowed; broke bankroll; this hand loses
+	s.highScore = 2500
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {10, suitHeart}, {5, suitClub}}, bet: 50}} // 25, bust
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
+
+	rm.settle(tr)
+
+	// The losing stake Settles for 0, leaving the seat broke, so the platform
+	// broke-relief Buyback tops the balance up to the double's rebuy amount (1000).
+	if s.bal != 1000 {
+		t.Errorf("bal = %d, want re-buy to 1000", s.bal)
+	}
+	if s.highScore != 2500 {
+		t.Errorf("highScore = %d, want 2500 (a bust must not lower it)", s.highScore)
+	}
+}
+
+func TestRebuyWhenBelowMinimumBet(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	staked(tr, s, 5, 5) // 5 left, above zero but under the 10-credit minimum bet
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {8, suitHeart}}, bet: 5}}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19 beats 18
+
+	rm.settle(tr)
+
+	// After the loss settles, 5 credits is under the minimum bet, so Buyback tops
+	// the balance up to the double's rebuy amount (1000) rather than soft-locking.
+	if s.bal != 1000 {
+		t.Errorf("bal = %d, want re-buy to 1000 (a stack under the minimum bet must re-buy)", s.bal)
+	}
+}
+
+func TestBehindBetsAreStickyAcrossRounds(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.bal = 1000
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}}
+
+	rm.enterBetting(tr) // a fresh betting window must carry a's back on b
+
+	back := sa.backs[b.AccountID]
+	if back == nil {
+		t.Fatalf("back on b was dropped; behind bets should be sticky")
+	}
+	if back.behind != 50 || back.pairs != 10 {
+		t.Fatalf("carried back = behind %d pairs %d, want behind 50 pairs 10", back.behind, back.pairs)
+	}
+}
+
+func TestStickyBackPrunedWhenTargetLeaves(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.bal = 1000
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
+
+	rm.OnLeave(tr, b)   // b leaves the table
+	rm.enterBetting(tr) // a's carried back on the now-absent b must be pruned
+
+	if _, ok := sa.backs[b.AccountID]; ok {
+		t.Fatalf("a back on a departed target should be pruned, not carried")
+	}
+}
+
+func TestDoubledHandTagged(t *testing.T) {
+	doubled := &phand{cards: hand{{5, suitSpade}, {6, suitHeart}, {10, suitClub}}, bet: 100, doubled: true}
+	if got := dblTag(doubled); got != " DBL" {
+		t.Errorf("dblTag(doubled) = %q, want %q", got, " DBL")
+	}
+	plain := &phand{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}
+	if got := dblTag(plain); got != "" {
+		t.Errorf("dblTag(plain) = %q, want empty", got)
+	}
+}
+
+func TestBlackjackPays3to2(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	staked(tr, s, 950, 100) // 100 escrowed at deal, 950 bankroll left
+	s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 100}}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19, no blackjack
+
+	rm.settle(tr)
+
+	// 3:2 blackjack grosses stake (100) + 150 = 250; Settle(250) tops 950 -> 1200.
+	if s.bal != 1200 {
+		t.Errorf("bal = %d, want 1200 (blackjack pays 3:2)", s.bal)
+	}
+}
+
+func TestInsurancePaysOnDealerBlackjack(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 50
+	fund(tr, s, 950)
+	rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}} // dealer blackjack
+	rm.dealerHole = true
+
+	rm.takeInsurance(s, true) // insurance = 25 Wagered onto the open stake -> bal 925
+	if s.insurance != 25 || s.bal != 925 {
+		t.Fatalf("after taking insurance: ins=%d bal=%d, want 25/925", s.insurance, s.bal)
+	}
+
+	rm.resolveInsurance(tr)     // 2:1 -> +75 folds into gross, dealer BJ defers settle
+	pump(rm, tr, 5*time.Second) // let the reveal + settle play out
+
+	// The insurance 2:1 (75 gross) settles the 25 stake: 925 -> 1000.
+	if s.bal != 1000 {
+		t.Errorf("bal = %d, want 1000 (insurance paid 2:1)", s.bal)
+	}
+	if rm.dealerHole {
+		t.Error("dealer hole card should be revealed after a dealer blackjack")
+	}
+}
+
+// TestDealingOrderIsDeterministic asserts the deal ranges the join-ordered slice,
+// never Go's map iteration order: two identically-seeded rooms deal identical
+// hands to the same seats.
+func TestDealingOrderIsDeterministic(t *testing.T) {
+	deal := func() (hand, hand, hand) {
+		a, b := mkPlayer("a"), mkPlayer("b")
+		rm, tr := newGame(t, a, b)
+		rm.OnJoin(tr, a)
+		rm.OnJoin(tr, b)
+		rm.OnInput(tr, a, runeInput(' '))
+		rm.OnInput(tr, b, runeInput(' '))
+		pump(rm, tr, bettingDur+gracePeriod+time.Second)
+		return rm.seats[a.AccountID].hands[0].cards, rm.seats[b.AccountID].hands[0].cards, rm.dealer
+	}
+	a1, b1, d1 := deal()
+	a2, b2, d2 := deal()
+	eq := func(x, y hand) bool {
+		if len(x) != len(y) {
+			return false
+		}
+		for i := range x {
+			if x[i] != y[i] {
+				return false
+			}
+		}
+		return true
+	}
+	if !eq(a1, a2) || !eq(b1, b2) || !eq(d1, d2) {
+		t.Fatalf("same-seed rooms dealt differently:\n a:%v/%v b:%v/%v d:%v/%v", a1, a2, b1, b2, d1, d2)
+	}
+}
+
+// TestBalanceSeedsAndPostsPeak covers the account-wide credits path: a fresh
+// seat caches the default balance, and a winning settle raises the peak
+// (sourced from the post-Settle balance) and Posts it to the leaderboard.
+func TestBalanceSeedsAndPostsPeak(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+
+	// A fresh seat seeds the credits double's default balance into the cache.
+	s := rm.seats[a.AccountID]
+	if s.bal != 1000 || s.highScore != 1000 {
+		t.Fatalf("seeded seat: bal=%d high=%d, want 1000/1000", s.bal, s.highScore)
+	}
+
+	// A winning settle raises the peak from the fresh balance and posts it.
+	s.placed = true
+	staked(tr, s, 1900, 100) // 100 escrowed, hand will gross 200
+	s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 100}}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19, player 20 wins
+	rm.settle(tr)
+
+	if s.bal != 2100 || s.highScore != 2100 {
+		t.Fatalf("after win: bal=%d high=%d, want 2100/2100", s.bal, s.highScore)
+	}
+	if got := tr.Credits[a.AccountID]; got != 2100 {
+		t.Errorf("settled account balance = %d, want 2100", got)
+	}
+	if len(tr.Posted) == 0 {
+		t.Fatal("a new peak should Post to the leaderboard")
+	}
+	last := tr.Posted[len(tr.Posted)-1]
+	if len(last.Rankings) != 1 || last.Rankings[0].Metric != 2100 {
+		t.Errorf("posted ranking = %+v, want metric 2100", last.Rankings)
+	}
+}
+
+// TestTopPrizeDoesNotClamp asserts the declared MaxPayoutMultiplier (26) covers
+// the game's largest single-stake outcome: a Perfect Pairs "perfect" pair pays
+// 25:1, grossing stake×(25+1) = stake×26 — exactly the ceiling, so it settles in
+// full and is never clamped.
+func TestTopPrizeDoesNotClamp(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	// A lone 100 Perfect Pairs stake hitting a "perfect" pair: gross 2600.
+	staked(tr, s, 0, 100)
+	s.grossThisRound = 100 * (25 + 1) // 2600, the top pairs payout
+	net := rm.settleOpenStake(s)
+	if net != 2500 {
+		t.Fatalf("net = %d, want 2500 (top pairs prize settles unclamped)", net)
+	}
+	if got := tr.Credits[a.AccountID]; got != 2600 {
+		t.Fatalf("settled balance = %d, want 2600 (full 26x top prize paid)", got)
+	}
+}
+
+// colIndex returns the COLUMN (rune) index of sub in row, or -1 — unlike
+// strings.Index, whose byte offsets drift once a character glyph (e.g. λ)
+// occupies a cell to the left.
+func colIndex(row, sub string) int {
+	rs, ss := []rune(row), []rune(sub)
+	for i := 0; i+len(ss) <= len(rs); i++ {
+		match := true
+		for j := range ss {
+			if rs[i+j] != ss[j] {
+				match = false
+				break
+			}
+		}
+		if match {
+			return i
+		}
+	}
+	return -1
+}
+
+// TestSeatRendersCharacterTile asserts the arcade character tile (kit v2.9.0)
+// lands on the seat rail immediately before the player's name — one styled
+// cell plus one space — on the frames BOTH players receive.
+func TestSeatRendersCharacterTile(t *testing.T) {
+	a, b := mkPlayer("alice"), mkPlayer("bob")
+	a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'}
+	b.Character = kit.Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+
+	for _, viewer := range []kit.Player{a, b} {
+		f := tr.LastFrame(viewer)
+		if f == nil {
+			t.Fatalf("no frame for %s", viewer.Handle)
+		}
+		row := kittest.String(f, seatNameRow)
+		for _, p := range []kit.Player{a, b} {
+			idx := colIndex(row, p.Handle)
+			if idx < 2 {
+				t.Fatalf("%s's seat name not on row %d: %q", p.Handle, seatNameRow, row)
+			}
+			want := kit.CharacterCell(p.Character)
+			got := f.Cells[seatNameRow][idx-2]
+			if got != want {
+				t.Errorf("viewer %s: cell before %q = %+v, want character tile %+v", viewer.Handle, p.Handle, got, want)
+			}
+			if sp := f.Cells[seatNameRow][idx-1].Rune; sp != ' ' && sp != 0 {
+				t.Errorf("viewer %s: no space between %q's tile and name (got %q)", viewer.Handle, p.Handle, sp)
+			}
+		}
+	}
+}
+
+// TestWaitLineRendersCharacterTile asserts the turn-wait line carries the
+// active player's character tile right before their name.
+func TestWaitLineRendersCharacterTile(t *testing.T) {
+	a, b := mkPlayer("alice"), mkPlayer("bob")
+	a.Character = kit.Character{Glyph: "λ", InkR: 9, Fallback: 'L'}
+	rm, tr, _, _ := func() (*room, *kittest.Room, kit.Player, kit.Player) {
+		rm, tr := newGame(t, a, b)
+		rm.what = pendNone
+		rm.OnJoin(tr, a)
+		rm.OnJoin(tr, b)
+		rm.seats[a.AccountID].placed = true
+		rm.seats[b.AccountID].placed = true
+		rm.seats[a.AccountID].hands = []*phand{{cards: hand{{10, suitSpade}, {5, suitHeart}}, bet: 50}}
+		rm.seats[b.AccountID].hands = []*phand{{cards: hand{{10, suitClub}, {6, suitDiamond}}, bet: 50}}
+		rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
+		rm.phase = phTurns
+		return rm, tr, a, b
+	}()
+	rm.render(tr) // a is first unresolved: b's frame shows "waiting on alice..."
+
+	f := tr.LastFrame(b)
+	row := kittest.String(f, actionRow)
+	idx := colIndex(row, "waiting on")
+	if idx < 0 {
+		t.Fatalf("no wait line on row %d: %q", actionRow, row)
+	}
+	nameIdx := colIndex(row, a.Handle)
+	if nameIdx < 0 {
+		t.Fatalf("active player's name missing from wait line: %q", row)
+	}
+	if got, want := f.Cells[actionRow][nameIdx-2], kit.CharacterCell(a.Character); got != want {
+		t.Errorf("cell before active name = %+v, want character tile %+v", got, want)
+	}
+}
+
+// TestHibernationStableDealReplays asserts the deal is reconstructable from guest
+// memory + the room clock: a deal recorded, then re-composed after the schedule
+// settles, draws every card settled (no RNG re-consult).
+func TestHibernationStableDealReplays(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	rm.OnInput(tr, a, runeInput(' '))
+	pump(rm, tr, bettingDur+gracePeriod+time.Second)
+	dealt := append(hand(nil), rm.seats[a.AccountID].hands[0].cards...)
+
+	// Let any animation schedule fully settle, then the dealt cards are fixed.
+	pump(rm, tr, 5*time.Second)
+	after := rm.seats[a.AccountID].hands[0].cards
+	if len(after) != len(dealt) {
+		t.Fatalf("card count changed across waking: %d -> %d", len(dealt), len(after))
+	}
+	for i := range dealt {
+		if after[i] != dealt[i] {
+			t.Fatalf("card %d changed across waking: %v -> %v", i, dealt[i], after[i])
+		}
+	}
+}
+
+// TestDoubledHandRendersDBL confirms a doubled hand's value line carries the DBL
+// flag on the frame, so onlookers can see who doubled down.
+func TestDoubledHandRendersDBL(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.hands = []*phand{{cards: hand{{5, suitSpade}, {6, suitHeart}, {10, suitClub}}, bet: 200, doubled: true, resolved: true}} // 21 after doubling
+	rm.phase = phTurns
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
+
+	rm.render(tr)
+
+	if row := kittest.String(tr.LastFrame(a), seatValRow); !strings.Contains(row, "DBL") {
+		t.Fatalf("doubled hand value row = %q, want it to contain DBL", row)
+	}
+}
+
+// dealerReady puts the table at the moment the last player has resolved, with a
+// live (non-bust) player so the dealer will reveal and draw. The dealer's hand
+// and the shoe's next draws are caller-supplied so the reveal is deterministic.
+func dealerReady(t *testing.T, dealer hand, nextDraws ...card) (*room, *kittest.Room, kit.Player) {
+	t.Helper()
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone // drop the betting one-shot; we drive the dealer directly
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50, resolved: true}} // 19, live
+	rm.phase = phTurns
+	rm.dealer = dealer
+	rm.dealerHole = true
+	if len(nextDraws) > 0 {
+		rm.sh.cards = append(hand(nil), nextDraws...) // stack the shoe's top
+		rm.sh.pos = 0
+		rm.sh.roundStart = 0
+	}
+	return rm, tr, a
+}
+
+// TestDealerBustWaitsForTheCardToLand is the heart of the reveal-UX fix: the
+// dealer's BUST verdict must not appear until the busting card has animated in.
+// Before the fix the label keyed off the authoritative (already complete) hand,
+// so BUST flashed up the instant the dealer's turn began.
+func TestDealerBustWaitsForTheCardToLand(t *testing.T) {
+	// Dealer 16 hits a ten -> 26, a deterministic bust on the first draw.
+	rm, tr, a := dealerReady(t, hand{{10, suitClub}, {6, suitDiamond}}, card{10, suitClub})
+
+	rm.enterDealer(tr) // schedules the slow reveal + the busting hit
+	rm.render(tr)
+
+	if !rm.dealingActive() {
+		t.Fatal("dealer reveal should still be animating right after enterDealer")
+	}
+	if row := kittest.String(tr.LastFrame(a), dealerValRow); strings.Contains(row, "BUST") {
+		t.Fatalf("dealer BUST shown before the hit landed: %q", row)
+	}
+
+	// Once the whole reveal has played out the bust shows and the round settles
+	// (stay inside the results window, before the next betting round clears it).
+	pump(rm, tr, 5*time.Second)
+	if rm.phase != phResults {
+		t.Fatalf("phase = %q, want results once the reveal finished", rm.phase)
+	}
+	if row := kittest.String(tr.LastFrame(a), dealerValRow); !strings.Contains(row, "BUST") {
+		t.Fatalf("dealer BUST not shown after the hit landed: %q", row)
+	}
+}
+
+// TestDealerRevealIsPaced asserts the reveal is unhurried: settlement is deferred
+// well past the bare hole-card flip even when the dealer stands pat, so the
+// turned hole card and its total have time to read.
+func TestDealerRevealIsPaced(t *testing.T) {
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {8, suitDiamond}}) // 18, no hit
+
+	start := tr.Now()
+	rm.enterDealer(tr)
+
+	if rm.what != pendSettle {
+		t.Fatalf("dealer reveal should defer settlement, what = %v", rm.what)
+	}
+	if delay := rm.pendAt.Sub(start); delay < holeRevealHold {
+		t.Fatalf("settle deferred only %v, want at least the hole-reveal hold %v", delay, holeRevealHold)
+	}
+}
+
+// TestDealerTotalTicksUpAsCardsLand checks the displayed total reflects only the
+// face-up cards: just the up card while the hole is concealed, then the hole, in
+// step with the animation rather than the full hand up front.
+func TestDealerTotalTicksUpAsCardsLand(t *testing.T) {
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {7, suitDiamond}}) // 17, stands
+
+	// Mid-turn the hole is still hidden: only the up card counts.
+	if got := rm.dealerShownCount(); got != 1 {
+		t.Fatalf("with the hole concealed, shown count = %d, want 1", got)
+	}
+
+	rm.enterDealer(tr) // turns the hole; the reveal flip is still in flight
+	rm.render(tr)
+	if got := rm.dealerShownCount(); got != 1 {
+		t.Fatalf("mid hole-flip, shown count = %d, want 1 (hole not yet face up)", got)
+	}
+
+	// After the reveal settles (still within the results window) both cards
+	// count and the total is complete.
+	pump(rm, tr, 3*time.Second)
+	if rm.phase != phResults {
+		t.Fatalf("phase = %q, want results once the reveal finished", rm.phase)
+	}
+	if got := rm.dealerShownCount(); got != 2 {
+		t.Fatalf("after the reveal, shown count = %d, want 2 (both cards face up)", got)
+	}
+	if got := rm.dealer.total(); got != 17 {
+		t.Fatalf("dealer total = %d, want 17", got)
+	}
+}
+
+// TestDealerHoleRevealIsDelayedAndAnimated asserts the second (hole) card turns
+// over after a short lead-in beat rather than the instant the dealer's turn
+// begins, and that it is an in-place flip animation (a flip scheduled, no slide)
+// so it visibly turns rather than snapping face up.
+func TestDealerHoleRevealIsDelayedAndAnimated(t *testing.T) {
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {8, suitDiamond}}) // 18, stands pat
+
+	start := tr.Now()
+	rm.enterDealer(tr)
+
+	if len(rm.sched) == 0 {
+		t.Fatal("no dealer reveal animation scheduled")
+	}
+	hole := rm.sched[0]
+	if hole.kind != animDealer || hole.cardIdx != 1 {
+		t.Fatalf("first scheduled anim is not the hole card: %+v", hole)
+	}
+	if lead := hole.flipStart.Sub(start); lead < holeRevealDelay {
+		t.Fatalf("hole flip starts %v after the turn, want at least the lead-in %v", lead, holeRevealDelay)
+	}
+	if hole.flipStart.IsZero() {
+		t.Fatal("hole reveal has no flip scheduled - it would snap, not animate")
+	}
+	if !hole.slideStart.IsZero() {
+		t.Fatalf("hole reveal should turn in place, not slide: %+v", hole)
+	}
+
+	// Through the lead-in beat the hole is still concealed: only the up card counts.
+	pump(rm, tr, holeRevealDelay/2)
+	if got := rm.dealerShownCount(); got != 1 {
+		t.Fatalf("during the lead-in, shown count = %d, want 1 (hole not yet turned)", got)
+	}
+}
+
+// TestDealerHitSlotHiddenUntilHoleShown asserts the dealer's row stays two cards
+// wide — no slot reserved for the upcoming hit — until the hole card has turned
+// over and that hit actually begins to slide in.
+func TestDealerHitSlotHiddenUntilHoleShown(t *testing.T) {
+	// Dealer 16 will draw a ten; with a live player the reveal schedules the hit.
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {6, suitDiamond}}, card{10, suitClub})
+	rm.enterDealer(tr)
+
+	// Before the hit begins arriving (through the lead-in, flip, and read beat),
+	// only the two dealt cards occupy the row even though the full hand is known.
+	if got := len(rm.dealer); got != 3 {
+		t.Fatalf("dealer hand size = %d, want 3 (hit already computed)", got)
+	}
+	rm.render(tr)
+	if got := rm.dealerLayoutCount(); got != 2 {
+		t.Fatalf("layout count = %d right after the turn, want 2 (no slot for the pending hit)", got)
+	}
+
+	// Past the reveal + read beat the hit is sliding in, so the row now includes it.
+	pump(rm, tr, holeRevealDelay+flipDur+holeRevealHold+slideDur/2)
+	if got := rm.dealerLayoutCount(); got != 3 {
+		t.Fatalf("layout count = %d once the hit is arriving, want 3", got)
+	}
+}
+
+// TestInsuranceSkipsTimerOnceAllDecided covers the insurance early-resolve: once
+// every placed seat has answered, the window resolves without waiting out the
+// timer.
+func TestInsuranceSkipsTimerOnceAllDecided(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	for _, p := range []kit.Player{a, b} {
+		s := rm.seats[p.AccountID]
+		s.placed = true
+		s.bet = 50
+		s.bal = 950
+		s.hands = []*phand{{cards: hand{{10, suitSpade}, {7, suitHeart}}, bet: 50}}
+	}
+	rm.dealer = hand{{rankAce, suitSpade}, {6, suitHeart}} // shows an Ace, no blackjack
+	rm.dealerHole = true
+	rm.enterInsurance(tr)
+
+	rm.OnInput(tr, a, runeInput('n')) // a answers
+	if rm.phase != phInsurance {
+		t.Fatalf("phase = %q, want still insurance (b has not answered)", rm.phase)
+	}
+	rm.OnInput(tr, b, runeInput('y')) // b answers -> every placed seat decided
+	if rm.phase != phTurns {
+		t.Fatalf("phase = %q, want turns (insurance resolved early without the timer)", rm.phase)
+	}
+}
+
+// TestResultLabelDoesNotLeakChips guards the results chip line: the settlement
+// summary is drawn instead of the stack, so a result narrower than the stack
+// (e.g. "PUSH" over "$1000") leaves no stray digit peeking out beside it.
+func TestResultLabelDoesNotLeakChips(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bal = 1000 // "$1000" is wider than "PUSH"
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}}
+	s.result = "PUSH"
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
+	rm.phase = phResults
+	rm.render(tr)
+
+	row := []rune(kittest.String(tr.LastFrame(a), seatChipRow))
+	slot := (kit.Cols - slotW) / 2 // single seat: the group is one centred slot
+	if got := strings.TrimSpace(string(row[slot : slot+slotW])); got != "PUSH" {
+		t.Fatalf("chip-row slot = %q, want exactly %q (no chips bleeding through)", got, "PUSH")
+	}
+}
+
+// TestReadyUpSkipsTheResultsWait covers the results-phase ready-up: a single
+// seated player confirming (Enter/Space) starts the next betting round at once
+// rather than waiting out the full results flash.
+func TestReadyUpSkipsTheResultsWait(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	staked(tr, s, 950, 50)
+	s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 50}} // 20
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                                        // 19, player wins
+	rm.settle(tr)
+
+	if rm.phase != phResults {
+		t.Fatalf("phase = %q, want results after settle", rm.phase)
+	}
+
+	// Confirm readies up; the only seated player being ready deals the next hand.
+	rm.OnInput(tr, a, runeInput(' '))
+	if rm.phase != phBetting {
+		t.Fatalf("phase = %q, want betting (an all-ready table skips the wait)", rm.phase)
+	}
+}
+
+// cellCol returns the column of the first cell on row matching want, or -1.
+func cellCol(f *kit.Frame, row int, want kit.Cell) int {
+	for c := range f.Cells[row] {
+		if f.Cells[row][c] == want {
+			return c
+		}
+	}
+	return -1
+}
+
+// TestBackersLineShowsBackerTile asserts that a seat being backed shows, on its
+// dedicated backers line, the backing player's character tile and stake — so you
+// can see who is backing whom.
+func TestBackersLineShowsBackerTile(t *testing.T) {
+	a, b := mkPlayer("alice"), mkPlayer("bob")
+	a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'}
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.placed = true
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 25}}
+	rm.render(tr)
+	f := tr.LastFrame(b)
+
+	if cellCol(f, seatBackRow, kit.CharacterCell(a.Character)) < 0 {
+		t.Fatalf("backer alice's character tile not on the backers line (row %d): %q",
+			seatBackRow, kittest.String(f, seatBackRow))
+	}
+	if row := kittest.String(f, seatBackRow); !strings.Contains(row, "25") {
+		t.Fatalf("backers line does not show the behind stake: %q", row)
+	}
+}
+
+// TestFocusedTargetShowsBackDetail asserts that while a viewer is focused on a
+// seat they are backing, the action bar spells out their behind/their-pairs
+// stakes on that seat.
+func TestFocusedTargetShowsBackDetail(t *testing.T) {
+	a, b := mkPlayer("alice"), mkPlayer("bob")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa := rm.seats[a.AccountID]
+	sa.focus = b.AccountID
+	sa.backs = map[string]*backBet{b.AccountID: {behind: 25, pairs: 10}}
+	rm.render(tr)
+
+	row := kittest.String(tr.LastFrame(a), actionRow)
+	if !strings.Contains(row, "BACKING") || !strings.Contains(row, "bob") {
+		t.Fatalf("focused action bar does not name the backed seat: %q", row)
+	}
+	if !strings.Contains(row, "25") || !strings.Contains(row, "10") {
+		t.Fatalf("focused action bar does not show behind/their-pairs stakes: %q", row)
+	}
+}
+
+// TestBettingShowsPairsSideBet asserts a seat's selected Perfect Pairs side
+// stake is shown during betting directly beneath that seat's main bet — so the
+// two lines form one contiguous per-seat block and it's unambiguous whose side
+// bet is whose at a multi-seat table.
+func TestBettingShowsPairsSideBet(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.bet = 50
+	s.pairsBet = 25
+	rm.render(tr)
+	f := tr.LastFrame(a)
+
+	betRow := kittest.String(f, seatCardRow+1)  // where the "bet N" status sits
+	pairRow := kittest.String(f, seatCardRow+2) // pairs must sit immediately below it
+	if !strings.Contains(betRow, "bet 50") {
+		t.Fatalf("expected the bet status on row %d: %q", seatCardRow+1, betRow)
+	}
+	if !strings.Contains(pairRow, "+pairs 25") {
+		t.Fatalf("expected the pairs side bet directly below the bet on row %d: %q", seatCardRow+2, pairRow)
+	}
+	// The two lines must align under the same seat slot.
+	if colIndex(betRow, "bet 50") < 0 || colIndex(pairRow, "+pairs 25") < 0 {
+		t.Fatalf("bet and pairs lines not aligned in the seat slot:\n%q\n%q", betRow, pairRow)
+	}
+}
+
+// TestPairsLineCarriesCharacterTile asserts the Perfect Pairs side-bet line is
+// prefixed with the placing player's arcade character tile, so whose side bet is
+// whose reads from the face beside it, not just the column.
+func TestPairsLineCarriesCharacterTile(t *testing.T) {
+	a := mkPlayer("alice")
+	a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'}
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	rm.seats[a.AccountID].pairsBet = 25
+	rm.render(tr)
+	f := tr.LastFrame(a)
+
+	row := kittest.String(f, seatCardRow+2)
+	idx := colIndex(row, "+pairs 25")
+	if idx < 2 {
+		t.Fatalf("pairs line not found (or no room for a tile) on row %d: %q", seatCardRow+2, row)
+	}
+	if got, want := f.Cells[seatCardRow+2][idx-2], kit.CharacterCell(a.Character); got != want {
+		t.Errorf("cell before the pairs bet = %+v, want the character tile %+v", got, want)
+	}
+	if sp := f.Cells[seatCardRow+2][idx-1].Rune; sp != ' ' && sp != 0 {
+		t.Errorf("no space between the character tile and the pairs bet (got %q)", sp)
+	}
+}
+
+// TestResultsShowsPerfectPairsWin asserts a winning Perfect Pairs side bet is
+// surfaced on the seat with its category and multiplier during results.
+func TestResultsShowsPerfectPairsWin(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 50
+	staked(tr, s, 1000, 75) // main 50 + pairs 25 escrowed
+	s.pairsBet = 25
+	s.pairsKind = "colored"
+	s.pairsWin = 325
+	s.grossThisRound = 325 // the colored pair already folded into the open stake
+	s.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitDiamond}}, bet: 50}}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
+	rm.settle(tr) // -> results phase
+	rm.render(tr)
+
+	row := kittest.String(tr.LastFrame(a), seatPairRow)
+	if !strings.Contains(row, "COLORED 12:1") {
+		t.Fatalf("results row %d does not show the pairs win: %q", seatPairRow, row)
+	}
+}
+
+// TestRulesTaglineFlanksTheDealer asserts the rules signage moved out of the
+// mid-felt row up to the dealer's label row, split into a left and a right
+// label, leaving the old mid-felt tagline row clear.
+func TestRulesTaglineFlanksTheDealer(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	rm.render(tr)
+	f := tr.LastFrame(a)
+
+	dealerLabelRow := kittest.String(f, dealerRow-1)
+	if !strings.Contains(dealerLabelRow, "blackjack pays 3:2") {
+		t.Errorf("payout rule not on the dealer label row: %q", dealerLabelRow)
+	}
+	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {
+		t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow)
+	}
+	if !strings.Contains(dealerLabelRow, "D E A L E R") {
+		t.Errorf("DEALER label should remain centred between the rules: %q", dealerLabelRow)
+	}
+	// The old mid-felt tagline (row 9) must be clear now.
+	if mid := kittest.String(f, 9); strings.Contains(mid, "blackjack pays") {
+		t.Errorf("rules tagline still sits mid-felt on row 9: %q", mid)
+	}
+}
+
+// TestSplitSeatShowsEveryHandsCards is the regression guard for the reported
+// bug: a seat split into two hands must render BOTH hands' cards (each on its
+// own compact line), not collapse the second hand to a bare "+". The active
+// hand is marked so the player can see which one they are acting on.
+func TestSplitSeatShowsEveryHandsCards(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bal = 800
+	s.hands = []*phand{
+		{cards: hand{{8, suitSpade}, {3, suitHeart}}, bet: 50, fromSplit: true},
+		{cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50, fromSplit: true},
+	}
+	rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
+	rm.phase = phTurns
+	rm.render(tr)
+
+	f := tr.LastFrame(a)
+	// Gather the seat's content rows into one blob.
+	var blob string
+	for _, row := range []int{seatCardRow, seatCardRow + 1, seatCardRow + 2, seatCardRow + 3} {
+		blob += kittest.String(f, row) + "\n"
+	}
+	// Both hands' cards must be present — second hand included.
+	for _, tok := range []string{"8♠", "3♥", "8♣", "T♦"} {
+		if !strings.Contains(blob, tok) {
+			t.Fatalf("split seat is missing card %q from its hands:\n%s", tok, blob)
+		}
+	}
+	if strings.Contains(blob, "+") {
+		t.Fatalf("split seat collapsed a hand to \"+\" instead of showing its cards:\n%s", blob)
+	}
+}
+
+// TestReadyUpWaitsOnOtherPlayers asserts one player readying up does not skip
+// the wait while another seated player is still not ready.
+func TestReadyUpWaitsOnOtherPlayers(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	for _, p := range []kit.Player{a, b} {
+		s := rm.seats[p.AccountID]
+		s.placed = true
+		staked(tr, s, 950, 50)
+		s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 50}}
+	}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
+	rm.settle(tr)
+
+	rm.OnInput(tr, a, runeInput(' ')) // only a readies up
+	if !rm.seats[a.AccountID].ready {
+		t.Fatal("a should be marked ready after confirming")
+	}
+	if rm.phase != phResults {
+		t.Fatalf("phase = %q, want results still (b has not readied)", rm.phase)
+	}
+
+	rm.OnInput(tr, b, runeInput(' ')) // now both ready -> next hand
+	if rm.phase != phBetting {
+		t.Fatalf("phase = %q, want betting once everyone is ready", rm.phase)
+	}
+}
+
+// TestValueLabelSplit21NotBlackjack asserts a two-card 21 formed by splitting
+// reads as a plain "21", while a natural two-card 21 still reads "BJ" — a split
+// 21 pays even money, not 3:2, so labelling it "BJ" would mislead.
+func TestValueLabelSplit21NotBlackjack(t *testing.T) {
+	twentyOne := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
+	if got := valueLabel(twentyOne, false); got != "BJ" {
+		t.Errorf("natural two-card 21 label = %q, want BJ", got)
+	}
+	if got := valueLabel(twentyOne, true); got != "21" {
+		t.Errorf("split two-card 21 label = %q, want 21 (not BJ)", got)
+	}
+}
+
+// TestSplitAcesShowOneCardNote asserts a split pair of aces — which take one card
+// each and lock, passing the turn on immediately — names that rule on the felt,
+// so the player understands why the hands can't be played rather than reading it
+// as a bug.
+func TestSplitAcesShowOneCardNote(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bal = 900
+	s.hands = []*phand{
+		{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 50, fromSplit: true, resolved: true}, // 21
+		{cards: hand{{rankAce, suitClub}, {6, suitDiamond}}, bet: 50, fromSplit: true, resolved: true},       // soft 17
+	}
+	rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
+	rm.phase = phTurns
+	rm.render(tr)
+
+	f := tr.LastFrame(a)
+	if row := kittest.String(f, seatValRow); !strings.Contains(row, "aces") {
+		t.Fatalf("split-aces seat missing the one-card note on row %d: %q", seatValRow, row)
+	}
+	// And the split 21 must not read as a blackjack on the compact hand line.
+	var hands string
+	for _, row := range []int{seatCardRow, seatCardRow + 1} {
+		hands += kittest.String(f, row) + "\n"
+	}
+	if strings.Contains(hands, "BJ") {
+		t.Fatalf("split-ace 21 mislabeled BJ on the felt:\n%s", hands)
+	}
+}
+
+// TestPairsVerdictHeldUntilCardsLand is the regression guard for the reported
+// bug: the Perfect Pairs result must not appear while the seat's second card is
+// still animating in. The outcome is fixed at the deal, but revealing it early
+// spoils the reveal — so "pairs lost" (or a win) waits for both first cards to
+// land face up.
+func TestPairsVerdictHeldUntilCardsLand(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 50
+	s.bal = 1000
+	s.pairsBet = 25
+	// A non-pair hand: the side bet loses, and that must stay hidden mid-deal.
+	s.hands = []*phand{{cards: hand{{9, suitSpade}, {4, suitHeart}}, bet: 50}}
+	s.pairsKind = "" // resolved at the deal: no pair
+	rm.dealer = hand{{10, suitClub}, {7, suitDiamond}}
+	rm.phase = phTurns
+	rm.recordDeal(tr) // stagger the initial deal's slide + flip
+	rm.render(tr)
+
+	if row := kittest.String(tr.LastFrame(a), seatPairRow); strings.Contains(row, "pairs lost") {
+		t.Fatalf("pairs verdict shown before the second card landed: %q", row)
+	}
+
+	pump(rm, tr, 5*time.Second) // let the whole deal settle
+	rm.render(tr)
+	if row := kittest.String(tr.LastFrame(a), seatPairRow); !strings.Contains(row, "pairs lost") {
+		t.Fatalf("pairs verdict not shown after the cards landed: %q", row)
+	}
+}
+
+// TestInsuranceFoldsIntoResultNet asserts an insured loss to a dealer blackjack
+// reconciles in the results summary: the 2:1 insurance payout exactly offsets
+// the main-bet loss, so the seat reads PUSH (net 0) and its chips are unchanged,
+// rather than a phantom LOSE that the chip stack never reflected.
+func TestInsuranceFoldsIntoResultNet(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 100
+	staked(tr, s, 900, 100)                                                      // main 100 escrowed at the deal
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 100}} // 19, loses to BJ
+	rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}}                // dealer blackjack
+	rm.dealerHole = true
+
+	rm.takeInsurance(s, true)   // stake 50 Wagered -> bal 850, roundStake 150
+	rm.resolveInsurance(tr)     // dealer BJ pays 2:1 (+150 gross), defers settle
+	pump(rm, tr, 5*time.Second) // reveal + single Settle plays out
+
+	// Insurance gross 150 exactly offsets the lost main bet on a 150 stake: net 0.
+	if s.bal != 1000 {
+		t.Fatalf("bal = %d, want 1000 (insured loss breaks even)", s.bal)
+	}
+	if s.result != "PUSH" {
+		t.Fatalf("result = %q, want PUSH (insurance offsets the main-bet loss in the net)", s.result)
+	}
+}
+
+// TestManyCardHandStaysInItsSlot is the regression guard for the reported bug: a
+// hit-heavy hand of five or more cards has a card box wider than the 15-col seat
+// slot, so it used to spill past the slot's right edge into the neighbouring
+// seat. It must fall back to the compact one-line rendering and stay in its slot.
+func TestManyCardHandStaysInItsSlot(t *testing.T) {
+	a := mkPlayer("alice")
+	rm, tr := newGame(t, a)
+	rm.what = pendNone
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bal = 800
+	s.hands = []*phand{{cards: hand{{2, suitSpade}, {2, suitHeart}, {3, suitClub}, {4, suitDiamond}, {5, suitSpade}}, bet: 50}}
+	rm.dealer = hand{{10, suitClub}, {7, suitDiamond}}
+	rm.phase = phTurns
+	rm.render(tr)
+
+	// One centred seat: anything drawn at or past its right edge is an overflow
+	// into where the neighbouring seat's slot would sit.
+	slot := (kit.Cols - slotW) / 2
+	f := tr.LastFrame(a)
+	for _, row := range []int{seatCardRow, seatCardRow + 1, seatCardRow + 2} {
+		line := []rune(kittest.String(f, row))
+		for c := slot + slotW; c < kit.Cols-1; c++ {
+			if c < len(line) && line[c] != ' ' && line[c] != 0 {
+				t.Fatalf("seat card row %d spills past its slot at col %d: %q", row, c, string(line))
+			}
+		}
+	}
+}
+
+// A seat that lands in betting below the minimum stake can re-buy with R rather
+// than being soft-locked (Confirm no-ops while broke, and the auto-rebuy only
+// fires post-hand). Guards the betting-screen broke-relief affordance.
+func TestBrokeSeatRebuysOnRInBetting(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+
+	// Broke: below the min bet (betTiers[0]) and the buyback floor.
+	fund(tr, s, 5)
+	// Confirm cannot place a bet while broke — the seat is stuck without a rebuy.
+	rm.OnInput(tr, a, keyInput(kit.KeyEnter))
+	if s.placed {
+		t.Fatal("broke seat placed a bet it cannot afford")
+	}
+
+	// R triggers the broke-relief re-buy, topping the seat back up.
+	rm.OnInput(tr, a, runeInput('r'))
+	if s.bal != 1000 {
+		t.Fatalf("after re-buy bal = %d, want 1000", s.bal)
+	}
+	if tr.CreditsRebuys[a.AccountID] != 1 {
+		t.Fatalf("re-buy count = %d, want 1", tr.CreditsRebuys[a.AccountID])
+	}
+	// Now solvent: a further R is a no-op (no phantom extra rebuy).
+	rm.OnInput(tr, a, runeInput('r'))
+	if tr.CreditsRebuys[a.AccountID] != 1 {
+		t.Fatalf("solvent R triggered a re-buy: count = %d, want 1", tr.CreditsRebuys[a.AccountID])
+	}
+	// And the seat can now place the minimum bet.
+	rm.OnInput(tr, a, keyInput(kit.KeyEnter))
+	if !s.placed {
+		t.Fatal("re-bought seat still cannot place a bet")
+	}
+}
diff --git a/games/bcook/blackjack-challenge/round.go b/games/bcook/blackjack-challenge/round.go
new file mode 100644
index 0000000..8da766e
--- /dev/null
+++ b/games/bcook/blackjack-challenge/round.go
@@ -0,0 +1,118 @@
+package main
+
+import "math/rand"
+
+// outcome is a single player hand's result against the dealer.
+type outcome int
+
+const (
+	outLose outcome = iota
+	outPush
+	outWin
+	outBlackjack // player blackjack, dealer without blackjack
+)
+
+// dealerPlay draws for the dealer until the hand totals 17 or more, standing on
+// all 17 including soft 17 (S17). Callers skip this entirely when no player hand
+// is live. The rng feeds a possible mid-round discard recycle in draw.
+func dealerPlay(d hand, s *shoe, rng *rand.Rand) hand {
+	for {
+		if total, _ := d.value(); total >= 17 {
+			return d
+		}
+		d = append(d, s.draw(rng))
+	}
+}
+
+// settleHandEx compares a player hand against the dealer with explicit blackjack
+// flags, so a caller can declare a hand non-blackjack (e.g. a two-card 21 formed
+// by splitting is a plain 21). A busted player always loses; otherwise blackjack
+// beats non-blackjack, then totals are compared, with equal totals a push.
+func settleHandEx(p hand, pBlackjack bool, d hand, dBlackjack bool) outcome {
+	switch {
+	case p.isBust():
+		return outLose
+	case pBlackjack && dBlackjack:
+		return outPush
+	case pBlackjack:
+		return outBlackjack
+	case dBlackjack:
+		return outLose
+	case d.isBust():
+		return outWin
+	case p.total() > d.total():
+		return outWin
+	case p.total() < d.total():
+		return outLose
+	default:
+		return outPush
+	}
+}
+
+// settleHand compares a player hand against the dealer using natural blackjack
+// detection for both.
+func settleHand(p, d hand) outcome {
+	return settleHandEx(p, p.isBlackjack(), d, d.isBlackjack())
+}
+
+// halfUp halves n in integer chips with the half-chip rounded UP — the single
+// owner of the player-favorable rounding policy used by the 3:2 blackjack
+// payout and the surrender return (so the two sites can never drift apart and
+// silently break chip conservation on odd bets).
+func halfUp(n int) int { return (n + 1) / 2 }
+
+// creditFor is the amount returned to a player's chips at settlement for a hand
+// of the given bet. The stake was deducted at deal time, so a loss credits 0, a
+// push returns the stake, a win pays even money, and a blackjack pays 3:2 —
+// with an odd bet's half-chip rounded UP (the player-favorable integer-chip
+// reading of 3:2; never underpay the headline payout).
+func creditFor(o outcome, bet int) int {
+	switch o {
+	case outWin:
+		return 2 * bet
+	case outBlackjack:
+		return bet + halfUp(bet*3)
+	case outPush:
+		return bet
+	default: // outLose
+		return 0
+	}
+}
+
+// insuranceCredit returns the chips paid back on an insurance side bet of `ins`.
+// Insurance pays 2:1 when the dealer has blackjack (stake + 2×stake), else 0.
+func insuranceCredit(dealerBlackjack bool, ins int) int {
+	if dealerBlackjack {
+		return ins * 3
+	}
+	return 0
+}
+
+// perfectPairsOutcome classifies a player's first two cards for the Perfect
+// Pairs side bet and returns the result kind and its payout multiplier (the X
+// in X:1), or ("", 0) when the cards are not a pair. A perfect pair is the same
+// rank and suit (25:1), a colored pair is the same rank and colour but different
+// suit (12:1), and a mixed pair is the same rank in different colours (6:1).
+func perfectPairsOutcome(a, b card) (kind string, mult int) {
+	switch {
+	case a.r != b.r:
+		return "", 0
+	case a.s == b.s:
+		return "perfect", 25
+	case a.s.red() == b.s.red():
+		return "colored", 12
+	default:
+		return "mixed", 6
+	}
+}
+
+// pairsCreditFor is the chips returned to a player on a Perfect Pairs side bet of
+// `bet` at multiplier `mult` (the X in X:1). The stake was deducted when the bet
+// was placed, so a winning pair returns stake + mult×stake and a loss (mult 0)
+// returns nothing.
+func pairsCreditFor(mult, bet int) int {
+	if mult <= 0 {
+		return 0
+	}
+	return bet * (mult + 1)
+}
diff --git a/games/bcook/blackjack-challenge/round_test.go b/games/bcook/blackjack-challenge/round_test.go
new file mode 100644
index 0000000..637d9a5
--- /dev/null
+++ b/games/bcook/blackjack-challenge/round_test.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+	"math/rand"
+	"testing"
+)
+
+func TestDealerStandsOnSoft17(t *testing.T) {
+	rng := rand.New(rand.NewSource(1))
+	d := dealerPlay(hand{{rankAce, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // soft 17
+	if len(d) != 2 {
+		t.Fatalf("dealer drew on soft 17 (S17 should stand): %v", d)
+	}
+}
+
+func TestDealerDrawsTo17(t *testing.T) {
+	rng := rand.New(rand.NewSource(2))
+	d := dealerPlay(hand{{10, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // hard 16
+	if total := d.total(); total < 17 && !d.isBust() {
+		t.Fatalf("dealer stopped at %d, must reach 17+ or bust", total)
+	}
+}
+
+func TestSettleHand(t *testing.T) {
+	bj := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
+	twenty := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}
+	nineteen := hand{{10, suitSpade}, {9, suitHeart}}
+	bust := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}, {5, suitClub}}
+
+	cases := []struct {
+		name   string
+		player hand
+		dealer hand
+		want   outcome
+	}{
+		{"player blackjack beats 20", bj, twenty, outBlackjack},
+		{"both blackjack push", bj, bj, outPush},
+		{"dealer blackjack beats 20", twenty, bj, outLose},
+		{"higher beats lower", twenty, nineteen, outWin},
+		{"equal totals push", twenty, twenty, outPush},
+		{"player bust loses", bust, nineteen, outLose},
+		{"player bust loses even if dealer busts", bust, bust, outLose},
+		{"dealer bust, player stands", nineteen, bust, outWin},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := settleHand(c.player, c.dealer); got != c.want {
+				t.Fatalf("settleHand = %v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
+func TestSplit21IsNotBlackjack(t *testing.T) {
+	// A two-card 21 formed by splitting is a plain 21, not a 3:2 blackjack.
+	twentyOne := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
+	twenty := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}
+	if got := settleHandEx(twentyOne, false /* fromSplit */, twenty, false); got != outWin {
+		t.Fatalf("split 21 vs dealer 20 = %v, want outWin (not outBlackjack)", got)
+	}
+}
+
+func TestCreditFor(t *testing.T) {
+	cases := []struct {
+		o    outcome
+		bet  int
+		want int
+	}{
+		{outLose, 100, 0},
+		{outPush, 100, 100},
+		{outWin, 100, 200},
+		{outBlackjack, 100, 250}, // stake 100 + 3:2 (150)
+		{outBlackjack, 10, 25},   // stake 10 + 3:2 (15)
+		{outBlackjack, 25, 63},   // stake 25 + 3:2 (37.5 -> 38, half-chip rounds UP to the player)
+		{outBlackjack, 50, 125},  // stake 50 + 3:2 (75)
+	}
+	for _, c := range cases {
+		if got := creditFor(c.o, c.bet); got != c.want {
+			t.Errorf("creditFor(%v, %d) = %d, want %d", c.o, c.bet, got, c.want)
+		}
+	}
+}
+
+func TestInsuranceCredit(t *testing.T) {
+	if got := insuranceCredit(true, 50); got != 150 { // stake 50 + 2:1 (100)
+		t.Errorf("insurance on dealer blackjack = %d, want 150", got)
+	}
+	if got := insuranceCredit(false, 50); got != 0 {
+		t.Errorf("insurance with no dealer blackjack = %d, want 0", got)
+	}
+}
+
+func TestPerfectPairsOutcome(t *testing.T) {
+	cases := []struct {
+		name     string
+		a, b     card
+		wantKind string
+		wantMult int
+	}{
+		{"perfect: same rank and suit", card{8, suitHeart}, card{8, suitHeart}, "perfect", 25},
+		{"colored: same rank, both red", card{8, suitHeart}, card{8, suitDiamond}, "colored", 12},
+		{"colored: same rank, both black", card{rankKing, suitSpade}, card{rankKing, suitClub}, "colored", 12},
+		{"mixed: same rank, different colour", card{8, suitSpade}, card{8, suitHeart}, "mixed", 6},
+		{"no pair: different rank", card{8, suitSpade}, card{9, suitSpade}, "", 0},
+		{"no pair: ten and face are not a pair", card{10, suitSpade}, card{rankKing, suitSpade}, "", 0},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			kind, mult := perfectPairsOutcome(c.a, c.b)
+			if kind != c.wantKind || mult != c.wantMult {
+				t.Fatalf("perfectPairsOutcome = (%q, %d), want (%q, %d)", kind, mult, c.wantKind, c.wantMult)
+			}
+		})
+	}
+}
+
+func TestAffordTier(t *testing.T) {
+	tiers := []int{0, 10, 25, 50, 100}
+	cases := []struct {
+		want, budget, out int
+	}{
+		{100, 1000, 100}, // wanted tier fits the budget
+		{100, 30, 25},    // capped to the highest tier within budget
+		{50, 5, 0},       // nothing but "off" fits
+		{25, 25, 25},     // exact fit
+		{100, 0, 0},      // no budget -> off
+		{100, -5, 0},     // negative budget -> off (never panics)
+	}
+	for _, c := range cases {
+		if got := affordTier(tiers, c.want, c.budget); got != c.out {
+			t.Errorf("affordTier(want=%d, budget=%d) = %d, want %d", c.want, c.budget, got, c.out)
+		}
+	}
+}
+
+func TestPairsCreditFor(t *testing.T) {
+	cases := []struct {
+		mult int
+		bet  int
+		want int
+	}{
+		{0, 10, 0},     // no pair: side stake lost
+		{6, 10, 70},    // mixed 6:1: stake 10 + 60
+		{12, 25, 325},  // colored 12:1: stake 25 + 300
+		{25, 50, 1300}, // perfect 25:1: stake 50 + 1250
+	}
+	for _, c := range cases {
+		if got := pairsCreditFor(c.mult, c.bet); got != c.want {
+			t.Errorf("pairsCreditFor(%d, %d) = %d, want %d", c.mult, c.bet, got, c.want)
+		}
+	}
+}
diff --git a/games/bcook/blackjack-challenge/smoke.yaml b/games/bcook/blackjack-challenge/smoke.yaml
new file mode 100644
index 0000000..cb88566
--- /dev/null
+++ b/games/bcook/blackjack-challenge/smoke.yaml
@@ -0,0 +1,74 @@
+seed: 58
+seats: 2
+heartbeat: 50ms
+steps:
+  # (a) Initial table: both seats at the bet prompt. Rules flank the dealer, and
+  # the betting prompt offers Up/Down stake, P pairs, and Left/Right (pick the
+  # seat you're betting on — yourself or another player to back).
+  - shot: bet
+
+  # Seat 0 loops its own Perfect Pairs on (P -> 10), then presses Right to focus
+  # seat 1 and backs it: B B -> behind 25, P -> their-pairs 10. With seat 1
+  # focused, the action bar spells out the back ("BACKING seat1 ...").
+  - seat: 0
+  - rune: "p"
+  - key: right
+  - rune: "b"
+  - rune: "b"
+  - rune: "p"
+  - shot: backing
+
+  # Seat 0 places (Confirm places everything, focus or not); seat 1 sets its own
+  # pairs and places too.
+  - key: space
+  - seat: 1
+  - rune: "p"
+  - key: space
+
+  # (b) Both placed: seat 1's dedicated backers line shows seat 0's character tile
+  # and the back stakes; each seat shows its own "+pairs" beneath its bet.
+  - shot: sidebet
+
+  # Grace beat (1s) -> deal; sweep the staggered slide+flip deal animation.
+  - advance: 1200ms
+  - advance: 1200ms
+
+  # (c) Deal complete: both seats dealt a pair (own pairs pay out), and seat 0's
+  # back-pairs on seat 1 pay too; seat 0 (a pair of tens) is first on turn.
+  - shot: dealt
+
+  # Seat 0 splits its pair -> two hands stacked, the hand on turn marked.
+  - seat: 0
+  - rune: "p"
+  - advance: 700ms
+  - shot: split
+
+  # Seat 0 stands both split hands; the turn walks hand to hand, then to seat 1.
+  - rune: "s"
+  - advance: 200ms
+  - rune: "s"
+  - advance: 200ms
+
+  # Seat 1 stands on its 20.
+  - seat: 1
+  - rune: "s"
+  - advance: 200ms
+
+  # All hands resolved -> the dealer reveals and stands; sweep the paced reveal.
+  - advance: 1600ms
+  - advance: 1600ms
+  - advance: 1600ms
+
+  # (d) Settlement: each seat's result, with own pairs and (for seat 0) the
+  # behind + back-pairs on seat 1 all folded into the net; the backers line shows
+  # seat 0's net on seat 1.
+  - shot: payout
+
+  # (e) Ready-up flow preserved.
+  - seat: 0
+  - key: space
+  - shot: ready
+  - seat: 1
+  - key: space
+  - advance: 100ms
+  - shot: next

From 7bf380a97077e9a759d9e199a33b4ece8fd7d32b Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:14:58 +1000
Subject: [PATCH 02/14] blackjack-challenge: catalog identity, 31x ceiling,
 table signage

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/game.go      | 34 +++++++++-----------
 games/bcook/blackjack-challenge/layout.go    |  6 ++--
 games/bcook/blackjack-challenge/main.go      | 15 +++++----
 games/bcook/blackjack-challenge/room.go      |  8 ++---
 games/bcook/blackjack-challenge/room_test.go |  2 +-
 5 files changed, 31 insertions(+), 34 deletions(-)

diff --git a/games/bcook/blackjack-challenge/game.go b/games/bcook/blackjack-challenge/game.go
index 49c2660..18ee75f 100644
--- a/games/bcook/blackjack-challenge/game.go
+++ b/games/bcook/blackjack-challenge/game.go
@@ -10,9 +10,9 @@ type Game struct{}
 // Meta returns the static game metadata (mirrors the native blackjack meta).
 func (Game) Meta() kit.GameMeta {
 	return kit.GameMeta{
-		Slug:             "blackjack",
-		Name:             "Blackjack",
-		ShortDescription: "Take a seat at a shared dealer table: bet, hit, stand, and chase your high score.",
+		Slug:             "blackjack-challenge",
+		Name:             "Blackjack Challenge",
+		ShortDescription: "Face-up dealer, ties lose - but your blackjack never does, paying 2:1 up to 5:1.",
 		MinPlayers:       1,
 		MaxPlayers:       5,
 		Tags:             []string{"cards", "casino"},
@@ -24,13 +24,13 @@ func (Game) Meta() kit.GameMeta {
 		// A casino-kind game (kit v2.16.0): players gamble their account-wide
 		// platform Credits through the room's svc.Credits service — the host
 		// owns every balance. MaxPayoutMultiplier is the settlement ceiling:
-		// the top single-stake outcome is a Perfect Pairs "perfect" pair at
-		// 25:1, which returns stake×(25+1) = 26× on that side stake; the
-		// mandatory main bet only dilutes the per-seat aggregate, so 26 covers
-		// the largest honest payout. CtxFeatCredits is declared alongside so a
+		// the top single-stake outcome is a Star Pairs pair of aces at 30:1,
+		// which returns stake×(30+1) = 31× on that side stake; the mandatory
+		// main bet only dilutes the per-seat aggregate, so 31 covers the
+		// largest honest payout. CtxFeatCredits is declared alongside so a
 		// credits-capable front end negotiates the encoding.
 		Kind:                kit.GameKindCasino,
-		MaxPayoutMultiplier: 26,
+		MaxPayoutMultiplier: 31,
 
 		// Per-member arcade characters (kit v2.9.0): every roster member
 		// arrives with Player.Character populated, rendered as a one-cell
@@ -54,22 +54,18 @@ func (Game) Meta() kit.GameMeta {
 
 		// Touch deck chips (kit v2.10.0): every input beyond the canonical
 		// vocabulary (arrows/Confirm/Back, which the deck always provides) needs
-		// a chip so it is reachable on touch. The turn actions, the insurance
-		// answers, and the betting side-bet keys are all letter commands. Betting
-		// itself drives stake (Up/Down), the backed seat (Left/Right), and place
-		// (Confirm) off the canonical arrows; only P/B need declaring.
+		// a chip so it is reachable on touch. The turn actions and the betting
+		// side-bet keys are all letter commands. Betting itself drives stake
+		// (Up/Down), the backed seat (Left/Right), and place (Confirm) off the
+		// canonical arrows; only P/B need declaring.
 		Controls: []kit.ControlDecl{
 			kit.RuneControl('h', "HIT"),
 			kit.RuneControl('s', "STAND"),
 			kit.RuneControl('d', "DOUBLE"),
-			// P splits a pair on a turn AND loops the Perfect Pairs side bet
-			// during betting — one rune, so the chip carries both meanings.
+			// P splits a pair on a turn AND loops the Star Pairs side bet during
+			// betting — one rune, so the chip carries both meanings.
 			kit.RuneControl('p', "SPLIT/PAIRS"),
-			kit.RuneControl('r', "SURRENDER"),
-			kit.RuneControl('y', "YES"),
-			kit.RuneControl('n', "NO"),
-			// Betting only: B loops the behind bet on the focused seat (Left/Right
-			// pick the seat); P (above) loops that seat's Perfect Pairs.
+			// Betting only: B loops the behind bet on the focused seat.
 			kit.RuneControl('b', "BEHIND"),
 		},
 	}
diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 7f6ca0a..4e6077c 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -64,7 +64,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	// A casino game with no host economy renders out-of-service rather than
 	// trapping: the table cannot take a bet without the Credits service.
 	if rm.economyOff() {
-		f.Text(0, 1, "♠♥♦♣ BLACKJACK", stTitle)
+		f.Text(0, 1, "♠♥♦♣ BLACKJACK CHALLENGE", stTitle)
 		drawFelt(f, feltTop, feltBottom)
 		center(f, (feltTop+feltBottom)/2, "table closed — credits unavailable", stDim)
 		return
@@ -81,12 +81,12 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	f.TextRight(0, kit.Cols-1, phase, stPhase)
 
 	drawFelt(f, feltTop, feltBottom)
-	center(f, feltTop, " B L A C K J A C K ", stFelt)
+	center(f, feltTop, " B L A C K J A C K   C H A L L E N G E ", stFelt)
 
 	// Dealer, centred near the top of the felt, with the table rules as subtle
 	// signage flanking the DEALER label (left + right) rather than a banner
 	// across the middle of the felt — keeping the centre clear for the cards.
-	f.Text(dealerRow-1, 2, "blackjack pays 3:2", stDim)
+	f.Text(dealerRow-1, 2, "blackjack pays 2:1 - ties lose", stDim)
 	f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim)
 	center(f, dealerRow-1, "D E A L E R", stTitle)
 	rm.drawDealer(f)
diff --git a/games/bcook/blackjack-challenge/main.go b/games/bcook/blackjack-challenge/main.go
index 5a29a88..d071203 100644
--- a/games/bcook/blackjack-challenge/main.go
+++ b/games/bcook/blackjack-challenge/main.go
@@ -1,10 +1,11 @@
-// Blackjack — a no-winner, social multiplayer blackjack table on the 80x24
-// shellcade canvas: one shared auto-dealer, up to five seats, and rounds that
-// loop while anyone is seated. Bet, hit, stand, double, split, surrender, and
-// take insurance; the dealer stands on all 17 (S17) and blackjack pays 3:2.
-// Chips are integers: an odd bet's half-chip on the 3:2 payout and on a
-// surrender return rounds UP to the player; the insurance stake is half the
-// bet rounded down and pays exactly 2:1 on what was staked. Leaving with a
+// Blackjack Challenge — a no-winner, social multiplayer blackjack table on
+// the 80x24 shellcade canvas: one shared auto-dealer, up to five seats, and
+// rounds that loop while anyone is seated. The dealer's up card is face-up
+// and stands on all 17 (S17); a tie hand loses instead of pushing, and a
+// player blackjack is ranked, paying 2:1 up to 5:1 depending on its suits.
+// Bet, hit, stand, double, and split; the round also auto-wins/auto-loses a
+// hand outright without a turn where the outcome is already decided, and a
+// Star Pairs side bet pays out on the first two cards dealt. Leaving with a
 // live hand forfeits its stake. Stakes are the platform's account-wide Credits
 // (kit v2.16.0 casino ABI): every bet Wagers onto the seat's single open stake
 // and the round Settles that stake exactly once with the gross payout; the host
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 7561dcf..39dc085 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -32,10 +32,10 @@ const (
 
 	// maxPayoutMult mirrors Meta().MaxPayoutMultiplier: the host clamps every
 	// Settle to the seat's open stake × this multiplier, so the game clamps its
-	// own displayed/settled gross to the same ceiling before Settle. 26 covers a
-	// Perfect Pairs "perfect" pair (25:1 -> stake×26); the mandatory main bet
-	// only dilutes the per-seat aggregate, so it is never reached in practice.
-	maxPayoutMult = 26
+	// own displayed/settled gross to the same ceiling before Settle. 31 covers a
+	// Star Pairs pair of aces (30:1 -> stake×31); the mandatory main bet only
+	// dilutes the per-seat aggregate, so it is never reached in practice.
+	maxPayoutMult = 31
 )
 
 // betTiers are the selectable stakes, lowest first. The ×2.5/×2/×2 climb repeats
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 68de93a..f30dbd7 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -1268,7 +1268,7 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	f := tr.LastFrame(a)
 
 	dealerLabelRow := kittest.String(f, dealerRow-1)
-	if !strings.Contains(dealerLabelRow, "blackjack pays 3:2") {
+	if !strings.Contains(dealerLabelRow, "blackjack pays 2:1 - ties lose") {
 		t.Errorf("payout rule not on the dealer label row: %q", dealerLabelRow)
 	}
 	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {

From a18724f154ca6a142f05a3b80d4bf86eaa4f1466 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:19:18 +1000
Subject: [PATCH 03/14] blackjack-challenge: title reads BLACKJACK CHALLENGE on
 the live render path

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/layout.go    | 2 +-
 games/bcook/blackjack-challenge/room_test.go | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 4e6077c..aadfec1 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -73,7 +73,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	active, _ := rm.firstUnresolved()
 
 	// Title + phase/time above the felt.
-	f.Text(0, 1, "♠♥♦♣ BLACKJACK", stTitle)
+	f.Text(0, 1, "♠♥♦♣ BLACKJACK CHALLENGE", stTitle)
 	phase := rm.phase
 	if secs := rm.remaining(); secs > 0 {
 		phase = fmt.Sprintf("%s · %s", rm.phase, clock(secs))
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index f30dbd7..5a2bca5 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -1267,6 +1267,12 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	rm.render(tr)
 	f := tr.LastFrame(a)
 
+	// The title on row 0 is the live render path's (not the economy-off
+	// branch's) — it must carry the full variant name.
+	if top := kittest.String(f, 0); !strings.Contains(top, "♠♥♦♣ BLACKJACK CHALLENGE") {
+		t.Errorf("title on the normal render path is not BLACKJACK CHALLENGE: %q", top)
+	}
+
 	dealerLabelRow := kittest.String(f, dealerRow-1)
 	if !strings.Contains(dealerLabelRow, "blackjack pays 2:1 - ties lose") {
 		t.Errorf("payout rule not on the dealer label row: %q", dealerLabelRow)

From f052ca050a5c4b4b3e4b8fd70e44f10626bbeffb Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:22:34 +1000
Subject: [PATCH 04/14] blackjack-challenge: pure Challenge rules (Star Pairs,
 ranked blackjack, ties-lose grossMult)

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/room.go       |  3 +-
 games/bcook/blackjack-challenge/rules.go      | 85 +++++++++++++++++++
 games/bcook/blackjack-challenge/rules_test.go | 83 ++++++++++++++++++
 3 files changed, 170 insertions(+), 1 deletion(-)
 create mode 100644 games/bcook/blackjack-challenge/rules.go
 create mode 100644 games/bcook/blackjack-challenge/rules_test.go

diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 39dc085..54e5f72 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -52,8 +52,9 @@ var pairsTiers = []int{0, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
 type phand struct {
 	cards       hand
 	bet         int
-	resolved    bool // stood / busted / blackjack / doubled / surrendered
+	resolved    bool // stood / busted / blackjack / doubled / auto-won
 	doubled     bool
+	autoWon     bool // Player 21 or Five Card Trick: instant even-money win
 	surrendered bool
 	fromSplit   bool // a split hand: a two-card 21 is a plain 21, not a blackjack
 }
diff --git a/games/bcook/blackjack-challenge/rules.go b/games/bcook/blackjack-challenge/rules.go
new file mode 100644
index 0000000..1ed9900
--- /dev/null
+++ b/games/bcook/blackjack-challenge/rules.go
@@ -0,0 +1,85 @@
+package main
+
+// The Blackjack Challenge rules layer: every payout table, comparison, and
+// rule gate of the variant lives here (spec: docs/superpowers/specs/
+// 2026-07-04-blackjack-challenge-design.md). The dealer mechanics
+// (dealerPlay) migrate here from round.go when round.go is deleted (Task 7).
+
+// starPairsOutcome classifies a seat's first two cards for the Star Pairs
+// side bet, returning the result kind and its payout multiplier (the X in
+// X:1), or ("", 0) when the cards are not a same-rank pair. Highest event
+// only: a pair of aces (30:1) outranks the suit tiers; then a perfect pair
+// (same rank and suit, 20:1), a coloured pair (same rank and colour, 8:1),
+// and a mixed pair (same rank, 5:1). Ten-value cards pair by RANK: 10+J is
+// not a pair.
+func starPairsOutcome(a, b card) (kind string, mult int) {
+	switch {
+	case a.r != b.r:
+		return "", 0
+	case a.r == rankAce:
+		return "aces", 30
+	case a.s == b.s:
+		return "perfect", 20
+	case a.s.red() == b.s.red():
+		return "colored", 8
+	default:
+		return "mixed", 5
+	}
+}
+
+// bjTen returns the ten-value card's rank of a two-card blackjack, or 0 when
+// the hand is not one. Ranks compare directly for the ranked payout
+// (10 < J(11) < Q(12) < K(13)).
+func bjTen(h hand) rank {
+	if !h.isBlackjack() {
+		return 0
+	}
+	if h[0].r == rankAce {
+		return h[1].r
+	}
+	return h[0].r
+}
+
+// blackjackMult is the X in the X:1 blackjack payout: 2 against a dealer
+// without blackjack; against a dealer blackjack, by the rank of the player's
+// ten-card vs the dealer's (K>Q>J>10) — higher 5, equal 4, lower 3. A player
+// blackjack NEVER loses on this table.
+func blackjackMult(playerTen, dealerTen rank, dealerBJ bool) int {
+	if !dealerBJ {
+		return 2
+	}
+	switch {
+	case playerTen > dealerTen:
+		return 5
+	case playerTen == dealerTen:
+		return 4
+	default:
+		return 3
+	}
+}
+
+// grossMult is the stake-inclusive settlement multiplier for a played hand
+// against the dealer's final hand: credit = grossMult × bet. Challenge
+// order: busts lose outright; an auto-win (Player 21 / Five Card Trick) pays
+// even money no matter what the dealer holds; a blackjack pays its ranked
+// odds; any other hand loses to a dealer blackjack (the seat-level clawback
+// caps what the house collects — see settle); a dealer bust pays even money;
+// otherwise beat the dealer's total outright or lose — TIES LOSE.
+func grossMult(h *phand, d hand, dBJ bool) int {
+	switch {
+	case h.cards.isBust():
+		return 0
+	case h.autoWon:
+		return 2
+	case h.cards.isBlackjack():
+		return 1 + blackjackMult(bjTen(h.cards), bjTen(d), dBJ)
+	case dBJ:
+		return 0
+	case d.isBust():
+		return 2
+	case h.cards.total() > d.total():
+		return 2
+	default:
+		return 0
+	}
+}
diff --git a/games/bcook/blackjack-challenge/rules_test.go b/games/bcook/blackjack-challenge/rules_test.go
new file mode 100644
index 0000000..0ad8dbf
--- /dev/null
+++ b/games/bcook/blackjack-challenge/rules_test.go
@@ -0,0 +1,83 @@
+package main
+
+import "testing"
+
+func TestStarPairsOutcome(t *testing.T) {
+	cases := []struct {
+		a, b card
+		kind string
+		mult int
+	}{
+		{card{rankAce, suitSpade}, card{rankAce, suitHeart}, "aces", 30},
+		{card{rankAce, suitSpade}, card{rankAce, suitClub}, "aces", 30}, // aces outrank perfect/colored
+		{card{8, suitDiamond}, card{8, suitDiamond}, "perfect", 20},
+		{card{8, suitDiamond}, card{8, suitHeart}, "colored", 8},
+		{card{8, suitDiamond}, card{8, suitClub}, "mixed", 5},
+		{card{10, suitSpade}, card{rankJack, suitSpade}, "", 0}, // same points, different rank: not a pair
+		{card{rankKing, suitSpade}, card{rankQueen, suitSpade}, "", 0},
+	}
+	for _, c := range cases {
+		kind, mult := starPairsOutcome(c.a, c.b)
+		if kind != c.kind || mult != c.mult {
+			t.Errorf("starPairsOutcome(%v,%v) = %q,%d want %q,%d", c.a, c.b, kind, mult, c.kind, c.mult)
+		}
+	}
+}
+
+func TestBjTen(t *testing.T) {
+	if r := bjTen(hand{{rankAce, suitSpade}, {rankKing, suitHeart}}); r != rankKing {
+		t.Errorf("bjTen(A,K) = %v want K", r)
+	}
+	if r := bjTen(hand{{10, suitSpade}, {rankAce, suitHeart}}); r != 10 {
+		t.Errorf("bjTen(10,A) = %v want 10", r)
+	}
+	if r := bjTen(hand{{10, suitSpade}, {9, suitHeart}}); r != 0 {
+		t.Errorf("bjTen(non-blackjack) = %v want 0", r)
+	}
+}
+
+func TestBlackjackMult(t *testing.T) {
+	cases := []struct {
+		p, d rank
+		dBJ  bool
+		want int
+	}{
+		{rankKing, 0, false, 2},          // dealer no blackjack: 2:1
+		{rankKing, rankQueen, true, 5},   // player ten outranks: 5:1
+		{rankJack, rankJack, true, 4},    // same rank: 4:1
+		{10, rankJack, true, 3},          // outranked: 3:1
+	}
+	for _, c := range cases {
+		if got := blackjackMult(c.p, c.d, c.dBJ); got != c.want {
+			t.Errorf("blackjackMult(%v,%v,%v) = %d want %d", c.p, c.d, c.dBJ, got, c.want)
+		}
+	}
+}
+
+func TestGrossMult(t *testing.T) {
+	d19 := hand{{10, suitClub}, {9, suitDiamond}}
+	d22 := hand{{10, suitClub}, {6, suitDiamond}, {6, suitSpade}}
+	dBJhand := hand{{rankAce, suitClub}, {rankJack, suitDiamond}}
+	cases := []struct {
+		name string
+		h    *phand
+		d    hand
+		dBJ  bool
+		want int
+	}{
+		{"bust loses even vs dealer bust", &phand{cards: hand{{10, suitSpade}, {9, suitHeart}, {5, suitClub}}}, d22, false, 0},
+		{"auto-won pays 2 even vs dealer blackjack", &phand{cards: hand{{7, suitSpade}, {7, suitHeart}, {7, suitClub}}, autoWon: true}, dBJhand, true, 2},
+		{"blackjack vs no dealer blackjack pays 1+2", &phand{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}}, d19, false, 3},
+		{"blackjack K vs dealer blackjack J pays 1+5", &phand{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}}, dBJhand, true, 6},
+		{"ordinary hand loses to dealer blackjack", &phand{cards: hand{{10, suitSpade}, {9, suitHeart}}}, dBJhand, true, 0},
+		{"dealer bust pays even", &phand{cards: hand{{10, suitSpade}, {2, suitHeart}}}, d22, false, 2},
+		{"higher total wins even", &phand{cards: hand{{10, suitSpade}, {10, suitHeart}}}, d19, false, 2},
+		{"TIE LOSES", &phand{cards: hand{{10, suitSpade}, {9, suitHeart}}}, d19, false, 0},
+		{"lower total loses", &phand{cards: hand{{10, suitSpade}, {8, suitHeart}}}, d19, false, 0},
+	}
+	for _, c := range cases {
+		if got := grossMult(c.h, c.d, c.dBJ); got != c.want {
+			t.Errorf("%s: grossMult = %d want %d", c.name, got, c.want)
+		}
+	}
+}

From 2531abeb13f677c759f53135cdaa97c515154dd4 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:39:26 +1000
Subject: [PATCH 05/14] blackjack-challenge: one face-up dealer card; excise
 insurance and the hole card

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/anim.go       |  24 +-
 games/bcook/blackjack-challenge/layout.go     |  61 ++---
 games/bcook/blackjack-challenge/room.go       | 233 ++++--------------
 games/bcook/blackjack-challenge/room_test.go  | 225 +++--------------
 games/bcook/blackjack-challenge/round.go      |   9 -
 games/bcook/blackjack-challenge/round_test.go |   9 -
 6 files changed, 112 insertions(+), 449 deletions(-)

diff --git a/games/bcook/blackjack-challenge/anim.go b/games/bcook/blackjack-challenge/anim.go
index d691f3c..caecd11 100644
--- a/games/bcook/blackjack-challenge/anim.go
+++ b/games/bcook/blackjack-challenge/anim.go
@@ -24,25 +24,20 @@ const (
 	flipDur  = 240 * time.Millisecond
 
 	// The dealer's reveal is paced far more deliberately than the initial deal
-	// sweep: players need to watch the hole card turn over and read each hit as
-	// it lands, one card at a time, rather than the cards flashing in back to
-	// back at dealStagger.
+	// sweep: players need to read each drawn card as it lands, one card at a
+	// time, rather than the cards flashing in back to back at dealStagger.
 	//
-	//   - holeRevealDelay is the lead-in beat after the dealer's turn begins and
-	//     before the hole card starts to turn, so the reveal reads as the dealer
-	//     pausing then flipping the card rather than it snapping over the instant
-	//     play ends. The card sits face down through this beat, then flips.
-	//   - holeRevealHold is the beat the table holds on the just-turned hole card
-	//     (the dealer's two-card total) before the first hit slides in.
+	//   - dealerLeadIn is the beat after the dealer's turn begins before the
+	//     first drawn card starts to slide, so the draw-out reads as the dealer
+	//     taking over rather than cards instantly flying in.
 	//   - dealerDrawGap is the pause between one hit fully landing and the next
 	//     beginning to slide — the dealer reading the table and deciding to draw
 	//     again. Far longer than dealStagger so hits never overlap.
 	//   - dealerDoneHold is the final beat on the completed dealer hand (its made
 	//     total, blackjack, or bust) before the round settles to results.
-	holeRevealDelay = 500 * time.Millisecond
-	holeRevealHold  = 700 * time.Millisecond
-	dealerDrawGap   = 450 * time.Millisecond
-	dealerDoneHold  = 800 * time.Millisecond
+	dealerLeadIn   = 500 * time.Millisecond
+	dealerDrawGap  = 450 * time.Millisecond
+	dealerDoneHold = 800 * time.Millisecond
 )
 
 // animKind distinguishes where an animated card lands.
@@ -58,8 +53,7 @@ const (
 //
 //   - slide:  slideStart .. slideStart+slideDur  (right edge -> slot)
 //   - flip:   flipStart  .. flipStart+flipDur     (back -> edge -> face); zero
-//     flipStart means "no reveal flip" (the concealed dealer hole card until it
-//     is turned over, which schedules its own flip).
+//     flipStart means "no reveal flip" (a settled/unscheduled card).
 type cardAnim struct {
 	kind    animKind
 	player  kit.Player // seat owner (zero for the dealer)
diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index aadfec1..b33cd58 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -120,18 +120,13 @@ func (rm *room) drawDealer(f *kit.Frame) {
 		center(f, dealerRow+1, "(waiting for bets)", stDim)
 		return
 	}
-	hide := -1
-	if rm.dealerHole {
-		hide = 1
-	}
-	// Size and centre the row to the cards actually on the table (dealt cards
-	// plus any hit already arriving), and draw only those — so a pending hit's
-	// slot never appears before the hole card is turned over and that hit begins
-	// to slide in.
+	// Size and centre the row to the cards actually on the table (the one dealt
+	// card plus any hit already arriving), and draw only those — so a pending
+	// hit's slot never appears before it actually begins to slide in.
 	lc := rm.dealerLayoutCount()
 	w := cardsWidth(lc)
 	col := (kit.Cols - w) / 2
-	drawCardsAnim(f, dealerRow, col, rm.dealer[:lc], hide, rm.dealerResolver())
+	drawCardsAnim(f, dealerRow, col, rm.dealer[:lc], -1, rm.dealerResolver())
 	// The total and the verdict read off only the cards shown face up so far,
 	// never the authoritative hand — so the number ticks up and BUST/BLACKJACK
 	// appear as each card lands, not the instant the hand is dealt behind the
@@ -141,8 +136,9 @@ func (rm *room) drawDealer(f *kit.Frame) {
 	label, st := fmt.Sprintf("(%d)", shown.total()), stDim
 	switch {
 	case len(shown) < 2:
-		// Only the up card is face up — while the hole is concealed, mid lead-in,
-		// or mid-flip — so report what the dealer is showing, not a phantom total.
+		// Only the one dealt card is face up — before the dealer's turn, or mid
+		// lead-in/mid-flip on its first drawn card — so report what the dealer is
+		// showing, not a phantom total.
 		label = fmt.Sprintf("shows %d", hand{rm.dealer[0]}.total())
 	case shown.isBlackjack():
 		label, st = "BLACKJACK", stWin
@@ -405,22 +401,6 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
 		} else {
 			msg, st = "all bets in - dealing...", stPhase
 		}
-	case phInsurance:
-		switch n := rm.insuranceUndecidedCount(); {
-		case s.placed && !s.insuranceDecided:
-			// Spell out the side bet rather than just naming it: a stake of half the
-			// main bet that the dealer's hole card completes a blackjack, paying 2:1
-			// (so it exactly offsets the main-bet loss if the dealer does have it).
-			msg = fmt.Sprintf("Insurance? Stake %d that dealer has blackjack - pays 2:1   [Y]es   [N]o", s.bet/2)
-		case n > 0:
-			noun := "player"
-			if n != 1 {
-				noun = "players"
-			}
-			msg, st = fmt.Sprintf("waiting on %d %s for insurance - %s", n, noun, clock(rm.remaining())), stDim
-		default:
-			msg, st = "resolving insurance...", stDim
-		}
 	case phTurns:
 		switch {
 		case active != nil && active.p.AccountID == v.AccountID:
@@ -576,14 +556,11 @@ func (rm *room) seatCardFaceUp(p kit.Player, handIdx, cardIdx int) bool {
 }
 
 // dealerCardFaceUp reports whether dealer card i currently shows its face at the
-// latest composed instant: the concealed hole card never does, a settled (or
-// unscheduled) card always does, and an animating card only once its slide has
-// landed and its reveal flip has turned far enough to expose the face. It reads
-// only the recorded schedule and the frozen clock.
+// latest composed instant: a settled (or unscheduled) card always does, and an
+// animating card only once its slide has landed and its reveal flip has turned
+// far enough to expose the face. It reads only the recorded schedule and the
+// frozen clock.
 func (rm *room) dealerCardFaceUp(i int) bool {
-	if rm.dealerHole && i == 1 {
-		return false // hole card still concealed
-	}
 	a, ok := rm.animFor(animDealer, kit.Player{}, 0, i)
 	if !ok {
 		return true // settled or no animation -> face up
@@ -595,15 +572,15 @@ func (rm *room) dealerCardFaceUp(i int) bool {
 	return frame == 2 // face exposed only on the final flip frame
 }
 
-// dealerLayoutCount is how many dealer cards occupy the table right now: the two
-// dealt cards plus any hit that has actually begun sliding in. A hit that is
+// dealerLayoutCount is how many dealer cards occupy the table right now: the
+// one dealt card plus any hit that has actually begun sliding in. A hit that is
 // scheduled but has not yet started arriving is excluded, so the row is only as
-// wide as the cards on show and the next hit's arrival is announced by its slide
-// — never by a slot reserved before the hole card has even been turned over.
+// wide as the cards on show and the next hit's arrival is announced by its
+// slide — never by a slot reserved before it begins.
 func (rm *room) dealerLayoutCount() int {
 	n := 0
 	for i := range rm.dealer {
-		if i >= 2 {
+		if i >= 1 {
 			if a, ok := rm.animFor(animDealer, kit.Player{}, 0, i); ok && rm.lastNow.Before(a.slideStart) {
 				break // this hit has not begun arriving yet, nor have any after it
 			}
@@ -614,8 +591,8 @@ func (rm *room) dealerLayoutCount() int {
 }
 
 // dealerShownCount is how many leading dealer cards are face up right now. The
-// dealer reveals strictly in order — up card, then the hole flip, then each hit
-// in turn — so the shown set is always this contiguous prefix, and the displayed
+// dealer reveals strictly in order — its one dealt card, then each hit in
+// turn — so the shown set is always this contiguous prefix, and the displayed
 // total/verdict slice off it without allocating.
 func (rm *room) dealerShownCount() int {
 	n := 0
@@ -631,7 +608,7 @@ func (rm *room) dealerShownCount() int {
 // dealerResolver returns the dealer row's per-card animation resolver.
 func (rm *room) dealerResolver() func(i int) cardFace {
 	bustIdx := -1
-	if !rm.dealerHole && rm.dealer.isBust() {
+	if rm.dealer.isBust() {
 		bustIdx = len(rm.dealer) - 1
 	}
 	return func(i int) cardFace {
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 54e5f72..e0f328c 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -12,19 +12,17 @@ import (
 // memory — the lean ABI has no phase surface (SetPhase is gone; joinability is
 // host-derived), so a phase only drives this game's own logic and rendering.
 const (
-	phBetting   = "betting"
-	phInsurance = "insurance"
-	phTurns     = "player turns"
-	phResults   = "results"
+	phBetting = "betting"
+	phTurns   = "player turns"
+	phResults = "results"
 )
 
 const (
 	maxHands = 4 // a seat may split up to four hands
 
-	bettingDur   = 15 * time.Second
-	insuranceDur = 10 * time.Second
-	turnDur      = 20 * time.Second
-	resultsDur   = 6 * time.Second
+	bettingDur = 15 * time.Second
+	turnDur    = 20 * time.Second
+	resultsDur = 6 * time.Second
 
 	// gracePeriod is the short beat between the last seated player placing a bet
 	// and dealing, so an all-bets-in table deals early without feeling abrupt.
@@ -88,22 +86,19 @@ type seat struct {
 	// settle, and the whole thing Settles exactly once. roundStake is the total
 	// escrowed onto that open stake (sum of every Wager this round); >0 marks an
 	// open, unsettled stake and bounds the payout ceiling (roundStake×maxPayoutMult).
-	grossThisRound   int64
-	roundStake       int64
-	bet              int // currently selected/placed stake
-	placed           bool
-	pairsBet         int                 // Perfect Pairs side stake (0 = off), carried between rounds like bet
-	pairsKind        string              // this round's pairs result: "" | "mixed" | "colored" | "perfect"
-	pairsWin         int                 // chips credited on the pairs side bet this round (0 = lost/none)
-	focus            string              // betting UI: "" edits own bet, else the account id whose backs are being edited
-	backs            map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order)
-	insurance        int
-	insuranceWin     int // chips credited on the insurance side bet this round (0 = lost/none)
-	insuranceDecided bool
-	hands            []*phand
-	joinOrder        int
-	result           string // settlement summary for the results phase
-	ready            bool   // readied up during results to skip the wait
+	grossThisRound int64
+	roundStake     int64
+	bet            int // currently selected/placed stake
+	placed         bool
+	pairsBet       int                 // Perfect Pairs side stake (0 = off), carried between rounds like bet
+	pairsKind      string              // this round's pairs result: "" | "mixed" | "colored" | "perfect"
+	pairsWin       int                 // chips credited on the pairs side bet this round (0 = lost/none)
+	focus          string              // betting UI: "" edits own bet, else the account id whose backs are being edited
+	backs          map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order)
+	hands          []*phand
+	joinOrder      int
+	result         string // settlement summary for the results phase
+	ready          bool   // readied up during results to skip the wait
 }
 
 // pending names the deferred one-shot the room is waiting on, replacing the
@@ -114,7 +109,6 @@ type pending uint8
 const (
 	pendNone         pending = iota
 	pendBettingClose         // betting window closed (or grace beat elapsed)
-	pendInsurance            // insurance window closed -> resolve
 	pendTurn                 // active turn timed out -> auto-stand
 	pendResults              // results flash elapsed -> next round
 	pendSettle               // dealer reveal/draw animation done -> settle
@@ -125,13 +119,12 @@ type room struct {
 	cfg kit.RoomConfig
 	svc kit.Services
 
-	sh         *shoe
-	phase      string
-	seats      map[string]*seat // keyed by account id (hibernation-safe)
-	order      []string         // join order of account ids
-	dealer     hand
-	dealerHole bool // hole card concealed
-	joinSeq    int
+	sh      *shoe
+	phase   string
+	seats   map[string]*seat // keyed by account id (hibernation-safe)
+	order   []string         // join order of account ids
+	dealer  hand
+	joinSeq int
 
 	// deadline is the current phase deadline (rendered as the countdown) and
 	// what is the active pending one-shot. pendAt is the instant `what` fires;
@@ -350,9 +343,6 @@ func (rm *room) OnWake(r kit.Room) {
 		case pendBettingClose:
 			rm.what = pendNone
 			rm.onBettingClose(r)
-		case pendInsurance:
-			rm.what = pendNone
-			rm.resolveInsurance(r)
 		case pendTurn:
 			rm.what = pendNone
 			rm.autoStand(r)
@@ -378,15 +368,11 @@ func (rm *room) arm(what pending, at time.Time) {
 func (rm *room) enterBetting(r kit.Room) {
 	rm.phase = phBetting
 	rm.dealer = nil
-	rm.dealerHole = false
 	rm.bettingClosing = false
 	rm.clearSchedule()
 	for _, s := range rm.seats {
 		s.hands = nil
 		s.placed = false
-		s.insurance = 0
-		s.insuranceWin = 0
-		s.insuranceDecided = false
 		s.result = ""
 		s.pairsKind = ""
 		s.pairsWin = 0
@@ -637,8 +623,7 @@ func (rm *room) deal(r kit.Room) {
 	}
 	rm.sh.beginRound() // everything dealt before this point is recyclable discards
 	rng := r.Rand()
-	rm.dealer = hand{rm.sh.draw(rng), rm.sh.draw(rng)} // [up, hole]
-	rm.dealerHole = true
+	rm.dealer = hand{rm.sh.draw(rng)} // ONE face-up card; the rest draw at the dealer's turn
 	// Range the join-ordered slice (not the map) so dealing order is
 	// deterministic — never depends on Go's map iteration order.
 	for _, id := range rm.order {
@@ -665,19 +650,7 @@ func (rm *room) deal(r kit.Room) {
 
 	rm.recordDeal(r)
 
-	up := rm.dealer[0]
-	switch {
-	case up.r == rankAce:
-		rm.enterInsurance(r)
-	case up.r.points() == 10:
-		if rm.dealer.isBlackjack() {
-			rm.revealAndSettle(r)
-		} else {
-			rm.enterTurns(r)
-		}
-	default:
-		rm.enterTurns(r)
-	}
+	rm.enterTurns(r)
 }
 
 // resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards:
@@ -788,11 +761,10 @@ func (rm *room) computeSchedEnd() {
 	}
 }
 
-// recordDeal lays out the initial two-card deal as a staggered slide-and-flip
-// sweep: each card slides from the right felt edge to its slot and then flips
-// face up, except the dealer hole card, which slides in but stays concealed
-// until the reveal turns it over. Card identities are already fixed; this only
-// records cosmetic timings.
+// recordDeal lays out the initial deal as a staggered slide-and-flip sweep:
+// each seat's two cards and the dealer's single card slide from the right felt
+// edge to their slot and then flip face up. Card identities are already fixed;
+// this only records cosmetic timings.
 func (rm *room) recordDeal(r kit.Room) {
 	now := r.Now()
 	rm.sched = nil
@@ -806,7 +778,7 @@ func (rm *room) recordDeal(r kit.Room) {
 		step++
 	}
 	// Two passes around the table mirror a real deal: first card to every seat
-	// then the dealer up card; second card to every seat then the dealer hole.
+	// then the dealer's only dealt card; second card to every seat.
 	for round := 0; round < 2; round++ {
 		for _, id := range rm.order {
 			s := rm.seats[id]
@@ -816,9 +788,7 @@ func (rm *room) recordDeal(r kit.Room) {
 			add(cardAnim{kind: animSeat, player: s.p, cardIdx: round, flipStart: now})
 		}
 		if round == 0 {
-			add(cardAnim{kind: animDealer, cardIdx: 0, flipStart: now}) // up card flips
-		} else {
-			add(cardAnim{kind: animDealer, cardIdx: 1}) // hole card stays face down
+			add(cardAnim{kind: animDealer, cardIdx: 0, flipStart: now}) // the dealer's only dealt card
 		}
 	}
 	rm.computeSchedEnd()
@@ -839,21 +809,6 @@ func (rm *room) recordDraw(r kit.Room, p kit.Player, handIdx, cardIdx int) {
 	rm.computeSchedEnd()
 }
 
-// recordHoleReveal schedules the dealer hole card flipping over in place (no
-// slide) starting at `at`, and returns the room-clock instant the flip
-// completes so dealer play can pace off it. Callers lead in with a short beat
-// before `at` so the card sits face down a moment, then turns over, rather than
-// snapping up the instant the dealer's turn begins.
-func (rm *room) recordHoleReveal(at time.Time) time.Time {
-	rm.sched = []cardAnim{{
-		kind:      animDealer,
-		cardIdx:   1,
-		flipStart: at,
-	}}
-	rm.computeSchedEnd()
-	return at.Add(flipDur)
-}
-
 // recordDealerDraw appends a dealer hit card sliding in and flipping face up.
 func (rm *room) recordDealerDraw(start time.Time, cardIdx int) {
 	rm.sched = append(rm.sched, cardAnim{
@@ -864,84 +819,6 @@ func (rm *room) recordDealerDraw(start time.Time, cardIdx int) {
 	})
 }
 
-// --- insurance -------------------------------------------------------------
-
-func (rm *room) enterInsurance(r kit.Room) {
-	rm.phase = phInsurance
-	rm.deadline = r.Now().Add(insuranceDur)
-	r.SetInputContext(kit.CtxCommand) // y/n are domain commands
-	rm.arm(pendInsurance, rm.deadline)
-}
-
-func (rm *room) takeInsurance(s *seat, yes bool) {
-	if s == nil || !s.placed || s.insuranceDecided {
-		return
-	}
-	s.insuranceDecided = true
-	if yes {
-		ins := s.bet / 2
-		if ins <= 0 {
-			return
-		}
-		if !rm.wager(s, ins) {
-			return // can't cover the insurance stake: decline it
-		}
-		s.insurance = ins
-	}
-}
-
-// insuranceUndecidedCount is how many placed seats have not yet answered the
-// insurance offer (the seats the table is still waiting on).
-func (rm *room) insuranceUndecidedCount() int {
-	n := 0
-	for _, s := range rm.seats {
-		if s.placed && !s.insuranceDecided {
-			n++
-		}
-	}
-	return n
-}
-
-// maybeResolveInsurance resolves the insurance window early once every placed
-// seat has answered, instead of waiting out the timer (mirrors the all-bets-in
-// early deal and the all-ready results skip).
-func (rm *room) maybeResolveInsurance(r kit.Room) {
-	placed := false
-	for _, s := range rm.seats {
-		if !s.placed {
-			continue
-		}
-		placed = true
-		if !s.insuranceDecided {
-			return // still waiting on this seat
-		}
-	}
-	if !placed {
-		return
-	}
-	rm.what = pendNone // cancel the armed timer; resolving now
-	rm.resolveInsurance(r)
-}
-
-func (rm *room) resolveInsurance(r kit.Room) {
-	dbj := rm.dealer.isBlackjack()
-	// Order the credit loop by join order for determinism (chips are
-	// per-seat so order is harmless, but range-over-map is avoided on principle).
-	for _, id := range rm.order {
-		s := rm.seats[id]
-		if s == nil || s.insurance <= 0 {
-			continue
-		}
-		s.insuranceWin = insuranceCredit(dbj, s.insurance)
-		s.grossThisRound += int64(s.insuranceWin)
-	}
-	if dbj {
-		rm.revealAndSettle(r)
-		return
-	}
-	rm.enterTurns(r)
-}
-
 // --- player turns ----------------------------------------------------------
 
 func (rm *room) enterTurns(r kit.Room) {
@@ -1100,35 +977,24 @@ func (rm *room) handIndex(s *seat, h *phand) int {
 
 // --- dealer & settlement ---------------------------------------------------
 
-func (rm *room) revealAndSettle(r kit.Room) {
-	rm.dealerHole = false
-	// A beat with the card still face down, then turn it over, then hold a beat
-	// on the revealed blackjack before settling — so the reveal animates and
-	// registers instead of snapping straight to results.
-	flipAt := r.Now().Add(holeRevealDelay)
-	done := rm.recordHoleReveal(flipAt)
-	rm.settleAt(r, done.Add(dealerDoneHold))
-}
-
 func (rm *room) enterDealer(r kit.Room) {
-	rm.dealerHole = false
-	// The outcome is fixed up front from the seeded shoe; the schedule only paces
-	// the reveal. Hold a beat with the hole card still face down, turn it over,
-	// hold a beat on the dealer's two-card total, then slide in each hit one
-	// unhurried card at a time, and settle a final beat after the last card
-	// lands — never the flurry the initial deal uses.
-	flipAt := r.Now().Add(holeRevealDelay)
-	done := rm.recordHoleReveal(flipAt).Add(holeRevealHold)
+	// No hole card exists on this table: the dealer's turn is a lead-in beat,
+	// then each drawn card sliding in one unhurried card at a time. Pace off
+	// any animation still in flight (a table of instant naturals arrives here
+	// straight from the deal sweep) so the draw-out never overlaps it.
+	done := r.Now().Add(dealerLeadIn)
+	if rm.schedEnd.After(done) {
+		done = rm.schedEnd
+	}
 	if rm.anyLive() {
 		before := len(rm.dealer)
 		rm.dealer = dealerPlay(rm.dealer, rm.sh, r.Rand())
 		start := done
 		for i := before; i < len(rm.dealer); i++ {
 			rm.recordDealerDraw(start, i)
-			// The next hit waits for this card to fully land, plus a read beat.
 			start = start.Add(slideDur + flipDur + dealerDrawGap)
 		}
-		rm.computeSchedEnd() // across the appended dealer cards
+		rm.computeSchedEnd()
 		if len(rm.dealer) > before {
 			done = rm.schedEnd
 		}
@@ -1181,8 +1047,8 @@ func (rm *room) settle(r kit.Room) {
 			o := settleHandEx(h.cards, pbj, rm.dealer, dbj)
 			s.grossThisRound += int64(creditFor(o, h.bet))
 		}
-		// The Perfect Pairs and insurance side-bet wins already folded into gross
-		// as they resolved (deal / offer-close); settle the behind bets now.
+		// The Perfect Pairs side-bet wins already folded into gross at the deal;
+		// settle the behind bets now.
 		rm.settleBacks(s, dbj)
 		// Close the seat's open stake with ONE Settle of the accumulated gross
 		// (clamped to the payout ceiling), then feed the board on a new peak.
@@ -1316,17 +1182,6 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) {
 				}
 			}
 		}
-	case phInsurance:
-		if in.Kind == kit.InputRune {
-			switch in.Rune {
-			case 'y', 'Y':
-				rm.takeInsurance(s, true)
-				rm.maybeResolveInsurance(r) // all answered -> resolve, skip the timer
-			case 'n', 'N':
-				rm.takeInsurance(s, false)
-				rm.maybeResolveInsurance(r)
-			}
-		}
 	case phResults:
 		// Confirm (Enter/Space) readies up for the next hand; once every seated
 		// player is ready the table deals straight away instead of waiting out
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 5a2bca5..bcaaf8f 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -26,8 +26,8 @@ func newGame(t *testing.T, players ...kit.Player) (*room, *kittest.Room) {
 }
 
 // fund sets the seat's account-wide credits balance in the double AND the seat's
-// cached balance to n, so a subsequent Wager (deal/double/split/insurance) draws
-// against a known bankroll.
+// cached balance to n, so a subsequent Wager (deal/double/split) draws against a
+// known bankroll.
 func fund(tr *kittest.Room, s *seat, n int) {
 	if tr.Credits == nil {
 		tr.Credits = map[string]int64{}
@@ -75,7 +75,7 @@ func TestPairsSideBetLoopsOnP(t *testing.T) {
 	if s.pairsBet != 0 {
 		t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet)
 	}
-	s.bal = 100000                  // deep enough to afford every tier, so the loop wraps only at the top
+	s.bal = 100000                    // deep enough to afford every tier, so the loop wraps only at the top
 	rm.OnInput(tr, a, runeInput('p')) // P advances one tier
 	if s.pairsBet != pairsTiers[1] {
 		t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1])
@@ -201,9 +201,9 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) {
 	s.placed = true
 	s.pairsBet = 10
 	fund(tr, s, 1000)
-	// Stack the shoe: dealer up + hole, then the seat's two cards — a mixed pair.
+	// Stack the shoe: the dealer's one card, then the seat's two cards — a mixed pair.
 	rm.sh.cards = hand{
-		{10, suitClub}, {9, suitDiamond}, // dealer 19
+		{10, suitClub},                 // dealer
 		{8, suitSpade}, {8, suitHeart}, // seat: mixed pair of 8s
 		{2, suitClub}, {3, suitClub}, {4, suitClub}, // filler draws
 	}
@@ -243,9 +243,9 @@ func TestDealResolvesBackPairs(t *testing.T) {
 	fund(tr, sa, 1000)
 	fund(tr, sb, 1000)
 	sa.backs = map[string]*backBet{b.AccountID: {pairs: 10}} // a backs b's pairs
-	// dealer up+hole, then seat a's two cards, then seat b's two cards (a mixed pair).
+	// the dealer's one card, then seat a's two cards, then seat b's two cards (a mixed pair).
 	rm.sh.cards = hand{
-		{10, suitClub}, {9, suitDiamond}, // dealer
+		{10, suitClub},                 // dealer
 		{2, suitSpade}, {7, suitHeart}, // seat a (no pair)
 		{8, suitSpade}, {8, suitHeart}, // seat b: mixed pair of 8s
 		{3, suitClub}, {4, suitClub}, {5, suitClub}, // filler
@@ -278,7 +278,7 @@ func TestDealVoidsBackOnSatOutTarget(t *testing.T) {
 	fund(tr, sa, 1000)
 	sb.placed = false // b sits this round out
 	sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}}
-	rm.sh.cards = hand{{10, suitClub}, {9, suitDiamond}, {2, suitSpade}, {7, suitHeart}, {3, suitClub}, {4, suitClub}}
+	rm.sh.cards = hand{{10, suitClub}, {2, suitSpade}, {7, suitHeart}, {3, suitClub}, {4, suitClub}}
 	rm.sh.pos, rm.sh.roundStart = 0, 0
 	rm.deal(tr)
 
@@ -300,7 +300,7 @@ func TestSettleBehindBetWinFolds(t *testing.T) {
 	rm.OnJoin(tr, b)
 	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
 	sa.placed, sa.bet = true, 25
-	staked(tr, sa, 925, 75) // a's open stake: main 25 + behind 50 escrowed
+	staked(tr, sa, 925, 75)                                                     // a's open stake: main 25 + behind 50 escrowed
 	sa.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}}, bet: 25}} // a: 5, loses
 	sb.placed, sb.bet = true, 25
 	sb.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // b: 19, beats dealer
@@ -326,7 +326,7 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
 	rm.OnJoin(tr, b)
 	sa := rm.seats[a.AccountID]
 	sa.placed, sa.bet = true, 25
-	staked(tr, sa, 900, 75) // main 25 + behind 50 escrowed; 900 bankroll left
+	staked(tr, sa, 900, 75)                                                      // main 25 + behind 50 escrowed; 900 bankroll left
 	sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes
 	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
 	delete(rm.seats, b.AccountID) // b left mid-round
@@ -353,9 +353,9 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
 	s.placed = true
 	s.bet = 50
 	s.pairsBet = 10
-	staked(tr, s, 940, 60)   // main 50 + pairs 10 escrowed
-	s.pairsWin = 70          // a mixed pair already resolved at deal...
-	s.grossThisRound = 70    // ...and folded into the open stake's gross
+	staked(tr, s, 940, 60)                                                      // main 50 + pairs 10 escrowed
+	s.pairsWin = 70                                                             // a mixed pair already resolved at deal...
+	s.grossThisRound = 70                                                       // ...and folded into the open stake's gross
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
 	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 -> hand pushes
 	rm.settle(tr)
@@ -637,34 +637,6 @@ func TestBlackjackPays3to2(t *testing.T) {
 	}
 }
 
-func TestInsurancePaysOnDealerBlackjack(t *testing.T) {
-	a := mkPlayer("a")
-	rm, tr := newGame(t, a)
-	rm.OnJoin(tr, a)
-	s := rm.seats[a.AccountID]
-	s.placed = true
-	s.bet = 50
-	fund(tr, s, 950)
-	rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}} // dealer blackjack
-	rm.dealerHole = true
-
-	rm.takeInsurance(s, true) // insurance = 25 Wagered onto the open stake -> bal 925
-	if s.insurance != 25 || s.bal != 925 {
-		t.Fatalf("after taking insurance: ins=%d bal=%d, want 25/925", s.insurance, s.bal)
-	}
-
-	rm.resolveInsurance(tr)     // 2:1 -> +75 folds into gross, dealer BJ defers settle
-	pump(rm, tr, 5*time.Second) // let the reveal + settle play out
-
-	// The insurance 2:1 (75 gross) settles the 25 stake: 925 -> 1000.
-	if s.bal != 1000 {
-		t.Errorf("bal = %d, want 1000 (insurance paid 2:1)", s.bal)
-	}
-	if rm.dealerHole {
-		t.Error("dealer hole card should be revealed after a dealer blackjack")
-	}
-}
-
 // TestDealingOrderIsDeterministic asserts the deal ranges the join-ordered slice,
 // never Go's map iteration order: two identically-seeded rooms deal identical
 // hands to the same seats.
@@ -888,8 +860,9 @@ func TestDoubledHandRendersDBL(t *testing.T) {
 }
 
 // dealerReady puts the table at the moment the last player has resolved, with a
-// live (non-bust) player so the dealer will reveal and draw. The dealer's hand
-// and the shoe's next draws are caller-supplied so the reveal is deterministic.
+// live (non-bust) player so the dealer will draw out. The dealer's single dealt
+// card and the shoe's next draws are caller-supplied so the draw-out is
+// deterministic.
 func dealerReady(t *testing.T, dealer hand, nextDraws ...card) (*room, *kittest.Room, kit.Player) {
 	t.Helper()
 	a := mkPlayer("a")
@@ -901,7 +874,6 @@ func dealerReady(t *testing.T, dealer hand, nextDraws ...card) (*room, *kittest.
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50, resolved: true}} // 19, live
 	rm.phase = phTurns
 	rm.dealer = dealer
-	rm.dealerHole = true
 	if len(nextDraws) > 0 {
 		rm.sh.cards = append(hand(nil), nextDraws...) // stack the shoe's top
 		rm.sh.pos = 0
@@ -915,166 +887,79 @@ func dealerReady(t *testing.T, dealer hand, nextDraws ...card) (*room, *kittest.
 // Before the fix the label keyed off the authoritative (already complete) hand,
 // so BUST flashed up the instant the dealer's turn began.
 func TestDealerBustWaitsForTheCardToLand(t *testing.T) {
-	// Dealer 16 hits a ten -> 26, a deterministic bust on the first draw.
-	rm, tr, a := dealerReady(t, hand{{10, suitClub}, {6, suitDiamond}}, card{10, suitClub})
+	// Dealer starts on 10, draws a 6 (16, keeps drawing) then a ten -> 26, a
+	// deterministic bust on the second draw.
+	rm, tr, a := dealerReady(t, hand{{10, suitClub}}, card{6, suitDiamond}, card{10, suitClub})
 
-	rm.enterDealer(tr) // schedules the slow reveal + the busting hit
+	rm.enterDealer(tr) // schedules the draw-out, including the busting hit
 	rm.render(tr)
 
 	if !rm.dealingActive() {
-		t.Fatal("dealer reveal should still be animating right after enterDealer")
+		t.Fatal("dealer draw-out should still be animating right after enterDealer")
 	}
 	if row := kittest.String(tr.LastFrame(a), dealerValRow); strings.Contains(row, "BUST") {
 		t.Fatalf("dealer BUST shown before the hit landed: %q", row)
 	}
 
-	// Once the whole reveal has played out the bust shows and the round settles
+	// Once the whole draw-out has played out the bust shows and the round settles
 	// (stay inside the results window, before the next betting round clears it).
 	pump(rm, tr, 5*time.Second)
 	if rm.phase != phResults {
-		t.Fatalf("phase = %q, want results once the reveal finished", rm.phase)
+		t.Fatalf("phase = %q, want results once the draw-out finished", rm.phase)
 	}
 	if row := kittest.String(tr.LastFrame(a), dealerValRow); !strings.Contains(row, "BUST") {
 		t.Fatalf("dealer BUST not shown after the hit landed: %q", row)
 	}
 }
 
-// TestDealerRevealIsPaced asserts the reveal is unhurried: settlement is deferred
-// well past the bare hole-card flip even when the dealer stands pat, so the
-// turned hole card and its total have time to read.
+// TestDealerRevealIsPaced asserts the draw-out is unhurried: settlement is
+// deferred well past the dealer's lead-in beat, so the drawn card and the
+// dealer's total have time to read.
 func TestDealerRevealIsPaced(t *testing.T) {
-	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {8, suitDiamond}}) // 18, no hit
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}}, card{8, suitDiamond}) // draws to 18, stands
 
 	start := tr.Now()
 	rm.enterDealer(tr)
 
 	if rm.what != pendSettle {
-		t.Fatalf("dealer reveal should defer settlement, what = %v", rm.what)
+		t.Fatalf("dealer draw-out should defer settlement, what = %v", rm.what)
 	}
-	if delay := rm.pendAt.Sub(start); delay < holeRevealHold {
-		t.Fatalf("settle deferred only %v, want at least the hole-reveal hold %v", delay, holeRevealHold)
+	if delay := rm.pendAt.Sub(start); delay < dealerLeadIn {
+		t.Fatalf("settle deferred only %v, want at least the lead-in %v", delay, dealerLeadIn)
 	}
 }
 
 // TestDealerTotalTicksUpAsCardsLand checks the displayed total reflects only the
-// face-up cards: just the up card while the hole is concealed, then the hole, in
-// step with the animation rather than the full hand up front.
+// face-up cards: just the one dealt card before the draw-out, then each drawn
+// card in step with the animation rather than the full hand up front.
 func TestDealerTotalTicksUpAsCardsLand(t *testing.T) {
-	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {7, suitDiamond}}) // 17, stands
+	rm, tr, _ := dealerReady(t, hand{{10, suitClub}}, card{7, suitDiamond}) // draws to 17, stands
 
-	// Mid-turn the hole is still hidden: only the up card counts.
+	// Before the dealer's turn, only the one dealt card counts.
 	if got := rm.dealerShownCount(); got != 1 {
-		t.Fatalf("with the hole concealed, shown count = %d, want 1", got)
+		t.Fatalf("before the draw-out, shown count = %d, want 1", got)
 	}
 
-	rm.enterDealer(tr) // turns the hole; the reveal flip is still in flight
+	rm.enterDealer(tr) // schedules the draw; still mid lead-in
 	rm.render(tr)
 	if got := rm.dealerShownCount(); got != 1 {
-		t.Fatalf("mid hole-flip, shown count = %d, want 1 (hole not yet face up)", got)
+		t.Fatalf("mid lead-in, shown count = %d, want 1 (drawn card not yet arrived)", got)
 	}
 
-	// After the reveal settles (still within the results window) both cards
+	// After the draw-out settles (still within the results window) both cards
 	// count and the total is complete.
 	pump(rm, tr, 3*time.Second)
 	if rm.phase != phResults {
-		t.Fatalf("phase = %q, want results once the reveal finished", rm.phase)
+		t.Fatalf("phase = %q, want results once the draw-out finished", rm.phase)
 	}
 	if got := rm.dealerShownCount(); got != 2 {
-		t.Fatalf("after the reveal, shown count = %d, want 2 (both cards face up)", got)
+		t.Fatalf("after the draw-out, shown count = %d, want 2 (both cards face up)", got)
 	}
 	if got := rm.dealer.total(); got != 17 {
 		t.Fatalf("dealer total = %d, want 17", got)
 	}
 }
 
-// TestDealerHoleRevealIsDelayedAndAnimated asserts the second (hole) card turns
-// over after a short lead-in beat rather than the instant the dealer's turn
-// begins, and that it is an in-place flip animation (a flip scheduled, no slide)
-// so it visibly turns rather than snapping face up.
-func TestDealerHoleRevealIsDelayedAndAnimated(t *testing.T) {
-	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {8, suitDiamond}}) // 18, stands pat
-
-	start := tr.Now()
-	rm.enterDealer(tr)
-
-	if len(rm.sched) == 0 {
-		t.Fatal("no dealer reveal animation scheduled")
-	}
-	hole := rm.sched[0]
-	if hole.kind != animDealer || hole.cardIdx != 1 {
-		t.Fatalf("first scheduled anim is not the hole card: %+v", hole)
-	}
-	if lead := hole.flipStart.Sub(start); lead < holeRevealDelay {
-		t.Fatalf("hole flip starts %v after the turn, want at least the lead-in %v", lead, holeRevealDelay)
-	}
-	if hole.flipStart.IsZero() {
-		t.Fatal("hole reveal has no flip scheduled - it would snap, not animate")
-	}
-	if !hole.slideStart.IsZero() {
-		t.Fatalf("hole reveal should turn in place, not slide: %+v", hole)
-	}
-
-	// Through the lead-in beat the hole is still concealed: only the up card counts.
-	pump(rm, tr, holeRevealDelay/2)
-	if got := rm.dealerShownCount(); got != 1 {
-		t.Fatalf("during the lead-in, shown count = %d, want 1 (hole not yet turned)", got)
-	}
-}
-
-// TestDealerHitSlotHiddenUntilHoleShown asserts the dealer's row stays two cards
-// wide — no slot reserved for the upcoming hit — until the hole card has turned
-// over and that hit actually begins to slide in.
-func TestDealerHitSlotHiddenUntilHoleShown(t *testing.T) {
-	// Dealer 16 will draw a ten; with a live player the reveal schedules the hit.
-	rm, tr, _ := dealerReady(t, hand{{10, suitClub}, {6, suitDiamond}}, card{10, suitClub})
-	rm.enterDealer(tr)
-
-	// Before the hit begins arriving (through the lead-in, flip, and read beat),
-	// only the two dealt cards occupy the row even though the full hand is known.
-	if got := len(rm.dealer); got != 3 {
-		t.Fatalf("dealer hand size = %d, want 3 (hit already computed)", got)
-	}
-	rm.render(tr)
-	if got := rm.dealerLayoutCount(); got != 2 {
-		t.Fatalf("layout count = %d right after the turn, want 2 (no slot for the pending hit)", got)
-	}
-
-	// Past the reveal + read beat the hit is sliding in, so the row now includes it.
-	pump(rm, tr, holeRevealDelay+flipDur+holeRevealHold+slideDur/2)
-	if got := rm.dealerLayoutCount(); got != 3 {
-		t.Fatalf("layout count = %d once the hit is arriving, want 3", got)
-	}
-}
-
-// TestInsuranceSkipsTimerOnceAllDecided covers the insurance early-resolve: once
-// every placed seat has answered, the window resolves without waiting out the
-// timer.
-func TestInsuranceSkipsTimerOnceAllDecided(t *testing.T) {
-	a, b := mkPlayer("a"), mkPlayer("b")
-	rm, tr := newGame(t, a, b)
-	rm.OnJoin(tr, a)
-	rm.OnJoin(tr, b)
-	for _, p := range []kit.Player{a, b} {
-		s := rm.seats[p.AccountID]
-		s.placed = true
-		s.bet = 50
-		s.bal = 950
-		s.hands = []*phand{{cards: hand{{10, suitSpade}, {7, suitHeart}}, bet: 50}}
-	}
-	rm.dealer = hand{{rankAce, suitSpade}, {6, suitHeart}} // shows an Ace, no blackjack
-	rm.dealerHole = true
-	rm.enterInsurance(tr)
-
-	rm.OnInput(tr, a, runeInput('n')) // a answers
-	if rm.phase != phInsurance {
-		t.Fatalf("phase = %q, want still insurance (b has not answered)", rm.phase)
-	}
-	rm.OnInput(tr, b, runeInput('y')) // b answers -> every placed seat decided
-	if rm.phase != phTurns {
-		t.Fatalf("phase = %q, want turns (insurance resolved early without the timer)", rm.phase)
-	}
-}
-
 // TestResultLabelDoesNotLeakChips guards the results chip line: the settlement
 // summary is drawn instead of the stack, so a result narrower than the stack
 // (e.g. "PUSH" over "$1000") leaves no stray digit peeking out beside it.
@@ -1437,36 +1322,6 @@ func TestPairsVerdictHeldUntilCardsLand(t *testing.T) {
 	}
 }
 
-// TestInsuranceFoldsIntoResultNet asserts an insured loss to a dealer blackjack
-// reconciles in the results summary: the 2:1 insurance payout exactly offsets
-// the main-bet loss, so the seat reads PUSH (net 0) and its chips are unchanged,
-// rather than a phantom LOSE that the chip stack never reflected.
-func TestInsuranceFoldsIntoResultNet(t *testing.T) {
-	a := mkPlayer("alice")
-	rm, tr := newGame(t, a)
-	rm.what = pendNone
-	rm.OnJoin(tr, a)
-	s := rm.seats[a.AccountID]
-	s.placed = true
-	s.bet = 100
-	staked(tr, s, 900, 100)                                                      // main 100 escrowed at the deal
-	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 100}} // 19, loses to BJ
-	rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}}                // dealer blackjack
-	rm.dealerHole = true
-
-	rm.takeInsurance(s, true)   // stake 50 Wagered -> bal 850, roundStake 150
-	rm.resolveInsurance(tr)     // dealer BJ pays 2:1 (+150 gross), defers settle
-	pump(rm, tr, 5*time.Second) // reveal + single Settle plays out
-
-	// Insurance gross 150 exactly offsets the lost main bet on a 150 stake: net 0.
-	if s.bal != 1000 {
-		t.Fatalf("bal = %d, want 1000 (insured loss breaks even)", s.bal)
-	}
-	if s.result != "PUSH" {
-		t.Fatalf("result = %q, want PUSH (insurance offsets the main-bet loss in the net)", s.result)
-	}
-}
-
 // TestManyCardHandStaysInItsSlot is the regression guard for the reported bug: a
 // hit-heavy hand of five or more cards has a card box wider than the 15-col seat
 // slot, so it used to spill past the slot's right edge into the neighbouring
diff --git a/games/bcook/blackjack-challenge/round.go b/games/bcook/blackjack-challenge/round.go
index 8da766e..4bfbf24 100644
--- a/games/bcook/blackjack-challenge/round.go
+++ b/games/bcook/blackjack-challenge/round.go
@@ -79,15 +79,6 @@ func creditFor(o outcome, bet int) int {
 	}
 }
 
-// insuranceCredit returns the chips paid back on an insurance side bet of `ins`.
-// Insurance pays 2:1 when the dealer has blackjack (stake + 2×stake), else 0.
-func insuranceCredit(dealerBlackjack bool, ins int) int {
-	if dealerBlackjack {
-		return ins * 3
-	}
-	return 0
-}
-
 // perfectPairsOutcome classifies a player's first two cards for the Perfect
 // Pairs side bet and returns the result kind and its payout multiplier (the X
 // in X:1), or ("", 0) when the cards are not a pair. A perfect pair is the same
diff --git a/games/bcook/blackjack-challenge/round_test.go b/games/bcook/blackjack-challenge/round_test.go
index 637d9a5..8bef175 100644
--- a/games/bcook/blackjack-challenge/round_test.go
+++ b/games/bcook/blackjack-challenge/round_test.go
@@ -81,15 +81,6 @@ func TestCreditFor(t *testing.T) {
 	}
 }
 
-func TestInsuranceCredit(t *testing.T) {
-	if got := insuranceCredit(true, 50); got != 150 { // stake 50 + 2:1 (100)
-		t.Errorf("insurance on dealer blackjack = %d, want 150", got)
-	}
-	if got := insuranceCredit(false, 50); got != 0 {
-		t.Errorf("insurance with no dealer blackjack = %d, want 0", got)
-	}
-}
-
 func TestPerfectPairsOutcome(t *testing.T) {
 	cases := []struct {
 		name     string

From ba35c309d2f40fc5017eca455cb1f9a277d01d84 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:48:30 +1000
Subject: [PATCH 06/14] blackjack-challenge: pin the dealer draw-out cadence;
 retire hole-card prose

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/anim.go      |  2 +-
 games/bcook/blackjack-challenge/layout.go    |  7 ++++---
 games/bcook/blackjack-challenge/main.go      |  5 +++--
 games/bcook/blackjack-challenge/room_test.go | 13 +++++++++----
 4 files changed, 17 insertions(+), 10 deletions(-)

diff --git a/games/bcook/blackjack-challenge/anim.go b/games/bcook/blackjack-challenge/anim.go
index caecd11..92774f0 100644
--- a/games/bcook/blackjack-challenge/anim.go
+++ b/games/bcook/blackjack-challenge/anim.go
@@ -84,7 +84,7 @@ func (a cardAnim) slideProgress(now time.Time) float64 {
 // reports whether a flip is still in progress (a settled card returns 2,false).
 func (a cardAnim) flipFrame(now time.Time) (frame int, flipping bool) {
 	if a.flipStart.IsZero() {
-		return 2, false // no reveal scheduled (e.g. concealed hole card)
+		return 2, false // no reveal flip scheduled
 	}
 	d := now.Sub(a.flipStart)
 	switch {
diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index b33cd58..85637b3 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -415,8 +415,8 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
 			return
 		default:
 			// No hand left on turn: every player has resolved and the dealer is
-			// turning its hole card and drawing. Name the moment so the slow
-			// reveal reads as the dealer acting rather than a frozen table.
+			// drawing out from its one face-up card. Name the moment so the slow
+			// draw-out reads as the dealer acting rather than a frozen table.
 			msg, st = "dealer plays...", stDim
 		}
 	case phResults:
@@ -663,7 +663,8 @@ type cardFace struct {
 
 // drawCardsAnim renders the joined card group, consulting resolve(i) for each
 // card's animation aspect. resolve may be nil (the static layout). hideIdx
-// conceals a card as "??" (the dealer hole card).
+// conceals a card as "??" — a concealment hook currently unused on this table
+// (every caller passes -1; no card is ever dealt face down).
 func drawCardsAnim(f *kit.Frame, row, col int, cards hand, hideIdx int, resolve func(i int) cardFace) {
 	if len(cards) == 0 {
 		f.Text(row+1, col, "( )", stDim)
diff --git a/games/bcook/blackjack-challenge/main.go b/games/bcook/blackjack-challenge/main.go
index d071203..7141b52 100644
--- a/games/bcook/blackjack-challenge/main.go
+++ b/games/bcook/blackjack-challenge/main.go
@@ -1,7 +1,8 @@
 // Blackjack Challenge — a no-winner, social multiplayer blackjack table on
 // the 80x24 shellcade canvas: one shared auto-dealer, up to five seats, and
-// rounds that loop while anyone is seated. The dealer's up card is face-up
-// and stands on all 17 (S17); a tie hand loses instead of pushing, and a
+// rounds that loop while anyone is seated. The dealer takes a single face-up
+// card at the deal — no hole card — draws out at the dealer's turn, and
+// stands on all 17 (S17); a tie hand loses instead of pushing, and a
 // player blackjack is ranked, paying 2:1 up to 5:1 depending on its suits.
 // Bet, hit, stand, double, and split; the round also auto-wins/auto-loses a
 // hand outright without a turn where the outcome is already decided, and a
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index bcaaf8f..0b1318b 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -913,8 +913,9 @@ func TestDealerBustWaitsForTheCardToLand(t *testing.T) {
 }
 
 // TestDealerRevealIsPaced asserts the draw-out is unhurried: settlement is
-// deferred well past the dealer's lead-in beat, so the drawn card and the
-// dealer's total have time to read.
+// deferred by the full per-card cadence, not just the lead-in beat. This
+// scenario stages exactly one draw, so the settle deadline must cover the
+// lead-in, that card's slide and flip, and the done-hold on the made hand.
 func TestDealerRevealIsPaced(t *testing.T) {
 	rm, tr, _ := dealerReady(t, hand{{10, suitClub}}, card{8, suitDiamond}) // draws to 18, stands
 
@@ -924,8 +925,12 @@ func TestDealerRevealIsPaced(t *testing.T) {
 	if rm.what != pendSettle {
 		t.Fatalf("dealer draw-out should defer settlement, what = %v", rm.what)
 	}
-	if delay := rm.pendAt.Sub(start); delay < dealerLeadIn {
-		t.Fatalf("settle deferred only %v, want at least the lead-in %v", delay, dealerLeadIn)
+	// One drawn card at minimum: the lead-in beat, then the card slides in and
+	// flips face up, then the final hold before results (dealerDrawGap only
+	// separates consecutive draws, so it does not appear on a single draw).
+	minDelay := dealerLeadIn + slideDur + flipDur + dealerDoneHold
+	if delay := rm.pendAt.Sub(start); delay < minDelay {
+		t.Fatalf("settle deferred only %v, want at least the one-draw cadence %v", delay, minDelay)
 	}
 }
 

From b81112ab591e3974751f8727f746e0b2d9e38f2b Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sat, 4 Jul 2026 23:59:15 +1000
Subject: [PATCH 07/14] blackjack-challenge: auto-wins, 3-card double,
 point-value split to three hands, no surrender

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/layout.go    |  52 ++----
 games/bcook/blackjack-challenge/room.go      |  60 +++---
 games/bcook/blackjack-challenge/room_test.go | 186 +++++++++++++------
 3 files changed, 180 insertions(+), 118 deletions(-)

diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 85637b3..e459bb0 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -217,12 +217,6 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
 			line, st := compactHandLine(h, active && ah == h)
 			centerSlot(f, seatCardRow+hi, slot, line, st)
 		}
-		// Split aces take exactly one card each and stand — so both hands lock the
-		// moment they're split and the turn passes on. Name the rule beneath them,
-		// so a locked "can't hit, turn moved on" reads as intended, not broken.
-		if splitAces(s) {
-			centerSlot(f, seatValRow, slot, "aces: 1 card", stDim)
-		}
 		if rm.phase == phResults && s.result != "" {
 			centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result))
 		} else {
@@ -243,7 +237,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
 		}
 		drawCardsAnim(f, seatCardRow, col, h.cards, -1, rm.seatResolver(s.p, hi, h))
 		col += w + 1
-		vals = append(vals, valueLabel(h.cards, h.fromSplit)+dblTag(h))
+		vals = append(vals, valueLabel(h.cards, h.autoWon)+dblTag(h))
 	}
 	// During results the value line doubles as the ready indicator: a readied
 	// seat shows READY where its hand total was, so who's holding up the table
@@ -450,37 +444,19 @@ func (rm *room) unplacedCount() int {
 	return n
 }
 
-// splitAces reports whether a seat's hands are a split pair of aces — two or more
-// hands, all formed by splitting, each led by an ace. Such hands take one card
-// each and cannot be played on, so the felt names the rule beside them.
-func splitAces(s *seat) bool {
-	if len(s.hands) < 2 {
-		return false
-	}
-	for _, h := range s.hands {
-		if !h.fromSplit || len(h.cards) == 0 || h.cards[0].r != rankAce {
-			return false
-		}
-	}
-	return true
-}
-
 // legalActions lists the action prompts available for hand h.
 func legalActions(s *seat, h *phand) string {
 	if h == nil {
 		return ""
 	}
 	parts := []string{"[H]it", "[S]tand"}
-	first := len(h.cards) == 2 && !h.doubled
-	if first && s.bal >= h.bet {
+	if !h.doubled && len(h.cards) <= 3 && s.bal >= h.bet {
 		parts = append(parts, "[D]ouble")
 	}
-	if first && h.cards[0].r == h.cards[1].r && s.bal >= h.bet && len(s.hands) < maxHands {
+	if len(h.cards) == 2 && h.cards[0].r.points() == h.cards[1].r.points() &&
+		s.bal >= h.bet && len(s.hands) < maxHands {
 		parts = append(parts, "[P]split")
 	}
-	if first && len(s.hands) == 1 {
-		parts = append(parts, "[R]surrender")
-	}
 	return strings.Join(parts, "  ")
 }
 
@@ -834,7 +810,7 @@ func compactHandLine(h *phand, active bool) (string, kit.Style) {
 		cards.WriteString(c.r.boxLabel())
 		cards.WriteRune(c.s.pip())
 	}
-	total := valueLabel(h.cards, h.fromSplit) + dblTag(h)
+	total := valueLabel(h.cards, h.autoWon) + dblTag(h)
 	marker, st := " ", stCard
 	if h.cards.isBust() {
 		st = stLose
@@ -862,19 +838,23 @@ func dblTag(h *phand) string {
 	return ""
 }
 
-// valueLabel formats a hand's total for the felt. Only a NATURAL two-card 21
-// (not one formed by splitting) reads as "BJ"; a split two-card 21 — the kind a
-// split ace hitting a ten makes — reads as a plain "21", since it is a plain 21
-// (even money, not a 3:2 blackjack) and labelling it "BJ" would mislead.
-func valueLabel(h hand, fromSplit bool) string {
+// valueLabel formats a hand's total for the felt. Any two-card 21 reads as
+// "BJ" — split hands included, they ARE blackjacks on this table. An
+// auto-won hand names its win: "21!" for a Player 21, "5-CARD" for a Five
+// Card Trick.
+func valueLabel(h hand, autoWon bool) string {
 	total, soft := h.value()
 	switch {
 	case total > 21:
 		return "BUST"
-	case total == 21 && len(h) == 2 && !fromSplit:
+	case len(h) == 2 && total == 21:
 		return "BJ"
+	case autoWon && len(h) >= 5:
+		return "5-CARD"
+	case autoWon:
+		return "21!"
 	case total == 21:
-		return "21" // any other 21 (split-ace 21, multi-card 21) reads plainly
+		return "21"
 	case soft:
 		return fmt.Sprintf("s%d", total)
 	default:
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index e0f328c..28655eb 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -18,7 +18,7 @@ const (
 )
 
 const (
-	maxHands = 4 // a seat may split up to four hands
+	maxHands = 3 // split up to twice per seat, forming three hands (table rule)
 
 	bettingDur = 15 * time.Second
 	turnDur    = 20 * time.Second
@@ -864,20 +864,35 @@ func (rm *room) autoStand(r kit.Room) {
 	rm.beginTurn(r)
 }
 
+// autoWin marks h an automatic even-money winner — Player 21 (any
+// non-blackjack 21) or Five Card Trick (five cards without busting) — and
+// reports whether it fired. A blackjack is NOT an auto-win: it settles at
+// its own ranked odds once the dealer's hand is known.
+func autoWin(h *phand) bool {
+	if h.cards.isBust() || h.cards.isBlackjack() {
+		return false
+	}
+	if h.cards.total() == 21 || len(h.cards) == 5 {
+		h.autoWon = true
+		h.resolved = true
+		return true
+	}
+	return false
+}
+
 func (rm *room) act(r kit.Room, p kit.Player, a rune) {
 	s, h := rm.firstUnresolved()
 	if s == nil || s.p.AccountID != p.AccountID {
 		return // not this player's turn
 	}
 	hi := rm.handIndex(s, h)
-	first := len(h.cards) == 2 && !h.doubled // first decision on this hand
 	// A REJECTED action returns without beginTurn: re-arming there would reset
 	// the turn deadline, letting a player stall their own clock with no-ops.
 	switch a {
 	case 'h':
 		h.cards = append(h.cards, rm.sh.draw(r.Rand()))
 		rm.recordDraw(r, p, hi, len(h.cards)-1)
-		if h.cards.isBust() || h.cards.total() == 21 {
+		if !autoWin(h) && h.cards.isBust() {
 			h.resolved = true
 		}
 		rm.beginTurn(r)
@@ -885,10 +900,9 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) {
 		h.resolved = true
 		rm.beginTurn(r)
 	case 'd':
-		// The double's extra stake Wagers onto the seat's open stake; a failed
-		// Wager (can't cover it) rejects the action, exactly as the old
-		// `chips < bet` guard did — never proceed on a failed Wager.
-		if !first {
+		// Doubling is open on a two- OR three-card hand (original or split) that
+		// hasn't already doubled — the Challenge extension of the usual gate.
+		if h.doubled || len(h.cards) > 3 {
 			return
 		}
 		if !rm.wager(s, h.bet) {
@@ -898,6 +912,7 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) {
 		h.doubled = true
 		h.cards = append(h.cards, rm.sh.draw(r.Rand()))
 		rm.recordDraw(r, p, hi, len(h.cards)-1)
+		autoWin(h) // a doubled 21 is a Player 21: instant even money
 		h.resolved = true
 		rm.beginTurn(r)
 	case 'p':
@@ -905,24 +920,15 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) {
 			return
 		}
 		rm.beginTurn(r)
-	case 'r':
-		if !first || len(s.hands) != 1 {
-			return
-		}
-		h.surrendered = true
-		h.resolved = true
-		// Half the stake is returned at settle: settle() folds halfUp(h.bet) into
-		// the seat's gross for a surrendered hand (the full bet is already escrowed
-		// on the open stake). An odd bet's half-chip rounds UP to the player
-		// (halfUp owns the policy, shared with the 3:2 payout in creditFor).
-		rm.beginTurn(r)
 	}
 }
 
-// split turns a two-card equal-rank pair into two hands, each taking a new card,
-// reporting whether the split happened. Split aces take one card and stand.
+// split turns a two-card pair of equal POINT VALUE (a K and a ten split on
+// this table) into two hands, each taking a new card, reporting whether the
+// split happened. Split hands play on in full — and an Ace + ten-card drawn
+// to one IS a Challenge blackjack, resolved on the spot at its ranked odds.
 func (rm *room) split(r kit.Room, s *seat, h *phand) bool {
-	if len(h.cards) != 2 || h.cards[0].r != h.cards[1].r || len(s.hands) >= maxHands {
+	if len(h.cards) != 2 || h.cards[0].r.points() != h.cards[1].r.points() || len(s.hands) >= maxHands {
 		return false
 	}
 	// The new hand's stake Wagers onto the seat's open stake; a failed Wager
@@ -932,12 +938,12 @@ func (rm *room) split(r kit.Room, s *seat, h *phand) bool {
 	}
 	c0, c1 := h.cards[0], h.cards[1]
 	rng := r.Rand()
-	nh := &phand{cards: hand{c1, rm.sh.draw(rng)}, bet: h.bet, fromSplit: true}
+	nh := &phand{cards: hand{c1, rm.sh.draw(rng)}, bet: h.bet}
 	h.cards = hand{c0, rm.sh.draw(rng)}
-	h.fromSplit = true
-	if c0.r == rankAce {
-		h.resolved = true
-		nh.resolved = true
+	for _, sp := range []*phand{h, nh} {
+		if sp.cards.isBlackjack() {
+			sp.resolved = true
+		}
 	}
 	// insert nh directly after h
 	idx := 0
@@ -1209,8 +1215,6 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) {
 				rm.act(r, p, 'd')
 			case 'p', 'P':
 				rm.act(r, p, 'p')
-			case 'r', 'R':
-				rm.act(r, p, 'r')
 			}
 		}
 	}
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 0b1318b..b845830 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -502,22 +502,147 @@ func TestSplitFormsTwoHands(t *testing.T) {
 	}
 }
 
-func TestSplitAcesTakeOneCardEach(t *testing.T) {
-	rm, tr, a, _ := turnsSetup(t, hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
-	fund(tr, rm.seats[a.AccountID], 950)
-	rm.OnInput(tr, a, runeInput('p'))
+func TestHitTo21AutoWins(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {6, suitHeart}}, bet: 100}}
+	rm.sh.cards[rm.sh.pos] = card{5, suitClub} // next draw: 16 + 5 = 21
+	rm.act(tr, a, 'h')
+	h := s.hands[0]
+	if !h.autoWon || !h.resolved {
+		t.Errorf("hit to 21: autoWon=%v resolved=%v, want true/true (Player 21)", h.autoWon, h.resolved)
+	}
+}
+
+func TestFiveCardTrickAutoWins(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}, {4, suitClub}, {5, suitDiamond}}, bet: 100}}
+	rm.sh.cards[rm.sh.pos] = card{2, suitClub} // fifth card, total 16 <= 21
+	rm.act(tr, a, 'h')
+	h := s.hands[0]
+	if !h.autoWon || !h.resolved {
+		t.Errorf("five cards under 21: autoWon=%v resolved=%v, want true/true", h.autoWon, h.resolved)
+	}
+}
 
+func TestDoubleAllowedOnThreeCards(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
 	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}, {4, suitClub}}, bet: 100}}
+	rm.sh.cards[rm.sh.pos] = card{10, suitClub} // doubled draw: 9 + 10 = 19
+	rm.act(tr, a, 'd')
+	h := s.hands[0]
+	if !h.doubled || h.bet != 200 || len(h.cards) != 4 || !h.resolved {
+		t.Errorf("3-card double: doubled=%v bet=%d cards=%d resolved=%v", h.doubled, h.bet, len(h.cards), h.resolved)
+	}
+}
+
+func TestSplitOnEqualPointValue(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {10, suitHeart}}, bet: 100}} // K+10: same points, different rank
+	rm.act(tr, a, 'p')
 	if len(s.hands) != 2 {
-		t.Fatalf("split aces: %d hands, want 2", len(s.hands))
+		t.Fatalf("K+10 should split on this table: hands = %d, want 2", len(s.hands))
 	}
+}
+
+func TestResplitCapsAtThreeHands(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 10000)
+	rm.phase = phTurns
+	s.hands = []*phand{
+		{cards: hand{{8, suitSpade}, {8, suitHeart}}, bet: 100},
+		{cards: hand{{9, suitSpade}, {9, suitHeart}}, bet: 100},
+		{cards: hand{{7, suitSpade}, {7, suitHeart}}, bet: 100},
+	}
+	if rm.split(tr, s, s.hands[0]) {
+		t.Error("fourth hand formed: split must cap at 3 hands per seat")
+	}
+}
+
+func TestSplitAcesPlayOn(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, bet: 100}}
+	rm.sh.cards[rm.sh.pos] = card{5, suitClub}   // first split hand's draw: A+5, playable
+	rm.sh.cards[rm.sh.pos+1] = card{7, suitClub} // second: A+7, playable
+	rm.act(tr, a, 'p')
 	for i, h := range s.hands {
-		if !h.resolved || len(h.cards) != 2 {
-			t.Errorf("split-ace hand %d: resolved=%v cards=%d, want resolved/2", i, h.resolved, len(h.cards))
+		if h.resolved {
+			t.Errorf("split-ace hand %d frozen; Challenge split hands play on", i)
 		}
 	}
 }
 
+func TestSplitHandBlackjackResolves(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, bet: 100}}
+	// Split draw order: the NEW hand (inserted after the original, at hands[1])
+	// draws first; the original hand (hands[0]) draws second. Stuff accordingly
+	// so hands[0] becomes the A+K blackjack and hands[1] the playable A+5.
+	rm.sh.cards[rm.sh.pos] = card{5, suitClub}          // first draw -> new hand (hands[1]): A+5
+	rm.sh.cards[rm.sh.pos+1] = card{rankKing, suitClub} // second draw -> original hand (hands[0]): A+K IS blackjack here
+	rm.act(tr, a, 'p')
+	if !s.hands[0].resolved || !s.hands[0].cards.isBlackjack() {
+		t.Errorf("split A+K should resolve as blackjack: resolved=%v", s.hands[0].resolved)
+	}
+	if s.hands[1].resolved {
+		t.Error("A+5 split hand should still be playable")
+	}
+}
+
+func TestSurrenderKeyIgnored(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	fund(tr, s, 900)
+	rm.phase = phTurns
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {6, suitHeart}}, bet: 100}}
+	rm.OnInput(tr, a, runeInput('r'))
+	if s.hands[0].resolved {
+		t.Error("no surrender on this table: R must be a no-op on a turn")
+	}
+}
+
 func TestTurnTimeoutAutoStands(t *testing.T) {
 	rm, tr, a, _ := turnsSetup(t, hand{{10, suitSpade}, {5, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
 	rm.beginTurn(tr) // arm the per-turn deadline
@@ -1246,53 +1371,6 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) {
 	}
 }
 
-// TestValueLabelSplit21NotBlackjack asserts a two-card 21 formed by splitting
-// reads as a plain "21", while a natural two-card 21 still reads "BJ" — a split
-// 21 pays even money, not 3:2, so labelling it "BJ" would mislead.
-func TestValueLabelSplit21NotBlackjack(t *testing.T) {
-	twentyOne := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
-	if got := valueLabel(twentyOne, false); got != "BJ" {
-		t.Errorf("natural two-card 21 label = %q, want BJ", got)
-	}
-	if got := valueLabel(twentyOne, true); got != "21" {
-		t.Errorf("split two-card 21 label = %q, want 21 (not BJ)", got)
-	}
-}
-
-// TestSplitAcesShowOneCardNote asserts a split pair of aces — which take one card
-// each and lock, passing the turn on immediately — names that rule on the felt,
-// so the player understands why the hands can't be played rather than reading it
-// as a bug.
-func TestSplitAcesShowOneCardNote(t *testing.T) {
-	a := mkPlayer("alice")
-	rm, tr := newGame(t, a)
-	rm.what = pendNone
-	rm.OnJoin(tr, a)
-	s := rm.seats[a.AccountID]
-	s.placed = true
-	s.bal = 900
-	s.hands = []*phand{
-		{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 50, fromSplit: true, resolved: true}, // 21
-		{cards: hand{{rankAce, suitClub}, {6, suitDiamond}}, bet: 50, fromSplit: true, resolved: true},       // soft 17
-	}
-	rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
-	rm.phase = phTurns
-	rm.render(tr)
-
-	f := tr.LastFrame(a)
-	if row := kittest.String(f, seatValRow); !strings.Contains(row, "aces") {
-		t.Fatalf("split-aces seat missing the one-card note on row %d: %q", seatValRow, row)
-	}
-	// And the split 21 must not read as a blackjack on the compact hand line.
-	var hands string
-	for _, row := range []int{seatCardRow, seatCardRow + 1} {
-		hands += kittest.String(f, row) + "\n"
-	}
-	if strings.Contains(hands, "BJ") {
-		t.Fatalf("split-ace 21 mislabeled BJ on the felt:\n%s", hands)
-	}
-}
-
 // TestPairsVerdictHeldUntilCardsLand is the regression guard for the reported
 // bug: the Perfect Pairs result must not appear while the seat's second card is
 // still animating in. The outcome is fixed at the deal, but revealing it early

From e43bf633291063bfdaf4d8503ddcd9507894a90f Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 00:09:30 +1000
Subject: [PATCH 08/14] blackjack-challenge: ties-lose settlement, ranked
 blackjack, dealer-BJ clawback

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/room.go      |  65 +++++----
 games/bcook/blackjack-challenge/room_test.go | 146 ++++++++++++++++---
 2 files changed, 161 insertions(+), 50 deletions(-)

diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 28655eb..85cc616 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -48,13 +48,11 @@ var pairsTiers = []int{0, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
 
 // phand is one hand a seat plays (a seat holds more than one after a split).
 type phand struct {
-	cards       hand
-	bet         int
-	resolved    bool // stood / busted / blackjack / doubled / auto-won
-	doubled     bool
-	autoWon     bool // Player 21 or Five Card Trick: instant even-money win
-	surrendered bool
-	fromSplit   bool // a split hand: a two-card 21 is a plain 21, not a blackjack
+	cards    hand
+	bet      int
+	resolved bool // stood / busted / blackjack / doubled / auto-won
+	doubled  bool
+	autoWon  bool // Player 21 or Five Card Trick: instant even-money win
 }
 
 // backBet is one seat's wager ON ANOTHER seat: a "behind" bet that rides the
@@ -1020,7 +1018,10 @@ func (rm *room) settleAt(r kit.Room, at time.Time) {
 	rm.arm(pendSettle, at)
 }
 
-// anyLive reports whether any player hand can still beat the dealer.
+// anyLive reports whether any hand's payout still depends on the dealer's
+// draw-out: busts are already lost and auto-wins already fixed at even
+// money, but every other hand — a blackjack's ranked odds included — needs
+// the dealer's final hand.
 func (rm *room) anyLive() bool {
 	for _, id := range rm.order {
 		s := rm.seats[id]
@@ -1028,7 +1029,7 @@ func (rm *room) anyLive() bool {
 			continue
 		}
 		for _, h := range s.hands {
-			if !h.surrendered && !h.cards.isBust() {
+			if !h.cards.isBust() && !h.autoWon {
 				return true
 			}
 		}
@@ -1043,18 +1044,22 @@ func (rm *room) settle(r kit.Room) {
 		if s == nil || !s.placed {
 			continue // skip seats that never opened a stake this round
 		}
-		// Fold every resolved credit into the seat's single open-stake gross.
+		// Fold every hand's gross into the seat's single open-stake gross, and
+		// track the stake dealer-blackjack losses would collect: the house only
+		// keeps the seat's ORIGINAL bet when its blackjack lands after doubles
+		// and splits — the excess is refunded (the Challenge clawback). Busted
+		// hands lost before the dealer's hand existed and stay lost.
+		lostToBJ := 0
 		for _, h := range s.hands {
-			if h.surrendered {
-				s.grossThisRound += int64(halfUp(h.bet)) // half the stake returned
-				continue
+			m := grossMult(h, rm.dealer, dbj)
+			if dbj && m == 0 && !h.cards.isBust() {
+				lostToBJ += h.bet
 			}
-			pbj := h.cards.isBlackjack() && !h.fromSplit
-			o := settleHandEx(h.cards, pbj, rm.dealer, dbj)
-			s.grossThisRound += int64(creditFor(o, h.bet))
+			s.grossThisRound += int64(m * h.bet)
+		}
+		if lostToBJ > s.bet {
+			s.grossThisRound += int64(lostToBJ - s.bet)
 		}
-		// The Perfect Pairs side-bet wins already folded into gross at the deal;
-		// settle the behind bets now.
 		rm.settleBacks(s, dbj)
 		// Close the seat's open stake with ONE Settle of the accumulated gross
 		// (clamped to the payout ceiling), then feed the board on a new peak.
@@ -1077,10 +1082,11 @@ func (rm *room) settle(r kit.Room) {
 }
 
 // settleBacks settles seat s's behind bets, folding each behind win into the
-// seat's open-stake gross. Their-pairs already folded in at the deal; each behind
-// bet is judged now against the target's first hand vs the dealer — even money on
-// a win, 3:2 on a natural blackjack, push returned — refunded if the target has
-// left (their hand can't be judged) or lost if that hand surrendered.
+// seat's open-stake gross. Each behind stake rides the target's FIRST hand at
+// the table odds — even money, an auto-win's even money, or the ranked
+// blackjack payout — and is refunded if the target has left. A behind stake
+// is its own original bet, so a dealer blackjack collects it in full (the
+// clawback shields only stakes ADDED to a hand by doubling and splitting).
 func (rm *room) settleBacks(s *seat, dealerBJ bool) {
 	for _, tid := range sortedBackIDs(s) {
 		b := s.backs[tid]
@@ -1088,15 +1094,10 @@ func (rm *room) settleBacks(s *seat, dealerBJ bool) {
 			continue
 		}
 		t := rm.seats[tid]
-		switch {
-		case t == nil || len(t.hands) == 0:
-			b.behindWin = b.behind // target left: refund the behind stake (push)
-		case t.hands[0].surrendered:
-			b.behindWin = 0 // backed a hand that bailed out
-		default:
-			h0 := t.hands[0]
-			o := settleHandEx(h0.cards, h0.cards.isBlackjack() && !h0.fromSplit, rm.dealer, dealerBJ)
-			b.behindWin = creditFor(o, b.behind)
+		if t == nil || len(t.hands) == 0 {
+			b.behindWin = b.behind // target left: refund the behind stake
+		} else {
+			b.behindWin = grossMult(t.hands[0], rm.dealer, dealerBJ) * b.behind
 		}
 		s.grossThisRound += int64(b.behindWin)
 	}
@@ -1228,6 +1229,6 @@ func resultText(net int) string {
 	case net < 0:
 		return "LOSE " + strconv.Itoa(net)
 	default:
-		return "PUSH"
+		return "EVEN"
 	}
 }
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index b845830..b42c394 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -327,7 +327,7 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
 	sa := rm.seats[a.AccountID]
 	sa.placed, sa.bet = true, 25
 	staked(tr, sa, 900, 75)                                                      // main 25 + behind 50 escrowed; 900 bankroll left
-	sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes
+	sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19 vs 19: a tie LOSES on this table
 	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
 	delete(rm.seats, b.AccountID) // b left mid-round
 	rm.order = []string{a.AccountID}
@@ -338,10 +338,10 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
 	if bb := sa.backs[b.AccountID]; bb.behindWin != 50 {
 		t.Fatalf("behindWin = %d, want 50 (behind refunded when target left)", bb.behindWin)
 	}
-	// own hand pushes (gross 25), behind refunded (gross 50): Settle(75) on a 75
-	// stake tops the 900 bankroll back to 975.
-	if sa.bal != 975 {
-		t.Fatalf("a bal = %d, want 975 (push + behind refund settled once)", sa.bal)
+	// own hand loses the tie (gross 0), behind refunded (gross 50): Settle(50) on a
+	// 75 stake nets -25 against the 900 bankroll -> 950.
+	if sa.bal != 950 {
+		t.Fatalf("a bal = %d, want 950 (tie loss + behind refund settled once)", sa.bal)
 	}
 }
 
@@ -357,11 +357,11 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
 	s.pairsWin = 70                                                             // a mixed pair already resolved at deal...
 	s.grossThisRound = 70                                                       // ...and folded into the open stake's gross
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
-	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 -> hand pushes
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 vs 19 -> hand LOSES the tie
 	rm.settle(tr)
-	// Hand pushes (gross 50) atop the pairs gross 70 = 120 on a 60 stake: net +60.
-	if s.result != "WIN +60" {
-		t.Fatalf("result = %q, want WIN +60 (pairs win folded into the round net)", s.result)
+	// Hand loses the tie (gross 0) atop the pairs gross 70 = 70 on a 60 stake: net +10.
+	if s.result != "WIN +10" {
+		t.Fatalf("result = %q, want WIN +10 (pairs win folded into the round net)", s.result)
 	}
 }
 
@@ -744,21 +744,131 @@ func TestDoubledHandTagged(t *testing.T) {
 	}
 }
 
-func TestBlackjackPays3to2(t *testing.T) {
+func TestBlackjackRankedPayouts(t *testing.T) {
+	cases := []struct {
+		name   string
+		player hand
+		dealer hand
+		want   int // final balance from 900 after settling a 100 stake
+	}{
+		{"K beats dealer J: 5:1", hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, hand{{rankAce, suitClub}, {rankJack, suitDiamond}}, 1500},
+		{"same rank: 4:1", hand{{rankAce, suitSpade}, {rankJack, suitHeart}}, hand{{rankAce, suitClub}, {rankJack, suitDiamond}}, 1400},
+		{"10 loses rank to J: 3:1", hand{{rankAce, suitSpade}, {10, suitHeart}}, hand{{rankAce, suitClub}, {rankJack, suitDiamond}}, 1300},
+		{"no dealer blackjack: 2:1", hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, hand{{10, suitClub}, {9, suitDiamond}}, 1200},
+	}
+	for _, c := range cases {
+		a := mkPlayer("a")
+		rm, tr := newGame(t, a)
+		rm.OnJoin(tr, a)
+		s := rm.seats[a.AccountID]
+		s.placed = true
+		s.bet = 100
+		staked(tr, s, 900, 100)
+		s.hands = []*phand{{cards: c.player, bet: 100}}
+		rm.dealer = c.dealer
+		rm.settle(tr)
+		if s.bal != c.want {
+			t.Errorf("%s: bal = %d, want %d", c.name, s.bal, c.want)
+		}
+	}
+}
+
+func TestTiesLose(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 100
+	staked(tr, s, 900, 100)
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 100}}
+	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19 vs 19
+	rm.settle(tr)
+	if s.bal != 900 {
+		t.Errorf("bal = %d, want 900 (a tie LOSES the stake on this table)", s.bal)
+	}
+	if s.result != "LOSE -100" {
+		t.Errorf("result = %q, want LOSE -100", s.result)
+	}
+}
+
+func TestAutoWinPaysEvenMoneyRegardlessOfDealer(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 100
+	staked(tr, s, 900, 100)
+	s.hands = []*phand{{cards: hand{{7, suitSpade}, {7, suitHeart}, {7, suitClub}}, bet: 100, autoWon: true, resolved: true}}
+	rm.dealer = hand{{rankAce, suitClub}, {rankJack, suitDiamond}} // even a dealer blackjack
+	rm.settle(tr)
+	if s.bal != 1100 {
+		t.Errorf("bal = %d, want 1100 (Player 21 pays even money vs anything)", s.bal)
+	}
+}
+
+func TestDealerBlackjackClawback(t *testing.T) {
+	// Split into two hands, one doubled: 300 total staked, but a dealer
+	// blackjack collects only the ORIGINAL 100 bet — 200 comes back.
 	a := mkPlayer("a")
 	rm, tr := newGame(t, a)
 	rm.OnJoin(tr, a)
 	s := rm.seats[a.AccountID]
 	s.placed = true
-	staked(tr, s, 950, 100) // 100 escrowed at deal, 950 bankroll left
-	s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 100}}
-	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19, no blackjack
+	s.bet = 100
+	staked(tr, s, 700, 300)
+	s.hands = []*phand{
+		{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 200, doubled: true, resolved: true},
+		{cards: hand{{10, suitDiamond}, {8, suitHeart}}, bet: 100, resolved: true},
+	}
+	rm.dealer = hand{{rankAce, suitClub}, {rankJack, suitDiamond}}
+	rm.settle(tr)
+	if s.bal != 900 {
+		t.Errorf("bal = %d, want 900 (700 + 200 clawback refund; net -100)", s.bal)
+	}
+}
 
+func TestClawbackSkipsBustedHands(t *testing.T) {
+	// The busted hand lost before the dealer's blackjack existed: its doubled
+	// 200 stake is NOT shielded. Only the live 100 hand faces the dealer BJ,
+	// and it alone is within the original-bet collection — no refund due.
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 100
+	staked(tr, s, 700, 300)
+	s.hands = []*phand{
+		{cards: hand{{10, suitSpade}, {9, suitHeart}, {5, suitClub}}, bet: 200, doubled: true, resolved: true}, // bust 24
+		{cards: hand{{10, suitDiamond}, {8, suitHeart}}, bet: 100, resolved: true},
+	}
+	rm.dealer = hand{{rankAce, suitClub}, {rankJack, suitDiamond}}
 	rm.settle(tr)
+	if s.bal != 700 {
+		t.Errorf("bal = %d, want 700 (busted stake stays lost; live loss within original bet)", s.bal)
+	}
+}
 
-	// 3:2 blackjack grosses stake (100) + 150 = 250; Settle(250) tops 950 -> 1200.
-	if s.bal != 1200 {
-		t.Errorf("bal = %d, want 1200 (blackjack pays 3:2)", s.bal)
+func TestBehindBetRidesTheChallengeOdds(t *testing.T) {
+	a, b := mkPlayer("a"), mkPlayer("b")
+	rm, tr := newGame(t, a, b)
+	rm.OnJoin(tr, a)
+	rm.OnJoin(tr, b)
+	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
+	sa.placed, sb.placed = true, true
+	sa.bet, sb.bet = 100, 100
+	staked(tr, sa, 900, 100)
+	staked(tr, sb, 850, 150) // 100 main + 50 behind on a
+	sb.backs = map[string]*backBet{a.AccountID: {behind: 50}}
+	sa.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 100, resolved: true}}
+	sb.hands = []*phand{{cards: hand{{10, suitDiamond}, {9, suitHeart}}, bet: 100, resolved: true}}
+	rm.dealer = hand{{10, suitClub}, {8, suitDiamond}} // 18: b wins even, a has blackjack 2:1
+	rm.settle(tr)
+	// b: main 19 beats 18 -> 200, behind rides a's blackjack at 2:1 -> 150. 850+350 = 1200.
+	if sb.bal != 1200 {
+		t.Errorf("backer bal = %d, want 1200 (behind pays the ranked blackjack odds)", sb.bal)
 	}
 }
 
@@ -1317,8 +1427,8 @@ func TestSplitSeatShowsEveryHandsCards(t *testing.T) {
 	s.placed = true
 	s.bal = 800
 	s.hands = []*phand{
-		{cards: hand{{8, suitSpade}, {3, suitHeart}}, bet: 50, fromSplit: true},
-		{cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50, fromSplit: true},
+		{cards: hand{{8, suitSpade}, {3, suitHeart}}, bet: 50},
+		{cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50},
 	}
 	rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
 	rm.phase = phTurns

From 242d4773f2622a8723be336d501c70d7eb9e0153 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 00:22:00 +1000
Subject: [PATCH 09/14] blackjack-challenge: Star Pairs side bet; retire the
 classic rules layer

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/layout.go     |  21 +--
 games/bcook/blackjack-challenge/room.go       |  24 +--
 games/bcook/blackjack-challenge/room_test.go  |  87 ++++++-----
 games/bcook/blackjack-challenge/round.go      | 109 -------------
 games/bcook/blackjack-challenge/round_test.go | 143 ------------------
 games/bcook/blackjack-challenge/rules.go      |  28 +++-
 games/bcook/blackjack-challenge/rules_test.go |  47 +++++-
 7 files changed, 145 insertions(+), 314 deletions(-)
 delete mode 100644 games/bcook/blackjack-challenge/round.go
 delete mode 100644 games/bcook/blackjack-challenge/round_test.go

diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index e459bb0..8adfe09 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -23,7 +23,7 @@ const (
 	seatCardRow = 11 // seat card group occupies seatCardRow..seatCardRow+2
 	seatValRow  = 14
 	seatChipRow = 15
-	seatPairRow = 16 // Perfect Pairs side-bet line (stake while betting, result once dealt)
+	seatPairRow = 16 // Star Pairs side-bet line (stake while betting, result once dealt)
 	seatBackRow = 17 // backers line: who is backing this seat, with their tiles
 	actionRow   = 18
 	slotW       = 15
@@ -257,21 +257,24 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
 	}
 }
 
-// pairsMult maps a Perfect Pairs result kind to its payout multiplier (X:1),
-// for the result label; 0 for no pair.
+// pairsMult maps a Star Pairs result kind to its payout multiplier (X:1), for
+// the result label; 0 for no pair. Mirrors starPairsOutcome's table (rules.go).
 func pairsMult(kind string) int {
 	switch kind {
+	case "aces":
+		return 30
 	case "perfect":
-		return 25
+		return 20
 	case "colored":
-		return 12
+		return 8
 	case "mixed":
-		return 6
+		return 5
+	default:
+		return 0
 	}
-	return 0
 }
 
-// drawPairsLine renders the seat's Perfect Pairs side-bet line. While betting it
+// drawPairsLine renders the seat's Star Pairs side-bet line. While betting it
 // sits directly beneath that seat's main bet (seatCardRow+2), so each seat's
 // bet+pairs read as one contiguous block and whose side bet is whose is never
 // ambiguous; it shows only once placed, or for the seat's owner, matching how
@@ -292,7 +295,7 @@ func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) {
 	if s.pairsBet <= 0 {
 		return
 	}
-	// Hold the Perfect Pairs verdict until BOTH of the seat's first two cards are
+	// Hold the Star Pairs verdict until BOTH of the seat's first two cards are
 	// face up. The result is fixed at the deal, but revealing "pairs lost" (or a
 	// win) while the second card is still sliding/flipping in would spoil it — the
 	// side bet reads off exactly those two cards, so wait for them to land.
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 85cc616..a8bb969 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -41,7 +41,7 @@ const (
 // minimum up to a 10k high roller.
 var betTiers = []int{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
 
-// pairsTiers are the Perfect Pairs / behind side-bet stakes, lowest first; index
+// pairsTiers are the Star Pairs / behind side-bet stakes, lowest first; index
 // 0 is "off" (no side bet). Adjusted on the Left/Right axis during betting, and
 // mirrors betTiers' upper reaches so side action can scale with the main bet.
 var pairsTiers = []int{0, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
@@ -56,13 +56,13 @@ type phand struct {
 }
 
 // backBet is one seat's wager ON ANOTHER seat: a "behind" bet that rides the
-// backed player's first hand vs the dealer, and/or a Perfect Pairs bet on the
+// backed player's first hand vs the dealer, and/or a Star Pairs bet on the
 // backed player's first two cards. Stakes are chosen during betting; the result
 // fields are filled at settlement (their-pairs at the deal, behind at settle).
 type backBet struct {
 	behind    int    // behind-bet stake (0 = none)
-	pairs     int    // their-Perfect-Pairs stake (0 = none)
-	pairsKind string // resolved at deal: "" | "mixed" | "colored" | "perfect"
+	pairs     int    // their-Star-Pairs stake (0 = none)
+	pairsKind string // resolved at deal: "" | "mixed" | "colored" | "perfect" | "aces"
 	pairsWin  int    // their-pairs chips credited at deal (0 = lost/none)
 	behindWin int    // behind chips credited at settle (0 = lost)
 }
@@ -88,8 +88,8 @@ type seat struct {
 	roundStake     int64
 	bet            int // currently selected/placed stake
 	placed         bool
-	pairsBet       int                 // Perfect Pairs side stake (0 = off), carried between rounds like bet
-	pairsKind      string              // this round's pairs result: "" | "mixed" | "colored" | "perfect"
+	pairsBet       int                 // Star Pairs side stake (0 = off), carried between rounds like bet
+	pairsKind      string              // this round's pairs result: "" | "mixed" | "colored" | "perfect" | "aces"
 	pairsWin       int                 // chips credited on the pairs side bet this round (0 = lost/none)
 	focus          string              // betting UI: "" edits own bet, else the account id whose backs are being edited
 	backs          map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order)
@@ -468,7 +468,7 @@ func tierIndex(bet int) int {
 	return 0
 }
 
-// cycleOwnPairs advances the seat's own Perfect Pairs stake one tier, wrapping
+// cycleOwnPairs advances the seat's own Star Pairs stake one tier, wrapping
 // back to 0 past the top (and resetting to 0 if the next tier is unaffordable).
 func (rm *room) cycleOwnPairs(s *seat) {
 	s.pairsBet = loopTier(pairsTiers, s.pairsBet, s.bal-(s.committed()-s.pairsBet))
@@ -534,7 +534,7 @@ func pairsTierIndex(bet int) int {
 }
 
 // committed totals every chip the seat has wagered this betting window: its main
-// bet, its own Perfect Pairs, and every behind/their-pairs stake across backs.
+// bet, its own Star Pairs, and every behind/their-pairs stake across backs.
 func (s *seat) committed() int {
 	total := s.bet + s.pairsBet
 	for _, b := range s.backs {
@@ -651,7 +651,7 @@ func (rm *room) deal(r kit.Room) {
 	rm.enterTurns(r)
 }
 
-// resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards:
+// resolvePairs settles a seat's Star Pairs side bet against its dealt cards:
 // the stake is Wagered onto the seat's open stake and any winning pair folds its
 // gross into grossThisRound immediately — the casino way, where the side bet
 // stands apart from how the hand goes on to play out. A stake the balance can't
@@ -664,7 +664,7 @@ func (rm *room) resolvePairs(s *seat, dealt hand) {
 		s.pairsBet = 0
 		return
 	}
-	kind, mult := perfectPairsOutcome(dealt[0], dealt[1])
+	kind, mult := starPairsOutcome(dealt[0], dealt[1])
 	s.pairsKind = kind
 	s.pairsWin = pairsCreditFor(mult, s.pairsBet)
 	s.grossThisRound += int64(s.pairsWin)
@@ -687,7 +687,7 @@ func sortedBackIDs(s *seat) []string {
 	return ids
 }
 
-// resolveBackPairs settles the their-Perfect-Pairs side of every seat's backs,
+// resolveBackPairs settles the their-Star-Pairs side of every seat's backs,
 // once all hands are dealt: a back on a seat that didn't get dealt in is voided
 // (never charged); otherwise each stake is Wagered onto the backer's open stake
 // and any winning pair on the target's first two cards folds its gross into the
@@ -709,7 +709,7 @@ func (rm *room) resolveBackPairs() {
 			}
 			if b.pairs > 0 {
 				if rm.wager(s, b.pairs) {
-					kind, mult := perfectPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1])
+					kind, mult := starPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1])
 					b.pairsKind = kind
 					b.pairsWin = pairsCreditFor(mult, b.pairs)
 					s.grossThisRound += int64(b.pairsWin)
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index b42c394..6a46f3c 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -191,7 +191,26 @@ func TestBackBetBudgetClamped(t *testing.T) {
 	}
 }
 
-func TestDealResolvesPerfectPairsSideBet(t *testing.T) {
+func TestAffordTier(t *testing.T) {
+	tiers := []int{0, 10, 25, 50, 100}
+	cases := []struct {
+		want, budget, out int
+	}{
+		{100, 1000, 100}, // wanted tier fits the budget
+		{100, 30, 25},    // capped to the highest tier within budget
+		{50, 5, 0},       // nothing but "off" fits
+		{25, 25, 25},     // exact fit
+		{100, 0, 0},      // no budget -> off
+		{100, -5, 0},     // negative budget -> off (never panics)
+	}
+	for _, c := range cases {
+		if got := affordTier(tiers, c.want, c.budget); got != c.out {
+			t.Errorf("affordTier(want=%d, budget=%d) = %d, want %d", c.want, c.budget, got, c.out)
+		}
+	}
+}
+
+func TestDealResolvesStarPairsSideBet(t *testing.T) {
 	a := mkPlayer("a")
 	rm, tr := newGame(t, a)
 	rm.what = pendNone
@@ -214,11 +233,11 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) {
 	if s.pairsKind != "mixed" {
 		t.Fatalf("pairsKind = %q, want mixed", s.pairsKind)
 	}
-	if s.pairsWin != 70 { // mixed 6:1 on 10 -> 10 + 60
-		t.Fatalf("pairsWin = %d, want 70", s.pairsWin)
+	if s.pairsWin != 60 { // mixed 5:1 on 10 -> 10 + 50
+		t.Fatalf("pairsWin = %d, want 60", s.pairsWin)
 	}
 	// The deal Wagers the main bet (50) + the pairs stake (10) onto the seat's
-	// open stake: bankroll 1000 -> 940, roundStake 60. The mixed pair's 70 gross
+	// open stake: bankroll 1000 -> 940, roundStake 60. The mixed pair's 60 gross
 	// folds into grossThisRound (paid only at the single settle), NOT the balance.
 	if s.bal != 940 {
 		t.Fatalf("bal = %d, want 940 (bet + pairs Wagered at deal)", s.bal)
@@ -226,8 +245,8 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) {
 	if s.roundStake != 60 {
 		t.Fatalf("roundStake = %d, want 60 (main 50 + pairs 10)", s.roundStake)
 	}
-	if s.grossThisRound != 70 {
-		t.Fatalf("grossThisRound = %d, want 70 (mixed pair folded into the open stake)", s.grossThisRound)
+	if s.grossThisRound != 60 {
+		t.Fatalf("grossThisRound = %d, want 60 (mixed pair folded into the open stake)", s.grossThisRound)
 	}
 }
 
@@ -254,16 +273,16 @@ func TestDealResolvesBackPairs(t *testing.T) {
 	rm.deal(tr)
 
 	bb := sa.backs[b.AccountID]
-	if bb.pairsKind != "mixed" || bb.pairsWin != 70 {
-		t.Fatalf("back-pairs on b = kind %q win %d, want mixed/70", bb.pairsKind, bb.pairsWin)
+	if bb.pairsKind != "mixed" || bb.pairsWin != 60 {
+		t.Fatalf("back-pairs on b = kind %q win %d, want mixed/60", bb.pairsKind, bb.pairsWin)
 	}
-	// a Wagered main 25 + back-pairs 10 (bal 1000 -> 965, roundStake 35); the 70
+	// a Wagered main 25 + back-pairs 10 (bal 1000 -> 965, roundStake 35); the 60
 	// back-pairs gross folds into a's open stake, paid at settle not now.
 	if sa.bal != 965 {
 		t.Fatalf("a bal = %d, want 965 (main 25 + back-pairs 10 Wagered)", sa.bal)
 	}
-	if sa.grossThisRound != 70 {
-		t.Fatalf("a grossThisRound = %d, want 70 (back-pairs win folded in)", sa.grossThisRound)
+	if sa.grossThisRound != 60 {
+		t.Fatalf("a grossThisRound = %d, want 60 (back-pairs win folded in)", sa.grossThisRound)
 	}
 }
 
@@ -354,14 +373,14 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
 	s.bet = 50
 	s.pairsBet = 10
 	staked(tr, s, 940, 60)                                                      // main 50 + pairs 10 escrowed
-	s.pairsWin = 70                                                             // a mixed pair already resolved at deal...
-	s.grossThisRound = 70                                                       // ...and folded into the open stake's gross
+	s.pairsWin = 60                                                             // a mixed pair already resolved at deal...
+	s.grossThisRound = 60                                                       // ...and folded into the open stake's gross
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
 	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 vs 19 -> hand LOSES the tie
 	rm.settle(tr)
-	// Hand loses the tie (gross 0) atop the pairs gross 70 = 70 on a 60 stake: net +10.
-	if s.result != "WIN +10" {
-		t.Fatalf("result = %q, want WIN +10 (pairs win folded into the round net)", s.result)
+	// Hand loses the tie (gross 0) atop the pairs gross 60 = 60 on a 60 stake: net 0.
+	if s.result != "EVEN" {
+		t.Fatalf("result = %q, want EVEN (pairs win folded into the round net)", s.result)
 	}
 }
 
@@ -940,24 +959,24 @@ func TestBalanceSeedsAndPostsPeak(t *testing.T) {
 	}
 }
 
-// TestTopPrizeDoesNotClamp asserts the declared MaxPayoutMultiplier (26) covers
-// the game's largest single-stake outcome: a Perfect Pairs "perfect" pair pays
-// 25:1, grossing stake×(25+1) = stake×26 — exactly the ceiling, so it settles in
-// full and is never clamped.
+// TestTopPrizeDoesNotClamp asserts the declared MaxPayoutMultiplier (31) covers
+// the game's largest single-stake outcome: a Star Pairs "aces" pair pays 30:1,
+// grossing stake×(30+1) = stake×31 — exactly the ceiling, so it settles in full
+// and is never clamped.
 func TestTopPrizeDoesNotClamp(t *testing.T) {
 	a := mkPlayer("a")
 	rm, tr := newGame(t, a)
 	rm.OnJoin(tr, a)
 	s := rm.seats[a.AccountID]
-	// A lone 100 Perfect Pairs stake hitting a "perfect" pair: gross 2600.
+	// A lone 100 Star Pairs stake hitting an "aces" pair: gross 3100.
 	staked(tr, s, 0, 100)
-	s.grossThisRound = 100 * (25 + 1) // 2600, the top pairs payout
+	s.grossThisRound = 100 * (30 + 1) // 3100, the top pairs payout
 	net := rm.settleOpenStake(s)
-	if net != 2500 {
-		t.Fatalf("net = %d, want 2500 (top pairs prize settles unclamped)", net)
+	if net != 3000 {
+		t.Fatalf("net = %d, want 3000 (top pairs prize settles unclamped)", net)
 	}
-	if got := tr.Credits[a.AccountID]; got != 2600 {
-		t.Fatalf("settled balance = %d, want 2600 (full 26x top prize paid)", got)
+	if got := tr.Credits[a.AccountID]; got != 3100 {
+		t.Fatalf("settled balance = %d, want 3100 (full 31x top prize paid)", got)
 	}
 }
 
@@ -1304,7 +1323,7 @@ func TestFocusedTargetShowsBackDetail(t *testing.T) {
 	}
 }
 
-// TestBettingShowsPairsSideBet asserts a seat's selected Perfect Pairs side
+// TestBettingShowsPairsSideBet asserts a seat's selected Star Pairs side
 // stake is shown during betting directly beneath that seat's main bet — so the
 // two lines form one contiguous per-seat block and it's unambiguous whose side
 // bet is whose at a multi-seat table.
@@ -1332,7 +1351,7 @@ func TestBettingShowsPairsSideBet(t *testing.T) {
 	}
 }
 
-// TestPairsLineCarriesCharacterTile asserts the Perfect Pairs side-bet line is
+// TestPairsLineCarriesCharacterTile asserts the Star Pairs side-bet line is
 // prefixed with the placing player's arcade character tile, so whose side bet is
 // whose reads from the face beside it, not just the column.
 func TestPairsLineCarriesCharacterTile(t *testing.T) {
@@ -1357,9 +1376,9 @@ func TestPairsLineCarriesCharacterTile(t *testing.T) {
 	}
 }
 
-// TestResultsShowsPerfectPairsWin asserts a winning Perfect Pairs side bet is
+// TestResultsShowsStarPairsWin asserts a winning Star Pairs side bet is
 // surfaced on the seat with its category and multiplier during results.
-func TestResultsShowsPerfectPairsWin(t *testing.T) {
+func TestResultsShowsStarPairsWin(t *testing.T) {
 	a := mkPlayer("alice")
 	rm, tr := newGame(t, a)
 	rm.OnJoin(tr, a)
@@ -1369,15 +1388,15 @@ func TestResultsShowsPerfectPairsWin(t *testing.T) {
 	staked(tr, s, 1000, 75) // main 50 + pairs 25 escrowed
 	s.pairsBet = 25
 	s.pairsKind = "colored"
-	s.pairsWin = 325
-	s.grossThisRound = 325 // the colored pair already folded into the open stake
+	s.pairsWin = 225
+	s.grossThisRound = 225 // the colored pair already folded into the open stake
 	s.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitDiamond}}, bet: 50}}
 	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
 	rm.settle(tr) // -> results phase
 	rm.render(tr)
 
 	row := kittest.String(tr.LastFrame(a), seatPairRow)
-	if !strings.Contains(row, "COLORED 12:1") {
+	if !strings.Contains(row, "COLORED 8:1") {
 		t.Fatalf("results row %d does not show the pairs win: %q", seatPairRow, row)
 	}
 }
@@ -1482,7 +1501,7 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) {
 }
 
 // TestPairsVerdictHeldUntilCardsLand is the regression guard for the reported
-// bug: the Perfect Pairs result must not appear while the seat's second card is
+// bug: the Star Pairs result must not appear while the seat's second card is
 // still animating in. The outcome is fixed at the deal, but revealing it early
 // spoils the reveal — so "pairs lost" (or a win) waits for both first cards to
 // land face up.
diff --git a/games/bcook/blackjack-challenge/round.go b/games/bcook/blackjack-challenge/round.go
deleted file mode 100644
index 4bfbf24..0000000
--- a/games/bcook/blackjack-challenge/round.go
+++ /dev/null
@@ -1,109 +0,0 @@
-package main
-
-import "math/rand"
-
-// outcome is a single player hand's result against the dealer.
-type outcome int
-
-const (
-	outLose outcome = iota
-	outPush
-	outWin
-	outBlackjack // player blackjack, dealer without blackjack
-)
-
-// dealerPlay draws for the dealer until the hand totals 17 or more, standing on
-// all 17 including soft 17 (S17). Callers skip this entirely when no player hand
-// is live. The rng feeds a possible mid-round discard recycle in draw.
-func dealerPlay(d hand, s *shoe, rng *rand.Rand) hand {
-	for {
-		if total, _ := d.value(); total >= 17 {
-			return d
-		}
-		d = append(d, s.draw(rng))
-	}
-}
-
-// settleHandEx compares a player hand against the dealer with explicit blackjack
-// flags, so a caller can declare a hand non-blackjack (e.g. a two-card 21 formed
-// by splitting is a plain 21). A busted player always loses; otherwise blackjack
-// beats non-blackjack, then totals are compared, with equal totals a push.
-func settleHandEx(p hand, pBlackjack bool, d hand, dBlackjack bool) outcome {
-	switch {
-	case p.isBust():
-		return outLose
-	case pBlackjack && dBlackjack:
-		return outPush
-	case pBlackjack:
-		return outBlackjack
-	case dBlackjack:
-		return outLose
-	case d.isBust():
-		return outWin
-	case p.total() > d.total():
-		return outWin
-	case p.total() < d.total():
-		return outLose
-	default:
-		return outPush
-	}
-}
-
-// settleHand compares a player hand against the dealer using natural blackjack
-// detection for both.
-func settleHand(p, d hand) outcome {
-	return settleHandEx(p, p.isBlackjack(), d, d.isBlackjack())
-}
-
-// halfUp halves n in integer chips with the half-chip rounded UP — the single
-// owner of the player-favorable rounding policy used by the 3:2 blackjack
-// payout and the surrender return (so the two sites can never drift apart and
-// silently break chip conservation on odd bets).
-func halfUp(n int) int { return (n + 1) / 2 }
-
-// creditFor is the amount returned to a player's chips at settlement for a hand
-// of the given bet. The stake was deducted at deal time, so a loss credits 0, a
-// push returns the stake, a win pays even money, and a blackjack pays 3:2 —
-// with an odd bet's half-chip rounded UP (the player-favorable integer-chip
-// reading of 3:2; never underpay the headline payout).
-func creditFor(o outcome, bet int) int {
-	switch o {
-	case outWin:
-		return 2 * bet
-	case outBlackjack:
-		return bet + halfUp(bet*3)
-	case outPush:
-		return bet
-	default: // outLose
-		return 0
-	}
-}
-
-// perfectPairsOutcome classifies a player's first two cards for the Perfect
-// Pairs side bet and returns the result kind and its payout multiplier (the X
-// in X:1), or ("", 0) when the cards are not a pair. A perfect pair is the same
-// rank and suit (25:1), a colored pair is the same rank and colour but different
-// suit (12:1), and a mixed pair is the same rank in different colours (6:1).
-func perfectPairsOutcome(a, b card) (kind string, mult int) {
-	switch {
-	case a.r != b.r:
-		return "", 0
-	case a.s == b.s:
-		return "perfect", 25
-	case a.s.red() == b.s.red():
-		return "colored", 12
-	default:
-		return "mixed", 6
-	}
-}
-
-// pairsCreditFor is the chips returned to a player on a Perfect Pairs side bet of
-// `bet` at multiplier `mult` (the X in X:1). The stake was deducted when the bet
-// was placed, so a winning pair returns stake + mult×stake and a loss (mult 0)
-// returns nothing.
-func pairsCreditFor(mult, bet int) int {
-	if mult <= 0 {
-		return 0
-	}
-	return bet * (mult + 1)
-}
diff --git a/games/bcook/blackjack-challenge/round_test.go b/games/bcook/blackjack-challenge/round_test.go
deleted file mode 100644
index 8bef175..0000000
--- a/games/bcook/blackjack-challenge/round_test.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package main
-
-import (
-	"math/rand"
-	"testing"
-)
-
-func TestDealerStandsOnSoft17(t *testing.T) {
-	rng := rand.New(rand.NewSource(1))
-	d := dealerPlay(hand{{rankAce, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // soft 17
-	if len(d) != 2 {
-		t.Fatalf("dealer drew on soft 17 (S17 should stand): %v", d)
-	}
-}
-
-func TestDealerDrawsTo17(t *testing.T) {
-	rng := rand.New(rand.NewSource(2))
-	d := dealerPlay(hand{{10, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // hard 16
-	if total := d.total(); total < 17 && !d.isBust() {
-		t.Fatalf("dealer stopped at %d, must reach 17+ or bust", total)
-	}
-}
-
-func TestSettleHand(t *testing.T) {
-	bj := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
-	twenty := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}
-	nineteen := hand{{10, suitSpade}, {9, suitHeart}}
-	bust := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}, {5, suitClub}}
-
-	cases := []struct {
-		name   string
-		player hand
-		dealer hand
-		want   outcome
-	}{
-		{"player blackjack beats 20", bj, twenty, outBlackjack},
-		{"both blackjack push", bj, bj, outPush},
-		{"dealer blackjack beats 20", twenty, bj, outLose},
-		{"higher beats lower", twenty, nineteen, outWin},
-		{"equal totals push", twenty, twenty, outPush},
-		{"player bust loses", bust, nineteen, outLose},
-		{"player bust loses even if dealer busts", bust, bust, outLose},
-		{"dealer bust, player stands", nineteen, bust, outWin},
-	}
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			if got := settleHand(c.player, c.dealer); got != c.want {
-				t.Fatalf("settleHand = %v, want %v", got, c.want)
-			}
-		})
-	}
-}
-
-func TestSplit21IsNotBlackjack(t *testing.T) {
-	// A two-card 21 formed by splitting is a plain 21, not a 3:2 blackjack.
-	twentyOne := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
-	twenty := hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}
-	if got := settleHandEx(twentyOne, false /* fromSplit */, twenty, false); got != outWin {
-		t.Fatalf("split 21 vs dealer 20 = %v, want outWin (not outBlackjack)", got)
-	}
-}
-
-func TestCreditFor(t *testing.T) {
-	cases := []struct {
-		o    outcome
-		bet  int
-		want int
-	}{
-		{outLose, 100, 0},
-		{outPush, 100, 100},
-		{outWin, 100, 200},
-		{outBlackjack, 100, 250}, // stake 100 + 3:2 (150)
-		{outBlackjack, 10, 25},   // stake 10 + 3:2 (15)
-		{outBlackjack, 25, 63},   // stake 25 + 3:2 (37.5 -> 38, half-chip rounds UP to the player)
-		{outBlackjack, 50, 125},  // stake 50 + 3:2 (75)
-	}
-	for _, c := range cases {
-		if got := creditFor(c.o, c.bet); got != c.want {
-			t.Errorf("creditFor(%v, %d) = %d, want %d", c.o, c.bet, got, c.want)
-		}
-	}
-}
-
-func TestPerfectPairsOutcome(t *testing.T) {
-	cases := []struct {
-		name     string
-		a, b     card
-		wantKind string
-		wantMult int
-	}{
-		{"perfect: same rank and suit", card{8, suitHeart}, card{8, suitHeart}, "perfect", 25},
-		{"colored: same rank, both red", card{8, suitHeart}, card{8, suitDiamond}, "colored", 12},
-		{"colored: same rank, both black", card{rankKing, suitSpade}, card{rankKing, suitClub}, "colored", 12},
-		{"mixed: same rank, different colour", card{8, suitSpade}, card{8, suitHeart}, "mixed", 6},
-		{"no pair: different rank", card{8, suitSpade}, card{9, suitSpade}, "", 0},
-		{"no pair: ten and face are not a pair", card{10, suitSpade}, card{rankKing, suitSpade}, "", 0},
-	}
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			kind, mult := perfectPairsOutcome(c.a, c.b)
-			if kind != c.wantKind || mult != c.wantMult {
-				t.Fatalf("perfectPairsOutcome = (%q, %d), want (%q, %d)", kind, mult, c.wantKind, c.wantMult)
-			}
-		})
-	}
-}
-
-func TestAffordTier(t *testing.T) {
-	tiers := []int{0, 10, 25, 50, 100}
-	cases := []struct {
-		want, budget, out int
-	}{
-		{100, 1000, 100}, // wanted tier fits the budget
-		{100, 30, 25},    // capped to the highest tier within budget
-		{50, 5, 0},       // nothing but "off" fits
-		{25, 25, 25},     // exact fit
-		{100, 0, 0},      // no budget -> off
-		{100, -5, 0},     // negative budget -> off (never panics)
-	}
-	for _, c := range cases {
-		if got := affordTier(tiers, c.want, c.budget); got != c.out {
-			t.Errorf("affordTier(want=%d, budget=%d) = %d, want %d", c.want, c.budget, got, c.out)
-		}
-	}
-}
-
-func TestPairsCreditFor(t *testing.T) {
-	cases := []struct {
-		mult int
-		bet  int
-		want int
-	}{
-		{0, 10, 0},     // no pair: side stake lost
-		{6, 10, 70},    // mixed 6:1: stake 10 + 60
-		{12, 25, 325},  // colored 12:1: stake 25 + 300
-		{25, 50, 1300}, // perfect 25:1: stake 50 + 1250
-	}
-	for _, c := range cases {
-		if got := pairsCreditFor(c.mult, c.bet); got != c.want {
-			t.Errorf("pairsCreditFor(%d, %d) = %d, want %d", c.mult, c.bet, got, c.want)
-		}
-	}
-}
diff --git a/games/bcook/blackjack-challenge/rules.go b/games/bcook/blackjack-challenge/rules.go
index 1ed9900..69750c8 100644
--- a/games/bcook/blackjack-challenge/rules.go
+++ b/games/bcook/blackjack-challenge/rules.go
@@ -1,9 +1,22 @@
 package main
 
+import "math/rand"
+
 // The Blackjack Challenge rules layer: every payout table, comparison, and
 // rule gate of the variant lives here (spec: docs/superpowers/specs/
-// 2026-07-04-blackjack-challenge-design.md). The dealer mechanics
-// (dealerPlay) migrate here from round.go when round.go is deleted (Task 7).
+// 2026-07-04-blackjack-challenge-design.md).
+
+// dealerPlay draws for the dealer until the hand totals 17 or more, standing on
+// all 17 including soft 17 (S17). Callers skip this entirely when no player hand
+// is live. The rng feeds a possible mid-round discard recycle in draw.
+func dealerPlay(d hand, s *shoe, rng *rand.Rand) hand {
+	for {
+		if total, _ := d.value(); total >= 17 {
+			return d
+		}
+		d = append(d, s.draw(rng))
+	}
+}
 
 // starPairsOutcome classifies a seat's first two cards for the Star Pairs
 // side bet, returning the result kind and its payout multiplier (the X in
@@ -83,3 +96,14 @@ func grossMult(h *phand, d hand, dBJ bool) int {
 		return 0
 	}
 }
+
+// pairsCreditFor is the chips returned to a player on a Star Pairs side bet of
+// `bet` at multiplier `mult` (the X in X:1). The stake was deducted when the bet
+// was placed, so a winning pair returns stake + mult×stake and a loss (mult 0)
+// returns nothing.
+func pairsCreditFor(mult, bet int) int {
+	if mult <= 0 {
+		return 0
+	}
+	return bet * (mult + 1)
+}
diff --git a/games/bcook/blackjack-challenge/rules_test.go b/games/bcook/blackjack-challenge/rules_test.go
index 0ad8dbf..fd7d4f9 100644
--- a/games/bcook/blackjack-challenge/rules_test.go
+++ b/games/bcook/blackjack-challenge/rules_test.go
@@ -1,6 +1,43 @@
 package main
 
-import "testing"
+import (
+	"math/rand"
+	"testing"
+)
+
+func TestDealerStandsOnSoft17(t *testing.T) {
+	rng := rand.New(rand.NewSource(1))
+	d := dealerPlay(hand{{rankAce, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // soft 17
+	if len(d) != 2 {
+		t.Fatalf("dealer drew on soft 17 (S17 should stand): %v", d)
+	}
+}
+
+func TestDealerDrawsTo17(t *testing.T) {
+	rng := rand.New(rand.NewSource(2))
+	d := dealerPlay(hand{{10, suitSpade}, {6, suitHeart}}, newShoe(rng), rng) // hard 16
+	if total := d.total(); total < 17 && !d.isBust() {
+		t.Fatalf("dealer stopped at %d, must reach 17+ or bust", total)
+	}
+}
+
+func TestPairsCreditFor(t *testing.T) {
+	cases := []struct {
+		mult int
+		bet  int
+		want int
+	}{
+		{0, 10, 0},     // no pair: side stake lost
+		{6, 10, 70},    // mixed 6:1: stake 10 + 60
+		{12, 25, 325},  // colored 12:1: stake 25 + 300
+		{25, 50, 1300}, // perfect 25:1: stake 50 + 1250
+	}
+	for _, c := range cases {
+		if got := pairsCreditFor(c.mult, c.bet); got != c.want {
+			t.Errorf("pairsCreditFor(%d, %d) = %d, want %d", c.mult, c.bet, got, c.want)
+		}
+	}
+}
 
 func TestStarPairsOutcome(t *testing.T) {
 	cases := []struct {
@@ -42,10 +79,10 @@ func TestBlackjackMult(t *testing.T) {
 		dBJ  bool
 		want int
 	}{
-		{rankKing, 0, false, 2},          // dealer no blackjack: 2:1
-		{rankKing, rankQueen, true, 5},   // player ten outranks: 5:1
-		{rankJack, rankJack, true, 4},    // same rank: 4:1
-		{10, rankJack, true, 3},          // outranked: 3:1
+		{rankKing, 0, false, 2},        // dealer no blackjack: 2:1
+		{rankKing, rankQueen, true, 5}, // player ten outranks: 5:1
+		{rankJack, rankJack, true, 4},  // same rank: 4:1
+		{10, rankJack, true, 3},        // outranked: 3:1
 	}
 	for _, c := range cases {
 		if got := blackjackMult(c.p, c.d, c.dBJ); got != c.want {

From 8d0c8cce7092b40d469e47a9804109679a99bb6e Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 00:40:13 +1000
Subject: [PATCH 10/14] blackjack-challenge: smoke script for the Challenge
 round shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Rewrites the smoke scenario for this variant: both seats bet (seat 0 adds
its own Star Pairs and backs seat 1 with behind + their-pairs), the deal
lands ONE face-up dealer card, seat 0 splits a mixed pair of eights (its
Star Pairs pays 5:1 on the spot) and stands both hands, seat 1 stands 17,
and the dealer draws out from its single card one paced hit at a time —
a mid-draw shot catches the second hit still face down. The dealer busts
on the third card and the payout shot folds main hands, own pairs, and
the back into each seat's net; ready-up flow preserved. Seed 16 produces
the pair; comments now document Star Pairs, the no-hole-card dealer, and
ties-lose settlement.

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/smoke.yaml | 61 +++++++++++++++-------
 1 file changed, 43 insertions(+), 18 deletions(-)

diff --git a/games/bcook/blackjack-challenge/smoke.yaml b/games/bcook/blackjack-challenge/smoke.yaml
index cb88566..87b72c0 100644
--- a/games/bcook/blackjack-challenge/smoke.yaml
+++ b/games/bcook/blackjack-challenge/smoke.yaml
@@ -1,13 +1,14 @@
-seed: 58
+seed: 16
 seats: 2
 heartbeat: 50ms
 steps:
-  # (a) Initial table: both seats at the bet prompt. Rules flank the dealer, and
-  # the betting prompt offers Up/Down stake, P pairs, and Left/Right (pick the
-  # seat you're betting on — yourself or another player to back).
+  # (a) Initial table: both seats at the bet prompt. The Challenge rules flank
+  # the dealer ("blackjack pays 2:1 - ties lose" / "dealer stands on 17"), and
+  # the betting prompt offers Up/Down stake, P Star Pairs, B behind, and
+  # Left/Right (pick the seat you're betting on — yourself or another player).
   - shot: bet
 
-  # Seat 0 loops its own Perfect Pairs on (P -> 10), then presses Right to focus
+  # Seat 0 loops its own Star Pairs on (P -> 10), then presses Right to focus
   # seat 1 and backs it: B B -> behind 25, P -> their-pairs 10. With seat 1
   # focused, the action bar spells out the back ("BACKING seat1 ...").
   - seat: 0
@@ -19,25 +20,33 @@ steps:
   - shot: backing
 
   # Seat 0 places (Confirm places everything, focus or not); seat 1 sets its own
-  # pairs and places too.
+  # Star Pairs and places too.
   - key: space
   - seat: 1
   - rune: "p"
   - key: space
 
-  # (b) Both placed: seat 1's dedicated backers line shows seat 0's character tile
-  # and the back stakes; each seat shows its own "+pairs" beneath its bet.
+  # (b) Both placed: seat 1's dedicated backers line shows seat 0's character
+  # tile and the back stakes; each seat shows its own "+pairs" under its bet.
   - shot: sidebet
 
-  # Grace beat (1s) -> deal; sweep the staggered slide+flip deal animation.
+  # Grace beat (1s) -> deal; sweep the staggered slide+flip deal animation
+  # (two cards per seat + the dealer's SINGLE face-up card — no hole card on
+  # this table, so the sweep is five cards: 4x120ms stagger + 260ms slide
+  # + 240ms flip).
   - advance: 1200ms
   - advance: 1200ms
 
-  # (c) Deal complete: both seats dealt a pair (own pairs pay out), and seat 0's
-  # back-pairs on seat 1 pay too; seat 0 (a pair of tens) is first on turn.
+  # (c) Deal complete: ONE dealer card showing ("shows 4"). Seat 0 holds a
+  # mixed pair of eights — its Star Pairs line reads "MIXED 5:1" (paid on the
+  # spot; aces 30 / perfect 20 / colored 8 / mixed 5). Seat 1's pairs lost, and
+  # seat 0's back rides under seat 1 from seat 0's view. Seat 0 is first on
+  # turn with [P]split offered on the pair.
   - shot: dealt
 
-  # Seat 0 splits its pair -> two hands stacked, the hand on turn marked.
+  # Seat 0 splits its eights -> two hands stacked, each drawing a second card
+  # (split is by POINT VALUE on this table, so K+10 would split too); the hand
+  # on turn is marked.
   - seat: 0
   - rune: "p"
   - advance: 700ms
@@ -49,22 +58,38 @@ steps:
   - rune: "s"
   - advance: 200ms
 
-  # Seat 1 stands on its 20.
+  # Seat 1 stands on its 17 — if the dealer also makes 17 that's a loss, not a
+  # push: TIES LOSE on this table.
   - seat: 1
   - rune: "s"
   - advance: 200ms
 
-  # All hands resolved -> the dealer reveals and stands; sweep the paced reveal.
+  # All hands resolved -> the dealer draws out from its single card: a 500ms
+  # lead-in, then each hit slides+flips in one at a time (950ms apart), and an
+  # 800ms hold on the made hand before settling.
+  - advance: 800ms
+  - advance: 800ms
+
+  # (d) Mid-draw-out: the dealer's first hit (9) has landed next to its dealt 4
+  # and the next card is still sliding in face down — the paced one-at-a-time
+  # reveal, not a hole-card flip.
+  - shot: draw
+
+  # Sweep the rest of the draw-out generously (it ends early if the dealer
+  # makes a hand sooner; settle fires 800ms after the last card).
   - advance: 1600ms
   - advance: 1600ms
   - advance: 1600ms
 
-  # (d) Settlement: each seat's result, with own pairs and (for seat 0) the
-  # behind + back-pairs on seat 1 all folded into the net; the backers line shows
-  # seat 0's net on seat 1.
+  # (e) Settlement: the dealer busts on its third card, so every standing hand
+  # wins even money. Each seat's net folds in its main hands, its own Star
+  # Pairs, and (for seat 0) the behind + back-pairs riding on seat 1 — the
+  # backers line under seat 1 flips to seat 0's net on the back. (Had the
+  # dealer made 17 instead, seat 1's 17 would LOSE: ties lose on this table,
+  # and only a blackjack beats a dealer blackjack, at ranked 5/4/3:1.)
   - shot: payout
 
-  # (e) Ready-up flow preserved.
+  # (f) Ready-up flow preserved.
   - seat: 0
   - key: space
   - shot: ready

From 8e68ac1b07f7fed6b6727b0c9905420035a96018 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 01:09:08 +1000
Subject: [PATCH 11/14] blackjack-challenge: re-author the preview pack for the
 Challenge rules
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The preview/ frames were a verbatim copy of the classic blackjack pack:
title "BLACKJACK" and a rendered dealer hole card "[??]" — exactly the
thing this variant doesn't have (one face-up dealer card, no hole card).
Regenerated the game's smoke shots (smoke.yaml seed 16) as ground truth
and hand-authored four new frames from them: a deal with a single dealer
card, a split moment, the dealer's draw-out to a bust, and the payout
(with the Star Pairs tier folded in). Frames now read "BLACKJACK
CHALLENGE" and never depict a face-down dealer card.

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/preview/01-deal.frame    | 8 ++++----
 games/bcook/blackjack-challenge/preview/02-split.frame   | 7 +++++++
 games/bcook/blackjack-challenge/preview/02-stand.frame   | 7 -------
 games/bcook/blackjack-challenge/preview/03-bust.frame    | 7 -------
 games/bcook/blackjack-challenge/preview/03-drawout.frame | 7 +++++++
 games/bcook/blackjack-challenge/preview/04-payout.frame  | 6 +++---
 games/bcook/blackjack-challenge/preview/preview.yaml     | 4 ++--
 7 files changed, 23 insertions(+), 23 deletions(-)
 create mode 100644 games/bcook/blackjack-challenge/preview/02-split.frame
 delete mode 100644 games/bcook/blackjack-challenge/preview/02-stand.frame
 delete mode 100644 games/bcook/blackjack-challenge/preview/03-bust.frame
 create mode 100644 games/bcook/blackjack-challenge/preview/03-drawout.frame

diff --git a/games/bcook/blackjack-challenge/preview/01-deal.frame b/games/bcook/blackjack-challenge/preview/01-deal.frame
index 38cf677..ce0690c 100644
--- a/games/bcook/blackjack-challenge/preview/01-deal.frame
+++ b/games/bcook/blackjack-challenge/preview/01-deal.frame
@@ -1,7 +1,7 @@
- ♠♥♦♣ BLACKJACK
+ ♠♥♦♣ BLACKJACK CHALLENGE
 
-   DEALER   [6♣] [??]         shows 6
+   DEALER   [4♣]                    shows 4
 
-   YOU      [T♣] [T♦]         20
+   YOU      [8♥] [8♠]         16
 
-   ► hit · stand · double        chips 950
+   ► hit · stand · double · split   chips 930
diff --git a/games/bcook/blackjack-challenge/preview/02-split.frame b/games/bcook/blackjack-challenge/preview/02-split.frame
new file mode 100644
index 0000000..95da4c1
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/02-split.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK CHALLENGE
+
+   DEALER   [4♣]                    shows 4
+
+   YOU      ►8♥6♦ 14
+             8♠2♣ 10
+   split: hand 1 of 2               chips 905
diff --git a/games/bcook/blackjack-challenge/preview/02-stand.frame b/games/bcook/blackjack-challenge/preview/02-stand.frame
deleted file mode 100644
index 2fe86e8..0000000
--- a/games/bcook/blackjack-challenge/preview/02-stand.frame
+++ /dev/null
@@ -1,7 +0,0 @@
- ♠♥♦♣ BLACKJACK
-
-   DEALER   [6♣] [K♠]         16
-
-   YOU      [T♣] [T♦]         20
-
-   standing…                     chips 950
diff --git a/games/bcook/blackjack-challenge/preview/03-bust.frame b/games/bcook/blackjack-challenge/preview/03-bust.frame
deleted file mode 100644
index 104c4bf..0000000
--- a/games/bcook/blackjack-challenge/preview/03-bust.frame
+++ /dev/null
@@ -1,7 +0,0 @@
- ♠♥♦♣ BLACKJACK
-
-   DEALER   [6♣] [K♠] [9♥]    25 BUST
-
-   YOU      [T♣] [T♦]         20
-
-   dealer busts!                 chips 950
diff --git a/games/bcook/blackjack-challenge/preview/03-drawout.frame b/games/bcook/blackjack-challenge/preview/03-drawout.frame
new file mode 100644
index 0000000..c0fc321
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/03-drawout.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK CHALLENGE
+
+   DEALER   [4♣] [9♦] [J♠]    25 BUST
+
+   YOU      8♥6♦ 14 · 8♠2♣ 10
+
+   dealer busts!                 chips 905
diff --git a/games/bcook/blackjack-challenge/preview/04-payout.frame b/games/bcook/blackjack-challenge/preview/04-payout.frame
index d11fad6..0f91aa4 100644
--- a/games/bcook/blackjack-challenge/preview/04-payout.frame
+++ b/games/bcook/blackjack-challenge/preview/04-payout.frame
@@ -1,7 +1,7 @@
- ♠♥♦♣ BLACKJACK
+ ♠♥♦♣ BLACKJACK CHALLENGE
 
         ★   Y O U   W I N   ★
 
-   YOU      [T♣] [T♦]         20
+   YOU      8♥6♦ 14 · 8♠2♣ 10
 
-   +100                          chips 1050
+   +115  MIXED 5:1                 chips 1115
diff --git a/games/bcook/blackjack-challenge/preview/preview.yaml b/games/bcook/blackjack-challenge/preview/preview.yaml
index d395eeb..6993853 100644
--- a/games/bcook/blackjack-challenge/preview/preview.yaml
+++ b/games/bcook/blackjack-challenge/preview/preview.yaml
@@ -1,9 +1,9 @@
 frames:
   - file: 01-deal.frame
     ms: 1100
-  - file: 02-stand.frame
+  - file: 02-split.frame
     ms: 700
-  - file: 03-bust.frame
+  - file: 03-drawout.frame
     ms: 1000
   - file: 04-payout.frame
     ms: 1400

From 83129661f2436082f60c5fc7421545dcd0fd4711 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 01:11:13 +1000
Subject: [PATCH 12/14] blackjack-challenge: fix stale docs and comments

Sweep of doc/comment drift left over from the classic-blackjack fork and
the Perfect Pairs -> Star Pairs rename: main.go's package doc said the
ranked blackjack payout depends on the ten-card's suits (it's RANK,
K>Q>J>10); game.go's registry comments still named the old
games/bcook/blackjack path and "the native blackjack meta"; layout.go's
example strings referenced retired numbers ("COLORED 12:1", "PUSH" - this
table has no push, ties lose, so the real narrow-label example is
"EVEN"); room.go/room_test.go said turns bind h/s/d/p/r as domain
commands, but r (re-buy) is betting-only; rules_test.go's
TestPairsCreditFor still exercised the retired Perfect Pairs 6/12/25
table instead of the live Star Pairs 5/8/20/30 one.

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/game.go       |  9 +++++----
 games/bcook/blackjack-challenge/layout.go     |  4 ++--
 games/bcook/blackjack-challenge/main.go       |  3 ++-
 games/bcook/blackjack-challenge/room.go       |  2 +-
 games/bcook/blackjack-challenge/room_test.go  | 14 ++++++++------
 games/bcook/blackjack-challenge/rules_test.go |  7 ++++---
 6 files changed, 22 insertions(+), 17 deletions(-)

diff --git a/games/bcook/blackjack-challenge/game.go b/games/bcook/blackjack-challenge/game.go
index 18ee75f..5e3e567 100644
--- a/games/bcook/blackjack-challenge/game.go
+++ b/games/bcook/blackjack-challenge/game.go
@@ -2,12 +2,13 @@ package main
 
 import kit "github.com/shellcade/kit/v2"
 
-// Game is the blackjack registry entry: static metadata plus the per-room
-// factory. The catalog slug is composed by the platform from the directory path
-// (games/bcook/blackjack -> "bcook/blackjack"); Meta carries the bare name.
+// Game is the blackjack-challenge registry entry: static metadata plus the
+// per-room factory. The catalog slug is composed by the platform from the
+// directory path (games/bcook/blackjack-challenge -> "bcook/blackjack-challenge");
+// Meta carries the bare name.
 type Game struct{}
 
-// Meta returns the static game metadata (mirrors the native blackjack meta).
+// Meta returns the static game metadata for this Challenge-rules table.
 func (Game) Meta() kit.GameMeta {
 	return kit.GameMeta{
 		Slug:             "blackjack-challenge",
diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 8adfe09..2ca4f81 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -248,7 +248,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
 	}
 	centerSlot(f, seatValRow, slot, valStr, valSt)
 	// The chip line carries the settlement summary during results, drawn instead
-	// of the stack (not over it) — a shorter result like "PUSH" centred over the
+	// of the stack (not over it) — a shorter result like "EVEN" centred over the
 	// wider "$1000" would otherwise leave a stray digit peeking out beside it.
 	if rm.phase == phResults && s.result != "" {
 		centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result))
@@ -279,7 +279,7 @@ func pairsMult(kind string) int {
 // bet+pairs read as one contiguous block and whose side bet is whose is never
 // ambiguous; it shows only once placed, or for the seat's owner, matching how
 // the main bet stays private until placed. Once the cards are dealt it moves to
-// seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 12:1")
+// seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 8:1")
 // or a quiet "pairs lost".
 func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) {
 	ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet
diff --git a/games/bcook/blackjack-challenge/main.go b/games/bcook/blackjack-challenge/main.go
index 7141b52..a65f8bb 100644
--- a/games/bcook/blackjack-challenge/main.go
+++ b/games/bcook/blackjack-challenge/main.go
@@ -3,7 +3,8 @@
 // rounds that loop while anyone is seated. The dealer takes a single face-up
 // card at the deal — no hole card — draws out at the dealer's turn, and
 // stands on all 17 (S17); a tie hand loses instead of pushing, and a
-// player blackjack is ranked, paying 2:1 up to 5:1 depending on its suits.
+// player blackjack is ranked, paying 2:1 up to 5:1 depending on its ten-card's
+// rank (K>Q>J>10).
 // Bet, hit, stand, double, and split; the round also auto-wins/auto-loses a
 // hand outright without a turn where the outcome is already decided, and a
 // Star Pairs side bet pays out on the first two cards dealt. Leaving with a
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index a8bb969..9be6b1d 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -848,7 +848,7 @@ func (rm *room) beginTurn(r kit.Room) {
 		return
 	}
 	rm.deadline = r.Now().Add(turnDur)
-	r.SetInputContext(kit.CtxCommand) // h/s/d/p/r are domain commands
+	r.SetInputContext(kit.CtxCommand) // h/s/d/p are domain commands
 	rm.arm(pendTurn, rm.deadline)
 }
 
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 6a46f3c..6761b39 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -472,7 +472,7 @@ func TestPublishesContextPerPhase(t *testing.T) {
 	if tr.InputCtx != kit.CtxNav {
 		t.Errorf("betting InputCtx = %v, want CtxNav", tr.InputCtx)
 	}
-	// Player turns bind h/s/d/p/r as domain commands.
+	// Player turns bind h/s/d/p as domain commands.
 	rm, tr2, _, _ := turnsSetup(t, hand{{10, suitSpade}, {5, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}})
 	rm.beginTurn(tr2)
 	if tr2.InputCtx != kit.CtxCommand {
@@ -1221,24 +1221,26 @@ func TestDealerTotalTicksUpAsCardsLand(t *testing.T) {
 
 // TestResultLabelDoesNotLeakChips guards the results chip line: the settlement
 // summary is drawn instead of the stack, so a result narrower than the stack
-// (e.g. "PUSH" over "$1000") leaves no stray digit peeking out beside it.
+// (e.g. "EVEN" over "$1000") leaves no stray digit peeking out beside it. This
+// table has no push (ties lose), but a net of exactly zero is still reachable
+// (resultText's "EVEN" case) and is the narrow-label example here.
 func TestResultLabelDoesNotLeakChips(t *testing.T) {
 	a := mkPlayer("a")
 	rm, tr := newGame(t, a)
 	rm.OnJoin(tr, a)
 	s := rm.seats[a.AccountID]
 	s.placed = true
-	s.bal = 1000 // "$1000" is wider than "PUSH"
+	s.bal = 1000 // "$1000" is wider than "EVEN"
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}}
-	s.result = "PUSH"
+	s.result = "EVEN"
 	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}
 	rm.phase = phResults
 	rm.render(tr)
 
 	row := []rune(kittest.String(tr.LastFrame(a), seatChipRow))
 	slot := (kit.Cols - slotW) / 2 // single seat: the group is one centred slot
-	if got := strings.TrimSpace(string(row[slot : slot+slotW])); got != "PUSH" {
-		t.Fatalf("chip-row slot = %q, want exactly %q (no chips bleeding through)", got, "PUSH")
+	if got := strings.TrimSpace(string(row[slot : slot+slotW])); got != "EVEN" {
+		t.Fatalf("chip-row slot = %q, want exactly %q (no chips bleeding through)", got, "EVEN")
 	}
 }
 
diff --git a/games/bcook/blackjack-challenge/rules_test.go b/games/bcook/blackjack-challenge/rules_test.go
index fd7d4f9..a3ec94d 100644
--- a/games/bcook/blackjack-challenge/rules_test.go
+++ b/games/bcook/blackjack-challenge/rules_test.go
@@ -28,9 +28,10 @@ func TestPairsCreditFor(t *testing.T) {
 		want int
 	}{
 		{0, 10, 0},     // no pair: side stake lost
-		{6, 10, 70},    // mixed 6:1: stake 10 + 60
-		{12, 25, 325},  // colored 12:1: stake 25 + 300
-		{25, 50, 1300}, // perfect 25:1: stake 50 + 1250
+		{5, 10, 60},    // mixed 5:1: stake 10 + 50
+		{8, 25, 225},   // colored 8:1: stake 25 + 200
+		{20, 50, 1050}, // perfect 20:1: stake 50 + 1000
+		{30, 10, 310},  // aces 30:1: stake 10 + 300
 	}
 	for _, c := range cases {
 		if got := pairsCreditFor(c.mult, c.bet); got != c.want {

From 59f84df5144b95d3b0870aa7337cf3c3039e3eb0 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 01:11:54 +1000
Subject: [PATCH 13/14] blackjack-challenge: show the ranked blackjack tier on
 settle

The spec's layout-deltas section requires the result to name the tier a
blackjack paid (e.g. "BLACKJACK 5:1"), but the value line only ever read
"BJ" and the net was shown separately, leaving the ranked 2:1/3:1/4:1/5:1
payout invisible on the felt. Following the Star Pairs precedent (the
pairs line renders e.g. "MIXED 5:1"), phand gains a display-only bjMult
field set in settle() from blackjackMult; valueLabel appends " X:1" to
"BJ" once it's nonzero, in both the single-hand and split-hand (compact
line) render paths. Payout arithmetic (grossMult) is unchanged - this is
display only. TDD: TestSettleShowsRankedBlackjackTier failed red before
the change and is green after.

Also pins two more invariants surfaced during this pass:
TestMetaMaxPayoutMultiplierMatchesConst (Meta().MaxPayoutMultiplier must
track the maxPayoutMult const the settlement clamp actually uses) and
TestPairsMult (a table test over pairsMult's four Star Pairs tiers plus
the no-pair default).

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/layout.go    | 17 ++++--
 games/bcook/blackjack-challenge/room.go      |  8 +++
 games/bcook/blackjack-challenge/room_test.go | 60 ++++++++++++++++++++
 3 files changed, 79 insertions(+), 6 deletions(-)

diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 2ca4f81..f986c31 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -237,7 +237,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
 		}
 		drawCardsAnim(f, seatCardRow, col, h.cards, -1, rm.seatResolver(s.p, hi, h))
 		col += w + 1
-		vals = append(vals, valueLabel(h.cards, h.autoWon)+dblTag(h))
+		vals = append(vals, valueLabel(h.cards, h.autoWon, h.bjMult)+dblTag(h))
 	}
 	// During results the value line doubles as the ready indicator: a readied
 	// seat shows READY where its hand total was, so who's holding up the table
@@ -813,7 +813,7 @@ func compactHandLine(h *phand, active bool) (string, kit.Style) {
 		cards.WriteString(c.r.boxLabel())
 		cards.WriteRune(c.s.pip())
 	}
-	total := valueLabel(h.cards, h.autoWon) + dblTag(h)
+	total := valueLabel(h.cards, h.autoWon, h.bjMult) + dblTag(h)
 	marker, st := " ", stCard
 	if h.cards.isBust() {
 		st = stLose
@@ -842,14 +842,19 @@ func dblTag(h *phand) string {
 }
 
 // valueLabel formats a hand's total for the felt. Any two-card 21 reads as
-// "BJ" — split hands included, they ARE blackjacks on this table. An
-// auto-won hand names its win: "21!" for a Player 21, "5-CARD" for a Five
-// Card Trick.
-func valueLabel(h hand, autoWon bool) string {
+// "BJ" — split hands included, they ARE blackjacks on this table. Once
+// settle has fixed bjMult (the ranked X:1 this blackjack paid), the tier
+// appends: "BJ 5:1", mirroring how the Star Pairs line names its own tier
+// (e.g. "MIXED 5:1"). bjMult is 0 before settlement, so mid-round the label
+// stays a bare "BJ". An auto-won hand names its win: "21!" for a Player 21,
+// "5-CARD" for a Five Card Trick.
+func valueLabel(h hand, autoWon bool, bjMult int) string {
 	total, soft := h.value()
 	switch {
 	case total > 21:
 		return "BUST"
+	case len(h) == 2 && total == 21 && bjMult > 0:
+		return fmt.Sprintf("BJ %d:1", bjMult)
 	case len(h) == 2 && total == 21:
 		return "BJ"
 	case autoWon && len(h) >= 5:
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 9be6b1d..4f01f74 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -53,6 +53,11 @@ type phand struct {
 	resolved bool // stood / busted / blackjack / doubled / auto-won
 	doubled  bool
 	autoWon  bool // Player 21 or Five Card Trick: instant even-money win
+	// bjMult is the X in this blackjack's X:1 ranked payout (blackjackMult),
+	// 0 until settle fixes it. It does not change the payout math — grossMult
+	// computes that independently — it only lets the felt name the paid tier
+	// (valueLabel appends " X:1" to "BJ" once this is set).
+	bjMult int
 }
 
 // backBet is one seat's wager ON ANOTHER seat: a "behind" bet that rides the
@@ -1051,6 +1056,9 @@ func (rm *room) settle(r kit.Room) {
 		// hands lost before the dealer's hand existed and stay lost.
 		lostToBJ := 0
 		for _, h := range s.hands {
+			if h.cards.isBlackjack() {
+				h.bjMult = blackjackMult(bjTen(h.cards), bjTen(rm.dealer), dbj)
+			}
 			m := grossMult(h, rm.dealer, dbj)
 			if dbj && m == 0 && !h.cards.isBust() {
 				lostToBJ += h.bet
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 6761b39..0726b78 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -792,6 +792,33 @@ func TestBlackjackRankedPayouts(t *testing.T) {
 	}
 }
 
+// TestSettleShowsRankedBlackjackTier asserts the paid tier of a ranked
+// blackjack is visible on the felt after settlement — the spec's layout-deltas
+// section requires the result to name the tier (e.g. "BLACKJACK 5:1"), and
+// before this the hand's value line only ever read "BJ" with the net shown
+// separately, leaving the ranked payout invisible. Following the Star Pairs
+// precedent (drawPairsLine renders "MIXED 5:1"), a made blackjack's value line
+// grows the tier suffix once bjMult is set at settle.
+func TestSettleShowsRankedBlackjackTier(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	s.placed = true
+	s.bet = 100
+	staked(tr, s, 900, 100)
+	// Player blackjack (A,K) outranks the dealer's blackjack ten (J): 5:1.
+	s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 100}}
+	rm.dealer = hand{{rankAce, suitClub}, {rankJack, suitDiamond}}
+	rm.settle(tr)
+	rm.render(tr)
+
+	row := kittest.String(tr.LastFrame(a), seatValRow)
+	if !strings.Contains(row, "BJ 5:1") {
+		t.Fatalf("seat value row %q does not show the ranked blackjack tier BJ 5:1", row)
+	}
+}
+
 func TestTiesLose(t *testing.T) {
 	a := mkPlayer("a")
 	rm, tr := newGame(t, a)
@@ -1603,3 +1630,36 @@ func TestBrokeSeatRebuysOnRInBetting(t *testing.T) {
 		t.Fatal("re-bought seat still cannot place a bet")
 	}
 }
+
+// TestMetaMaxPayoutMultiplierMatchesConst pins the declared
+// Meta().MaxPayoutMultiplier (the host-enforced settlement ceiling) against
+// the package's own maxPayoutMult constant (settleOpenStake's clamp,
+// roundStake*maxPayoutMult). The two are required to stay in sync — a drift
+// would either let a payout slip past what the host actually honors, or
+// clamp the game's own math below what it declares it can pay.
+func TestMetaMaxPayoutMultiplierMatchesConst(t *testing.T) {
+	if got := (Game{}).Meta().MaxPayoutMultiplier; got != maxPayoutMult {
+		t.Fatalf("Meta().MaxPayoutMultiplier = %d, want maxPayoutMult (%d)", got, maxPayoutMult)
+	}
+}
+
+// TestPairsMult is a table test for pairsMult (layout.go), which mirrors
+// starPairsOutcome's payout table for the results-row label ("MIXED 5:1"
+// etc.). Covers all four Star Pairs kinds plus the no-pair default.
+func TestPairsMult(t *testing.T) {
+	cases := []struct {
+		kind string
+		want int
+	}{
+		{"aces", 30},
+		{"perfect", 20},
+		{"colored", 8},
+		{"mixed", 5},
+		{"", 0},
+	}
+	for _, c := range cases {
+		if got := pairsMult(c.kind); got != c.want {
+			t.Errorf("pairsMult(%q) = %d, want %d", c.kind, got, c.want)
+		}
+	}
+}

From 30ae0978577aef4bdb97d98d014036b748d3d887 Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Sun, 5 Jul 2026 01:16:31 +1000
Subject: [PATCH 14/14] blackjack-challenge: correct the draw-out preview's
 dealer total (4+9+J = 23)

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/preview/03-drawout.frame | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/games/bcook/blackjack-challenge/preview/03-drawout.frame b/games/bcook/blackjack-challenge/preview/03-drawout.frame
index c0fc321..32c9ef0 100644
--- a/games/bcook/blackjack-challenge/preview/03-drawout.frame
+++ b/games/bcook/blackjack-challenge/preview/03-drawout.frame
@@ -1,6 +1,6 @@
  ♠♥♦♣ BLACKJACK CHALLENGE
 
-   DEALER   [4♣] [9♦] [J♠]    25 BUST
+   DEALER   [4♣] [9♦] [J♠]    23 BUST
 
    YOU      8♥6♦ 14 · 8♠2♣ 10