Index Ai Academy
Phase 7 · Software engineering · Python projects

Project: Pack Train, an arcade game

A grid game on the turtle canvas: game state as classes, a timer loop, key handlers and a saved best score.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Games are a demanding test of program design. Things happen on a clock, the player can press a key at any moment, and the picture must always match the state. The design that copes keeps the rules in plain classes, puts the drawing in one function, and uses small handlers to join the two. It's the same design behind desktop apps, web front ends and robot controllers, and it makes the rules testable without anyone playing. You'll also end up with something you can play and show people.

What you'll be able to do

  • Draw a grid game with the turtle module: coordinates, stamps, text and whole frames
  • Keep a game's rules in classes that know nothing about the screen
  • Drive a game with a timer and key handlers, redrawing after every change
  • Save a best score in a file, and test the rules without playing

What it covers

  1. The brief
  2. Checkpoint 1: a grid on the screen
  3. Checkpoint 2: the train
  4. Checkpoint 3: the rules
  5. Free cells
  6. Checkpoint 4: the clock and the keys
  7. Keeping the best score
  8. Worked example: testing the rules without playing
  9. Go further
  10. Quarry Breaker, a brick game
  11. Storm Front, a fixed shooter

The brief

You'll build Pack Train. A pack mule leads a train of supply carts across a fenced meadow, laid out as a grid of 20 columns and 15 rows:

  • every tick, the train moves one cell, and the carts follow the mule;
  • the arrow keys steer the mule, but it can't turn straight back on itself;
  • a supply crate waits on a free cell. Reaching it scores a point, adds a cart, and speeds the train up a little;
  • the game ends when the mule walks into the fence or into its own carts. Space starts a new game;
  • the best score is saved, so it's still there next time.

You'll build it in four checkpoints: the screen, the train, the rules, and the loop that joins them. The first three don't need a player at all, and that's on purpose.

Try it now

Keys and the timer only change state. The picture is redrawn from that state every tick.

Checkpoint 1: a grid on the screen

Python's turtle module draws with pens called turtles. The screen's middle is (0, 0), x grows to the right, and y grows upward, as in math class. In the academy the picture appears under your code; on your own computer the same code opens a window.

A grid game shouldn't store pixel positions. Positions live as grid cells, (column, row), counted from the bottom left, and one function turns a cell into the pixel at its center. Three turtle tools do the drawing:

  • stamp() leaves a copy of the turtle's shape where it stands. A "square" turtle is 20 pixels across, one cell;
  • write(text) puts text where the turtle stands;
  • tracer(0) stops drawing each step as it happens, and update() then shows everything at once, one frame.
import turtle

CELL = 20
COLS, ROWS = 20, 15

screen = turtle.Screen()
screen.setup(COLS * CELL, ROWS * CELL + 40)
screen.bgcolor("#1d2b22")
screen.tracer(0)

def to_xy(cell):
    x, y = cell
    return ((x - COLS / 2 + 0.5) * CELL, (y - ROWS / 2 + 0.5) * CELL)

fence = turtle.Turtle()
fence.hideturtle()
fence.penup()
fence.color("#8a9a7b")
fence.goto(-COLS * CELL / 2, -ROWS * CELL / 2)
fence.pendown()
for side in (COLS, ROWS, COLS, ROWS):
    fence.forward(side * CELL)
    fence.left(90)

pen = turtle.Turtle("square")
pen.hideturtle()
pen.penup()
pen.shapesize(0.9)
for i, cell in enumerate([(10, 7), (9, 7), (8, 7)]):
    pen.goto(to_xy(cell))
    pen.color("gold" if i == 0 else "peru")
    pen.stamp()
pen.goto(to_xy((15, 11)))
pen.color("#9fd39a")
pen.stamp()
pen.color("white")
pen.goto(-COLS * CELL / 2, ROWS * CELL / 2 + 4)
pen.write("Crates: 0", font=("Courier", 12, "bold"))
screen.update()
turtle.done()

The mule is gold, its carts are brown, and the crate is green. The pen turtle is hidden, so only its stamps show. shapesize(0.9) shrinks each square a little, leaving a thin gap between cells.

Quick check
With 20 columns, 15 rows and 20-pixel cells, where does to_xy((0, 0)) put the bottom-left cell's center?
CELL = 20
COLS, ROWS = 20, 15
def to_xy(cell):
    x, y = cell
    return ((x - COLS / 2 + 0.5) * CELL, (y - ROWS / 2 + 0.5) * CELL)
print(to_xy((0, 0)))
  1. (-200.0, -150.0)
  2. (10.0, 10.0)
  3. (-190.0, -140.0)
  4. (0.0, 0.0)
Show the answer

(-190.0, -140.0)

The grid's left edge is at -200 and its bottom edge at -150. A cell's center is half a cell, 10 pixels, inside each edge.

Checkpoint 2: the train

The train is a list of cells, head first. Moving is two list operations: put the cell ahead on the front, and take the last cell off the end. Growing is the same move without taking the tail off. Each cart follows the one in front for free, because the list shifts along.

DIRECTIONS = {"Up": (0, 1), "Down": (0, -1), "Left": (-1, 0), "Right": (1, 0)}

class Train:
    def __init__(self, start=(10, 7), length=3):
        x, y = start
        self.cells = [(x - i, y) for i in range(length)]  # head first
        self.direction = (1, 0)   # where the next move goes
        self.last_move = (1, 0)   # where the last move went

    def head(self):
        return self.cells[0]

    def turn(self, name):
        dx, dy = DIRECTIONS[name]
        if (dx, dy) != (-self.last_move[0], -self.last_move[1]):
            self.direction = (dx, dy)

    def next_head(self):
        x, y = self.cells[0]
        dx, dy = self.direction
        return (x + dx, y + dy)

    def move(self, grow=False):
        self.cells.insert(0, self.next_head())
        self.last_move = self.direction
        if not grow:
            self.cells.pop()

train = Train()
train.move()
print(train.cells)
train.turn("Up")
train.move(grow=True)
print(train.cells)
train.turn("Down")  # straight back: ignored
train.move()
print(train.cells)
It runs right here in your browser, with nothing to install.

Why keep two directions? Compare a turn with direction, and you get a classic bug. The train heads right; the player taps Up, then Left, quickly, before the next tick. Up changes direction. Left is the reverse of Up, so it's refused, or worse, if the check compares with something else, accepted, and the mule turns straight into its first cart. Comparing with last_move, the way the train actually went, makes any burst of key presses safe.

Quick check
The train is heading Right. Before the next tick, the player presses Up and then Left. With the Train above, which way does it move on that tick?
  1. Right: two turns cancel out
  2. It crashes into its first cart
  3. Up: Left is refused, because it's the reverse of the last move (Right)
  4. Left: the last key pressed wins
Show the answer

Up: Left is refused, because it's the reverse of the last move (Right)

turn compares with last_move, which is still Right until the train moves. Up isn't the reverse of Right, so it's taken; Left is, so it's refused.

Checkpoint 3: the rules

A Game holds everything else: the train, the crate, the score, and whether it's over. Its tick() is the heart of the program, and it still draws nothing:

import random

COLS, ROWS = 20, 15
DIRECTIONS = {"Up": (0, 1), "Down": (0, -1), "Left": (-1, 0), "Right": (1, 0)}

class Train:
    def __init__(self, start=(10, 7), length=3):
        x, y = start
        self.cells = [(x - i, y) for i in range(length)]
        self.direction = self.last_move = (1, 0)
    def head(self):
        return self.cells[0]
    def turn(self, name):
        dx, dy = DIRECTIONS[name]
        if (dx, dy) != (-self.last_move[0], -self.last_move[1]):
            self.direction = (dx, dy)
    def next_head(self):
        return (self.cells[0][0] + self.direction[0], self.cells[0][1] + self.direction[1])
    def move(self, grow=False):
        self.cells.insert(0, self.next_head())
        self.last_move = self.direction
        if not grow:
            self.cells.pop()

def on_grid(cell):
    x, y = cell
    return 0 <= x < COLS and 0 <= y < ROWS

class Game:
    def __init__(self, rng=None):
        self.rng = rng or random.Random()
        self.train = Train()
        self.score = 0
        self.over = False
        self.crate = self.place_crate()

    def free_cells(self):
        taken = set(self.train.cells)
        return [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in taken]

    def place_crate(self):
        free = self.free_cells()
        return self.rng.choice(free) if free else None

    def tick(self):
        if self.over:
            return
        nxt = self.train.next_head()
        grow = nxt == self.crate
        body = self.train.cells if grow else self.train.cells[:-1]
        if not on_grid(nxt) or nxt in body:
            self.over = True
            return
        self.train.move(grow)
        if grow:
            self.score += 1
            self.crate = self.place_crate()
            if self.crate is None:
                self.over = True  # the train fills the meadow: nothing left to collect

game = Game(random.Random(4))
game.crate = (12, 7)  # put a crate in the way, to watch one get collected
for _ in range(12):
    game.tick()
    print(game.train.head(), "score", game.score, "over" if game.over else "")

Three rules hide in tick. A crate is placed on a free cell, never under the train. A crash is checked before moving, so a stopped train is drawn where it hit. And the mule may step into the cell the tail is leaving, because the tail moves away on the same tick; that's why body drops the last cell, unless the train is growing and its tail stays put.

Drill

Free cells

Write free_cells(taken, cols, rows): every (x, y) cell on a cols by rows grid that isn't in taken, column by column (all of x = 0 first, from y = 0 upward). Turn taken into a set first, so each lookup is fast.

def free_cells(taken, cols, rows):
    return []


print(free_cells([(0, 0), (1, 1)], 2, 2))
In the lesson, your code is checked as you write it.

Checkpoint 4: the clock and the keys

Now the screen joins in, through two kinds of event handler: functions you hand to the screen for it to call later.

  • screen.ontimer(tick, 150) calls tick once, about 150 milliseconds from now. To keep going, tick asks for the next one itself, and stops asking when the game is over. That chain of ticks is the game loop.
  • screen.onkeypress(handler, "Up") calls handler whenever Up is pressed, and screen.listen() starts listening for keys.
  • screen.mainloop() waits for timers and keys until the window closes (here, until you press Stop).

Each key's handler only turns the train; it never draws or moves anything. Click the picture, then steer with the arrow keys or the buttons under it. (Browsers that can't pause Python show a recording of the train going straight instead.)

import json
import random
import turtle

CELL = 20
COLS, ROWS = 20, 15
DIRECTIONS = {"Up": (0, 1), "Down": (0, -1), "Left": (-1, 0), "Right": (1, 0)}

class Train:
    def __init__(self, start=(10, 7), length=3):
        x, y = start
        self.cells = [(x - i, y) for i in range(length)]
        self.direction = self.last_move = (1, 0)
    def turn(self, name):
        dx, dy = DIRECTIONS[name]
        if (dx, dy) != (-self.last_move[0], -self.last_move[1]):
            self.direction = (dx, dy)
    def next_head(self):
        return (self.cells[0][0] + self.direction[0], self.cells[0][1] + self.direction[1])
    def move(self, grow=False):
        self.cells.insert(0, self.next_head())
        self.last_move = self.direction
        if not grow:
            self.cells.pop()

class Game:
    def __init__(self):
        self.train, self.score, self.over = Train(), 0, False
        self.crate = self.place_crate()
    def place_crate(self):
        free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in self.train.cells]
        return random.choice(free) if free else None
    def tick(self):
        if self.over:
            return
        nxt = self.train.next_head()
        grow = nxt == self.crate
        body = self.train.cells if grow else self.train.cells[:-1]
        if not (0 <= nxt[0] < COLS and 0 <= nxt[1] < ROWS) or nxt in body:
            self.over = True
            return
        self.train.move(grow)
        if grow:
            self.score += 1
            self.crate = self.place_crate()

screen = turtle.Screen()
screen.setup(COLS * CELL, ROWS * CELL + 40)
screen.bgcolor("#1d2b22")
screen.title("Pack Train")
screen.tracer(0)
pen = turtle.Turtle("square")
pen.hideturtle()
pen.penup()
pen.shapesize(0.9)

def to_xy(cell):
    return ((cell[0] - COLS / 2 + 0.5) * CELL, (cell[1] - ROWS / 2 + 0.5) * CELL)

def draw(game):
    pen.clear()
    for i, cell in enumerate(game.train.cells):
        pen.goto(to_xy(cell))
        pen.color("gold" if i == 0 else "peru")
        pen.stamp()
    if game.crate:
        pen.goto(to_xy(game.crate))
        pen.color("#9fd39a")
        pen.stamp()
    pen.color("white")
    pen.goto(-COLS * CELL / 2, ROWS * CELL / 2 + 4)
    pen.write(f"Crates: {game.score}", font=("Courier", 12, "bold"))
    if game.over:
        pen.goto(0, 0)
        pen.write("The train stopped. Space to go again", align="center", font=("Courier", 13, "bold"))
    screen.update()

game = Game()

def tick():
    game.tick()
    draw(game)
    if not game.over:
        screen.ontimer(tick, max(60, 150 - 5 * game.score))

def steer(name):
    return lambda: game.train.turn(name)

def restart():
    global game
    if game.over:
        game = Game()
        tick()

for name in DIRECTIONS:
    screen.onkeypress(steer(name), name)
screen.onkeypress(restart, "space")
screen.listen()
tick()
screen.mainloop()

draw starts with pen.clear(), which wipes everything that turtle stamped and wrote, and then draws the whole scene again from the game's state. Redrawing everything every frame sounds wasteful, but it means the picture can never drift away from the truth.

Quick check
Why does the game use steer(name), a function that returns a handler, instead of screen.onkeypress(lambda: game.train.turn(name), name) inside the loop? What does this shortcut print?
handlers = []
for name in ["Up", "Down"]:
    handlers.append(lambda: name)
print([h() for h in handlers])
  1. ['Down', 'Down']
  2. NameError: name is gone after the loop
  3. ['Up', 'Up']
  4. ['Up', 'Down']
Show the answer

['Down', 'Down']

Each lambda looks up name when it's called, after the loop has finished, so it sees the final value: every arrow would steer the same way. steer(name) gives each handler its own name, fixed when steer was called.

Keeping the best score

A best score that vanishes when the program ends isn't much of a record, so save it in a file. JSON is a good fit: it's readable, and json.load gives back the same numbers you saved.

import json

def load_best(path="best.json"):
    try:
        with open(path) as f:
            return json.load(f)["best"]
    except (FileNotFoundError, KeyError, ValueError):
        return 0

def save_best(score, path="best.json"):
    with open(path, "w") as f:
        json.dump({"best": score}, f)

print("before:", load_best("trial-best.json"))
save_best(12, "trial-best.json")
print("after:", load_best("trial-best.json"))

The first game has no file yet, which isn't an error worth stopping for: load_best treats a missing or damaged file as a best of 0. In the game, tick checks once the train stops: if the score beats best, it updates best (which needs global best, since tick assigns to it) and saves it. In the academy, files live in your browser tab's own small file system, so the record lasts until you reload the page; on your computer it lasts for good.

Worked example

Worked example: testing the rules without playing

Because Train and Game never touch the screen, a test can set up any situation directly, a crate just ahead or a train about to hit the fence, and check what one tick does. Passing in a seeded random generator makes the crates repeatable.

import random

COLS, ROWS = 20, 15
DIRECTIONS = {"Up": (0, 1), "Down": (0, -1), "Left": (-1, 0), "Right": (1, 0)}

class Train:
    def __init__(self, start=(10, 7), length=3):
        x, y = start
        self.cells = [(x - i, y) for i in range(length)]
        self.direction = self.last_move = (1, 0)
    def turn(self, name):
        dx, dy = DIRECTIONS[name]
        if (dx, dy) != (-self.last_move[0], -self.last_move[1]):
            self.direction = (dx, dy)
    def next_head(self):
        return (self.cells[0][0] + self.direction[0], self.cells[0][1] + self.direction[1])
    def move(self, grow=False):
        self.cells.insert(0, self.next_head())
        self.last_move = self.direction
        if not grow:
            self.cells.pop()

class Game:
    def __init__(self, rng):
        self.rng, self.train, self.score, self.over = rng, Train(), 0, False
        self.crate = self.place_crate()
    def place_crate(self):
        free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in self.train.cells]
        return self.rng.choice(free) if free else None
    def tick(self):
        nxt = self.train.next_head()
        grow = nxt == self.crate
        body = self.train.cells if grow else self.train.cells[:-1]
        if not (0 <= nxt[0] < COLS and 0 <= nxt[1] < ROWS) or nxt in body:
            self.over = True
            return
        self.train.move(grow)
        if grow:
            self.score += 1
            self.crate = self.place_crate()

g = Game(random.Random(1))
g.crate = (11, 7)
g.tick()
assert g.score == 1 and len(g.train.cells) == 4, "a crate just ahead is collected"
assert g.crate not in g.train.cells, "the next crate is on a free cell"

g = Game(random.Random(1))
g.crate = (0, 0)
g.train.cells = [(10, 8), (10, 7), (9, 7), (9, 8)]
g.train.direction = g.train.last_move = (0, 1)
g.train.turn("Left")
g.tick()
assert not g.over, "stepping into the cell the tail leaves is allowed"

g = Game(random.Random(1))
g.crate = (0, 0)
for _ in range(10):
    g.tick()
assert g.over and g.train.cells[0] == (19, 7), "the tenth tick right hits the fence"
print("all rule tests pass")
In the lesson you can change this and run it.

Each test builds one situation that would be hard to reach by playing: a train coiled so its head meets its tail, or a crate exactly one step ahead. Setting g.crate and g.train.cells by hand is fine in a test; the point is to check the rule, not the luck of the crates. These are the edge cases where game bugs hide, and every one of them runs in a fraction of a second. The academy checks your exercise the same way, then drives your finished game with simulated key presses and a fast-forwarded clock.

Go further

Pack Train is a complete game, and a good base. Some ideas, in rough order of difficulty:

  • Levels: every five crates, add a rock to a random free cell that ends the game if you hit it.
  • A pause key. tick keeps going but skips game.tick() while a paused flag is set.
  • A paddle rally for two players: two paddles, W and S for one and the arrows for the other, and a ball with a velocity (dx, dy) that flips dy at the top and bottom walls and dx off a paddle. The ball moves in pixels, not cells, so collisions compare distances.
  • A lane crossing: a hiker crosses lanes of carts moving at different speeds; reaching the far side starts a faster level. Each lane is an object with its own list of carts.

Each one reuses the shape you've built: rules in classes, one draw, small handlers, and a timer that keeps it all moving.

Portfolio brief

Quarry Breaker, a brick game

Build an original brick-breaking game on the turtle canvas. Rows of rock blocks hang at the top of a quarry. A cart slides along the bottom, and you bounce a stone off it to break the blocks. Miss the stone and you lose a life; clear every block and the next level starts faster.

The starter has the cart and a stone that bounces off the walls, on a timer loop like Pack Train's. Keep the same split: a class for the game's state with a tick() that knows nothing about drawing, one draw function, and small key handlers.

It must

  • Rows of blocks that disappear when the stone hits them, with the stone bouncing back
  • The cart moves with the arrow keys and the stone bounces off it, changing angle depending on where it hits
  • Three lives and a score, shown on screen, and a clear message when the game is won or lost
  • The rules live in a class whose tick() doesn't draw, so they can be tested without the screen
  • A few tests (asserts are fine) of the rules: a block hit, a bounce off the cart, a life lost

Go further

  • Levels that start faster, or with blocks that take two hits
  • A saved best score, as in Pack Train
  • Space to pause and resume
import turtle

W, H = 480, 360
screen = turtle.Screen()
screen.setup(W, H)
screen.bgcolor("#26221f")
screen.tracer(0)

cart = turtle.Turtle("square")
cart.shapesize(0.6, 4)
cart.color("#d9b26f")
cart.penup()
cart.goto(0, -H / 2 + 30)

stone = turtle.Turtle("circle")
stone.shapesize(0.6)
stone.color("#e8e4dc")
stone.penup()
dx, dy = 4, 5


def left():
    cart.setx(max(-W / 2 + 40, cart.xcor() - 30))


def right():
    cart.setx(min(W / 2 - 40, cart.xcor() + 30))


def tick():
    global dx, dy
    stone.goto(stone.xcor() + dx, stone.ycor() + dy)
    if abs(stone.xcor()) > W / 2 - 10:
        dx = -dx
    if stone.ycor() > H / 2 - 10 or stone.ycor() < -H / 2 + 10:
        dy = -dy  # your code: the bottom should cost a life, and the cart should bounce it
    screen.update()
    screen.ontimer(tick, 20)


screen.onkeypress(left, "Left")
screen.onkeypress(right, "Right")
screen.listen()
tick()
screen.mainloop()
In the lesson, you build it here and the tutor can review it.
Portfolio brief

Storm Front, a fixed shooter

Build an original fixed-shooter game. A weather station sits on a ridge, moving left and right along the bottom of the screen. Rows of storm clouds drift sideways above it, stepping down a row each time they reach an edge, and now and then a cloud drops hail. The station fires bursts of sunshine upward to clear the clouds. Clear them all to win the round; let a cloud reach the ridge, or take three hailstones, and the storm wins.

As with Pack Train, the clouds, bursts and hail are state in classes, tick() moves everything and checks for hits, and drawing happens in one place.

It must

  • The station moves with the arrow keys and fires with space, with at most two bursts in the air at once
  • A grid of clouds moves sideways together and steps down when any cloud reaches an edge
  • Bursts clear the cloud they hit, and random clouds drop hail that costs the station a life
  • A score and lives on screen, and clear win and lose messages
  • Game state in classes with a tick() that doesn't draw, and a few tests of the rules

Go further

  • The clouds speed up as fewer are left
  • A new round with a faster storm after a win
  • Seed the random hail so a test can replay a whole round
import turtle

W, H = 480, 400
screen = turtle.Screen()
screen.setup(W, H)
screen.bgcolor("#18233a")
screen.tracer(0)

station = turtle.Turtle("triangle")
station.setheading(90)
station.color("#f2c94c")
station.penup()
station.goto(0, -H / 2 + 30)

burst = turtle.Turtle("circle")
burst.shapesize(0.4)
burst.color("#fff3b0")
burst.penup()
burst.hideturtle()
flying = False


def left():
    station.setx(max(-W / 2 + 20, station.xcor() - 20))


def right():
    station.setx(min(W / 2 - 20, station.xcor() + 20))


def fire():
    global flying
    if not flying:
        burst.goto(station.xcor(), station.ycor() + 15)
        burst.showturtle()
        flying = True


def tick():
    global flying
    if flying:
        burst.sety(burst.ycor() + 12)
        if burst.ycor() > H / 2:
            burst.hideturtle()
            flying = False
    # your code: the clouds, the hail, hits, lives and score
    screen.update()
    screen.ontimer(tick, 30)


screen.onkeypress(left, "Left")
screen.onkeypress(right, "Right")
screen.onkeypress(fire, "space")
screen.listen()
tick()
screen.mainloop()
In the lesson, you build it here and the tutor can review it.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does screen.ontimer(tick, 150) do?

  1. Calls tick once, about 150 milliseconds later; tick must ask again to keep the game going
  2. Calls tick every 150 milliseconds until the program ends
  3. Waits 150 milliseconds, blocking the key handlers
  4. Calls tick 150 times

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson