Build a Game

You now have every piece a small game needs: lists and dictionaries to hold what is going on, functions to keep the rules in one place, a way to be told about clicks, and a method for finding the bugs you will certainly write. This module puts them together. You will build Tic-Tac-Toe with guidance, and the way of building it is the real subject: it is how programs larger than one screen get written.

Before this, you can: keep data in a list of lists or a dictionary; write a click handler; find a bug with a trace and an assert.

By the end you will be able to keep a game's whole situation in data and draw the picture from it, say what must be remembered between clicks and keep it in one place, check that a move is allowed before changing anything, and plan a program as a list of small functions before writing any of them.

Before you read on, decide what this prints:

turn = "X"

def clicked(x, y):
    turn = "O"

clicked(120, 80)
print(turn)
X

The handler was called and the turn did not change. turn = "O" inside the function made a new name that lived for that one call, exactly as in Functions. A game has to remember things from one click to the next, and this is the first thing that goes wrong when you try. The second section shows where to keep them.

The data is the game

It is tempting to think of a game as its picture. Turn that round. The game is its data: for Tic-Tac-Toe, nine marks. The picture is only a way of showing the data, and it is drawn again from the data whenever something changes.

board = ["X", "", "O",
         "", "X", "",
         "", "", "O"]
print(board[4], len(board))
X 9

A list of nine strings, read row by row: "X", "O", or "" for an empty square. The square in row row and column col is at position row * 3 + col, and going the other way, position i is in row i // 3 and column i % 3.

from scimigo import canvas, rectangle, text

canvas(600, 400)
board = ["X", "", "O", "", "X", "", "", "", "O"]
for i in range(9):
    row = i // 3
    col = i % 3
    rectangle(150 + col * 100, 30 + row * 100, 96, 96, color="lightgray")
    if board[i] != "":
        text(180 + col * 100, 98 + row * 100, board[i], size=60)
XOXO

Nothing in this loop decides anything about the game. It reads the list and draws what it finds. To put an X in the corner you do not draw an X; you change board[8] and draw the board again. Keeping the two apart is what makes the rest possible: the rules can be written, and tested, without a picture at all.

Remembering between clicks

A click handler runs from start to finish on every click, and its own names are gone when it ends. So what the game must remember cannot live in the handler. It lives at the top level, in one dictionary, and the handler changes that dictionary in place:

state = {"turn": "X", "moves": 0}

def clicked(x, y):
    state["moves"] = state["moves"] + 1
    if state["turn"] == "X":
        state["turn"] = "O"
    else:
        state["turn"] = "X"

clicked(120, 80)
clicked(300, 200)
print(state)
{'turn': 'X', 'moves': 2}

This works where the opening program did not, and References and Grids says why. state["turn"] = "O" does not make a new name: it changes, in place, the dictionary that the top-level name state refers to. The function is reading state, which it is allowed to do, and changing the object it finds.

One dictionary for everything has a second benefit. To start a new game, or to check what the game remembers, there is one place to look.

Check, then change

Every move a player makes has to be checked before anything is changed. In Tic-Tac-Toe a move is not allowed if the square is taken, if the position is not on the board, or if the game is already over.

def legal(board, index):
    if index < 0 or index > 8:
        return False
    return board[index] == ""

board = ["X", "", "O", "", "", "", "", "", ""]
print(legal(board, 1), legal(board, 0), legal(board, 9), legal(board, -1))
True False False False

Look at the last case. board[-1] is perfectly good Python: it is the last square. A click outside the board that turned into position -1 would quietly play in the bottom corner. Python will not stop you, so the check has to.

The order matters. If a function changes the board first and then discovers the move was not allowed, it has to undo what it did, and undoing is where bugs breed. Check everything, return early if anything is wrong, and only then change. A refused move must leave the data exactly as it was.

Plan before you type

A game is too big to write from the top down in one go. Before writing any code, list the functions it needs, and for each one say what it is given and what it hands back. For Tic-Tac-Toe:

Function Given Hands back or does
play(board, turn, index) the board, whose turn, a position places the mark if the move is legal; returns whose turn is next
winner_for(board) the board "X", "O", or "" if nobody has three in a row
board_is_full(board) the board True when no square is empty
square_at(x, y) a click's position the position of the square there, or -1
draw() nothing draws the picture from state
clicked(x, y) a click's position ties the others together

Notice how little clicked has to know. It asks square_at which square was meant, asks play to make the move, asks winner_for whether that ended the game, and asks draw to show the result. Each of the others is small enough to hold in your head, and four of the six can be tested with an assert and no clicking at all.

Build in stages, test each stage

Write the functions in an order that lets you run something at every stage:

  1. the data and draw, so that you can see a board, and change the list by hand to see the picture follow;
  2. play, tested with asserts on a list, with no picture;
  3. winner_for, tested the same way, with every kind of line and a board with no winner;
  4. clicked, last, when everything it calls is known to work.
def play(board, turn, index):
    if index < 0 or index > 8 or board[index] != "":
        return turn
    board[index] = turn
    if turn == "X":
        return "O"
    return "X"

board = ["", "", "", "", "", "", "", "", ""]
assert play(board, "X", 4) == "O"
assert board[4] == "X"
assert play(board, "O", 4) == "O"
assert board[4] == "X"
print("play works")
play works

When the last stage misbehaves, you then know where to look first: the newest part, which has been tested least. It is the first place to look, not the only one. Your asserts covered the cases you thought of, and a new stage can hand an earlier function an input it has never met, so a bug can still turn out to be in play or winner_for. When it does, add the assert that would have caught it. This is the method of Find the Bug used in advance: a bug in a program built all at once could be anywhere, and one built in tested stages gives you a short list.

In the lab

You will read a board as data, draw the marks from the list, trace what a game remembers across two clicks, decide which moves are legal, write play and the three-in-a-row check and test them without a picture, wire everything to clicks and play a whole game, and then plan a different game from its description.