The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 4 — Blackjack

Blackjack is the classic card game: get closer to 21 than the dealer without going over. The player starts with a pile of money, bets some of it, and is dealt two cards; the dealer gets two as well, but keeps one face down. Picture cards count 10, aces count 1 or 11 (whichever helps more without busting), and everything else counts its face value. The player can hit (take another card), stand (stop), or — on the very first move, if there is enough money left — double down (raise the bet and take exactly one more card). Once the player stops, the dealer draws up to 17 and the hands are compared: a bust, a higher total, a tie, or a loss decides the round, and the money carries over to the next bet.

Adapted from project 4 of Al Sweigart's The Big Book of Small Python Projects (No Starch Press, 2021), under CC BY-NC-SA 4.0.

A sample run

The deck is shuffled randomly, so the golden run fixes it with BIGBOOK_SEED=2 (from seed.txt). The player's side of input.txt is three lines: a bet, a stand, and a quit at the next bet prompt —

400
S
QUIT
BIGBOOK_SEED=2 ek9 blackjack.ek9 < input.txt

None of those three answers is echoed back as typed; what follows is only what the program itself prints, including the hands as they are dealt and drawn:

Blackjack, by Al Sweigart al@inventwithpython.com

    Rules:
      Try to get as close to 21 without going over.
      Kings, Queens, and Jacks are worth 10 points.
      Aces are worth 1 or 11 points.
      Cards 2 through 10 are worth their face value.
      (H)it to take another card.
      (S)tand to stop taking cards.
      On your first play, you can (D)ouble down to increase your bet
      but must hit exactly one more time before standing.
      In case of a tie, the bet is returned to the player.
      The dealer stops hitting at 17.
Money: 5000
How much do you bet? (1-5000, or QUIT)
Bet: 400

DEALER: ???
 ___   ___  
|## | |6  | 
|###| | ♠ | 
|_##| |__6| 
PLAYER: 20
 ___   ___  
|J  | |Q  | 
| ♣ | | ♥ | 
|__J| |__Q| 

(H)it, (S)tand, (D)ouble down
Dealer hits...

DEALER: ???
 ___   ___   ___  
|## | |6  | |5  | 
|###| | ♠ | | ♠ | 
|_##| |__6| |__5| 
PLAYER: 20
 ___   ___  
|J  | |Q  | 
| ♣ | | ♥ | 
|__J| |__Q| 

DEALER: 21
 ___   ___   ___  
|Q  | |6  | |5  | 
| ♠ | | ♠ | | ♠ | 
|__Q| |__6| |__5| 
PLAYER: 20
 ___   ___  
|J  | |Q  | 
| ♣ | | ♥ | 
|__J| |__Q| 
You lost!
Money: 4600
How much do you bet? (1-4600, or QUIT)
Thanks for playing!

The program

#!ek9
defines module bigbook.blackjack

  defines constant

    TwentyOne <- 21
    DealerStands <- 17
    StartMoney <- 5000
    //An Ace counts 1, or 11 when that does not bust the hand: 10 more.
    AceBonus <- 10
    //Doubling down is allowed only on the first move, while the hand still has its first two cards.
    StartingHandSize <- 2
    //A rank label is one or two characters ("7", "10"); the card face pads it to two.
    LabelWidth <- 2

  defines type

    //A card's rank and suit are CLOSED SETS, not strings. The 52-card deck is
    //DERIVED by iterating these two types, so it cannot desynchronise from them.
    Suit
      Hearts
      Diamonds
      Spades
      Clubs

    Rank
      Two
      Three
      Four
      Five
      Six
      Seven
      Eight
      Nine
      Ten
      Jack
      Queen
      King
      Ace

    Move
      Hit
      Stand
      DoubleDown

    Outcome
      DealerBust
      PlayerLost
      PlayerWon
      Tie

    List of Card
    shuffled of Card
    Dict of (Rank, String)
    Dict of (Rank, Integer)

  defines record

    //Fields default UNSET: Card() is "no card", never a made-up Two of Hearts.
    Card
      rank as Rank: Rank()
      suit as Suit: Suit()

      default Card() as pure

      Card() as pure
        ->
          cardRank as Rank
          cardSuit as Suit

        rank :=? cardRank
        suit :=? cardSuit

      default operator ?

  defines class

    //THE GAME's state: the deck and the round being played. Neither shows anything.

    //A shuffled deck, dealt from the top. Dealing moves along the shuffled list.
    Deck
      cards as List of Card: List() of Card
      dealt as Integer: 0

      //An empty deck: every deal() is an unset Card.
      default Deck()

      Deck()
        -> rng as Random
        ordered <- List() of Card
        for suit in Suit
          for rank in Rank
            ordered += Card(rank, suit)
        shuffle <- () is shuffled of Card as function
        cards: shuffle(ordered, rng)

      deal()
        <- rtn as Card: cards.getOrDefault(dealt, Card())
        dealt: dealt + 1

      default operator ?

    //One hand of Blackjack: the deck it is dealt from, both hands, and the bet, which
    //doubling down raises. It plays by the rules and shows nothing.
    Round
      deck as Deck: Deck()
      dealer as List of Card: List() of Card
      player as List of Card: List() of Card
      bet as Integer: Integer()

      Round()
        ->
          rng as Random
          stake as Integer

        deck: Deck(rng)
        dealer += deck.deal()
        dealer += deck.deal()
        player += deck.deal()
        player += deck.deal()
        bet: stake

      dealerCards() as pure
        <- rtn as List of Card: dealer

      playerCards() as pure
        <- rtn as List of Card: player

      currentBet() as pure
        <- rtn as Integer: bet

      playerBust() as pure
        <- rtn as Boolean: handValue(player) > TwentyOne

      canDouble() as pure
        -> money as Integer
        <- rtn as Boolean: length player == StartingHandSize and money - bet > 0

      //A double down raises the bet by up to the original stake, and no more than the money covers.
      mostExtra() as pure
        -> money as Integer
        <- rtn as Integer: bet <? (money - bet)

      raiseBet()
        -> extra as Integer
        bet: bet + extra

      //The player's new card.
      hit()
        <- rtn as Card: deck.deal()
        player += rtn

      //The dealer draws to 17, unless the player has already bust.
      dealerDraws() as pure
        <- rtn as Boolean: not playerBust() and handValue(dealer) < DealerStands

      dealerHits()
        dealer += deck.deal()

      outcome() as pure
        <- rtn as Outcome: decideOutcome(playerValue: handValue(player), dealerValue: handValue(dealer))

      default operator ?

    //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are
    //there): read with next(), and every input loop also stops when ended() is true.
    LineInput
      stdin as Stdin: Stdin()
      inputEnded as Boolean: false

      next()
        <- rtn as String: stdin.next()
        if not rtn?
          inputEnded: true

      ended() as pure
        <- rtn as Boolean: Boolean(inputEnded)

      override operator ? as pure
        <- rtn as Boolean: true

    //THE INPUT AND OUTPUT: everything the player sees or types goes through here, worded by Say.
    Console
      stdout as Stdout: Stdout()
      input as LineInput: LineInput()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      showIntroduction()
        stdout.println(say.introduction())

      showMoney()
        -> money as Integer
        stdout.println(say.money(money))

      //The bet, from 1 to maxBet; unset when the player QUITs or the input ends.
      nextBet()
        -> maxBet as Integer
        <- rtn as Integer: Integer()
        quitting <- false
        while not rtn? and not quitting and not input.ended()
          stdout.println(say.askBet(maxBet))
          if entry <- input.next()
            reply <- entry.upperCase().trim()
            if reply == say.quit()
              quitting: true
            else if amount <- Integer(reply) then isBet(amount, maxBet)
              rtn: amount

      showBet()
        -> bet as Integer
        stdout.println(say.bet(bet))

      //The dealer's first card stays face down until the end.
      showHands()
        ->
          dealer as List of Card
          player as List of Card
          showDealer as Boolean

        stdout.println("")
        stdout.println(showDealer <- say.dealer(handValue(dealer)) : say.dealerHidden())
        showCards(dealer, not showDealer)
        stdout.println(say.player(handValue(player)))
        showCards(player, false)

      showPlayersTurn()
        ->
          dealer as List of Card
          player as List of Card

        showHands(dealer, player, false)
        stdout.println("")

      //The player's move; unset only if the input ends first.
      nextMove()
        -> canDouble as Boolean
        <- rtn as Move: Move()
        while not rtn? and not input.ended()
          stdout.println(canDouble <- say.hitStandOrDouble() : say.hitOrStand())
          if entry <- input.next()
            rtn: allowedMove(moveNamed(entry.upperCase().trim()), canDouble)

      showDrew()
        -> card as Card
        stdout.println(say.drew(labelOf(card), glyphOf(card)))

      showBetIncreased()
        -> bet as Integer
        stdout.println(say.betIncreased(bet))
        stdout.println(say.bet(bet))

      showDealerHits()
        stdout.println(say.dealerHits())

      showOutcome()
        ->
          outcome as Outcome
          bet as Integer

        switch outcome
          case Outcome.DealerBust
            stdout.println(say.dealerBusts(bet))
          case Outcome.PlayerLost
            stdout.println(say.lost())
          case Outcome.PlayerWon
            stdout.println(say.won(bet))
          case Outcome.Tie
            stdout.println(say.tie())
          default
            //decideOutcome always returns a set Outcome; the compiler requires this branch (E07320).
            stdout.println(say.tie())

      showBroke()
        stdout.println(say.broke())

      showGoodbye()
        stdout.println(say.goodbye())

      ended() as pure
        <- rtn as Boolean: input.ended()

      //Any other key is an unset Move, so the caller asks again.
      private moveNamed()
        -> key as String
        <- rtn as Move: Move()
        if key == say.hit()
          rtn: Move.Hit
        else if key == say.stand()
          rtn: Move.Stand
        else if key == say.doubleDown()
          rtn: Move.DoubleDown

      //The cards are drawn, not worded: the faces are built from the game's labels and glyphs.
      //hideFirst draws the first card face-down. There is no "backside" Card value -
      //concealment is a matter of showing, so it cannot leak into a hand or the deck.
      private showCards()
        ->
          cards as List of Card
          hideFirst as Boolean

        topRow <- ""
        labelRow <- ""
        suitRow <- ""
        bottomRow <- ""
        faceDown <- hideFirst
        for card in cards
          topRow += " ___  "
          if faceDown
            labelRow += "|## | "
            suitRow += "|###| "
            bottomRow += "|_##| "
          else
            label <- labelOf(card)
            labelRow += `|${label.rightPadded(LabelWidth)} | `
            suitRow += `| ${glyphOf(card)} | `
            bottomRow += `|_${label.leftPadded(LabelWidth, '_')}| `
          faceDown: false
        stdout.println(topRow)
        stdout.println(labelRow)
        stdout.println(suitRow)
        stdout.println(bottomRow)

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: its rules, with no input or output.

    //shuffled: a new List in random order (see lib/Shuffle.ek9). Inlined because each
    //port is a single compilation unit. Uses the Random it is given.
    shuffled() of type T as open
      ->
        source as List of T
        rng as Random
      <- rtn as List of T: List() of T

      work as List of T: List() of T
      work += source
      for pass in source
        size as Integer: length work
        pick as Integer: rng.next(0, size - 1)
        remaining as List of T: List() of T
        cursor as Integer: 0
        for element in work
          if cursor == pick
            rtn += element
          else
            remaining += element
          cursor += 1
        work: remaining

    //An Ace scores 1 here; handValue decides when it counts 11.
    rankValues() as pure
      <- rtn as Dict of (Rank, Integer): {
        Rank.Two: 2, Rank.Three: 3, Rank.Four: 4, Rank.Five: 5,
        Rank.Six: 6, Rank.Seven: 7, Rank.Eight: 8, Rank.Nine: 9,
        Rank.Ten: 10, Rank.Jack: 10, Rank.Queen: 10, Rank.King: 10,
        Rank.Ace: 1
      }

    points() as pure
      -> card as Card
      <- rtn as Integer: rankValues().getOrDefault(card.rank, 0)

    isAce() as pure
      -> card as Card
      <- rtn as Boolean: card.rank == Rank.Ace

    //Every Ace first counts 1. One Ace then counts 11 if that does not bust the hand; a second
    //never could, as two Aces at 11 already make 22. Collecting into an Integer adds the points up.
    handValue() as pure
      -> cards as List of Card
      <- rtn as Integer: cat cards | map with points | collect as Integer
      aces <- cat cards | filter by isAce | collect as List of Card
      if not aces.empty() and rtn + AceBonus <= TwentyOne
        rtn: rtn + AceBonus

    isBet() as pure
      ->
        amount as Integer
        maxBet as Integer
      <- rtn as Boolean: amount >= 1 and amount <= maxBet

    //Doubling down is a move only while it is allowed; any other move stands as it is.
    allowedMove() as pure
      ->
        move as Move
        canDouble as Boolean
      <- rtn as Move: move

      if move == Move.DoubleDown and not canDouble
        rtn: Move()

    //Who won, decided with no console in sight. Independently testable.
    decideOutcome() as pure
      ->
        playerValue as Integer
        dealerValue as Integer
      <- rtn as Outcome: Outcome.Tie

      if dealerValue > TwentyOne
        rtn: Outcome.DealerBust
      else if (playerValue > TwentyOne) or (playerValue < dealerValue)
        rtn: Outcome.PlayerLost
      else if playerValue > dealerValue
        rtn: Outcome.PlayerWon

    //The money won (negative when lost) for an outcome.
    winningsFor() as pure
      ->
        outcome as Outcome
        bet as Integer
      <- rtn as Integer: 0

      switch outcome
        case Outcome.DealerBust, Outcome.PlayerWon
          rtn: bet
        case Outcome.PlayerLost
          rtn: -bet
        case Outcome.Tie
          rtn: 0
        default
          //decideOutcome always returns a set Outcome, but an enumeration can be unset, so the
          //compiler requires this branch (E07320).
          rtn: 0

    //The card faces are data: the label on a card's corners and the glyph of its suit.
    rankLabels() as pure
      <- rtn as Dict of (Rank, String): {
        Rank.Two: "2", Rank.Three: "3", Rank.Four: "4", Rank.Five: "5",
        Rank.Six: "6", Rank.Seven: "7", Rank.Eight: "8", Rank.Nine: "9",
        Rank.Ten: "10", Rank.Jack: "J", Rank.Queen: "Q", Rank.King: "K",
        Rank.Ace: "A"
      }

    labelOf() as pure
      -> card as Card
      <- rtn as String: rankLabels().getOrDefault(card.rank, "")

    glyphOf() as pure
      -> card as Card
      <- rtn as String: String()
      switch card.suit
        case Suit.Hearts
          rtn: "♥"
        case Suit.Diamonds
          rtn: "♦"
        case Suit.Spades
          rtn: "♠"
        case Suit.Clubs
          rtn: "♣"
        default
          //An unset Suit has no glyph. The compiler requires this branch (E07320) because an
          //enumeration, like every EK9 type, can be unset.
          rtn: String()

  defines function

    //THE FLOW of one round, in the steps the book's main() takes; the program calls them.

    //Hit, stand or double down until the player stands or busts. False when the player
    //QUITs at the double-down bet or the input ends: the round goes no further.
    playerTurn()
      ->
        round as Round
        money as Integer
        console as Console
      <- rtn as Boolean: true

      finished <- false
      while rtn and not finished
        console.showPlayersTurn(round.dealerCards(), round.playerCards())
        if round.playerBust()
          finished: true
        else
          move <- console.nextMove(round.canDouble(money))
          if move == Move.DoubleDown
            rtn: doubleDown(round, money, console)
          else
            rtn: move?
          if rtn and (move == Move.Hit or move == Move.DoubleDown)
            console.showDrew(round.hit())
          if (move == Move.Stand or move == Move.DoubleDown) and not round.playerBust()
            finished: true

    //False if the player QUITs instead (or the input ends), as QUIT ends the game at any bet prompt.
    doubleDown()
      ->
        round as Round
        money as Integer
        console as Console
      <- rtn as Boolean: false

      if extra <- console.nextBet(round.mostExtra(money))
        round.raiseBet(extra)
        console.showBetIncreased(round.currentBet())
        rtn: true

    dealerTurn()
      ->
        round as Round
        console as Console

      while round.dealerDraws()
        console.showDealerHits()
        round.dealerHits()
        console.showHands(round.dealerCards(), round.playerCards(), false)

  defines function

    //THE source of randomness, copied unchanged from lib/RandomSource.ek9: SystemRandom(), or
    //SeededRandom(seed) when BIGBOOK_SEED holds a number (run-and-diff.sh sets it from seed.txt).
    randomSource()
      <- rtn as Random: SeededRandom(Integer(EnvVars().get("BIGBOOK_SEED"))) ?: SystemRandom()

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the player sees. In a backtick string '${' starts an interpolation,
  //so a dollar sign is written '\$'.
  defines text for "en"

    Say

      //The book's rules, indented as it prints them. The two numbers the game plays by come
      //from the constants, so the rules cannot disagree with the play.
      introduction()
        `Blackjack, by Al Sweigart al@inventwithpython.com

    Rules:
      Try to get as close to ${TwentyOne} without going over.
      Kings, Queens, and Jacks are worth 10 points.
      Aces are worth 1 or 11 points.
      Cards 2 through 10 are worth their face value.
      (H)it to take another card.
      (S)tand to stop taking cards.
      On your first play, you can (D)ouble down to increase your bet
      but must hit exactly one more time before standing.
      In case of a tie, the bet is returned to the player.
      The dealer stops hitting at ${DealerStands}.`

      money()
        -> money as Integer
        `Money: ${money}`

      askBet()
        -> maxBet as Integer
        `How much do you bet? (1-${maxBet}, or QUIT)`

      quit()
        "QUIT"

      bet()
        -> bet as Integer
        `Bet: ${bet}`

      dealer()
        -> total as Integer
        `DEALER: ${total}`

      dealerHidden()
        "DEALER: ???"

      player()
        -> total as Integer
        `PLAYER: ${total}`

      hitStandOrDouble()
        "(H)it, (S)tand, (D)ouble down"

      hitOrStand()
        "(H)it, (S)tand"

      hit()
        "H"

      stand()
        "S"

      doubleDown()
        "D"

      drew()
        ->
          label as String
          glyph as String
        `You drew a ${label} of ${glyph}.`

      betIncreased()
        -> bet as Integer
        `Bet increased to ${bet}.`

      dealerHits()
        "Dealer hits..."

      dealerBusts()
        -> bet as Integer
        `Dealer busts! You win \$${bet}!`

      lost()
        "You lost!"

      won()
        -> bet as Integer
        `You won \$${bet}!`

      tie()
        "It's a tie, the bet is returned to you."

      broke()
        `You're broke!
Good thing you weren't playing with real money.`

      goodbye()
        "Thanks for playing!"

  defines program

    //Blackjack (Big Book project 4). Classic 21 against the dealer: bet, then hit, stand or double
    //down. The game decides; the Console asks and tells, in the player's words. The golden replays
    //a seeded game (seed.txt).
    Blackjack()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))
      rng <- randomSource()

      console.showIntroduction()
      money <- StartMoney
      running <- true
      while running and not console.ended()
        if money <= 0
          console.showBroke()
          running: false
        else
          console.showMoney(money)
          //Stays unset if the player QUITs or the input ends, at either bet prompt.
          winnings <- Integer()
          if bet <- console.nextBet(money)
            console.showBet(bet)
            round <- Round(rng, bet)
            if playerTurn(round, money, console)
              dealerTurn(round, console)
              console.showHands(round.dealerCards(), round.playerCards(), true)
              outcome <- round.outcome()
              console.showOutcome(outcome, round.currentBet())
              winnings: winningsFor(outcome, round.currentBet())
          if winnings?
            money: money + winnings
          else
            running: false
      //The player quit, or went broke: the game says goodbye. At the end of the input it does not.
      console.showGoodbye()

How the port works

The original mixes the rules, the asking and telling, and the words in the same functions. The port keeps them apart, in four parts: