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:
- The game decides.
Suit,Rank,MoveandOutcomeare enumerations, and a card is aCardrecord withrank/suitfields, rather than a string like"7H". The 52-card deck is derived by iterating the two enumerations —for suit in Suitthenfor rank in Rank— so it cannot drift out of step with the types the way two hand-maintained lists could. ACardwith its rank and suit swapped is a compile error, not a silently wrong deck. Deckshuffles once and deals from the top of its list.Roundowns one hand of play — both hands, the deck, and the bet — and answers questions about it (playerBust,canDouble,dealerDraws,outcome) without printing a thing.decideOutcomesays who won andwinningsForwhat that is worth; both areas pure.handValueisas pureand has no loop at all:cat cards | map with points | collect as Integeradds up the card values, and a separate filter finds any aces, which bump the total by ten if that does not bust the hand. A function that accumulates in a loop cannot beas purein EK9 — this one sidesteps the loop entirely by using a stream.- The
Consoleasks and tells.nextBetandnextMovereturn an unset value — not a special string or an exception — forQUIT, for a key that is not a move, or for the input running out, and the caller tests that with a guard. Each asks the game whether the answer will do:isBet(amount, maxBet), andallowedMove(move, canDouble), which turns a double down into no move at all once it is no longer allowed. The keys themselves are words,say.hit()and the rest, so a translation can use its own letters. - The cards are drawn, not worded:
showCardsbuilds each row of the faces in a loop from the game's labels and glyphs. Concealing the dealer's first card is ahideFirstflag, not a fake "face-down" card value planted in the hand, so a concealed card can never leak into the deck or into scoring by mistake.showOutcometurns anOutcomeinto its sentence with aswitchthat lists every value and ends with adefault— the branch an unset enumeration takes. Sayholds the words. The title and the rules are one method,introduction(), a multi-line String indented exactly as the book's triple-quoted text, with the 21 and the 17 taken from the constants so the rules cannot disagree with the play. Every other sentence is one method, with the bet or the hand's value as a parameter.- The program is the flow, laid out as the original's
main(): the broke check, the money, the bet, the deal, the player's turn, the dealer's turn and the result. The two turns are flow functions,playerTurn(withdoubleDown) anddealerTurn, which take theRoundand theConsole; every loop that would use abreakin the original carries its exit condition in thewhileinstead. Doubling down raises the bet by at most what is left to cover it,bet <? (money - bet)— the "lesser of" operator. The goodbye is the program's too: once the loop ends, it says goodbye unless the input has run out. The program chooses the words withSay(language()) ?: Say("en").
In the EK9 reference: Enumerations in EK9