On this page

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.

Your turn

Now try it yourself

Predict what a program will do, run it, and step through it line by line. The checks tell you when your own code works.

Loading the lab…

In this lab

  1. From data to picture

    A Tic-Tac-Toe board is a list of nine marks, read row by row: "X", "O", or "" for an empty square. Position i is in row i // 3 and column i % 3. Decide what each line prints.

  2. Draw the board

    This program draws nine empty squares. Add the marks: for every position of board that is not empty, write its mark in its square.

    The square for position i is at x = 150 + 100 × col and y = 30 + 100 × row. Its mark is written 30 to the right of that x and 68 below that y, with size=60.

    Do not draw anything for an empty square. The checks try other boards.

  3. What is remembered?

    A click handler runs from start to finish on every click. Two programs try to remember whose turn it is from one click to the next. Each calls its handler twice, as two clicks would. Decide what each prints.

  4. Legal or not?

    Before a move changes anything, it has to be checked. legal says whether a move is allowed: the game must not be over, the position must be on the board, and the square must be empty. Decide what it gives for five attempted moves.

  5. Make a move

    Write play(board, turn, index). turn is "X" or "O", and index is the position the player chose.

    • If the move is not allowed, because index is not from 0 to 8 or that square is not empty, the board is left exactly as it was, and the function returns turn unchanged: the same player tries again.
    • Otherwise it puts turn in that square and returns the other player's mark.

    The function prints nothing. The lines at the bottom then make three moves and print O X 2.

  6. Three in a row

    Write winner_for(board). It returns "X" or "O" if that mark fills any of the eight lines, and an empty string if nobody has won. LINES holds the eight lines, each a group of three positions.

    Three empty squares in a row are not a win. The function prints nothing and does not change the board.

    Under your function, write at least two asserts of your own: one for a board somebody has won, and one for a board nobody has won.

  7. Wire it together

    Everything is here except two functions. state holds the whole game. play, winner_for, square_at and draw are written and tested. Write the last two, where the comment is.

    • board_is_full(board) returns True when no square is empty, and False otherwise. draw uses it.
    • clicked(x, y) handles one click:
      1. if the game already has a winner, do nothing at all;
      2. ask square_at which position was clicked (it gives -1 for a click outside the board);
      3. ask play to make the move on state["board"], and keep the turn it returns in state["turn"];
      4. ask winner_for about the board, and keep the answer in state["winner"];
      5. call draw().

    play already refuses a move that is not allowed, so clicked does not need to check the square itself. Then run the program and play a whole game.

  8. Plan a different game

    A different game: two players take turns dropping a counter into one of seven columns. A counter falls to the lowest free place in its column. Four in a row wins.

    Here one column is a list of six places, with position 0 at the bottom. Decide what the program prints, then plan the rest of the game.

  9. New game

    This is the finished game. Add a way to start again.

    • In draw, add a button: a "steelblue" rectangle at x = 350, y = 345, 110 wide and 30 tall, with the words New game written at (360, 367) with size=18. It is drawn every time, whatever the state of the game.
    • In clicked, a click inside that rectangle, edges included, starts a new game: the board is nine empty squares again, it is X's turn, and there is no winner. This has to work when the game has been won, too.

    Reset every part of state. A click anywhere else works as before.

  10. A drawn game

    When the board is full and nobody has won, the game is a draw. At the moment the game just says Board full.

    1. Write result(board). It returns "X" or "O" if that player has won, "draw" if the board is full and nobody has won, and an empty string if the game is still going. Use winner_for and board_is_full.
    2. In clicked, keep result(state["board"]) in state["winner"], in place of the answer from winner_for.
    3. In draw, when state["winner"] is "draw" the words under the board are Draw.
    4. Under result, write an assert that would stop a careless version: one that says "draw" for any full board, even one that X won with the last move.

    A full board that somebody has won is a win, not a draw.

  11. Take back a move

    This is the finished game. Add Undo. There are no steps to follow this time, only what it must do.

    • An Undo button is drawn every time: a "slategray" rectangle at x = 470, y = 30, 110 wide and 30 tall, with the word Undo written at (480, 52) with size=18.
    • A click inside the button, edges included, takes back the most recent move that is still on the board. That square is empty again, and it is the turn of the player whose move was taken back.
    • Undo can be clicked again and again, taking back one move each time, until the board is empty. With no move to take back it changes nothing.
    • Taking back a winning move means the game is no longer won, and play goes on.
    • After an Undo, the next click on a free square is an ordinary move.

    The game has to remember something it does not remember now. Decide what, and where it lives. Plan the change before you type, and test it by playing.

  12. Graduation project

    This is the last project of the course, and it has no steps. You get a description of a small program, and you plan it, write it, test it and repair it yourself.

    Choose one of the four. Three are games and one is a tool. None of them is something the course has built, and each needs what Tic-Tac-Toe needed: data that holds the whole situation, a picture drawn from that data, a rule checked before anything changes, and an end to detect.

    Each project can be done in two ways, and you choose when you open it:

    • Open. The description and the list under Check your own work, nothing else. You choose every function. This is the full version of the project.
    • With a contract. As above, and a short contract names a few functions and says what each takes and gives back, so that Check my work can test the rules at the heart of your program. How you write them, what your data looks like, every other function and the whole picture are still yours.

    The standard is the same either way: the seven lines under Check your own work.