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 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:
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 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 move 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 move 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
.
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 move 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\n".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/.
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 move 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\n'.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\n')
, 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 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.
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.
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/.
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) + "|\n"
BOARD_TEMPLATE = "\n " + "".join(COLUMN_LABELS) + "\n" + BOARD_EDGE + "\n" + (BOARD_ROW * BOARD_HEIGHT) + 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.
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.