14
Practice Projects

So far, this book has taught you techniques for writing readable, Pythonic code. Let’s put these techniques into practice by looking at the source code for two command line games: the Tower of Hanoi and Four-in-a-Row.

These projects are short and text-based to keep their scope small, but they demonstrate the principles this book outlines so far. I formatted the code using the Black tool described in “Black: The Uncompromising Code Formatter” on page 53. I chose the variable names according to the guidelines in Chapter 4. I wrote the code in a Pythonic style, as described in Chapter 6. In addition, I wrote comments and docstrings as described in Chapter 11. Because the programs are small and we haven’t yet covered object-oriented programming (OOP), I wrote these two projects without the classes you’ll learn more about in Chapters 15 to 17.

This chapter presents the full source code for these two projects along with a detailed breakdown of the code. These explanations aren’t so much for how the code works (a basic understanding of Python syntax is all that’s needed for that), but why the code was written the way it was. Still, different software developers have different opinions on how to write code and what they deem as Pythonic. You’re certainly welcome to question and critique the source code in these projects.

After reading through a project in this book, I recommend typing the code yourself and running the programs a few times to understand how they work. Then try to reimplement the programs from scratch. Your code doesn’t have to match the code in this chapter, but rewriting the code will give you a sense of the decision making and design trade-offs that programming requires.

The Tower of Hanoi

The Tower of Hanoi puzzle uses a stack of disks of different sizes. The disks have holes in their centers, so you can place them over one of three poles (Figure 14-1). To solve the puzzle, the player must move the stack of disks to one of the other poles. There are three restrictions:

  1. The player can move only one disk at a time.
  2. The player can only move disks to and from the top of a tower.
  3. The player can never place a larger disk on top of a smaller disk.
f14001

Figure 14-1: A physical Tower of Hanoi puzzle set

Solving this puzzle is a common computer science problem used for teaching recursive algorithms. Our program won’t solve this puzzle; rather, it will present the puzzle to a human player to solve. You’ll find more information about the Tower of Hanoi at https://en.wikipedia.org/wiki/Tower_of_Hanoi.

The Output

The Tower of Hanoi program displays the towers as ASCII art by using text characters to represent the disks. It might look primitive compared to modern apps, but this approach keeps the implementation simple, because we only need print() and input() calls to interact with the user. When you run the program, the output will look something like the following. The text the player enters is in bold.

THE TOWER OF HANOI, by Al Sweigart [email protected]

Move the tower of disks, one disk at a time, to another tower. Larger
disks cannot rest on top of a smaller disk.

More info at https://en.wikipedia.org/wiki/Tower_of_Hanoi

     ||          ||          ||     
    @_1@         ||          ||     
   @@_2@@        ||          ||     
  @@@_3@@@       ||          ||     
 @@@@_4@@@@      ||          ||     
@@@@@_5@@@@@     ||          ||     
      A           B           C

Enter the letters of "from" and "to" towers, or QUIT.
(e.g., AB to moves a disk from tower A to tower B.)

> AC
     ||          ||          ||     
     ||          ||          ||     
   @@_2@@        ||          ||     
  @@@_3@@@       ||          ||     
 @@@@_4@@@@      ||          ||     
@@@@@_5@@@@@     ||         @_1@    
      A           B           C

Enter the letters of "from" and "to" towers, or QUIT.
(e.g., AB to moves a disk from tower A to tower B.)

--snip--

     ||          ||          ||     
     ||          ||         @_1@    
     ||          ||        @@_2@@   
     ||          ||       @@@_3@@@  
     ||          ||      @@@@_4@@@@ 
     ||          ||     @@@@@_5@@@@@
      A           B           C

You have solved the puzzle! Well done!

For n disks, it takes a minimum of 2n – 1 moves to solve the Tower of Hanoi. So this five-disk tower requires 31 steps: AC, AB, CB, AC, BA, BC, AC, AB, CB, CA, BA, CB, AC, AB, CB, AC, BA, BC, AC, BA, CB, CA, BA, BC, AC, AB, CB, AC, BA, BC, and finally AC. If you want a greater challenge to solve on your own, you can increase the TOTAL_DISKS variable in the program from 5 to 6.

The Source Code

Open a new file in your editor or IDE, and enter the following code. Save it as towerofhanoi.py.

"""THE TOWER OF HANOI, by Al Sweigart [email protected]
A stack-moving puzzle game."""

import copy
import sys

TOTAL_DISKS = 5  # More disks means a more difficult puzzle.

# Start with all disks on tower A:
SOLVED_TOWER = list(range(TOTAL_DISKS, 0, -1))


def main():
    """Runs a single game of The Tower of Hanoi."""
    print(
        """THE TOWER OF HANOI, by Al Sweigart [email protected]

Move the tower of disks, one disk at a time, to another tower. Larger
disks cannot rest on top of a smaller disk.

More info at https://en.wikipedia.org/wiki/Tower_of_Hanoi
"""
    )

    """The towers dictionary has keys "A", "B", and "C" and values
    that are lists representing a tower of disks. The list contains
    integers representing disks of different sizes, and the start of
    the list is the bottom of the tower. For a game with 5 disks,
    the list [5, 4, 3, 2, 1] represents a completed tower. The blank
    list [] represents a tower of no disks. The list [1, 3] has a
    larger disk on top of a smaller disk and is an invalid
    configuration. The list [3, 1] is allowed since smaller disks
    can go on top of larger ones."""
    towers = {"A": copy.copy(SOLVED_TOWER), "B": [], "C": []}

    while True:  # Run a single turn on each iteration of this loop.
        # Display the towers and disks:
        displayTowers(towers)

        # Ask the user for a move:
        fromTower, toTower = getPlayerMove(towers)

        # Move the top disk from fromTower to toTower:
        disk = towers[fromTower].pop()
        towers[toTower].append(disk)

        # Check if the user has solved the puzzle:
        if SOLVED_TOWER in (towers["B"], towers["C"]):
            displayTowers(towers)  # Display the towers one last time.
            print("You have solved the puzzle! Well done!")
            sys.exit()


def getPlayerMove(towers):
    """Asks the player for a move. Returns (fromTower, toTower)."""

    while True:  # Keep asking player until they enter a valid move.
        print('Enter the letters of "from" and "to" towers, or QUIT.')
        print("(e.g., AB to moves a disk from tower A to tower B.)")
        print()
        response = input("> ").upper().strip()

        if response == "QUIT":
            print("Thanks for playing!")
            sys.exit()

        # Make sure the user entered valid tower letters:
        if response not in ("AB", "AC", "BA", "BC", "CA", "CB"):
            print("Enter one of AB, AC, BA, BC, CA, or CB.")
            continue  # Ask player again for their move.

        # Use more descriptive variable names:
        fromTower, toTower = response[0], response[1]

        if len(towers[fromTower]) == 0:
            # The "from" tower cannot be an empty tower:
            print("You selected a tower with no disks.")
            continue  # Ask player again for their move.
        elif len(towers[toTower]) == 0:
            # Any disk can be moved onto an empty "to" tower:
            return fromTower, toTower
        elif towers[toTower][-1] < towers[fromTower][-1]:
            print("Can't put larger disks on top of smaller ones.")
            continue  # Ask player again for their move.
        else:
            # This is a valid move, so return the selected towers:
            return fromTower, toTower


def displayTowers(towers):
    """Display the three towers with their disks."""

    # Display the three towers:
    for level in range(TOTAL_DISKS, -1, -1):
        for tower in (towers["A"], towers["B"], towers["C"]):
            if level >= len(tower):
                displayDisk(0)  # Display the bare pole with no disk.
            else:
                displayDisk(tower[level])  # Display the disk.
        print()

    # Display the tower labels A, B, and C:
    emptySpace = " " * (TOTAL_DISKS)
    print("{0} A{0}{0} B{0}{0} C
".format(emptySpace))


def displayDisk(width):
    """Display a disk of the given width. A width of 0 means no disk."""
    emptySpace = " " * (TOTAL_DISKS - width)

    if width == 0:
        # Display a pole segment without a disk:
        print(f"{emptySpace}||{emptySpace}", end="")
    else:
        # Display the disk:
        disk = "@" * width
        numLabel = str(width).rjust(2, "_")
        print(f"{emptySpace}{disk}{numLabel}{disk}{emptySpace}", end="")


# If this program was run (instead of imported), run the game:
if __name__ == "__main__":
    main()

Run this program and play a few games to get an idea of what this program does before reading the explanation of the source code. To check for typos, copy and paste it to the online diff tool at https://inventwithpython.com/beyond/diff/.

Writing the Code

Let’s take a closer look at the source code to see how it follows the best practices and patterns described in this book.

We’ll begin at the top of the program:

"""THE TOWER OF HANOI, by Al Sweigart [email protected]
A stack-moving puzzle game."""

The program starts with a multiline comment that serves as a docstring for the towerofhanoi module. The built-in help() function will use this information to describe the module:

>>> import towerofhanoi
>>> help(towerofhanoi)
Help on module towerofhanoi:

NAME
    towerofhanoi

DESCRIPTION
    THE TOWER OF HANOI, by Al Sweigart [email protected]
    A stack-moving puzzle game.

FUNCTIONS
    displayDisk(width)
        Display a single disk of the given width.
--snip--

You can add more words, even paragraphs of information, to the module’s docstring if you need to. I’ve written only a small amount here because the program is so simple.

After the module docstring are the import statements:

import copy
import sys

Black formats these as separate statements rather than a single one, such as import copy, sys. This makes the addition or removal of imported modules easier to see in version control systems, such as Git, that track changes programmers make.

Next, we define the constants this program will need:

TOTAL_DISKS = 5  # More disks means a more difficult puzzle.

# Start with all disks on tower A:
SOLVED_TOWER = list(range(TOTAL_DISKS, 0, -1))

We define these near the top of the file to group them together and make them global variables. We’ve written their names in capitalized snake_case to mark them as constants.

The TOTAL_DISKS constant indicates how many disks the puzzle has. The SOLVED_TOWER variable is an example of a list that contains a solved tower: it contains every disk with the largest at the bottom and the smallest at the top. We generate this value from the TOTAL_DISKS value, and for five disks it’s [5, 4, 3, 2, 1].

Notice that there are no type hints in this file. The reason is that we can infer the types of all variables, parameters, and return values from the code. For example, we’ve assigned the TOTAL_DISKS constant the integer value 5. From this, type checkers, such as Mypy, would infer that TOTAL_DISKS should contain integers only.

We define a main() function, which the program calls near the bottom of the file:

def main():
    """Runs a single game of The Tower of Hanoi."""
    print(
        """THE TOWER OF HANOI, by Al Sweigart [email protected]

Move the tower of disks, one disk at a time, to another tower. Larger
disks cannot rest on top of a smaller disk.

More info at https://en.wikipedia.org/wiki/Tower_of_Hanoi
"""
    )

Functions can have docstrings, too. Notice the docstring for main() below the def statement. You can view this docstring by running import towerofhanoi and help(towerofhanoi.main) from the interactive shell.

Next, we write a comment that extensively describes the data structure we use to represent the tower, because it forms the core of how this program works:

    """The towers dictionary has keys "A", "B", and "C" and values
    that are lists representing a tower of disks. The list contains
    integers representing disks of different sizes, and the start of
    the list is the bottom of the tower. For a game with 5 disks,
    the list [5, 4, 3, 2, 1] represents a completed tower. The blank
    list [] represents a tower of no disks. The list [1, 3] has a
    larger disk on top of a smaller disk and is an invalid
    configuration. The list [3, 1] is allowed since smaller disks
    can go on top of larger ones."""
    towers = {"A": copy.copy(SOLVED_TOWER), "B": [], "C": []}

We use the SOLVED_TOWER list as a stack, one of the simplest data structures in software development. A stack is an ordered list of values altered only through adding (also called pushing) or removing (also called popping) values from the top of the stack. This data structure perfectly represents the tower in our program. We can turn a Python list into a stack if we use the append() method for pushing and the pop() method for popping, and avoid altering the list in any other way. We’ll treat the end of the list as the top of the stack.

Each integer in the towers list represents a single disk of a certain size. For example, in a game with five disks, the list [5, 4, 3, 2, 1] would represent a full stack of disks from the largest (5) at the bottom to the smallest (1) at the top.

Notice that our comment also provides examples of a valid and invalid tower stack.

Inside the main() function, we write an infinite loop that runs a single turn of our puzzle game:

    while True:  # Run a single turn on each iteration of this loop.
        # Display the towers and disks:
        displayTowers(towers)

        # Ask the user for a move:
        fromTower, toTower = getPlayerMove(towers)

        # Move the top disk from fromTower to toTower:
        disk = towers[fromTower].pop()
        towers[toTower].append(disk)

In a single turn, the player views the current state of the towers and enters a move. The program then updates the towers data structure. We’ve hid the details of these tasks in the displayTowers() and getPlayerMove() functions. These descriptive function names allow the main() function to provide a general overview of what the program does.

The next lines check whether the player has solved the puzzle by comparing the complete tower in SOLVED_TOWER to towers["B"] and towers["C"]:

        # Check if the user has solved the puzzle:
        if SOLVED_TOWER in (towers["B"], towers["C"]):
            displayTowers(towers)  # Display the towers one last time.
            print("You have solved the puzzle! Well done!")
            sys.exit()

We don’t compare it to towers["A"], because that pole begins with an already complete tower; a player needs to form the tower on the B or C poles to solve the puzzle. Note that we reuse SOLVED_TOWER to make the starting towers and check whether the player solved the puzzle. Because SOLVED_TOWER is a constant, we can trust that it will always have the value we assigned to it at the beginning of the source code.

The condition we use is equivalent to but shorter than SOLVED_TOWER == towers["B"] or SOLVED_TOWER == towers["C"], a Python idiom we covered in Chapter 6. If this condition is True, the player has solved the puzzle, and we end the program. Otherwise, we loop back for another turn.

The getPlayerMove() function asks the player for a disk move and validates the move against the game rules:

def getPlayerMove(towers):
    """Asks the player for a move. Returns (fromTower, toTower)."""
    while True:  # Keep asking player until they enter a valid move.
        print('Enter the letters of "from" and "to" towers, or QUIT.')
        print("(e.g., AB to moves a disk from tower A to tower B.)")
        print()
        response = input("> ").upper().strip()

We start an infinite loop that continues looping until either a return statement causes the execution to leave the loop and function or a sys.exit() call terminates the program. The first part of the loop asks the player to enter a move by specifying from and to towers.

Notice the input("> ").upper().strip() instruction that receives keyboard input from the player. The input("> ") call accepts text input from the player by presenting a > prompt. This symbol indicates that the player should enter something. If the program didn’t present a prompt, the player might momentarily think the program had frozen.

We call the upper() method on the string returned from input() so it returns an uppercase form of the string. This allows the player to enter either uppercase or lowercase tower labels, such as 'a' or 'A' for tower A. In turn, the uppercase string’s strip() method is called, returning a string without any whitespace on either side in case the user accidentally added a space when entering their move. This user friendliness makes our program slightly easier for players to use.

Still in the getPlayerMove() function, we check the input the user enters:

        if response == "QUIT":
            print("Thanks for playing!")
            sys.exit()

        # Make sure the user entered valid tower letters:
        if response not in ("AB", "AC", "BA", "BC", "CA", "CB"):
            print("Enter one of AB, AC, BA, BC, CA, or CB.")
            continue  # Ask player again for their move.

If the user enters 'QUIT' (in any case, or even with spaces at the beginning or end of the string, due to the calls to upper() and strip()), the program terminates. We could have made getPlayerMove() return 'QUIT' to indicate to the caller that it should call sys.exit(), rather than have getPlayerMove() call sys.exit(). But this would complicate the return value of getPlayerMove(): it would return either a tuple of two strings (for the player’s move) or a single 'QUIT' string. A function that returns values of a single data type is easier to understand than a function that can return values of many possible types. I discussed this in “Return Values Should Always Have the Same Data Type” on page 177.

Between the three towers, only six to-from tower combinations are possible. Despite the fact that we hardcoded all six values in the condition that checks the move, the code is much easier to read than something like len(response) != 2 or response[0] not in 'ABC' or response[1] not in 'ABC'or response[0] == response[1]. Given these circumstances, the hardcoding approach is the most straightforward.

Generally, it’s considered bad practice to hardcode values such as "AB", "AC", and other values as magic values, which are valid only as long as the program has three poles. But although we might want to adjust the number of disks by changing the TOTAL_DISKS constant, it’s highly unlikely that we’ll add more poles to the game. Writing out every possible pole move on this line is fine.

We create two new variables, fromTower and toTower, as descriptive names for the data. They don’t serve a functional purpose, but they make the code easier to read than response[0] and response[1]:

        # Use more descriptive variable names:
        fromTower, toTower = response[0], response[1]

Next, we check whether or not the selected towers constitute a legal move:

        if len(towers[fromTower]) == 0:
            # The "from" tower cannot be an empty tower:
            print("You selected a tower with no disks.")
            continue  # Ask player again for their move.
        elif len(towers[toTower]) == 0:
            # Any disk can be moved onto an empty "to" tower:
            return fromTower, toTower
        elif towers[toTower][-1] < towers[fromTower][-1]:
            print("Can't put larger disks on top of smaller ones.")
            continue  # Ask player again for their move.

If not, a continue statement causes the execution to move back to the beginning of the loop, which asks the player to enter their move again. Note that we check whether toTower is empty; if it is, we return fromTower, toTower to emphasize that the move was valid, because you can always put a disk on an empty pole. These first two conditions ensure that by the time the third condition is checked, towers[toTower] and towers[fromTower] won’t be empty or cause an IndexError. We’ve ordered these conditions in such a way to prevent IndexError or additional checking.

It’s important that your programs handle any invalid input from the user or potential error cases. Users might not know what to enter, or they might make typos. Similarly, files could unexpectedly go missing, or databases could crash. Your programs need to be resilient to the exceptional cases; otherwise, they’ll crash unexpectedly or cause subtle bugs later on.

If none of the previous conditions are True, getPlayerMove() returns fromTower, toTower:

        else:
            # This is a valid move, so return the selected towers:
            return fromTower, toTower

In Python, return statements always return a single value. Although this return statement looks like it returns two values, Python actually returns a single tuple of two values, which is equivalent to return (fromTower, toTower). Python programmers often omit the parentheses in this context. The parentheses don’t define a tuple as much as the commas do.

Notice that the program calls the getPlayerMove() function only once from the main() function. The function doesn’t save us from duplicate code, which is the most common purpose for using one. There’s no reason we couldn’t put all the code in getPlayerMove() in the main() function. But we can also use functions as a way to organize code into separate units, which is how we’re using getPlayerMove(). Doing so prevents the main() function from becoming too long and unwieldy.

The displayTowers() function displays the disks on towers A, B, and C in the towers argument:

def displayTowers(towers):
    """Display the three towers with their disks."""

    # Display the three towers:
    for level in range(TOTAL_DISKS, -1, -1):
        for tower in (towers["A"], towers["B"], towers["C"]):
            if level >= len(tower):
                displayDisk(0)  # Display the bare pole with no disk.
            else:
                displayDisk(tower[level])  # Display the disk.
        print()

It relies on the displayDisk() function, which we’ll cover next, to display each disk in the tower. The for level loop checks every possible disk for a tower, and the for tower loop checks towers A, B, and C.

The displayTowers() function calls displayDisk() to display each disk at a specific width, or if 0 is passed, the pole with no disk:

    # Display the tower labels A, B, and C:
    emptySpace = ' ' * (TOTAL_DISKS)
    print('{0} A{0}{0} B{0}{0} C
'.format(emptySpace))

We display the A, B, and C labels onscreen. The player needs this information to distinguish between the towers and to reinforce that the towers are labeled A, B, and C rather than 1, 2, and 3 or Left, Middle, and Right. I chose not to use 1, 2, and 3 for the tower labels to prevent players from confusing these numbers with the numbers used for the disks’ sizes.

We set the emptySpace variable to the number of spaces to place in between each label, which in turn is based on TOTAL_DISKS, because the more disks in the game, the wider apart the poles are. Rather than use an f-string, as in print(f'{emptySpace} A{emptySpace}{emptySpace} B{emptySpace}{emptySpace} C '), we use the format() string method. This allows us to use the same emptySpace argument wherever {0} appears in the associated string, producing shorter and more readable code than the f-string version.

The displayDisk() function displays a single disk along with its width. If no disk is present, it displays just the pole:

def displayDisk(width):
    """Display a disk of the given width. A width of 0 means no disk."""
    emptySpace = ' ' * (TOTAL_DISKS - width)
    if width == 0:
        # Display a pole segment without a disk:
        print(f'{emptySpace}||{emptySpace}', end='')
    else:
        # Display the disk:
        disk = '@' * width
        numLabel = str(width).rjust(2, '_')
        print(f"{emptySpace}{disk}{numLabel}{disk}{emptySpace}", end='')

We represent a disk using a leading empty space, a number of @ characters equal to the disk width, two characters for the width (including an underscore if the width is a single digit), another series of @ characters, and then the trailing empty space. To display just the empty pole, all we need are the leading empty space, two pipe characters, and trailing empty space. As a result, we’ll need six calls to displayDisk() with six different arguments for width to display the following tower:

     ||
    @_1@
   @@_2@@
  @@@_3@@@
 @@@@_4@@@@
@@@@@_5@@@@@

Notice how the displayTowers() and displayDisk() functions split the responsibility of displaying the towers. Although displayTowers() decides how to interpret the data structures that represent each tower, it relies on displayDisk() to actually display each disk of the tower. Breaking your program into smaller functions like this makes each part easier to test. If the program displays the disks incorrectly, the problem is likely in displayDisk(). If the disks appear in the wrong order, the problem is likely in displayTowers(). Either way, the section of code you’ll have to debug will be much smaller.

To call the main() function, we use a common Python idiom:

# If this program was run (instead of imported), run the game:
if __name__ == '__main__':
    main()

Python automatically sets the __name__ variable to '__main__' if a player runs the towerofhanoi.py program directly. But if someone imports the program as a module using import towerofhanoi, then __name__ would be set to 'towerofhanoi'. The if __name__ == '__main__': line will call the main() function if someone runs our program, starting a game of Tower of Hanoi. But if we simply want to import the program as a module so we could, say, call the individual functions in it for unit testing, this condition will be False and main() won’t be called.

Four-in-a-Row

Four-in-a-Row is a two-player, tile-dropping game. Each player tries to create a row of four of their tiles, whether horizontally, vertically, or diagonally. It’s similar to the board games Connect Four and Four Up. The game uses a 7 by 6 stand-up board, and tiles drop to the lowest unoccupied space in a column. In our Four-in-a-Row game, two human players, X and O, will play against each other, as opposed to one human player against the computer.

The Output

When you run the Four-in-a-Row program in this chapter, the output will look like this:

Four-in-a-Row, by Al Sweigart [email protected]

Two players take turns dropping tiles into one of seven columns, trying
to make four in a row horizontally, vertically, or diagonally.


     1234567
    +-------+
    |.......|
    |.......|
    |.......|
    |.......|
    |.......|
    |.......|
    +-------+
Player X, enter 1 to 7 or QUIT:
> 1

     1234567
    +-------+
    |.......|
    |.......|
    |.......|
    |.......|
    |.......|
    |X......|
    +-------+
Player O, enter 1 to 7 or QUIT:
--snip--
Player O, enter 1 to 7 or QUIT:
> 4

     1234567
    +-------+
    |.......|
    |.......|
    |...O...|
    |X.OO...|
    |X.XO...|
    |XOXO..X|
    +-------+
Player O has won!

Try to figure out the many subtle strategies you can use to get four tiles in a row while blocking your opponent from doing the same.

The Source Code

Open a new file in your editor or IDE, enter the following code, and save it as fourinarow.py:

"""Four-in-a-Row, by Al Sweigart [email protected]
A tile-dropping game to get four-in-a-row, similar to Connect Four."""

import sys

# Constants used for displaying the board:
EMPTY_SPACE = "."  # A period is easier to count than a space.
PLAYER_X = "X"
PLAYER_O = "O"

# Note: Update BOARD_TEMPLATE & COLUMN_LABELS if BOARD_WIDTH is changed.
BOARD_WIDTH = 7
BOARD_HEIGHT = 6
COLUMN_LABELS = ("1", "2", "3", "4", "5", "6", "7")
assert len(COLUMN_LABELS) == BOARD_WIDTH

# The template string for displaying the board:
BOARD_TEMPLATE = """
     1234567
    +-------+
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    +-------+"""


def main():
    """Runs a single game of Four-in-a-Row."""
    print(
        """Four-in-a-Row, by Al Sweigart [email protected]

Two players take turns dropping tiles into one of seven columns, trying
to make Four-in-a-Row horizontally, vertically, or diagonally.
"""
    )

    # Set up a new game:
    gameBoard = getNewBoard()
    playerTurn = PLAYER_X

    while True:  # Run a player's turn.
        # Display the board and get player's move:
        displayBoard(gameBoard)
        playerMove = getPlayerMove(playerTurn, gameBoard)
        gameBoard[playerMove] = playerTurn

        # Check for a win or tie:
        if isWinner(playerTurn, gameBoard):
            displayBoard(gameBoard)  # Display the board one last time.
            print("Player {} has won!".format(playerTurn))
            sys.exit()
        elif isFull(gameBoard):
            displayBoard(gameBoard)  # Display the board one last time.
            print("There is a tie!")
            sys.exit()

        # Switch turns to other player:
        if playerTurn == PLAYER_X:
            playerTurn = PLAYER_O
        elif playerTurn == PLAYER_O:
            playerTurn = PLAYER_X


def getNewBoard():
    """Returns a dictionary that represents a Four-in-a-Row board.

    The keys are (columnIndex, rowIndex) tuples of two integers, and the
    values are one of the "X", "O" or "." (empty space) strings."""
    board = {}
    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            board[(columnIndex, rowIndex)] = EMPTY_SPACE
    return board


def displayBoard(board):
    """Display the board and its tiles on the screen."""

    # Prepare a list to pass to the format() string method for the board
    # template. The list holds all of the board's tiles (and empty
    # spaces) going left to right, top to bottom:
    tileChars = []
    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            tileChars.append(board[(columnIndex, rowIndex)])

    # Display the board:
    print(BOARD_TEMPLATE.format(*tileChars))


def getPlayerMove(playerTile, board):
    """Let a player select a column on the board to drop a tile into.

    Returns a tuple of the (column, row) that the tile falls into."""
    while True:  # Keep asking player until they enter a valid move.
        print(f"Player {playerTile}, enter 1 to {BOARD_WIDTH} or QUIT:")
        response = input("> ").upper().strip()

        if response == "QUIT":
            print("Thanks for playing!")
            sys.exit()

        if response not in COLUMN_LABELS:
            print(f"Enter a number from 1 to {BOARD_WIDTH}.")
            continue  # Ask player again for their move.

        columnIndex = int(response) - 1  # -1 for 0-based column indexes.

        # If the column is full, ask for a move again:
        if board[(columnIndex, 0)] != EMPTY_SPACE:
            print("That column is full, select another one.")
            continue  # Ask player again for their move.

        # Starting from the bottom, find the first empty space.
        for rowIndex in range(BOARD_HEIGHT - 1, -1, -1):
            if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
                return (columnIndex, rowIndex)


def isFull(board):
    """Returns True if the `board` has no empty spaces, otherwise
    returns False."""
    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
                return False  # Found an empty space, so return False.
    return True  # All spaces are full.


def isWinner(playerTile, board):
    """Returns True if `playerTile` has four tiles in a row on `board`,
    otherwise returns False."""

    # Go through the entire board, checking for four-in-a-row:
    for columnIndex in range(BOARD_WIDTH - 3):
        for rowIndex in range(BOARD_HEIGHT):
            # Check for four-in-a-row going across to the right:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex + 1, rowIndex)]
            tile3 = board[(columnIndex + 2, rowIndex)]
            tile4 = board[(columnIndex + 3, rowIndex)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

    for columnIndex in range(BOARD_WIDTH):
        for rowIndex in range(BOARD_HEIGHT - 3):
            # Check for four-in-a-row going down:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex, rowIndex + 1)]
            tile3 = board[(columnIndex, rowIndex + 2)]
            tile4 = board[(columnIndex, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

    for columnIndex in range(BOARD_WIDTH - 3):
        for rowIndex in range(BOARD_HEIGHT - 3):
            # Check for four-in-a-row going right-down diagonal:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex + 1, rowIndex + 1)]
            tile3 = board[(columnIndex + 2, rowIndex + 2)]
            tile4 = board[(columnIndex + 3, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

            # Check for four-in-a-row going left-down diagonal:
            tile1 = board[(columnIndex + 3, rowIndex)]
            tile2 = board[(columnIndex + 2, rowIndex + 1)]
            tile3 = board[(columnIndex + 1, rowIndex + 2)]
            tile4 = board[(columnIndex, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True
    return False


# If this program was run (instead of imported), run the game:
if __name__ == "__main__":
    main()

Run this program and play a few games to get an idea of what this program does before reading the explanation of the source code. To check for typos, copy and paste it to the online diff tool at https://inventwithpython.com/beyond/diff/.

Writing the Code

Let’s look at the program’s source code, as we did for the Tower of Hanoi program. Once again, I formatted this code using Black with a line limit of 75 characters.

We’ll begin at the top of the program:

"""Four-in-a-Row, by Al Sweigart [email protected]
A tile-dropping game to get four-in-a-row, similar to Connect Four."""

import sys

# Constants used for displaying the board:
EMPTY_SPACE = "."  # A period is easier to count than a space.
PLAYER_X = "X"
PLAYER_O = "O"

We start the program with a docstring, module imports, and constant assignments, as we did in the Tower of Hanoi program. We define the PLAYER_X and PLAYER_O constants so we don’t have to use the strings "X" and "O" throughout the program, making errors easier to catch. If we enter a typo while using the constants, such as PLAYER_XX, Python will raise NameError, instantly pointing out the problem. But if we make a typo with the "X" character, such as "XX" or "Z", the resulting bug might not be immediately obvious. As explained in “Magic Numbers” on page 71, using constants instead of the string value directly provides not just a description, but also an early warning for any typos in your source code.

Constants shouldn’t change while the program runs. But the programmer can update their values in future versions of the program. For this reason, we make a note telling programmers that they should update the BOARD_TEMPLATE and COLUMN_LABELS constants, described later, if they change the value of BOARD_WIDTH:

# Note: Update BOARD_TEMPLATE & COLUMN_LABELS if BOARD_WIDTH is changed.
BOARD_WIDTH = 7
BOARD_HEIGHT = 6

Next, we create the COLUMN_LABELS constant:

COLUMN_LABELS = ("1", "2", "3", "4", "5", "6", "7")
assert len(COLUMN_LABELS) == BOARD_WIDTH

We’ll use this constant later to ensure the player selects a valid column. Note that if we ever set BOARD_WIDTH to a value other than 7, we’ll have to add labels to or remove labels from the COLUMN_LABELS tuple. I could have avoided this by generating the value of COLUMN_LABELS based on BOARD_WIDTH with code like this: COLUMN_LABELS = tuple([str(n) for n in range(1, BOARD_WIDTH + 1)]). But COLUMN_LABELS is unlikely to change in the future, because the standard Four-in-a-Row game is played on a 7 by 6 board, so I decided to write out an explicit tuple value.

Sure, this hardcoding is a code smell, as described in “Magic Numbers” on page 71, but it’s more readable than the alternative. Also, the assert statement warns us about changing BOARD_WIDTH without updating COLUMN_LABELS.

As with Tower of Hanoi, the Four-in-a-Row program uses ASCII art to draw the game board. The following lines are a single assignment statement with a multiline string:

# The template string for displaying the board:
BOARD_TEMPLATE = """
     1234567
    +-------+
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    |{}{}{}{}{}{}{}|
    +-------+"""

This string contains braces ({}) that the format() string method will replace with the board’s contents. (The displayBoard() function, explained later, will handle this.) Because the board consists of seven columns and six rows, we use seven brace pairs {} in each of the six rows to represent every slot. Note that just like COLUMN_LABELS, we’re technically hardcoding the board to create a set number of columns and rows. If we ever change BOARD_WIDTH or BOARD_HEIGHT to new integers, we’ll have to update the multiline string in BOARD_TEMPLATE as well.

We could have written code to generate BOARD_TEMPLATE based on the BOARD_WIDTH and BOARD_HEIGHT constants, like so:

BOARD_EDGE = "    +" + ("-" * BOARD_WIDTH) + "+"
BOARD_ROW = "    |" + ("{}" * BOARD_WIDTH) + "|
"
BOARD_TEMPLATE = "
     " + "".join(COLUMN_LABELS) + "
" + BOARD_EDGE + "
" + (BOARD_ROW * BOARD_WIDTH) + BOARD_EDGE

But this code is not as readable as a simple multiline string, and we’re unlikely to change the game board’s size anyway, so we’ll use the simple multiline string.

We begin writing the main() function, which will call all the other functions we’ve made for this game:

def main():
    """Runs a single game of Four-in-a-Row."""
    print(
        """Four-in-a-Row, by Al Sweigart [email protected]

Two players take turns dropping tiles into one of seven columns, trying
to make four-in-a-row horizontally, vertically, or diagonally.
"""
    )

    # Set up a new game:
    gameBoard = getNewBoard()
    playerTurn = PLAYER_X

We give the main() function a docstring, viewable with the built-in help() function. The main() function also prepares the game board for a new game and chooses the first player.

Inside the main() function is an infinite loop:

    while True:  # Run a player's turn.
        # Display the board and get player's move:
        displayBoard(gameBoard)
        playerMove = getPlayerMove(playerTurn, gameBoard)
        gameBoard[playerMove] = playerTurn

Each iteration of this loop consists of a single turn. First, we display the game board to the player. Second, the player selects a column to drop a tile in, and third, we update the game board data structure.

Next, we evaluate the results of the player’s move:

        # Check for a win or tie:
        if isWinner(playerTurn, gameBoard):
            displayBoard(gameBoard)  # Display the board one last time.
            print("Player {} has won!".format(playerTurn))
            sys.exit()
        elif isFull(gameBoard):
            displayBoard(gameBoard)  # Display the board one last time.
            print("There is a tie!")
            sys.exit()

If the player made a winning move, isWinner() returns True and the game ends. If the player filled the board and there is no winner, isFull() returns True and the game ends. Note that instead of calling sys.exit(), we could have used a simple break statement. This would have caused the execution to break out of the while loop, and because there is no code in the main() function after this loop, the function would return to the main() call at the bottom of the program, causing the program to end. But I opted to use sys.exit() to make it clear to programmers reading the code that the program will immediately terminate.

If the game hasn’t ended, the following lines set playerTurn to the other player:

        # Switch turns to other player:
        if playerTurn == PLAYER_X:
            playerTurn = PLAYER_O
        elif playerTurn == PLAYER_O:
            playerTurn = PLAYER_X

Notice that I could have made the elif statement into a simple else statement without a condition. But recall the Zen of Python tenet that explicit is better than implicit. This code explicitly says that if it’s player O’s turn now, it will be player X’s turn next. The alternative would have just said that if it’s not player X’s turn now, it will be player X’s turn next. Even though if and else statements are a natural fit with Boolean conditions, the PLAYER_X and PLAYER_O values aren’t the same as True, and False: not PLAYER_X is not the same as PLAYER_O. Therefore, it’s helpful to be direct when checking the value of playerTurn.

Alternatively, I could have performed the same actions in a one-liner:

playerTurn = {PLAYER_X: PLAYER_O, PLAYER_O: PLAYER_X}[ playerTurn]

This line uses the dictionary trick mentioned in “Use Dictionaries Instead of a switch Statement” on page 101. But like many one-liners, it’s not as readable as a direct if and elif statement.

Next, we define the getNewBoard() function:

def getNewBoard():
    """Returns a dictionary that represents a Four-in-a-Row board.

    The keys are (columnIndex, rowIndex) tuples of two integers, and the
    values are one of the "X", "O" or "." (empty space) strings."""
    board = {}
    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            board[(columnIndex, rowIndex)] = EMPTY_SPACE
    return board

This function returns a dictionary that represents a Four-in-a-Row board. It has (columnIndex, rowIndex) tuples for keys (where columnIndex and rowIndex are integers), and the 'X', 'O', or '.' character for the tile at each place on the board. We store these strings in PLAYER_X, PLAYER_O, and EMPTY_SPACE, respectively.

Our Four-in-a-Row game is rather simple, so using a dictionary to represent the game board is a suitable technique. Still, we could have used an OOP approach instead. We’ll explore OOP in Chapters 15 through 17.

The displayBoard() function takes a game board data structure for the board argument and displays the board onscreen using the BOARD_TEMPLATE constant:

def displayBoard(board):
    """Display the board and its tiles on the screen."""

    # Prepare a list to pass to the format() string method for the board
    # template. The list holds all of the board's tiles (and empty
    # spaces) going left to right, top to bottom:
    tileChars = []

Recall that the BOARD_TEMPLATE is a multiline string with several brace pairs. When we call the format() method on BOARD_TEMPLATE, these braces will be replaced by the arguments passed to format().

The tileChars variable will contain a list of these arguments. We start by assigning it a blank list. The first value in tileChars will replace the first pair of braces in BOARD_TEMPLATE, the second value will replace the second pair, and so on. Essentially, we’re creating a list of the values from the board dictionary:

    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            tileChars.append(board[(columnIndex, rowIndex)])

    # Display the board:
    print(BOARD_TEMPLATE.format(*tileChars))

These nested for loops iterate over every possible row and column on the board, appending them to the list in tileChars. Once these loops have finished, we pass the values in the tileChars list as individual arguments to the format() method using the star * prefix. “Using * to Create Variadic Functions” section on page 167 explained how to use this syntax to treat the values in a list as separate function arguments: the code print(*['cat', 'dog', 'rat']) is equivalent to print('cat', 'dog', 'rat'). We need the star because the format() method expects one argument for every brace pair, not a single list argument.

Next, we write the getPlayerMove() function:

def getPlayerMove(playerTile, board):
    """Let a player select a column on the board to drop a tile into.
    
    Returns a tuple of the (column, row) that the tile falls into."""
    while True:  # Keep asking player until they enter a valid move.
        print(f"Player {playerTile}, enter 1 to {BOARD_WIDTH} or QUIT:")
        response = input("> ").upper().strip()

        if response == "QUIT":
            print("Thanks for playing!")
            sys.exit()

The function begins with an infinite loop that waits for the player to enter a valid move. This code resembles the getPlayerMove() function in the Tower of Hanoi program. Note that the print() call at the start of the while loop uses an f-string so we don’t have to change the message if we update BOARD_WIDTH.

We check that the player’s response is a column; if it isn’t, the continue statement moves the execution back to the start of the loop to ask the player for a valid move:

        if response not in COLUMN_LABELS:
            print(f"Enter a number from 1 to {BOARD_WIDTH}.")
            continue  # Ask player again for their move.

We could have written this input validation condition as not response.isdecimal() or spam < 1 or spam > BOARD_WIDTH, but it’s simpler to just use response not in COLUMN_LABELS.

Next, we need to find out which row a tile dropped in the player’s selected column would land on:

        columnIndex = int(response) - 1  # -1 for 0-based column indexes.

        # If the column is full, ask for a move again:
        if board[(columnIndex, 0)] != EMPTY_SPACE:
            print("That column is full, select another one.")
            continue  # Ask player again for their move.

The board displays the column labels 1 to 7 onscreen. But the (columnIndex, rowIndex) indexes on the board use 0-based indexing, so they range from 0 to 6. To solve this discrepancy, we convert the string values '1' to '7' to the integer values 0 to 6.

The row indexes start at 0 at the top of the board and increase to 6 at the bottom of the board. We check the top row in the selected column to see whether it’s occupied. If so, this column is completely full and the continue statement moves the execution back to the start of the loop to ask the player for another move.

If the column isn’t full, we need to find the lowest unoccupied space for the tile to land on:

        # Starting from the bottom, find the first empty space.
        for rowIndex in range(BOARD_HEIGHT - 1, -1, -1):
            if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
                return (columnIndex, rowIndex)

This for loop starts at the bottom row index, BOARD_HEIGHT - 1 or 6, and moves up until it finds the first empty space. The function then returns the indexes of the lowest empty space.

Anytime the board is full, the game ends in a tie:

def isFull(board):
    """Returns True if the `board` has no empty spaces, otherwise
    returns False."""
    for rowIndex in range(BOARD_HEIGHT):
        for columnIndex in range(BOARD_WIDTH):
            if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
                return False  # Found an empty space, so return False.
    return True  # All spaces are full.

The isFull() function uses a pair of nested for loops to iterate over every place on the board. If it finds a single empty space, the board isn’t full, and the function returns False. If the execution makes it through both loops, the isFull() function found no empty space, so it returns True.

The isWinner() function checks whether a player has won the game:

def isWinner(playerTile, board):
    """Returns True if `playerTile` has four tiles in a row on `board`,
    otherwise returns False."""

    # Go through the entire board, checking for four-in-a-row:
    for columnIndex in range(BOARD_WIDTH - 3):
        for rowIndex in range(BOARD_HEIGHT):
            # Check for four-in-a-row going across to the right:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex + 1, rowIndex)]
            tile3 = board[(columnIndex + 2, rowIndex)]
            tile4 = board[(columnIndex + 3, rowIndex)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

This function returns True if playerTile appears four times in a row horizontally, vertically, or diagonally. To figure out whether the condition is met, we have to check every set of four adjacent spaces on the board. We’ll use a series of nested for loops to do this.

The (columnIndex, rowIndex) tuple represents a starting point. We check the starting point and the three spaces to the right of it for the playerTile string. If the starting space is (columnIndex, rowIndex), the space to its right will be (columnIndex + 1, rowIndex), and so on. We’ll save the tiles in these four spaces to the variables tile1, tile2, tile3, and tile4. If all of these variables have the same value as playerTile, we’ve found a four-in-a-row, and the isWinner() function returns True.

In “Variables with Numeric Suffixes” on page 76, I mentioned that variable names with sequential numeric suffixes (like tile1 through tile4 in this game) are often a code smell that indicates you should use a single list instead. But in this context, these variable names are fine. We don’t need to replace them with a list, because the Four-in-a-Row program will always require exactly four of these tile variables. Remember that a code smell doesn’t necessarily indicate a problem; it only means we should take a second look and confirm that we’ve written our code in the most readable way. In this case, using a list would make our code more complicated, and it wouldn’t add any benefit, so we’ll stick to using tile1, tile2, tile3, and tile4.

We use a similar process to check for vertical four-in-a-row tiles:

    for columnIndex in range(BOARD_WIDTH):
        for rowIndex in range(BOARD_HEIGHT - 3):
            # Check for four-in-a-row going down:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex, rowIndex + 1)]
            tile3 = board[(columnIndex, rowIndex + 2)]
            tile4 = board[(columnIndex, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile: 
                return True

Next, we check for four-in-a-row tiles in a diagonal pattern going down and to the right; then we check for four-in-a-row tiles in a diagonal pattern going down and to the left:

    for columnIndex in range(BOARD_WIDTH - 3):
        for rowIndex in range(BOARD_HEIGHT - 3):
            # Check for four-in-a-row going right-down diagonal:
            tile1 = board[(columnIndex, rowIndex)]
            tile2 = board[(columnIndex + 1, rowIndex + 1)]
            tile3 = board[(columnIndex + 2, rowIndex + 2)]
            tile4 = board[(columnIndex + 3, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

            # Check for four-in-a-row going left-down diagonal:
            tile1 = board[(columnIndex + 3, rowIndex)]
            tile2 = board[(columnIndex + 2, rowIndex + 1)]
            tile3 = board[(columnIndex + 1, rowIndex + 2)]
            tile4 = board[(columnIndex, rowIndex + 3)]
            if tile1 == tile2 == tile3 == tile4 == playerTile:
                return True

This code is similar to the horizontal four-in-a-row checks, so I won’t repeat the explanation here. If all the checks for four-in-a-row tiles fail to find any, the function returns False to indicate that playerTile is not a winner on this board:

    return False

The only task left is to call the main() function:

# If this program was run (instead of imported), run the game:
if __name__ == '__main__':
    main()

Once again, we use a common Python idiom that will call main() if fourinarow.py is run directly but not if fourinarow.py is imported as a module.

Summary

The Tower of Hanoi puzzle game and Four-in-a-Row game are short programs, but by following the practices in this book, you can ensure that their code is readable and easy to debug. These programs follow several good practices: they’ve been automatically formatted with Black, use docstrings to describe the module and functions, and place the constants near the top of the file. They limit the variables, function parameters, and function return values to a single data type so type hinting, although a beneficial form of additional documentation, is unnecessary.

In the Tower of Hanoi, we represent the three towers as a dictionary with keys 'A', 'B', and 'C' whose values are lists of integers. This works, but if our program were any larger or more complicated, it would be a good idea to represent this data using a class. Classes and OOP techniques weren’t used in this chapter because I don’t cover OOP until Chapters 15 through 17. But keep in mind that it’s perfectly valid to use a class for this data structure. The towers render as ASCII art onscreen, using text characters to show each disk of the towers.

The Four-in-a-Row game uses ASCII art to display a representation of the game board. We display this using a multiline string stored in the BOARD_TEMPLATE constant. This string has 42 brace pairs {} to display each space on the 7 by 6 board. We use braces so the format() string method can replace them with the tile at that space. This way, it’s more obvious how the BOARD_TEMPLATE string produces the game board as it appears onscreen.

Although their data structures differ, these two programs share many similarities. They both render their data structures onscreen, ask the player for input, validate that input, and then use it to update their data structures before looping back to the beginning. But there are many different ways we could have written code to carry out these actions. What makes code readable is ultimately a subjective opinion rather than an objective measure of how closely it adheres to some list of rules. The source code in this chapter shows that although we should always give any code smells a second look, not all code smells indicate a problem that we need to fix. Code readability is more important than mindlessly following a “zero code smells” policy for your programs.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.4.221