Python for Humans · Draft v0.1 · Oct 29, 2026 · Draft position: chapter 16 · Build a deck that remembers its remaining cards, then use it in a high-card game.
Classes
- You should already know
- Lists, dictionaries, functions, imports, assertions and running saved programs with uv.
- You'll build
- A high-card game using a Deck class that shuffles and deals without replacement.
- You'll be able to
- Initialize separate instances, store their state in attributes, write methods and check their behavior with fixed cards.
- You need
- Your local Python setup, any plain-text editor and your existing course folder. No new packages or downloads are required.
Your word generator chooses a random follower. Choosing the same word again later is allowed.
A card game needs a different rule. Once someone draws the Ace of hearts, that card should be gone from the deck until we put it back. Calling random.choice() repeatedly doesn't remove anything.
We'll build a deck that remembers which cards remain, then play High Card against the computer. Each round deals one card to each player. The higher rank wins a point; equal ranks earn neither player a point. Aces are high, and suits don't break ties.
The outcome depends on luck. Our programming task is to make the dealing and scoring follow the stated rules.
A Card and a Deck
Use a dictionary for one card:
card = {'rank': 14, 'suit': 'hearts'}
Ranks run from 2 through 14. We'll display 11 as Jack, 12 as Queen, 13 as King and 14 as Ace. Numeric ranks make comparisons straightforward: 14 > 13 expresses our Ace-high rule.
A list can hold several cards. Before writing a class, try removing a card from a tiny deck. Save this as deck_demo.py in your course folder:
# deck_demo.py cards = [ {'rank': 2, 'suit': 'clubs'}, {'rank': 14, 'suit': 'hearts'} ] card = cards.pop() print(card) print('Cards left:', len(cards))
Run uv run deck_demo.py. You should see:
{'rank': 14, 'suit': 'hearts'}
Cards left: 1
pop() removes the last item from a list and returns it. We'll treat that end as the top of our deck. Calling it again would draw the 2 of clubs. Calling it a third time would fail because the list would be empty.
We could write functions that accept this list. That would work. A class gives us another way to organize the same work: keep each deck's cards together with the operations that use them.
Give Each Deck Its Own State
Create cards.py beside deck_demo.py:
# cards.py class Deck: def __init__(self, cards): self.cards = cards.copy() def remaining(self): return len(self.cards) def draw(self): if self.remaining() == 0: raise ValueError('Cannot draw from an empty deck.') return self.cards.pop()
class Deck: defines a new kind of object. Like a function definition, it introduces an indented block. Class names conventionally start with a capital letter.
The three def blocks belong inside the class. They define methods: functions available through a deck object.
Replace deck_demo.py with this caller:
# deck_demo.py from cards import Deck sample_cards = [ {'rank': 2, 'suit': 'clubs'}, {'rank': 14, 'suit': 'hearts'} ] deck = Deck(sample_cards) print('Cards left:', deck.remaining()) print(deck.draw()) print('Cards left:', deck.remaining())
Save both files and run uv run deck_demo.py:
Cards left: 2
{'rank': 14, 'suit': 'hearts'}
Cards left: 1
Deck(sample_cards) creates an instance of Deck: one particular deck, with its own state. The name deck refers to that instance.
Python calls __init__ to initialize the new instance. Type two underscores before and after init. In __init__(self, cards), Python supplies the new instance as self, and our supplied list becomes cards.
self.cards is an attribute, a value stored on that instance. The parameter cards is a local name during initialization; the attribute remains available for later method calls.
The .copy() call makes a separate list containing the supplied cards. Removing an item from the deck's list won't remove it from sample_cards. This is a shallow copy: the card dictionaries themselves are shared. Our game reads those dictionaries without changing their rank or suit.
Which Deck Does self Mean?
Consider this call:
card = deck.draw()
Python finds the method and supplies deck as its first argument, self. We don't write deck.draw(deck).
Inside draw(), self.remaining() asks this same deck how many cards it holds, and self.cards.pop() removes a card from this deck's list.
The method returns the drawn card to its caller. It also changes the deck. remaining() only reports a count; it leaves the cards alone. Reading the method body tells us which behavior to expect.
self is the conventional parameter name. It isn't a special command that means every deck at once. The instance receiving a particular call determines its value.
Try it: Add a second print(deck.draw()) and a final count to deck_demo.py. Predict the second card and count before running. Then add a third draw and read the resulting ValueError. Remove that deliberate failure afterward.
Two Decks, One Definition
Replace deck_demo.py with this experiment. Predict the two counts before running:
# deck_demo.py from cards import Deck sample_cards = [ {'rank': 2, 'suit': 'clubs'}, {'rank': 14, 'suit': 'hearts'} ] first = Deck(sample_cards) second = Deck(sample_cards) print(first.draw()) print(first.remaining(), second.remaining())
The counts are 1 2. Each call to Deck(sample_cards) initializes a new instance with its own list. Both instances use the same method definitions, but drawing from first doesn't draw from second.
Now append:
other_name = first other_name.draw() print(first.remaining(), other_name.remaining(), second.remaining())
This prints 0 0 2. Assignment gives us another name for first; it doesn't call Deck or create another instance. Both names reach the same deck.
Build a Full Pack
Two cards make a good check, but a short game. Add this function below the class in cards.py, with def starting at the left edge:
def make_cards(): cards = [] suits = ['clubs', 'diamonds', 'hearts', 'spades'] ranks = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] for suit in suits: for rank in ranks: cards.append({'rank': rank, 'suit': suit}) return cards
The inner loop visits all thirteen ranks for one suit. The outer loop repeats that work for each of four suits, making 52 dictionaries. Each pass creates a new card dictionary.
make_cards() is an ordinary function. It doesn't need an existing deck; its job is to supply a new list of cards.
Before adding randomness, check the starting order. Replace the demo with:
# deck_demo.py from cards import Deck, make_cards deck = Deck(make_cards()) print(deck.remaining()) print(deck.draw()) print(deck.remaining())
The results should be 52, {'rank': 14, 'suit': 'spades'} and 51. Spades was the last suit added, and Ace was its last rank. We draw from the end.
Shuffle the Remaining Cards
Add import random at the top of cards.py. Then add this method inside Deck, at the same indentation as draw:
def shuffle(self): random.shuffle(self.cards)
random.shuffle() rearranges the existing list in place. It returns None, so writing self.cards = random.shuffle(self.cards) would replace the list with None.
In deck_demo.py, put deck.shuffle() immediately after creating the deck. Run it several times. The counts should still be 52 and 51, but the drawn card can vary. Two runs can also draw the same first card by chance.
Shuffling changes the order of the remaining cards. It doesn't restore cards already drawn. Starting a fresh game will create a fresh deck.
Give Face Cards Readable Names
Add this ordinary function below make_cards():
def card_name(card): names = {11: 'Jack', 12: 'Queen', 13: 'King', 14: 'Ace'} rank = card['rank'] if rank in names: label = names[rank] else: label = str(rank) return f"{label} of {card['suit']}"
This dictionary has number keys. We look up the names of face cards and the Ace; str() supplies a text label for the other ranks. The function formats a card without changing it.
We have one class and two functions. The deck's methods manage its changing list. Creating card data and formatting one card remain useful standalone jobs.
Compare your complete file after making those additions:
Compare cards.py
# cards.py import random class Deck: def __init__(self, cards): self.cards = cards.copy() def remaining(self): return len(self.cards) def draw(self): if self.remaining() == 0: raise ValueError('Cannot draw from an empty deck.') return self.cards.pop() def shuffle(self): random.shuffle(self.cards) def make_cards(): cards = [] suits = ['clubs', 'diamonds', 'hearts', 'spades'] ranks = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] for suit in suits: for rank in ranks: cards.append({'rank': rank, 'suit': suit}) return cards def card_name(card): names = {11: 'Jack', 12: 'Queen', 13: 'King', 14: 'Ace'} rank = card['rank'] if rank in names: label = names[rank] else: label = str(rank) return f"{label} of {card['suit']}"
Importing cards defines the class and functions without creating a deck, dealing cards or starting a game.
Play High Card
Create high_card.py beside cards.py. Start by importing Deck, make_cards and card_name. Create and shuffle one deck, draw two cards, and print their names. Check that 50 cards remain.
Next, write round_result(player_card, computer_card). Have it return 'player', 'computer' or 'tie' according to the numeric ranks. A fixed Ace against a King should favor the Ace. Two Kings should tie even if their suits differ.
Finally, put the game in main(). Keep two score variables. Before each deal, ask for Enter to continue or q to quit. Only deal while at least two cards remain, and award one point to the round's winner. A tie leaves both scores unchanged.
Try those pieces before comparing the complete program:
Compare high_card.py
# high_card.py from cards import Deck, make_cards, card_name def round_result(player_card, computer_card): if player_card['rank'] > computer_card['rank']: return 'player' elif player_card['rank'] < computer_card['rank']: return 'computer' return 'tie' def main(): deck = Deck(make_cards()) deck.shuffle() player_score = 0 computer_score = 0 print('High Card: Aces high. Suits do not break ties.') while deck.remaining() >= 2: command = input('Enter to deal, or q to quit: ').strip().lower() if command == 'q': break if command != '': print('Press Enter or type q.') continue player_card = deck.draw() computer_card = deck.draw() print('You:', card_name(player_card)) print('Computer:', card_name(computer_card)) result = round_result(player_card, computer_card) if result == 'player': player_score = player_score + 1 print('You win the round.') elif result == 'computer': computer_score = computer_score + 1 print('Computer wins the round.') else: print('Tie. No points this round.') print('Score - you:', player_score, 'computer:', computer_score) print('Cards left:', deck.remaining()) if deck.remaining() < 2: print('No pair of cards remains.') print('Final score - you:', player_score, 'computer:', computer_score) if __name__ == '__main__': main()
Run uv run high_card.py. Play a few rounds, then quit. Start it again to get a fresh shuffled deck and fresh scores.
A full pack allows 26 rounds. Ties still consume two cards. Invalid commands consume none: continue returns to the loop's next iteration before either draw.
The deck manages cards; the calling program manages rounds, input and scores. We can use the same deck class in another game without carrying this game's scoring rule along with it.
Check Rules With Known Cards
Playing a shuffled game isn't enough to check every outcome. Use fixed cards for comparisons and a tiny deck for drawing. Save this as test_cards.py:
# test_cards.py from cards import Deck, make_cards, card_name from high_card import round_result def test_draws_remove_cards(): low = {'rank': 2, 'suit': 'clubs'} high = {'rank': 14, 'suit': 'hearts'} source = [low, high] deck = Deck(source) assert deck.draw() == high assert deck.remaining() == 1 assert deck.draw() == low assert deck.remaining() == 0 assert source == [low, high] def test_separate_decks(): source = make_cards() first = Deck(source) second = Deck(source) first.draw() assert first.remaining() == 51 assert second.remaining() == 52 assert len(source) == 52 def test_empty_deck(): deck = Deck([]) try: deck.draw() except ValueError: pass else: raise AssertionError('An empty deck should reject a draw.') assert deck.remaining() == 0 def test_shuffle_keeps_all_cards(): source = make_cards() deck = Deck(source) deck.shuffle() assert deck.remaining() == 52 drawn = [] while deck.remaining() > 0: card = deck.draw() assert card in source assert card not in drawn drawn.append(card) assert len(drawn) == 52 def test_names_and_rounds(): ace = {'rank': 14, 'suit': 'hearts'} king = {'rank': 13, 'suit': 'clubs'} other_king = {'rank': 13, 'suit': 'spades'} assert card_name(ace) == 'Ace of hearts' assert card_name({'rank': 2, 'suit': 'clubs'}) == '2 of clubs' assert round_result(ace, king) == 'player' assert round_result(king, ace) == 'computer' assert round_result(king, other_king) == 'tie' if __name__ == '__main__': test_draws_remove_cards() test_separate_decks() test_empty_deck() test_shuffle_keeps_all_cards() test_names_and_rounds() print('Card checks passed.')
Run uv run test_cards.py.
In the empty-deck check, pass means no action is needed in that block: we received the expected exception. The else belonging to try runs if the call raised no exception, which would be a failed check.
The shuffle check allows any order. It verifies that all 52 distinct cards can still be drawn, without repeats. Requiring a particular random order would test the wrong promise; even an unchanged order is a possible shuffle.
The main guard in high_card.py lets us import round_result without prompting for input. These are the same ordinary assertions and explicit calls we've used since Testing.
What a Class Helps Us Keep Together
Our deck has one changing collection and three operations on it. A call such as deck.draw() identifies both the data and the operation. Two decks can follow the same rules while holding different remaining cards.
The class doesn't make its contents untouchable. Python still allows code outside it to access deck.cards, and our initializer doesn't validate every card dictionary. Callers in this small program use the methods and supply the documented card shape.
Keeping the empty-deck check inside draw() gives every caller the same clear failure. Checking for a pair in the game prevents an incomplete round. Those checks serve different jobs.
The existing word counter still works well as a function accepting text and returning a dictionary. We don't need to turn it into a class to use it on a website. Choose a class when keeping particular state with the operations on that state makes the program easier to follow.
Your Turn
Predict Two Names and Two Decks
Create one deck, assign it to another name, then create a second deck with a separate Deck(...) call. Draw using the extra name. Predict each deck's remaining count before checking it.
Explain how many instances you created and which call supplied self to draw().
Break the Draw
Temporarily change return self.cards.pop() to return self.cards[-1]. The index -1 reads the last item without removing it.
Run test_cards.py. Find the first failed assertion, then explain how this bug could make a game deal the same card repeatedly. Restore the method and rerun the checks.
Make a Smaller Game
Build a list containing four known cards and pass it to Deck. Predict the two rounds before shuffling. Check one win for each player, then change the list to produce a tie. Remember that the last card is drawn first.
Adapt main() to use that list temporarily and leave out deck.shuffle() so the known order stays fixed. Confirm that the game stops after two rounds and that trying an invalid command doesn't remove a card. Restore the full pack and its shuffle afterward.
Optional: Hold a Hand
Write a separate Hand class. Initialize each hand with its own empty list, add a card with add(card), and return its card count with count().
Create two hands and deal one card to each from one deck. Then deal one extra card only to the first hand. Check counts of two and one. Keep the empty list on each instance inside __init__, so the hands don't share one collection.
You can extend this into a game where the player chooses a card to play. First write down the rules and a tiny example whose outcome you can check.
For another setting, Meet the Castle's Characters uses one class for a dragon and a ghost with different names and gifts.
What I've Learned
Explain the difference between defining Deck, calling Deck(...) twice and assigning another name to an existing deck. Include what self refers to in one method call.
You now have a class that keeps state between calls and a game that uses it. The optional Django chapter will connect familiar functions to requests from a browser. You can also take these skills straight into a project of your own.
Projects to Build Next
- Text Adventure: Meet the Castle's Characters (optional) — Give the dragon and a ghost their own data and conversation behavior.