
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:
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 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 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\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 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