Project: a card game of 21
Turn written rules into functions, and make randomness testable.
Create a free account Try part of it here
Why it matters
Most software starts as rules written in words: a refund policy, a pricing table, the steps of a checkout. Turning prose into code that matches it exactly is a core skill, and the edge cases are where it goes wrong. A card game is a friendly place to practice, because the rules are short, everyone can check the answer by hand, and one rule has a real twist in it.
What you'll be able to do
- Turn a page of written rules into small functions, one rule at a time
- Handle the tricky rule, an ace worth 11 or 1, and test its edge cases
- Make a random game testable by passing the deck in
- Write the player's turn and the dealer's turn as two different loops
What it covers
- The rules
- Checkpoint 1: what a hand is worth
- Checkpoint 2: a deck you can control
- The dealer's turn
- Two loops, two ways of stopping
- Checkpoint 3: who won
- Worked example: a whole round, typed for you
- Go further
The rules
This is the card game called 21. It's played here for points, with no money involved.
- Number cards (2 to 10) are worth their number. Jacks, queens and kings (
J,Q,K) are worth 10. - An ace (
A) is worth 11, unless that would take the hand over 21. Then it's worth 1. - The player and the dealer each get two cards.
- The player takes more cards, one at a time ("hit"), or stops ("stand"). Going over 21 is a bust, and loses at once.
- Then the dealer takes cards until their hand is worth 17 or more.
- If the dealer busts, the player wins. Otherwise the higher hand wins, and equal hands are a draw.
Read them twice. Rule 2 is the only hard one, and it's worth getting right first, on its own, before any game exists around it.
A good way in is to write each rule as a function whose name says what it decides, and to test each one with examples you can check by hand. When the pieces are right, the game is mostly gluing them together. Writing everything at once and debugging the result is far slower.
Checkpoint 1: what a hand is worth
Cards are short strings: "7", "K", "A". Count every ace as 11 at first, then turn aces into 1s, one at a time, while the total is over 21:
def card_value(card): if card in ("J", "Q", "K"): return 10 if card == "A": return 11 return int(card) def hand_score(cards): total = sum(card_value(c) for c in cards) aces = cards.count("A") while total > 21 and aces > 0: total = total - 10 aces = aces - 1 return total print(hand_score(["K", "7"])) print(hand_score(["A", "K"])) print(hand_score(["A", "A", "9"])) print(hand_score(["A", "7", "K"]))
Taking 10 away turns one ace from 11 into 1. Two aces and a 9 start at 31: one ace drops to 1, the total becomes 21, and the loop stops, so the other ace stays at 11. That's the rule exactly: only as many aces count as 1 as needed.
def card_value(card): if card in ("J", "Q", "K"): return 10 if card == "A": return 11 return int(card) def hand_score(cards): total = sum(card_value(c) for c in cards) aces = cards.count("A") while total > 21 and aces > 0: total = total - 10 aces = aces - 1 return total print(hand_score(["A", "A", "A"]))
Show the answer
13
Three aces start at 33. Two of them drop to 1 (33 to 23 to 13), and the loop stops because 13 isn't over 21. So one ace is 11 and two are 1.
Two loops with different stopping rules: the player's ends on a choice or a bust, the dealer's on a fixed number.
Checkpoint 2: a deck you can control
A real game shuffles. A test needs to know which cards come next. Both are possible if the functions take the deck as a parameter and deal from the front of the list with pop(0):
import random def new_deck(rng): cards = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] * 4 rng.shuffle(cards) return cards def deal(deck): return deck.pop(0) deck = new_deck(random.Random(5)) print(len(deck), deal(deck), deal(deck), len(deck)) fixed = ["K", "9", "5"] print(deal(fixed), fixed)
In play, new_deck(random.Random()) gives a fresh shuffle every time. A check can skip shuffling and hand in ["K", "9", "5"], so it knows the first card is a king. pop(0) removes and returns the first item, so the deck shrinks as cards are dealt, like a real one.
This is the same idea as passing the random generator in, from the word game: the function asks for what it depends on, instead of reaching out for it.
The dealer's turn
Write dealer_turn(hand, deck): deal cards onto the dealer's hand (a list) while its score is under 17, then return the hand. Use the hand_score and deal given.
def card_value(card): if card in ("J", "Q", "K"): return 10 if card == "A": return 11 return int(card) def hand_score(cards): total = sum(card_value(c) for c in cards) aces = cards.count("A") while total > 21 and aces > 0: total = total - 10 aces = aces - 1 return total def deal(deck): return deck.pop(0) def dealer_turn(hand, deck): pass
Two loops, two ways of stopping
The player's turn and the dealer's turn are both loops, but they stop for different reasons, and it helps to name the difference.
The player's loop stops on a choice or on a fact. The choice is "stand". The facts are a bust, or reaching exactly 21, where hitting could only hurt. So it's while hand_score(player) < 21: with a break for "stand" inside. An answer that is neither "hit" nor "stand" changes nothing, and the loop simply asks again.
The dealer's loop has no choices at all. The rule says "take cards until 17 or more", which is exactly a while loop's condition: while hand_score(dealer) < 17:. There's nothing to ask and nothing to break out of.
Spotting which kind of loop a rule describes saves time. "Until the person says stop" needs input and a break. "Until a number reaches a limit" is the loop's condition, written straight from the rule.
Checkpoint 3: who won
One function compares the finished hands. The order of the tests matters, the same lesson as the labels that were checked in the wrong order:
def result(player_total, dealer_total): if player_total > 21: return "lose" if dealer_total > 21: return "win" if player_total > dealer_total: return "win" if player_total < dealer_total: return "lose" return "draw" print(result(22, 25), result(19, 23), result(20, 18), result(18, 18))
The player's bust comes first: in this game, a player who busts loses even if the dealer would bust too. Get the order wrong and that case gives the wrong answer, while every other case looks fine. That's exactly the kind of quiet bug that only a test for the edge case catches.
result(22, 25) return, and why does that line come first?Show the answer
"lose": a player who busts loses before the dealer even plays
The player's turn happens first, so a bust ends the round as a loss. Checking the player's bust before the dealer's encodes that.
Worked example: a whole round, typed for you
This round deals from a fixed deck, so you know what happens: the player has 12, hits once to reach 19, and stands. Run it and play other answers too.
def card_value(card): if card in ("J", "Q", "K"): return 10 if card == "A": return 11 return int(card) def hand_score(cards): total = sum(card_value(c) for c in cards) aces = cards.count("A") while total > 21 and aces > 0: total, aces = total - 10, aces - 1 return total def play_round(deck): player = [deck.pop(0), deck.pop(0)] dealer = [deck.pop(0), deck.pop(0)] while hand_score(player) < 21: choice = input(f"You have {player} = {hand_score(player)}. Hit or stand? ").strip().lower() if choice == "stand": break if choice == "hit": player.append(deck.pop(0)) while hand_score(player) <= 21 and hand_score(dealer) < 17: dealer.append(deck.pop(0)) print("You:", hand_score(player), "Dealer:", hand_score(dealer)) play_round(["2", "K", "10", "6", "7", "8", "4"])
The player starts with 2 and K, which is 12. "hit" takes the 7, for 19, and "stand" ends the player's loop. The dealer has 10 and 6, which is 16, so the rule makes them take the 8, for 24: a bust. Any answer other than hit or stand is simply asked again, because neither branch runs. The player's loop also stops by itself at 21, since there's no reason to hit then.
Go further
The game works. Here are some ways to grow it:
- Show only the dealer's first card until the player has finished, as in a real game.
- Keep a running score over several rounds, with a fresh shuffle each time.
- Add "double": the player takes exactly one more card and stands.
- Build a warm-up game from the same parts: show two products from the shop and ask which one has more in stock. It's another rules function plus a short loop.
Whatever you add, keep the pattern: rules in small functions you can check with fixed decks, and one loop that asks questions.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why does play_round take the deck as a parameter instead of shuffling one itself?