Index Ai Academy
Phase 7 · Software engineering · Python projects

Project: a card game of 21

Turn written rules into functions, and make randomness testable.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The rules
  2. Checkpoint 1: what a hand is worth
  3. Checkpoint 2: a deck you can control
  4. The dealer's turn
  5. Two loops, two ways of stopping
  6. Checkpoint 3: who won
  7. Worked example: a whole round, typed for you
  8. Go further

The rules

This is the card game called 21. It's played here for points, with no money involved.

  1. Number cards (2 to 10) are worth their number. Jacks, queens and kings (J, Q, K) are worth 10.
  2. An ace (A) is worth 11, unless that would take the hand over 21. Then it's worth 1.
  3. The player and the dealer each get two cards.
  4. The player takes more cards, one at a time ("hit"), or stops ("stand"). Going over 21 is a bust, and loses at once.
  5. Then the dealer takes cards until their hand is worth 17 or more.
  6. 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"]))
It runs right here in your browser, with nothing to install.

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.

Quick check
Using hand_score above, what does this print?
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"]))
  1. 3
  2. 13
  3. 33
  4. 23
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.

Try it now

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.

Drill

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
In the lesson, your code is checked as you write it.

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.

Quick check
What does result(22, 25) return, and why does that line come first?
  1. An error, because both totals are over 21
  2. "draw": both busted
  3. "win": the dealer busted
  4. "lose": a player who busts loses before the dealer even plays
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

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"])
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why does play_round take the deck as a parameter instead of shuffling one itself?

  1. So a check can hand in a fixed deck and know exactly what happens
  2. Because Python can't shuffle inside a function
  3. To make the game faster
  4. So the dealer can't cheat

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson