Debugging part 13: Finishing Go Fish

MP 156: Implementing the computer's turn, and recognizing the end of the game.

Note: This post is part of an ongoing series about debugging in Python. The posts in this series will only be available to paid subscribers for the first 6 weeks. After that they will be available to everyone. Thank you to everyone who supports my ongoing work on Mostly Python.

In the last post, we used an IDE's debugger to resolve a subtle recursion issue. In this post, we'll finish the basic implementation of Go Fish. In the remaining posts in the series, we'll clean up any issues that have come up, and identify the most meaningful takeaways from all this debugging work.

Finishing Go Fish

Here's the plan for finishing Go Fish:

  • Verify we don't have any known buggy behavior in the current version of the project.
  • Remove the random seed.
  • Check for initial matches when the game starts.
  • Finish processing the player's turn.
  • Implement the computer's turn.
  • Make sure we can play through a full game, with the option to start another game.

Let's get started.

Looking for old bugs

We fixed the bug where stale invalid guesses interrupt the logical flow of the program. But we only tried one set of guesses. At the end of part 11 in this series I also saw a logical error when guessing a card that's in the computer's hand, but not in my hand, followed by a valid correct guess. In that situation the invalid guess was removed from the computer's hand when it shouldn't be.

I think that bug is resolved now, but let's check:

$ python go_fish.py -v
Player hand:
2♥ 3♦ 3♥ 5♦ 6♥ 8♣ Q♠

Computer hand:
2♦ 3♣ 4♠ 5♣ 10♦ J♥ K♣

What card would you like to ask for? 4
You don't have that card!

What card would you like to ask for? 2

Your guess was correct! Press Enter to continue.
Player hand:
3♦ 3♥ 5♦ 6♥ 8♣ Q♠

Computer hand:
3♣ 4♠ 5♣ 10♦ J♥ K♣

The bug seems to be resolved. I asked for a 4, which I don't have but the computer does. That guess is rejected, as it should be. When I ask for a 2, it's processed correctly. More importantly, the 4 is still in the computer's hand.

Seeding only when requested

Okay, we can move on. I want to put the random seed behind a --seed argument. I'm keeping this as simple as possible, so I'm just using sys.argv again, instead of argparse or a third-party CLI library:

class GoFish:

    def __init__(self):
        # Start with a new shuffled deck.
        if "--seed" in sys.argv:
            random.seed(42)
        self.deck = Deck()
        self.deck.shuffle()
go_fish.py

Now we can run python go_fish.py --seed -v whenever we want deterministic, verbose output.

Checking for initial matches

Let's start by looking for any pairs that might exist in each player's hand after the initial hands are dealt. Here's a first pass at a method that checks for pairs in the player's hand:

from collections import Counter
...

class GoFish:
    ...
    def start_game(self):
        ...
        # Check for any pairs either player already has.
        self.check_pairs()

        # Player goes first.
        self.player_turn()

    def check_pairs(self):
        """Check for pairs in either player's hand."""
        # Player's hand.
        ranks = [c.rank for c in self.player_hand.cards]
        ranks_counts = Counter(ranks)
        for rank, count in ranks_counts.items():
            if count in (2, 3):
                # Remove first two of this rank, and add to player_pairs.
                card_1 = go_fish_utils.remove_card(rank, self.player_hand)
                card_2 = go_fish_utils.remove_card(rank, self.player_hand)
                pair = (card_1, card_2)
                self.player_pairs.append(pair)
            if count == 4:
                # Player had all four cards of the same rank. Remove second pair.
                card_1 = go_fish_utils.remove_card(rank, self.player_hand)
                card_2 = go_fish_utils.remove_card(rank, self.player_hand)
                pair = (card_1, card_2)
                self.player_pairs.append(pair)
go_fish.py

We first call check_pairs() after dealing the two hands, and before starting the player's first turn.

Considering the player's hand first, we get a list of all the ranks the player is holding. We then use the Counter class from the collections module. If you give Counter a sequence, it will return a dictionary of items in the sequence, and counts of each item. Here's what ranks_counts looks like, from a pdb session:

Counter({'3': 2, '2': 1, '5': 1, '6': 1, '8': 1, 'Q': 1})

In this case the player had two 3s, and one each of a 2, 5, 6, 8, and Q.

To remove pairs, we loop over each rank and its corresponding count. If the count for any rank is 2 or 3, we remove the first two cards of that rank and add them to player_pairs. It would be unusual for a player to hold all four cards of the same rank at the start of a game, but we should address that possibility as well. When enough games are played, this situation is going to come up. If there are four cards of the same rank, we remove the second pair of matching cards and add those to player_pairs as well.

This seems to work:

$ python go_fish.py --seed -v
Player hand:
2♥ 5♦ 6♥ 8♣ Q♠

Computer hand:
2♦ 3♣ 4♠ 5♣ 10♦ J♥ K

The pair of threes that we've been starting with in a seeded game have been removed from the player's hand. I confirmed in a debugging session that player_pairs is holding the pair of threes, and we'll show that in a moment as well.

Refactoring

Let's write a helper function to remove a pair of cards of the same rank, to simplify check_pairs():

def remove_pair(target_rank, hand):
    """Remove and return a pair of cards with a matching rank."""
    return (
        remove_card(target_rank, hand),
        remove_card(target_rank, hand),
    )
go_fish_utils.py

The function remove_pair() takes in a rank and a hand. It removes one card at a time. These removed cards form a tuple, which is returned to the caller.

Looking more closely at check_pairs(), it doesn't really need to be a method in the class. By passing it player_hand and player_pairs, it can be a helper function as well:

def check_pairs(hand, pairs):
    """Check for pairs in a hand."""
    ranks = [c.rank for c in hand.cards]
    ranks_counts = Counter(ranks)
    for rank, count in ranks_counts.items():
        if count in (2, 3):
            # Remove first two of this rank, and add to pairs.
            pair = remove_pair(rank, hand)
            pairs.append(pair)
        if count == 4:
            # There were four cards of the same rank. Remove second pair.
            pair = remove_pair(rank, hand)
            pairs.append(pair)
go_fish_utils.py

The nice thing about making this a function is that it can be used to look for pairs in the computer's hand as well.

This simplifies the GoFish class:

class GoFish:
    ...
    def start_game(self):
        ...
        # Check for any pairs either player already has.
        go_fish_utils.check_pairs(self.player_hand, self.player_pairs)
        go_fish_utils.check_pairs(self.computer_hand, self.computer_pairs)

        # Player goes first.
        self.player_turn()
go_fish.py

Now any pairs that are dealt to either player at the start of the game are automatically moved to their collection of pairs.

Finishing player_turn()

There are a few things left to implement when processing the player's turn.

  • We need to let the player know when their guess is incorrect.
  • We need to draw a card when the player's guess is incorrect.
  • We need to check for pairs again after the player draws a card.
  • We need to inform the player when pairs are found.
  • We need to show the number of pairs each player has.

Processing an incorrect guess

Most of this can be taken care of in check_player_guess():

    def check_player_guess(self, guessed_rank):
        """Process the player's guess."""
        computer_ranks = [c.rank for c in self.computer_hand.cards]
        if guessed_rank in computer_ranks:
            ...
        else:
            # It's the computer's turn now.
            msg = "\nYour guess was incorrect."
            msg += " Press Enter to continue."
            input(msg)

            new_card = self.deck.draw()
            msg = f"\nYou drew: {new_card}."
            print(msg)

            self.player_hand.cards.append(new_card)
            self.player_hand.organize()
            go_fish_utils.check_pairs(self.player_hand, self.player_pairs)
go_fish.py

When the guess is incorrect, we let the player know and pause until they're ready to continue. We then draw a new card, and add it to their hand. After adding the new card, we sort the hand again by calling player_hand.organize(). Finally, we check the updated hand for any pairs.

Consistent pauses

I noticed a pattern emerging as I implemented more detailed features of the player's turn. I'm putting multiple pauses in to inform the player of updates to the game. These pauses are implemented in blocks that look like this:

# Player gets to go again.
msg = "\nYour guess was correct!"
msg += " Press Enter to continue."
input(msg)

I want to add pauses when pairs are found in check_pairs(), but I don't want to make more blocks that look just like this. Here's a pause() utility function that should reduce all blocks like this to just one line:

def pause(message):
    """Pause for player to see an update."""
    message += " Press Enter to continue."
    input(message)
go_fish_utils.py

Now any time we want to pause, we can pass a message to this new function. The function will add a note to press Enter to continue, and display the message. The game will continue when the user presses enter.

This simplifies GoFish:

    def check_player_guess(self, guessed_rank):
        ...
        if guessed_rank in computer_ranks:
            ...
            # Player gets to go again.
            go_fish_utils.pause("\nYour guess was correct!")
            self.player_turn()
        else:
            # It's the computer's turn now.
            go_fish_utils.pause("\nYour guess was incorrect.")
            new_card = self.deck.draw()
            go_fish_utils.pause(f"\nYou drew: {new_card}.\n")

            self.player_hand.cards.append(new_card)
            ...
go_fish.py

Also, when a pair is found I want to be able to show whose hand the pair was found in. I added an owner argument to check_pairs():

def check_pairs(hand, pairs, owner):
    """Check for pairs in a hand."""
    ranks = [c.rank for c in hand.cards]
    ranks_counts = Counter(ranks)
    for rank, count in ranks_counts.items():
        if count in (2, 3):
            # Remove first two of this rank, and add to pairs.
            pair = remove_pair(rank, hand)
            pairs.append(pair)
            pause(f"\nFound a pair in {owner} hand: {pair}\n")
        if count == 4:
            # There were four cards of the same rank. Remove second pair.
            pair = remove_pair(rank, hand)
            pairs.append(pair)
            pause(f"\nFound a pair in {owner} hand: {pair}\n")
go_fish_utils.py

The two values of owner are your for the player, and computer's for the computer. That results in output like this:

Found a pair in your hand: (10♣, 10♦)
Found a pair in computer's hand: (A♣, A♦)

The calls to check_pair() now look like this:

go_fish_utils.check_pairs(self.player_hand, self.player_pairs, "your")
go_fish_utils.check_pairs(self.computer_hand, self.computer_pairs, "computer's")

Now when you start a game, you can see which player a pair was dealt to:

$ python go_fish.py

Found a pair in your hand: (10♥, 10♠)
 Press Enter to continue.

Found a pair in computer's hand: (9♣, 9♠)
 Press Enter to continue.

When you press Enter, the screen clears and you see this:

Player hand:
2♥ 3♦ 4♣ 7♣ J♣

Computer hand:
X  X  X  X  X

What card would you like to ask for?

This is a nice clean interface, and the player gets to see how they ended up with only five cards in their hand.

Showing each player's pairs

The player can easily see how many cards they have now, and how many cards the computer has. But they can't see how many pairs each has collected. Let's show that:

class GoFish:

    def __init__(self):
        ...
        go_fish_utils.clear_terminal()

    ...
    def show_state(self):
        """Show the current state of the game."""
        msg = f"Player pairs:   {len(self.player_pairs)}"
        msg += f"\nComputer pairs: {len(self.computer_pairs)}"
        print(msg)

        print("\nPlayer hand:")
        self.player_hand.show()

        print("\nComputer hand:")
        ...
go_fish.py

I noticed that the terminal isn't cleared before showing the initial pairs that are found, so I added a call to clear_terminal() at the very start of the game. Showing the cards for each player's pairs would be a bit much, so we just show the count for each player whenever we show the state of the game, in show_state():

Player pairs:   2
Computer pairs: 2

Player hand:
4♣ 10♣ K♠

Computer hand:
X  X  X

What card would you like to ask for?

You can easily see how many pairs each player has, and the state of each player's hand.

The computer's turn

That should be everything we need for the player's turn. Now it's time to implement the computer's turn. This should be easier, because we can implement everything in player_turn(), with some slight adjustments. We'll take some actions on the computer's behalf, and inform the player about what's happening until it's their turn again.

class GoFish:

    def check_player_guess(self, guessed_rank):
        """Process the player's guess."""
        computer_ranks = [c.rank for c in self.computer_hand.cards]
        if guessed_rank in computer_ranks:
            ...
        else:
            # It's the computer's turn now.
            ...
            self.computer_turn()

    def computer_turn(self):
        """Manage the computer's turn."""
        go_fish_utils.clear_terminal()
        self.show_state()
        requested_card = random.choice(self.computer_hand.cards).rank
        self.check_computer_guess(requested_card)

    def check_computer_guess(self, guessed_rank):
        """Process the computer's guess."""
        msg = f"\nThe computer asked for a {guessed_rank}."
        print(msg)

        player_ranks = [c.rank for c in self.player_hand.cards]
        if guessed_rank in player_ranks:
            # Correct guess. Remove card from both hands.
            player_card = go_fish_utils.remove_card(
                guessed_rank, self.player_hand)
            computer_card = go_fish_utils.remove_card(
                guessed_rank, self.computer_hand)

            # Add cards to computer's pairs.
            self.computer_pairs.append((player_card, computer_card))

            # Computer gets to go again.
            go_fish_utils.pause("\nThe computer's guess was correct!")
            self.computer_turn()
        else:
            # It's the player's turn now.
            go_fish_utils.pause("The computer's guess was incorrect.")
            new_card = self.deck.draw()
            if "-v" in sys.argv:
                go_fish_utils.pause(f"Computer drew: {new_card}.\n")
            else:
                go_fish_utils.pause("The computer drew a card.")

            self.computer_hand.cards.append(new_card)
            self.computer_hand.organize()
            go_fish_utils.check_pairs(
                self.computer_hand, self.computer_pairs, "computer's")

            self.player_turn()
go_fish.py

This looks like a lot of code, but it's the same logic we used to manage the player's turn. At the end of check_player_guess(), after drawing a card for the player, we call computer_turn(). This short method clears the terminal, shows the current state of the game, and chooses a random card from the computer's hand as a guess.

In check_computer_guess(), we first display the computer's guess. We then check if the guess was correct or not. The only logical difference here is in the else block, where we show which card the computer drew in verbose mode. A call to player_turn() at the end of the else block turns play back to the human player. This is enough to allow a whole game to be played.

Ending the game

You can play through a whole game now, but the program doesn't recognize that the game has ended. Here's what the end of a game looks like at the moment:

...
Player pairs:   8
Computer pairs: 7

Player hand:


Computer hand:
X  X  X  X  X  X  X  X

What card would you like to ask for?

In this play-through, I didn't have any cards left but I'm still being asked to make a guess. We need to write a function that checks for an empty hand, and asks if we want to start a new game or not.

Here's the check_finished() method:

    def check_finished(self):
        """Check if the game is over."""
        # If both hands have at least one card, game is not over.
        if self.player_hand.cards and self.computer_hand.cards:
            return

        # Announce the winner.
        if not self.player_hand.cards and not self.computer_hand.cards:
            msg = "\nTie game!\n\n"
        if not self.player_hand.cards:
            msg = "\nYou won!!!\n\n"
        else:
            msg = "\nThe computer won. :/\n\n"
        go_fish_utils.pause(msg)

        # Play again?
        play_again = input("Do you want to play again? (y/n) ")
        if play_again.lower() in ("y", "yes"):
            self.start_game()
        else:
            print("Thanks for playing!")
            sys.exit()
go_fish.py

These kinds of functions are always interesting to write. Which condition do you check for first? The simplest situation is that the game is still active, and we don't need to do anything, so that's what we check for first. If both players have at least one card, their hands will evaluate to True. If that's the case, we simply return from this function.

If we're still in the function, then someone has an empty hand. We need to figure out who won, and compose an appropriate message. It's possible in Go Fish for both people to go out at the same time, so if both hands are empty we report a tie. If the player has an empty hand, we announce that they won. If only the computer's hand is empty, we sadly report that the computer won.

We then have a simple prompt asking whether the player wants to play again. If they do, we call start_game().

We need to call this method, and make a slight adjustment to start_game():

class GoFish:

    def __init__(self):
        # Set a random seed if requested.
        if "--seed" in sys.argv:
            random.seed(42)

    def start_game(self):
        # Shuffle, and deal two hands.
        self.deck = Deck()
        self.deck.shuffle()
        go_fish_utils.clear_terminal()

        hands = self.deck.deal(num_hands=2, num_cards=7)
        self.player_hand, self.computer_hand = hands
        ...

    def player_turn(self):
        """Manage the human player's turn."""
        go_fish_utils.clear_terminal()
        self.show_state()
        self.check_finished()
        requested_card = go_fish_utils.get_player_guess(
            self.player_hand)
        self.check_player_guess(requested_card)

    def computer_turn(self):
        """Manage the computer's turn."""
        go_fish_utils.clear_terminal()
        self.show_state()
        self.check_finished()
        requested_card = random.choice(self.computer_hand.cards).rank
        self.check_computer_guess(requested_card)

    ...
go_fish.py

We call check_finished() after showing the state in player_turn() and computer_turn(). This way the player can see the final state of the game when the concluding message is shown. We also need to move the initial work of building a deck, shuffling the cards, and clearing the terminal from __init__() to start_game().

With these changes, here's what the end of a long game looks like:

...
Player pairs:   10
Computer pairs: 8

Player hand:


Computer hand:
X

You won!!!

 Press Enter to continue.
Do you want to play again? (y/n)

I won 10 pairs and the computer won 8 pairs. I emptied my hand while the computer had one card left, so I won. Entering y here starts a new game.

Another bug!

When I played through my first full game, I was able to start a new game. But at one point I tried to start a new game, and ran into this error:

The computer won. :/

 Press Enter to continue.
Do you want to play again? (y/n) y
Traceback (most recent call last):
...
  File "go_fish.py", line 137, in check_finished
    self.start_game()
    ~~~~~~~~~~~~~~~^^
  File "go_fish.py", line 23, in start_game
    hands = self.deck.deal(num_hands=2, num_cards=7)
  File "card_models.py", line 100, in deal
    hand.cards.append(self.draw())
                      ~~~~~~~~~^^
  File "card_models.py", line 88, in draw
    return self.cards.pop()
           ~~~~~~~~~~~~~~^^
IndexError: pop from empty list

I tried to start a new game, but ran into an IndexError. This isn't overly surprising. I consider myself a decent programmer, but I definitely generate a lot of bugs in my initial attempts at solving new problems. It's a little humbling writing about debugging, and sharing the kinds of bugs I run into when working through a new project. People don't normally see these bugs, because I usually do a pretty good job of writing comprehensive tests before releasing projects.

I think I know where this bug is coming from, but I won't say anything specific about it yet in case anyone would like to dig in before the next post.

Note: This bug came from an earlier version of the project where I hadn't moved the code that builds a new deck from __init__() to start_game(). When I'm writing a long series like this, I tend to have many versions of a project around (one for each post, usually), and it's a little harder to keep track of than in a traditional programming project.

Conclusions

We now have a Go Fish game that you can play through once, all the way to a clear ending. There's a definite bug in the logic that starts a new game, which we'll address in the next post. I believe there are a couple other issues to address as well.