NateWM.net

Playing With Python #3: Go Fish Game

Now that we had learned enough about Python to make something, let’s program a game of Go Fish!

This Go Fish game had been written using only the Python syntax and features we had already learned about in the previous two articles (Playing With Python #1: Learning the Basics and Playing With Python #2: More of the Basics). There are some things that I would have done differently after learning about classes and object oriented programming, but I thought it would be good to take a break from learning and see how to apply what we now know.

If you somehow don’t know what the game of Go Fish is, it is a card game where players take turns asking if the other player has any cards with a face value matching something in their own hand. The goal is to try to get all four matching cards of the same value to form “books”, which are used to determine who the winner is – the one with the most books. If a player is able to take a card, or if they get a card with the value they wanted when drawing, they get to play again. The player draws a card if either their hand is empty, or their opponent did not have a card with the value they asked for.

What the game looks like when run:

*** Go Fish! ***

*** It is now the Player's turn ***

Player's hand has these 7 cards:
  3 of Hearts       : ♥♥♥
  6 of Hearts       : ♥♥♥♥♥♥
  7 of Clubs        : ♣♣♣♣♣♣♣
  7 of Diamonds     : ♦♦♦♦♦♦♦
  7 of Spades       : ♠♠♠♠♠♠♠
  9 of Diamonds     : ♦♦♦♦♦♦♦♦♦
  10 of Clubs       : ♣♣♣♣♣♣♣♣♣♣

Computer's hand has 7 cards

These can be asked for: 10, 3, 6, 7, 9
Ask, do you have a... 3

Player took this card:
  3 of Clubs        : ♣♣♣

Player gets to go again!

Player's hand has these 8 cards:
  3 of Clubs        : ♣♣♣
  3 of Hearts       : ♥♥♥
  6 of Hearts       : ♥♥♥♥♥♥
  7 of Clubs        : ♣♣♣♣♣♣♣
  7 of Diamonds     : ♦♦♦♦♦♦♦
  7 of Spades       : ♠♠♠♠♠♠♠
  9 of Diamonds     : ♦♦♦♦♦♦♦♦♦
  10 of Clubs       : ♣♣♣♣♣♣♣♣♣♣

Computer's hand has 6 cards

These can be asked for: 10, 3, 6, 7, 9
Ask, do you have a... 5

That is not in your hand.  Try again.

Ask, do you have a... 6

Player took this card:
  6 of Diamonds     : ♦♦♦♦♦♦

Player gets to go again!

Player's hand has these 9 cards:
  3 of Clubs        : ♣♣♣
  3 of Hearts       : ♥♥♥
  6 of Diamonds     : ♦♦♦♦♦♦
  6 of Hearts       : ♥♥♥♥♥♥
  7 of Clubs        : ♣♣♣♣♣♣♣
  7 of Diamonds     : ♦♦♦♦♦♦♦
  7 of Spades       : ♠♠♠♠♠♠♠
  9 of Diamonds     : ♦♦♦♦♦♦♦♦♦
  10 of Clubs       : ♣♣♣♣♣♣♣♣♣♣

Computer's hand has 5 cards

These can be asked for: 10, 3, 6, 7, 9
Ask, do you have a... 7

Go fish!

This card had been drawn:
  King of Hearts    : ♥♥♥♥♥♥♥♥♥♥♥♥♥

There are now 37 cards remaining in the deck.

----------------------------------------

*** It is now the Computer's turn ***

Player's hand has these 10 cards:
  3 of Clubs        : ♣♣♣
  3 of Hearts       : ♥♥♥
  6 of Diamonds     : ♦♦♦♦♦♦
  6 of Hearts       : ♥♥♥♥♥♥
  7 of Clubs        : ♣♣♣♣♣♣♣
  7 of Diamonds     : ♦♦♦♦♦♦♦
  7 of Spades       : ♠♠♠♠♠♠♠
  9 of Diamonds     : ♦♦♦♦♦♦♦♦♦
  10 of Clubs       : ♣♣♣♣♣♣♣♣♣♣
  King of Hearts    : ♥♥♥♥♥♥♥♥♥♥♥♥♥

Computer's hand has 5 cards

Do you have a 9?

Computer took this card:
  9 of Diamonds     : ♦♦♦♦♦♦♦♦♦

Computer gets to go again!

Do you have a 10?

Computer took this card:
  10 of Clubs       : ♣♣♣♣♣♣♣♣♣♣

Computer gets to go again!

Do you have a 2?

Go fish!

This card had been drawn:
  Card Faced Down   : ?

There are now 36 cards remaining in the deck.

----------------------------------------

*** It is now the Player's turn ***

Player's hand has these 8 cards:
  3 of Clubs        : ♣♣♣
  3 of Hearts       : ♥♥♥
  6 of Diamonds     : ♦♦♦♦♦♦
  6 of Hearts       : ♥♥♥♥♥♥
  7 of Clubs        : ♣♣♣♣♣♣♣
  7 of Diamonds     : ♦♦♦♦♦♦♦
  7 of Spades       : ♠♠♠♠♠♠♠
  King of Hearts    : ♥♥♥♥♥♥♥♥♥♥♥♥♥

Computer's hand has 8 cards

These can be asked for: 3, 6, 7, King
Ask, do you have a... _

Development Process

A lot of developing an application is about figuring out how to break it down into manageable pieces that can be developed and tested without overwhelming yourself with complexity. Because I was going to make a card game, I first decided what basic card, deck, and hand manipulation operations will be needed.

The first step was getting a deck of cards. A standard deck consists of an Ace, 2 through 10, Jack, Queen, and King for each of the four suits (spades, hearts, diamonds, and clubs). Using a nested loop, I could go through each face value for each suit and add it to a list of cards that will be the deck used in the game.

The next most important functionality is shuffling the deck. What makes things simple is that Python’s random module gives you a shuffle function that is designed to randomly rearrange lists – making the shuffle operation simply be a single line.

If I wanted to more manually make a deck shuffle function, it could have been made like this – shown in case you might be interested in how a Fisher-Yates shuffle could be implemented:

def shuffle(deck):
    # Loop through list indexes
    for i in range(len(deck)):
        # Select a random list index from current index to the end.
        index = random.randrange(i, len(deck))
        # Swap the items at these index positions.
        # Note, Python has syntax that makes this convenient, but other
        # languages might need a temporary variable to store a value to
        # do the swap because only one variable could be updated at at time.
        deck[i], deck[index] = deck[index], deck[i]

Other required card game operations include deal to deal cards for a player’s hand, draw to have a player draw cards from the deck and into their own hand, remove_from_hand to remove cards from a player’s hand, and add_to_hand to add cards to a player’s hand. For the game of Go Fish, we need find_face_in_hand to find cards with the specified face value – used for asking if a player has a card and when determining if a player had formed a book. The player is only allowed to ask if their opponent has a card with a face value in their own hand, so faces_in_hand is used for this validation check. These are the basic operations needed for playing the card game.

The next important thing to tackle is the presentation of useful information for the player to use for understanding the current state of the game and for making decisions about what they will play on their turn. The first function I had written in this category was print_card. I had actually originally written a much more complicated function that printed an ASCII representation of cards, but I thought the code would get to be too long and messy to use as an example, so I kept it as a simple line of information per card.

To help with card presentation, I used unicode characters for card suits, which is looked up using the SYMBOLS dictionary. To get a numeric count for each face value, I made a PIP_COUNT dictionary, for the number of “pips” on a card. This dictionary had been formed using dict comprehension, which is like list comprehension but for dictionaries – it generates the key/value pairs in the dictionary.

I am not going to go through every function because the full listing is below and a lot of the decisions that went into the print statements used for presentation were arbitrary – someone else likely would have made very different decisions about how they wanted the game to be presented. Also, there was quite a bit of back and forth iterating on this – it wasn’t done all in one go at this point in time.

It was now time to start writing the actual game, so I began work on implementing the player’s turn. I knew that I needed to get a card value from the player, do validation checks, and perform the basic operations of moving cards between the deck or opponent’s hand and the player’s hand. I focused only on the perspective of the player first and had it in a larger function, before deciding what can be refactored out to make the code simpler to read and test (for example, checking for books was not originally in its own function).

Here is a very high level overview of the flow of a player’s turn in the game of Go Fish. It will not go into all the technical details of how it had been implemented – you can see the code for that – but it will give you a good understanding of how Go Fish is played.

  flowchart TD
    _start(["Start turn"]) --> _player_select["Player selects<br>card to play"]
    _player_select --> _player_select_valid{"Is it a valid card<br>for the player<br>to play?"}
    _player_select_valid -- No --> _player_select
    _player_select_valid -- Yes --> _find_matches{"Does the<br>opponent have any<br>matching cards?"}
    _find_matches -- Yes --> _take_cards["Player gets to take cards from opponent's hand."]
    _take_cards --> _take_another_turn["Player will get<br>to take another turn."]
    _find_matches -- No --> _go_fish["Go Fish -- player draws a card from the deck."]
    _go_fish --> _lucky_draw{"Did the player draw<br>what they wanted?"}
    _lucky_draw -- Yes --> _take_another_turn["Player will get<br> to take another turn."]
    _lucky_draw -- No --> _check_books
    _take_another_turn --> _check_books{"Did the player<br>form a book?"}
    _check_books -- Yes --> _form_book["Form a book by taking the cards from player's hand."]
    _check_books -- No --> _empty_hand{"Is player's hand<br>empty and cards<br>remaining in deck?"}
    _form_book --> _empty_hand
    _empty_hand -- Yes --> _draw_card["Draw a card."]
    _empty_hand -- No --> _another_turn{"Will the player<br>take another turn?"}
    _draw_card --> _another_turn
    _another_turn -- Yes --> _player_select
    _another_turn -- No --> _end(["End turn"])
    style _start fill:#d1fae5,stroke:#059669
    style _end fill:#fee2e2,stroke:#dc2626
    style _player_select_valid fill:#fef3c7,stroke:#d97706
    style _find_matches fill:#fef3c7,stroke:#d97706
    style _lucky_draw fill:#fef3c7,stroke:#d97706
    style _check_books fill:#fef3c7,stroke:#d97706
    style _empty_hand fill:#fef3c7,stroke:#d97706
    style _another_turn fill:#fef3c7,stroke:#d97706

The computer’s AI had started extremely simple – just randomly choosing a value from their hand to play, with no real strategy involved. I wanted to test the basic flow of the game before getting too deep into deciding how I wanted the AI to behave. This was used to flesh out all the details of the full flow of the game, like when to take cards, draw cards, check for books, and take another turn. It was an iterative process of adding to the game’s flow and testing it does what I wanted it to.

Next came making the AI more “advanced”. It is still very simple AI, but a little smarter than just completely random. I did not want it to be too difficult, but there needed to be some challenge. This was mainly done by having the AI “remember” what face values the player had asked for, and also remember what faces the computer had recently asked for. If the computer had a card in their hand and knew the player recently asked for that card, they have a good chance to steal it. If the computer does not know about cards that they can steal, it would then ask for a card from their hand that it did not recently ask about (avoiding repeatedly asking for the same cards). If it has no other options, it just randomly picks some card from its hand to ask about.

All that was left was continued refinement, refactoring, testing, and commenting.

Full Code Listing

import random  # We need this to shuffle the deck and let the computer make random choices

# --- Constants ---
# Constants are written in ALL_CAPS by convention to show they shouldn't change while the program runs.

# A tuple (not a list) of the four card suits. We use a tuple because this data never needs
# to change - it's fixed for the whole game.
SUITS = ("Spades", "Hearts", "Diamonds", "Clubs")

# All 13 possible face values a card can have, in order from lowest to highest.
FACES = ("Ace", "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King")

# A dictionary (lookup table) that maps each suit name to its Unicode symbol character,
# e.g. "Spades" -> "♠". This lets us print a nice symbol instead of just the word "Spades".
SYMBOLS = {"Spades": "\u2660", "Hearts": "\u2665", "Diamonds": "\u2666", "Clubs": "\u2663"}

# This is a "dict comprehension" - a compact way to build a dictionary.
# enumerate(FACES) gives us pairs like (0, "Ace"), (1, "2"), (2, "3"), etc.
# We add 1 to the index so "Ace" becomes 1, "2" becomes 2, ... "King" becomes 13.
# The result is a dictionary like {"Ace": 1, "2": 2, ..., "King": 13} that tells us
# how many "pips" (symbols) to draw for each card, and lets us sort cards by rank.
PIP_COUNT = {face: i + 1 for i, face in enumerate(FACES)}

# How many face values the computer AI is allowed to "remember" at once. This keeps the
# computer's memory limited (more human-like) instead of remembering everything forever.
COMPUTER_MEM_SIZE = 5


# --- Deck mechanics ---
# This section handles building, shuffling, and dealing cards from the deck.

def make_deck():
    """Makes a full deck of suits for each card face value.
    Returns:
        List of (face, suit) card tuples.
    """
    deck = []  # Start with an empty list that we will fill with cards
    for suit in SUITS:          # Loop through each of the 4 suits
        for face in FACES:      # For each suit, loop through all 13 face values
            # Each card is represented as a tuple: (face, suit), e.g. ("Ace", "Spades").
            # By the time both loops finish, deck will have 4 x 13 = 52 cards.
            deck.append((face, suit))
    return deck


def shuffle(deck):
    """Randomly shuffles the deck of cards.
    Args:
        deck: List of card tuples.
    """
    # random.shuffle() rearranges the list "in place" - meaning it changes the original
    # list directly instead of returning a new one. That's why this function doesn't
    # need a "return" statement.
    random.shuffle(deck)


def deal(deck, count):
    """Deals a specified number of cards from the deck.
    Args:
        deck: List of card tuples.
        count: Number of cards to deal.
    Returns:
        The list of cards that had been dealt.
    """
    hand = []  # This will hold the cards we deal out
    # We use min(count, len(deck)) so that if someone asks for more cards than
    # are left in the deck, we don't crash - we just deal as many as we can.
    for i in range(min(count, len(deck))):
        # deck.pop() removes and returns the LAST card in the list. Using pop() (instead of
        # pop(0)) is efficient because removing from the end of a list is fast in Python.
        # Since the deck was shuffled already, it doesn't matter that we always take
        # from the "end" - the order is already random.
        hand.append(deck.pop())
    return hand


def draw(deck, hand, count):
    """Draws cards from the deck and adds to a hand.
    Args:
        deck: The list of cards to draw from.
        hand: The list of cards to add to.
        count: The number of cards to draw.
    Returns:
        List of cards that had been drawn.
    """
    new_cards = deal(deck, count)   # Take cards off the top of the deck
    # hand.extend() adds each item from new_cards individually to hand (unlike
    # hand.append(new_cards), which would add the whole list as one single item).
    hand.extend(new_cards)          # Add those cards onto the end of the given hand list
    return new_cards


# --- Hand utilities ---
# Helper functions for organizing, searching, and modifying a hand of cards.

def hand_sort_key(card):
    """Function for hand sort order.
    Args:
        card: Tuple for card face and suit.
    Returns:
        A tuple of pip count and suit.
    """
    # This function is used as a "key" for sorting. card[0] is the face (like "King"),
    # and PIPS[card[0]] converts that into a number (like 13) so cards sort by rank.
    # We also include card[1] (the suit) as a tie-breaker, so cards of the same rank
    # get sorted alphabetically by suit too.
    return (PIP_COUNT[card[0]], card[1])


def sort_hand(hand):
    """Sorts a hand of cards.
    Args:
        hand: List of cards to sort.
    """
    # hand.sort() rearranges the list in place. The key=hand_sort_key part tells Python
    # to use our custom hand_sort_key() function to decide the order, instead of trying
    # to compare the tuples directly (which wouldn't sort by rank correctly).
    hand.sort(key=hand_sort_key)


def find_face_in_hand(hand, face):
    """Finds all cards in a hand that matches a face value.
    Args:
        hand: List of cards to search.
        face: Face value to match on.
    Returns:
        List of cards from hand that match the face value.
    """
    cards = []  # Will collect any matching cards we find
    for card in hand:
        if card[0] == face:
            # card[0] is the face value part of the (face, suit) tuple
            cards.append(card)
    return cards


def remove_from_hand(hand, cards):
    """Removes all the specified cards from a hand.
    Args:
        hand: List of cards that will be removed from.
        cards: List of cards to remove.
    """
    for card in cards:
        # list.remove() finds the first matching item and deletes it. Since each card
        # in "cards" should also exist in "hand" at this point, this removes them one by one.
        hand.remove(card)


def add_to_hand(hand, cards):
    """Adds cards to a hand.
    Args:
        hand: List of cards to add to.
        cards: List of cards to add to the hand.
    """
    # Just like in draw(), extend() adds each card individually rather than nesting a list.
    hand.extend(cards)


def faces_in_hand(hand):
    """Gets set of distinct face values from a hand.
    Args:
        hand: List of cards.
    """
    # This is a "generator expression" inside set(). For every (face, suit) card in hand,
    # we pull out just the face and put it into a set. A set automatically removes
    # duplicates, so if you have two Kings, "King" only shows up once in the result.
    # This is useful for knowing which values you could ask your opponent for.
    return set(face for face, suit in hand)


# --- Display ---
# Functions in this section are only responsible for printing things to the screen.
# Keeping display code separate from game logic makes the program easier to understand.

def print_card(face, suit):
    """Prints visual representation of a card.
    Args:
        face: Card's face value.
        suit: Card's suit symbol name.
    """
    label = face + " of " + suit  # e.g. "King of Spades"
    # f"{label:17}" pads the label with spaces so it's always 17 characters wide,
    # which makes all the printed cards line up neatly in columns.
    # SYMBOLS[suit] * PIPS[face] repeats the suit symbol as many times as the card's
    # rank (e.g. a "3" prints the symbol three times, a "King" prints it 13 times).
    print(f"  {label:17} : {SYMBOLS[suit] * PIP_COUNT[face]}")


def print_hidden_card():
    """Prints a visual representation of a turned over card."""
    # Used when we don't want to reveal what a card is (e.g. showing the computer's
    # hand to the player, or hiding what the computer drew).
    print("  Card Faced Down   : ?")


def print_hand(name, hand, hidden=False):
    """Prints a player's hand of cards.
    Args:
        name: The name of the player whose cards are being printed.
        hand: List of cards to print.
        hidden: Will print cards facing down if True, else cards will be shown.
    """
    # These if/elif/else branches just pick the correct grammar (singular vs plural)
    # so the message reads naturally whether there are 0, 1, or many cards.
    if len(hand) == 0:
        print(f"{name}'s hand has no cards.")
    elif len(hand) == 1:
        print(f"{name}'s hand has this 1 card:")
    else:
        print(f"{name}'s hand has these {len(hand)} cards:")

    for face, suit in hand:
        # Python lets us "unpack" each (face, suit) tuple straight into two variables
        # as we loop, instead of writing card[0] and card[1] every time.
        if hidden:
            print_hidden_card()
        else:
            print_card(face, suit)
    print()  # Print a blank line afterward to visually separate this from what comes next


def print_drawn_cards(cards, hidden=False):
    """Prints information about cards drawn from the deck.
    Args:
        cards: List of cards to print information about.
        hidden: Card will be drawn turned over if True.
    """
    if len(cards) == 0:
        print("No cards were drawn.")
    elif len(cards) == 1:
        print("This card had been drawn:")
    else:
        print(f"These {len(cards)} cards have been drawn:")
    for face, suit in cards:
        if hidden:
            print_hidden_card()
        else:
            print_card(face, suit)
    print()


def print_turn_status(player_state, computer_state):
    """Prints the current status at the beginning of a turn.
    Args:
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    # We show the player their own cards face-up (default hidden=False),
    # but for the computer we only print how MANY cards it has, not what they are -
    # that would be cheating!
    print_hand("Player", player_state["hand"])
    print(f"Computer's hand has {len(computer_state['hand'])} cards")
    print()


# --- Computer memory ---
# These functions give the computer opponent a simple "memory" so it can play a bit
# smarter than picking completely at random every turn.

def remember(mem_list, face):
    """Adds face value to computer's memory.
    Args:
        mem_list: List of faces computer is remembering.
        face: Face value to add to memory.
    """
    # We check first so we don't add the same face twice - mem_list acts like a
    # set of memories, even though it's stored as a list (a list is used here so
    # we can control removal order later with limit_memory()).
    if face not in mem_list:
        mem_list.append(face)


def forget(mem_list, face):
    """Removes face value from computer's memory.
    Args:
        mem_list: List of faces computer is remembering.
        face: Face value to remove from memory.
    """
    # We check "if face in mem_list" first because list.remove() would raise an
    # error if the item isn't there at all.
    if face in mem_list:
        mem_list.remove(face)


def limit_memory(mem_list, mem_size=COMPUTER_MEM_SIZE):
    """Limits how much the computer remembers and forgets the oldest.
    Args:
        mem_list: List of faces computer is remembering.
        mem_size: Max length of memory list.
    """
    while len(mem_list) > mem_size:
        # pop(0) removes the FIRST (oldest) item in the list. Since new memories get
        # appended to the end, the oldest memory is always at the front - so this keeps
        # only the most recent COMPUTER_MEM_SIZE memories, like a "sliding window".
        mem_list.pop(0)


# --- Book handling ---
# In Go Fish, a "book" is having all 4 cards of the same face value (e.g. all 4 Kings).

def handle_book(state, opponent_state, face, is_computers_turn=False):
    """Handles checking for books (having all four of a face value).
    Args:
        state: Dict of the current player's state.
        opponent_state: Dict of the opponent player's state.
        face: Face value to check for a book.
        is_computers_turn: If it is currently the computer's turn.
    Returns:
        Face value of the book that had been formed, or None if no book.
    """
    if face:
        # "if face:" checks that face isn't None (and isn't an empty string). This guards
        # against being called with no face value to check.
        matches = find_face_in_hand(state["hand"], face)
        if len(matches) == 4:
            # There are only 4 suits, so having 4 matching cards means you have all of them.
            state["books"].append(face)
            # We take the completed set of 4 cards out of the hand since they're now
            # "used up" as a book and no longer part of active gameplay.
            remove_from_hand(state["hand"], matches)
            print(f"{state['name']} made a book of {face}!")
            print()
            if not is_computers_turn:
                # The PLAYER just formed a book, so this face has now completely left
                # their hand. If the computer was remembering that it "heard" the player
                # ask for this face, that memory is now stale (the player can't possibly
                # have any left) - so the computer should forget it.
                forget(opponent_state["heard"], face)
            return face

    return None


# --- Turn mechanics ---
# The core rules of how a turn plays out: asking for a card, going fish, taking cards, etc.

def ensure_can_take_turn(deck, state, hidden=False):
    """Tries to make sure a player can take a turn and handles if hand is empty.

    Args:
        deck: List of cards for the deck.
        state: Dict of current player's state.
        hidden: Cards will be drawn face down if True.
    Returns:
        True if player can take their turn, else False.
    """
    if len(state["hand"]) > 0:
        # Normal case: the player already has cards, so they can just take their turn.
        return True
    if len(deck) > 0:
        # If your hand is empty but the deck still has cards,
        # you draw one card for free before continuing your turn.
        drawn = draw(deck, state["hand"], 1)
        print(f"{state['name']}'s hand was empty, so a card was drawn.")
        print_drawn_cards(drawn, hidden)
        return True

    # If we get here, the hand is empty AND the deck is empty - there's nothing this
    # player can do, so their turn is skipped.
    print(f"{state['name']} has no cards and the deck is empty. Turn skipped.")
    print()
    return False


def go_fish(deck, state, selected_face, is_computers_turn):
    """Has the player "go fish" to draw a card and handle match.
    Args:
        deck: List of cards for the deck.
        state: Dict of current player's state values.
        is_computers_turn: True if it is currently the computer's turn, else false.
        selected_face: Face value that the player had selected.
    Returns:
        True if player gets to take another turn, else False.
        Face of new card drawn from the pile, or None if no card drawn.
    """
    another_turn = False   # Will become True only if the drawn card happens to match
    new_card_face = None   # Will store the face value of whatever card gets drawn
    print("Go fish!")
    print()
    drawn_cards = draw(deck, state["hand"], 1)
    print_drawn_cards(drawn_cards, is_computers_turn)

    if len(drawn_cards) > 0:
        # drawn_cards[0] is the single card we drew, e.g. ("7", "Hearts")
        new_card_face = drawn_cards[0][0]
        # Lucky draw! The player drew exactly the card they asked their opponent for,
        # so by the rules of Go Fish, they get to go again.
        if drawn_cards[0][0] == selected_face:
            another_turn = True
            print(f"{state['name']} got what they wanted!")
            print()
    else:
        # This happens if the deck was already empty and there was nothing left to draw.
        print("A card could not be drawn.")

    # These next lines just print how many cards are left in the deck, again handling
    # singular/plural grammar so the message reads naturally.
    if len(deck) == 0:
        print("The deck is empty.")
    elif len(deck) == 1:
        print("There is now 1 card remaining in the deck.")
    else:
        print(f"There are now {len(deck)} cards remaining in the deck.")
    print()
    # Note: this function returns TWO values at once. Python packs them into a tuple
    # automatically, and whoever calls this function can "unpack" both values at once,
    # e.g. `another_turn, new_card_face = go_fish(...)`.
    return another_turn, new_card_face


def take_cards(state, opponent_state, selected_face, found_cards, is_computers_turn):
    """Has the player take cards from opponent.
    Args:
        state: Dict of the current player's state values.
        opponent_state: Dict of the opponent player's state values.
        selected_face: Face value the player had selected.
        found_cards: Cards found to match the selected face value.
        is_computers_turn: True if it currently is the computer's turn.
    """
    # Together, these two lines move the matching cards from the opponent's hand
    # into the current player's hand.
    remove_from_hand(opponent_state["hand"], found_cards)
    add_to_hand(state["hand"], found_cards)

    if len(found_cards) == 1:
        print(f"{state['name']} took this card:")
    else:
        print(f"{state['name']} took these cards:")
    for face, suit in found_cards:
        print_card(face, suit)
    print()

    if is_computers_turn:
        # If the computer just successfully got the cards it asked for, it no longer
        # needs to "remember" that value as something to ask for again later.
        forget(state["heard"], selected_face)


def redraw_if_empty_hand(deck, state, is_computers_turn):
    """Tries to draw a new card if hand is empty.
    Args:
        deck: List of cards in the deck.
        state: Dict of the current player's state values.
        is_computers_turn: True if it currently is the computer's turn.
    """
    if len(state["hand"]) == 0 and len(deck) > 0:
        # If a player's hand became empty (for example, after successfully taking all
        # matching cards from the opponent) and the deck still has cards, give them
        # one new card so they aren't stuck with nothing.
        new_card = draw(deck, state["hand"], 1)
        if len(new_card) > 0:
            print(f"{state['name']} drew a card because their hand was empty.")
            if not is_computers_turn:
                # Only reveal the actual card if it's the human player's card,
                # otherwise keep the computer's new card secret.
                face, suit = new_card[0]
                print_card(face, suit)
            else:
                print_hidden_card()
            print()


def turn(deck, state, opponent_state, selected_face, is_computers_turn=False):
    """Takes the current player's turn and updates the state of the game.
    Args:
        deck: List of cards in the deck.
        state: Dict of the current player's state values.
        opponent_state: Dict of the opponent player's state values.
        selected_face: The face value the player selected to play.
        is_computers_turn: True if it is currently the computer's turn.
    Returns:
        True if the player gets to take another turn, else False.
    """
    another_turn = False
    new_card_face = None

    # Check whether the opponent actually has any cards matching the face we asked for.
    found_cards = find_face_in_hand(opponent_state["hand"], selected_face)

    if len(found_cards) == 0:
        # The opponent doesn't have any matches, so the current player must "go fish"
        # (draw a card from the deck instead).
        another_turn, new_card_face = go_fish(deck, state, selected_face, is_computers_turn)
    else:
        # The opponent DID have matching cards - the classic Go Fish rule is that a
        # successful ask always earns you another turn.
        another_turn = True
        new_card_face = selected_face
        take_cards(state, opponent_state, selected_face, found_cards, is_computers_turn)

    # After either outcome, check if the new card (whichever face it turned out to be)
    # completed a book of 4 for the current player.
    handle_book(state, opponent_state, new_card_face, is_computers_turn)

    if another_turn:
        print(f"{state['name']} gets to go again!")
        print()

    # If taking cards or drawing emptied this player's hand, give them a fresh card
    # (as long as the deck isn't also empty).
    redraw_if_empty_hand(deck, state, is_computers_turn)

    return another_turn


# --- Player's turn ---
# Functions that handle getting input from the human player.

def ask_face_from_player(valid_faces):
    """Asks the player to pick a value from a set of values.
    Args:
        valid_faces: Set of face values to pick from.
    Returns:
        The face value the player had selected.
    """
    selected_face = None
    # This is a "validation loop" - it keeps asking the player for input until
    # they type something that is actually a valid choice (a face value they hold).
    while selected_face not in valid_faces:
        # .strip() removes any accidental extra spaces the player typed.
        # .capitalize() makes the first letter uppercase and the rest lowercase, so
        # typing "king" or "KING" both get turned into "King" to match our FACES tuple.
        selected_face = input("Ask, do you have a... ").strip().capitalize()
        print()
        if selected_face in valid_faces:
            return selected_face
        print("That is not in your hand.  Try again.")
        print()
        # If the input wasn't valid, we loop back around and ask again.


def players_turn(deck, player_state, computer_state):
    """Processes the player's turn in the game.
    Args:
        deck: List of cards for the deck.
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    print("*** It is now the Player's turn ***")
    print()
    another_turn = True
    # This while loop lets the player keep taking turns in a row as long as they keep
    # getting matches (Go Fish rule: successful asks earn another go).
    while another_turn and ensure_can_take_turn(deck, player_state):
        another_turn = False  # Reset each loop; only set back to True if they succeed again
        print_turn_status(player_state, computer_state)
        # The player can only ask for a face value they themselves currently hold -
        # that's a standard Go Fish rule.
        valid_faces = faces_in_hand(player_state["hand"])
        print(f"These can be asked for: {', '.join(sorted(valid_faces))}")
        selected_face = ask_face_from_player(valid_faces)

        # Whatever the player asks for, the computer "hears" it and
        # remembers it for its own strategy later.
        remember(computer_state["heard"], selected_face)

        another_turn = turn(deck, player_state, computer_state, selected_face)
        # Re-sorting after every turn keeps the player's hand easy to read.
        sort_hand(player_state["hand"])


# --- Computer's turn ---
# Functions that control the simple AI logic for the computer opponent.

def select_face_ai(computer_state):
    """Selects face value from what the computer heard, not recently asked, or picks randomly.
    Args:
        computer_state: Dict of the computer's state values.
    Returns:
        Face selected by computer to play.
    """
    # The computer can only ask for faces it actually holds, same rule as the player.
    valid_faces = faces_in_hand(computer_state["hand"])

    # Strategy 1 (best option): ask for a face value the computer both holds AND has
    # heard the player ask for recently - since the player asking for it suggests
    # the player might have (or want) that card, which the computer can use.
    valid_heard = set(computer_state["heard"]) & valid_faces
    # The "&" operator finds the INTERSECTION of two sets - values present in both.
    if len(valid_heard) > 0:
        # random.choice() needs something indexable like a tuple/list, not a set,
        # which is why we convert it with tuple() first.
        return random.choice(tuple(valid_heard))

    # Strategy 2 (fallback): if there's nothing useful "heard", try asking for
    # something it hasn't already asked for recently, to avoid repeating itself.
    not_asked = valid_faces - set(computer_state["asked"])
    # The "-" operator finds values in valid_faces that are NOT in computer_state["asked"].
    if len(not_asked) > 0:
        return random.choice(tuple(not_asked))

    # Strategy 3 (last resort): if there's nothing smarter to do, just pick any
    # random face value from its own hand.
    else:
        return random.choice(tuple(valid_faces))


def computers_turn(deck, player_state, computer_state):
    """Processes the computer's turn in the game.
    Args:
        deck: List of cards for the deck.
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    print("*** It is now the Computer's turn ***")
    print()
    # Trim the computer's memory lists down to size before it starts thinking,
    # so its "recent memory" stays limited and realistic.
    limit_memory(computer_state["heard"])
    limit_memory(computer_state["asked"])
    print_turn_status(player_state, computer_state)

    another_turn = True
    # hidden=True here means if the computer has to draw a "free" card because
    # its hand was empty, we don't show the player what it drew.
    while another_turn and ensure_can_take_turn(deck, computer_state, hidden=True):
        another_turn = False
        selected_face = select_face_ai(computer_state)
        # Remember what it just asked for, so it can avoid repeating itself (Strategy 2 above).
        remember(computer_state["asked"], selected_face)
        print(f"Do you have a {selected_face}?")
        print()
        # Passing True as the last argument tells turn() this is the computer's turn,
        # which affects things like whether cards get shown or hidden.
        another_turn = turn(deck, computer_state, player_state, selected_face, True)


# --- Main ---
# This is where the overall game gets set up and run from start to finish.

def main():
    """Go Fish Game."""
    deck = make_deck()   # Build a fresh 52-card deck
    shuffle(deck)        # Randomize the order of the deck

    # A "state" dictionary bundles together all the information we need to track
    # for one player, so we can pass it around to functions as a single object
    # instead of many separate variables.
    player_state = {
        "name": "Player",
        "hand": deal(deck, 7),   # Standard Go Fish deals 7 cards to start (with 2 players)
        "books": []              # Will collect any completed 4-of-a-kind sets
    }
    sort_hand(player_state["hand"])

    computer_state = {
        "name": "Computer",
        "hand": deal(deck, 7),
        "books": [],
        "asked": [],   # Faces the computer has already asked the player for (its memory)
        "heard": []    # Faces the computer has heard the player ask for (its memory)
    }

    print("*** Go Fish! ***")
    print()

    # Before the game even starts, check the starting hands in case either player
    # was dealt all 4 of a kind right off the bat (unlikely, but the rules still apply!).
    for face in faces_in_hand(player_state["hand"]):
        handle_book(player_state, computer_state, face)
    for face in faces_in_hand(computer_state["hand"]):
        handle_book(computer_state, player_state, face, True)

    # Randomly choose whose turn it is -- either the computer's or player's.
    currently_players_turn = random.randrange(2) == 0

    # Main game loop: keep playing as long as there are cards left in the deck,
    # OR both players still have cards in hand to play with. The game ends once
    # the deck is empty AND at least one player has run out of cards.
    while len(deck) > 0 or (len(player_state["hand"]) > 0 and len(computer_state["hand"]) > 0):
        if currently_players_turn:
            players_turn(deck, player_state, computer_state)
        else:
            computers_turn(deck, player_state, computer_state)

        # Alternate whose turn it currently is.
        currently_players_turn = not currently_players_turn

        print('-' * 40)  # Prints a dashed line as a visual divider between turns
        print()

    # Once the loop ends, the game is over - show the final results.
    print("*** The game is over! ***")
    print()
    print(f"Player's books: {', '.join(player_state['books'])}")
    print(f"Computer's books: {', '.join(computer_state['books'])}")
    print()

    # Whoever collected the most books (4-of-a-kind sets) wins.
    if len(player_state["books"]) > len(computer_state["books"]):
        print("Player won!")
    elif len(player_state["books"]) < len(computer_state["books"]):
        print("Computer won!")
    else:
        print("It was a draw!")


if __name__ == "__main__":
    # This check means main() only runs when this file is executed directly
    # (like `python go_fish.py`), and NOT if this file were imported as a module
    # into another Python program. It's a very common Python pattern.
    main()