Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions games/bcook/blackjack-challenge/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/blackjack-challenge
21 changes: 21 additions & 0 deletions games/bcook/blackjack-challenge/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions games/bcook/blackjack-challenge/anim.go
Original file line number Diff line number Diff line change
@@ -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
}
221 changes: 221 additions & 0 deletions games/bcook/blackjack-challenge/cards.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading