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.
Create a free account Try part of it here
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
- The brief
- Checkpoint 1: a grid on the screen
- Checkpoint 2: the train
- Checkpoint 3: the rules
- Free cells
- Checkpoint 4: the clock and the keys
- Keeping the best score
- Worked example: testing the rules without playing
- Go further
- Quarry Breaker, a brick game
- 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.
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, andupdate()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.
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)))
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)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.
Train above, which way does it move on that tick?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.
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))
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)callstickonce, about 150 milliseconds from now. To keep going,tickasks 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")callshandlerwhenever Up is pressed, andscreen.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.
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])
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: 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")
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.
tickkeeps going but skipsgame.tick()while apausedflag 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 flipsdyat the top and bottom walls anddxoff 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.
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()
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()
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
Try a quiz question
What does screen.ontimer(tick, 150) do?