Learning Goals
3 minBy the end of this lesson you can:
- Model a deck of cards as a list and shuffle it with
random.shuffle. - Use
.pop()in a game loop to draw without replacement. - Run a
while True:game loop with three exits: quit, win, lose. - Combine f-strings and width specifiers for a tidy game screen.
Warm-Up · The Rules
5 minHigher-or-Lower works like this:
- Shuffle a deck. Deal one card face-up.
- Player guesses whether the next card will be higher or lower.
- Turn the next card. If the guess was right, score +1 and keep going.
- If wrong, game over. Final score is the streak length.
- Equal cards — let's skip them (just keep dealing until the next is different).
Predict the output of this snippet — it's the heart of the game:
import random deck = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] random.seed(0) random.shuffle(deck) print(deck) print(deck.pop()) print(deck.pop())
Show the answer
[6, 13, 10, 9, 5, 14, 4, 11, 8, 12, 7, 3, 2] 2 3
Shuffled, then dealt from the end. Each pop() removes the card so it can't be drawn again — that's what makes it a real deck.
A shuffled list + .pop() = a real deck of cards. Once you see this trick, every "deal me one" game gets easy.
New Concept · The Three Stages
14 minStage 1 · Make the deck
Cards have a rank and a suit. For comparison we only need numeric ranks — 2 through 14 (with 11=J, 12=Q, 13=K, 14=A). We'll store a friendlier name alongside.
SUITS = ["♠", "♥", "♦", "♣"] RANKS = [ (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10"), (11, "J"), (12, "Q"), (13, "K"), (14, "A"), ] def fresh_deck(): deck = [] for r, label in RANKS: for s in SUITS: deck.append((r, label + s)) return deck
The function returns a fresh 52-card deck — each card a tuple (rank, "label"). We'll compare with the rank and show the player the label.
Stage 2 · Deal one card
The deck is a list, so .pop() is "deal the top card":
def draw(deck): return deck.pop()
This returns a (rank, label) tuple and removes it from the deck so it can't come up again. .pop() from PY-L2-01.
Stage 3 · The guess
Ask the player "higher (h) or lower (l)?", then peek at the next card and compare ranks. We'll skip equal ranks by drawing again.
def round_one(current, deck): print(f"Current: {current[1]} (rank {current[0]})") guess = input("(h)igher or (l)ower? ").lower() # Draw — keep drawing if we tie while True: next_card = deck.pop() if next_card[0] != current[0]: break print(f"Next : {next_card[1]} (rank {next_card[0]})") if guess == "h" and next_card[0] > current[0]: return True, next_card if guess == "l" and next_card[0] < current[0]: return True, next_card return False, next_card
The function returns two things: whether the guess was right, and the new card (so we know what to compare against next round). Tuple multi-return from PY-L2-04.
Stage 4 · The game loop
Tie everything together:
def play(): import random deck = fresh_deck() random.shuffle(deck) current = deck.pop() streak = 0 while len(deck) > 0: won, current = round_one(current, deck) if not won: print("🪦 Wrong! Game over.") print(f"Final streak: {streak}") return streak += 1 print(f"✅ Correct. Streak: {streak}") again = input("Continue? (y/n) ").lower() if again != "y": print(f"You walked away with a streak of {streak}!") return print(f"Cleared the deck! Streak: {streak}") play()
One loop. Three ways out — wrong guess, choose to walk away, or run out of cards.
The option to quit on a winning streak is what makes Higher-or-Lower compelling. Without it, the game just runs until you lose. With it, players get the same loss-aversion feeling that powers Deal-or-No-Deal style game shows.
Worked Example · The Full Game
12 minSave the complete file as higher_lower.py:
Code
# higher_lower.py — the full card game import random SUITS = ["♠", "♥", "♦", "♣"] RANKS = [ (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10"), (11, "J"), (12, "Q"), (13, "K"), (14, "A"), ] def fresh_deck(): deck = [] for r, label in RANKS: for s in SUITS: deck.append((r, label + s)) return deck def round_one(current, deck): print(f"\nCurrent: {current[1]:<4} (rank {current[0]})") guess = input("(h)igher or (l)ower? ").lower() while True: if len(deck) == 0: print("Deck empty.") return None, None next_card = deck.pop() if next_card[0] != current[0]: break print(f"Next : {next_card[1]:<4} (rank {next_card[0]})") if guess == "h" and next_card[0] > current[0]: return True, next_card if guess == "l" and next_card[0] < current[0]: return True, next_card return False, next_card def play(): deck = fresh_deck() random.shuffle(deck) current = deck.pop() streak = 0 while True: result, new_card = round_one(current, deck) if result is None: print(f"Cleared the deck! Streak: {streak}") return current = new_card if not result: print(f"🪦 Wrong! Final streak: {streak}") return streak += 1 print(f"✅ Correct. Streak: {streak}") again = input("Continue? (y/n) ").lower() if again != "y": print(f"You walked away with a streak of {streak}!") return play()
Sample play
Current: 7♠ (rank 7) (h)igher or (l)ower? h Next : J♦ (rank 11) ✅ Correct. Streak: 1 Continue? (y/n) y Current: J♦ (rank 11) (h)igher or (l)ower? l Next : 4♣ (rank 4) ✅ Correct. Streak: 2 Continue? (y/n) y Current: 4♣ (rank 4) (h)igher or (l)ower? h Next : 3♥ (rank 3) 🪦 Wrong! Final streak: 2
Read the diff
Three patterns from earlier lessons quietly do most of the work. random.shuffle + .pop() is the deck. Tuples store (rank, label) pairs (PY-L2-03/04). Multi-return passes both the result and the new card back from round_one. Width specifier :<4 keeps the "Current" and "Next" lines aligned (PY-L2-16). Every Level-2 lesson so far is in there somewhere.
Try It Yourself
13 minAdd a high_score variable to the game. Print it after every play-through. (No file persistence yet — that's PY-L2-22.)
Hint
high_score = 0 while True: streak = play() # change play() to return streak if streak > high_score: high_score = streak print("🏆 New high score!") print(f"High score so far: {high_score}") if input("Play again? (y/n) ") != "y": break
You'll need to change play() to return streak instead of bare return. Then the outer loop tracks the best across runs.
Inside round_one, count how many times you had to skip equal cards. Print the total skips at the very end of the game.
Hint
# At the top of the file: skips = 0 def round_one(current, deck): global skips # ... existing code ... while True: next_card = deck.pop() if next_card[0] != current[0]: break skips += 1 # we drew an equal card and skipped it
global skips lets the function modify the top-level variable. We'll meet a tidier alternative (returning multiple values, or wrapping state in a class) later — but global works for a script.
Before each guess, show the player how many higher and how many lower cards are still in the deck. That turns the game from pure guessing into mild strategy.
Hint
def show_odds(current, deck): higher = sum(1 for c in deck if c[0] > current[0]) lower = sum(1 for c in deck if c[0] < current[0]) equal = sum(1 for c in deck if c[0] == current[0]) print(f" Higher in deck: {higher}, Lower: {lower}, Equal: {equal}") # Call show_odds(current, deck) at the top of round_one
Three sum(1 for ...) counts — three quick passes over the deck. Now the player can decide based on the actual remaining odds. Real Higher-or-Lower casino games disable this; with it on, the game becomes a strategy puzzle.
Mini-Challenge · Hi-or-Lo with a Best-of-5 Match
8 minBuild match.py. Two players take turns playing one round of Higher-or-Lower against a fresh deck each time. First to win 3 rounds out of 5 wins the match.
Your file must:
- Define
play_one_round(player_name)— same shape as today'splay(), returns the final streak. - In an outer loop, alternate two players and store their streaks.
- The winner of each round (higher streak) gets 1 match-point. Tie = 0 to both.
- Stop at best-of-5. Print the running scoreboard after each round.
Show one possible solution (top half)
# match.py — best-of-5 hi-lo match import random # ... fresh_deck() and round_one() copied from higher_lower.py ... def play_one_round(name): print(f"\n--- {name}'s turn ---") deck = fresh_deck() random.shuffle(deck) current = deck.pop() streak = 0 while True: result, new_card = round_one(current, deck) if result is None or not result: break current = new_card streak += 1 if input("Continue? (y/n) ") != "y": break print(f"{name} finished with streak {streak}") return streak scores = {"Aisyah": 0, "Wei Jie": 0} players = ["Aisyah", "Wei Jie"] for r in range(5): a = play_one_round(players[0]) b = play_one_round(players[1]) if a > b: scores[players[0]] += 1 elif b > a: scores[players[1]] += 1 print(f"\nScoreboard after round {r + 1}: {scores}") if max(scores.values()) >= 3: break winner = max(scores, key=scores.get) print(f"\n🏆 MATCH WINNER: {winner} ({scores})")
Non-negotiables: a fresh deck per round, alternating players, a points dict scored by streak comparison, and an early-stop at 3 wins. max(scores, key=scores.get) finds the dictionary key with the highest value — a clean Python idiom.
Recap
3 minHigher-or-Lower is the smallest interesting card game you can write — and it touches almost every Level-2 idea so far. A list of (rank, label) tuples is the deck. random.shuffle + .pop() deals from it without replacement. A while loop drives rounds; a function returning multiple values cleanly hands back "win/lose" and "new current card". f-string width specifiers keep the screen aligned. Three exits — wrong guess, walk away, empty deck — keep the loop honest.
Vocabulary Card
- deck
- A list of cards. Shuffled with
random.shuffle, dealt with.pop(). - walk-away exit
- A game loop exit that lets the player stop while ahead — the heart of any push-your-luck game.
- multi-return
- A function that returns several values at once. Higher-or-Lower returns "won?" and "new card" together.
Homework
4 minAdd three polish features to your higher_lower.py from the worked example:
- Card ASCII art. Print each card as a small 5-line ASCII frame instead of a plain string. Use a triple-quoted f-string with a width specifier.
- Round count. At the start of each round, print
Round N — N cards remaining. - Score history. Keep a list of
(round_number, current_card, guess, next_card, won)tuples. At the end of the game, print the full history in a tidy table.
Stretch. Save the high score in a global variable across multiple play-throughs in the same run.
Sample · history + ASCII (key snippets)
def render(card): label, rank = card[1], card[0] return f""" +-------+ | {label:<2} | | | | {rank:>2} | | | | {label:>2} | +-------+""" # In play(), keep a history list: history = [] # ... history.append((round_no, current, guess, new_card, result)) # At game end: print() print(f"{'#':<3}{'Was':<6}{'Guess':<6}{'Next':<6}{'OK?':<5}") for h in history: n, was, g, nxt, ok = h print(f"{n:<3}{was[1]:<6}{g:<6}{nxt[1]:<6}{'✓' if ok else '✗':<5}")
Non-negotiables: an ASCII-art card renderer, a round counter, and a history list printed as a table using width specifiers. Three small additions — but the game now feels like a game.