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..92774f0 --- /dev/null +++ b/games/bcook/blackjack-challenge/anim.go @@ -0,0 +1,125 @@ +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 read each drawn card as it lands, one card at a + // time, rather than the cards flashing in back to back at dealStagger. + // + // - 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. + dealerLeadIn = 500 * 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" (a settled/unscheduled card). +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 flip scheduled + } + 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..5e3e567 --- /dev/null +++ b/games/bcook/blackjack-challenge/game.go @@ -0,0 +1,78 @@ +package main + +import kit "github.com/shellcade/kit/v2" + +// 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 for this Challenge-rules table. +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + 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"}, + + // 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 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: 31, + + // 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 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 Star Pairs side bet during + // betting — one rune, so the chip carries both meanings. + kit.RuneControl('p', "SPLIT/PAIRS"), + // Betting only: B loops the behind bet on the focused seat. + 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..f986c31 --- /dev/null +++ b/games/bcook/blackjack-challenge/layout.go @@ -0,0 +1,894 @@ +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 // 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 + 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 CHALLENGE", 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 CHALLENGE", 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 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 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) + + // 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 + } + // 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], -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 + // 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 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 + 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) + } + 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.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 + // 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 "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)) + } else { + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) + } +} + +// 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 20 + case "colored": + return 8 + case "mixed": + return 5 + default: + return 0 + } +} + +// 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 +// 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 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 + 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 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. + 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 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 + // 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: + 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 +} + +// 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"} + if !h.doubled && len(h.cards) <= 3 && s.bal >= h.bet { + parts = append(parts, "[D]ouble") + } + 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") + } + 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: 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 { + 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 +// 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 it begins. +func (rm *room) dealerLayoutCount() int { + n := 0 + for i := range rm.dealer { + 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 + } + } + n++ + } + return n +} + +// dealerShownCount is how many leading dealer cards are face up right now. The +// 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 + 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.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 "??" — 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) + 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.autoWon, h.bjMult) + 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. Any two-card 21 reads as
+// "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:
+		return "5-CARD"
+	case autoWon:
+		return "21!"
+	case total == 21:
+		return "21"
+	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..a65f8bb
--- /dev/null
+++ b/games/bcook/blackjack-challenge/main.go
@@ -0,0 +1,32 @@
+// 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 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 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
+// 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..ce0690c
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/01-deal.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK CHALLENGE
+
+   DEALER   [4♣]                    shows 4
+
+   YOU      [8♥] [8♠]         16
+
+   ► 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/03-drawout.frame b/games/bcook/blackjack-challenge/preview/03-drawout.frame
new file mode 100644
index 0000000..32c9ef0
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/03-drawout.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK CHALLENGE
+
+   DEALER   [4♣] [9♦] [J♠]    23 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
new file mode 100644
index 0000000..0f91aa4
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/04-payout.frame
@@ -0,0 +1,7 @@
+ ♠♥♦♣ BLACKJACK CHALLENGE
+
+        ★   Y O U   W I N   ★
+
+   YOU      8♥6♦ 14 · 8♠2♣ 10
+
+   +115  MIXED 5:1                 chips 1115
diff --git a/games/bcook/blackjack-challenge/preview/preview.yaml b/games/bcook/blackjack-challenge/preview/preview.yaml
new file mode 100644
index 0000000..6993853
--- /dev/null
+++ b/games/bcook/blackjack-challenge/preview/preview.yaml
@@ -0,0 +1,9 @@
+frames:
+  - file: 01-deal.frame
+    ms: 1100
+  - file: 02-split.frame
+    ms: 700
+  - file: 03-drawout.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..4f01f74
--- /dev/null
+++ b/games/bcook/blackjack-challenge/room.go
@@ -0,0 +1,1242 @@
+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"
+	phTurns   = "player turns"
+	phResults = "results"
+)
+
+const (
+	maxHands = 3 // split up to twice per seat, forming three hands (table rule)
+
+	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.
+	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. 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
+// 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 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}
+
+// 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
+	// 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
+// 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-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)
+}
+
+// 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                 // 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)
+	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)
+	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
+	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 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.bettingClosing = false
+	rm.clearSchedule()
+	for _, s := range rm.seats {
+		s.hands = nil
+		s.placed = 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 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))
+}
+
+// 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 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 {
+		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)} // 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 {
+		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)
+
+	rm.enterTurns(r)
+}
+
+// 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
+// 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 := starPairsOutcome(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-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
+// 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 := 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)
+				} 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 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
+	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's only dealt card; second card to every seat.
+	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}) // the dealer's only dealt card
+		}
+	}
+	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()
+}
+
+// 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),
+	})
+}
+
+// --- 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 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)
+}
+
+// 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)
+	// 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 !autoWin(h) && h.cards.isBust() {
+			h.resolved = true
+		}
+		rm.beginTurn(r)
+	case 's':
+		h.resolved = true
+		rm.beginTurn(r)
+	case 'd':
+		// 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) {
+			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)
+		autoWin(h) // a doubled 21 is a Player 21: instant even money
+		h.resolved = true
+		rm.beginTurn(r)
+	case 'p':
+		if !rm.split(r, s, h) {
+			return
+		}
+		rm.beginTurn(r)
+	}
+}
+
+// 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.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
+	// (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}
+	h.cards = hand{c0, rm.sh.draw(rng)}
+	for _, sp := range []*phand{h, nh} {
+		if sp.cards.isBlackjack() {
+			sp.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) enterDealer(r kit.Room) {
+	// 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)
+			start = start.Add(slideDur + flipDur + dealerDrawGap)
+		}
+		rm.computeSchedEnd()
+		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 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]
+		if s == nil {
+			continue
+		}
+		for _, h := range s.hands {
+			if !h.cards.isBust() && !h.autoWon {
+				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 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.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
+			}
+			s.grossThisRound += int64(m * h.bet)
+		}
+		if lostToBJ > s.bet {
+			s.grossThisRound += int64(lostToBJ - s.bet)
+		}
+		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. 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]
+		if b.behind <= 0 {
+			continue
+		}
+		t := rm.seats[tid]
+		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)
+	}
+}
+
+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 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')
+			}
+		}
+	}
+	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 "EVEN"
+	}
+}
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
new file mode 100644
index 0000000..0726b78
--- /dev/null
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -0,0 +1,1665 @@
+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) 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 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
+	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: the dealer's one card, then the seat's two cards — a mixed pair.
+	rm.sh.cards = hand{
+		{10, suitClub},                 // dealer
+		{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 != 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 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)
+	}
+	if s.roundStake != 60 {
+		t.Fatalf("roundStake = %d, want 60 (main 50 + pairs 10)", s.roundStake)
+	}
+	if s.grossThisRound != 60 {
+		t.Fatalf("grossThisRound = %d, want 60 (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
+	// 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},                 // 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 != 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 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 != 60 {
+		t.Fatalf("a grossThisRound = %d, want 60 (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}, {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 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}
+	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 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)
+	}
+}
+
+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 = 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 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)
+	}
+}
+
+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 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 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("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 {
+			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
+
+	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 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)
+		}
+	}
+}
+
+// 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)
+	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
+	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)
+	}
+}
+
+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)
+	}
+}
+
+// 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 (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 Star Pairs stake hitting an "aces" pair: gross 3100.
+	staked(tr, s, 0, 100)
+	s.grossThisRound = 100 * (30 + 1) // 3100, the top pairs payout
+	net := rm.settleOpenStake(s)
+	if net != 3000 {
+		t.Fatalf("net = %d, want 3000 (top pairs prize settles unclamped)", net)
+	}
+	if got := tr.Credits[a.AccountID]; got != 3100 {
+		t.Fatalf("settled balance = %d, want 3100 (full 31x 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 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")
+	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
+	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 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 draw-out, including the busting hit
+	rm.render(tr)
+
+	if !rm.dealingActive() {
+		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 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 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 draw-out is unhurried: settlement is
+// 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
+
+	start := tr.Now()
+	rm.enterDealer(tr)
+
+	if rm.what != pendSettle {
+		t.Fatalf("dealer draw-out should defer settlement, what = %v", rm.what)
+	}
+	// 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)
+	}
+}
+
+// TestDealerTotalTicksUpAsCardsLand checks the displayed total reflects only the
+// 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}}, card{7, suitDiamond}) // draws to 17, stands
+
+	// Before the dealer's turn, only the one dealt card counts.
+	if got := rm.dealerShownCount(); got != 1 {
+		t.Fatalf("before the draw-out, shown count = %d, want 1", got)
+	}
+
+	rm.enterDealer(tr) // schedules the draw; still mid lead-in
+	rm.render(tr)
+	if got := rm.dealerShownCount(); got != 1 {
+		t.Fatalf("mid lead-in, shown count = %d, want 1 (drawn card not yet arrived)", got)
+	}
+
+	// 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 draw-out finished", rm.phase)
+	}
+	if got := rm.dealerShownCount(); got != 2 {
+		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)
+	}
+}
+
+// TestResultLabelDoesNotLeakChips guards the results chip line: the settlement
+// summary is drawn instead of the stack, so a result narrower than the stack
+// (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 "EVEN"
+	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}}
+	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 != "EVEN" {
+		t.Fatalf("chip-row slot = %q, want exactly %q (no chips bleeding through)", got, "EVEN")
+	}
+}
+
+// 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 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.
+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 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) {
+	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)
+	}
+}
+
+// TestResultsShowsStarPairsWin asserts a winning Star Pairs side bet is
+// surfaced on the seat with its category and multiplier during results.
+func TestResultsShowsStarPairsWin(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 = 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 8: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)
+
+	// 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)
+	}
+	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},
+		{cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50},
+	}
+	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)
+	}
+}
+
+// TestPairsVerdictHeldUntilCardsLand is the regression guard for the reported
+// 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.
+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)
+	}
+}
+
+// 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")
+	}
+}
+
+// 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)
+		}
+	}
+}
diff --git a/games/bcook/blackjack-challenge/rules.go b/games/bcook/blackjack-challenge/rules.go
new file mode 100644
index 0000000..69750c8
--- /dev/null
+++ b/games/bcook/blackjack-challenge/rules.go
@@ -0,0 +1,109 @@
+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).
+
+// 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
+// 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
+	}
+}
+
+// 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
new file mode 100644
index 0000000..a3ec94d
--- /dev/null
+++ b/games/bcook/blackjack-challenge/rules_test.go
@@ -0,0 +1,121 @@
+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 TestPairsCreditFor(t *testing.T) {
+	cases := []struct {
+		mult int
+		bet  int
+		want int
+	}{
+		{0, 10, 0},     // no pair: side stake lost
+		{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 {
+			t.Errorf("pairsCreditFor(%d, %d) = %d, want %d", c.mult, c.bet, got, c.want)
+		}
+	}
+}
+
+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)
+		}
+	}
+}
diff --git a/games/bcook/blackjack-challenge/smoke.yaml b/games/bcook/blackjack-challenge/smoke.yaml
new file mode 100644
index 0000000..87b72c0
--- /dev/null
+++ b/games/bcook/blackjack-challenge/smoke.yaml
@@ -0,0 +1,99 @@
+seed: 16
+seats: 2
+heartbeat: 50ms
+steps:
+  # (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 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
+  - 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
+  # 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" under its bet.
+  - shot: sidebet
+
+  # 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: 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 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
+  - 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 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 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
+
+  # (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
+
+  # (f) Ready-up flow preserved.
+  - seat: 0
+  - key: space
+  - shot: ready
+  - seat: 1
+  - key: space
+  - advance: 100ms
+  - shot: next