Learning Goals
3 minBy the end of this lesson you can:
- Move a brush actor with arrow keys and record each position in a list of tuples.
- Draw every saved dot inside
draw()to create a persistent trail on screen. - Clear the trail by pressing a key, using
on_key_downand a list clear.
Warm-Up · Lists Remember Things
5 minIn PZ-15 you dragged shapes with mouse hooks. Today's big idea is simpler: every time the brush moves, we append its position to a list. Later we loop through the list and draw every point. Predict the output:
trail = [] for step in range(4): pos = (step * 10, 50) trail.append(pos) print(trail)
Show the answer
Output
[(0, 50), (10, 50), (20, 50), (30, 50)]
Four positions stored as tuples. In the real game step * 10 is replaced by the actual brush x/y — but the idea is identical.
New Concept · Storing a Trail in a List
12 minThink of the trail like a stamp trail in wet sand. Every step you take leaves a mark. The list is the sand — it holds every mark permanently until you smooth it out (clear it).
The two-part trick
Part 1 — record: in update(), after moving the brush, append its current position.
trail = [] def update(): if keyboard.right: brush.x += 3 trail.append((brush.x, brush.y)) # record every frame
Part 2 — replay: in draw(), loop through the list and draw each dot.
def draw(): screen.fill("black") for pos in trail: screen.draw.filled_circle(pos, 4, "cyan")
Because draw() runs every frame and the list only grows, the dots accumulate — giving the illusion of a continuous line.
Clearing the trail
trail.clear() empties the list instantly. Call it inside on_key_down when the player presses C.
def on_key_down(key): if key == keys.C: trail.clear()
Why it matters
This record-then-replay pattern powers many game effects: bullet paths, rocket smoke, snake bodies, and particle sparks all use a list of past positions.
Worked Example · Sky Painter
12 minThe story
Nurul wants to draw pictures in the sky using arrow keys. Save this as sky_painter.py:
# sky_painter.py — part 1: setup and draw import pgzrun WIDTH = 600 HEIGHT = 400 TITLE = "Sky Painter" SPEED = 3 DOT_RADIUS = 5 DOT_COLOUR = "cyan" brush_x = 300 brush_y = 200 trail = [] def draw(): screen.fill("black") for pos in trail: screen.draw.filled_circle(pos, DOT_RADIUS, DOT_COLOUR) screen.draw.filled_circle((brush_x, brush_y), DOT_RADIUS + 3, "white") screen.draw.text("C = clear", topleft=(8, 8), fontsize=20, color="grey")
# sky_painter.py — part 2: update and key handler def update(): global brush_x, brush_y if keyboard.right: brush_x += SPEED if keyboard.left: brush_x -= SPEED if keyboard.up: brush_y -= SPEED if keyboard.down: brush_y += SPEED brush_x = max(0, min(WIDTH, brush_x)) brush_y = max(0, min(HEIGHT, brush_y)) trail.append((brush_x, brush_y)) def on_key_down(key): if key == keys.C: trail.clear() pgzrun.go()
What you'll see
Because we append every frame (even when stationary), the list gets large quickly. In the Try-It tasks you will learn to limit this — only append when the brush actually moves.
Try It Yourself
13 minChange DOT_COLOUR to "hotpink" and DOT_RADIUS to 8. Then only append to the trail when at least one arrow key is held — so dots do not pile up while the brush sits still.
Hint
DOT_COLOUR = "hotpink" DOT_RADIUS = 8 def update(): global brush_x, brush_y moved = False if keyboard.right: brush_x += SPEED moved = True # ... other directions ... if moved: trail.append((brush_x, brush_y))
Add a MIN_DIST = 8 constant. Only append a new dot when the brush has moved at least MIN_DIST pixels from the last recorded dot. This creates evenly-spaced dots regardless of speed.
Hint
MIN_DIST = 8 def update(): global brush_x, brush_y # ... move brush ... if len(trail) == 0: trail.append((brush_x, brush_y)) else: lx, ly = trail[-1] dx = brush_x - lx dy = brush_y - ly if dx * dx + dy * dy >= MIN_DIST * MIN_DIST: trail.append((brush_x, brush_y))
Mini-Challenge 🔥 · Fading Trail
8 minCombine the trail list with the list slicing you learnt in Level 2. Limit the trail to the last 80 dots so it fades away as the brush moves — old dots vanish, new ones appear. As a bonus mechanic, pressing keys.SPACE should toggle the trail on and off (painting = True/False).
It works if…
the trail never shows more than 80 dots and disappears naturally at the tail as the brush moves
Show one possible solution
# sky_painter_fade.py — fading trail + paint toggle import pgzrun WIDTH = 600 HEIGHT = 400 SPEED = 3 MAX_TRAIL = 80 brush_x = 300 brush_y = 200 trail = [] painting = True def draw(): screen.fill("black") total = len(trail) for i, pos in enumerate(trail): alpha = int(255 * (i + 1) / total) if total > 0 else 255 r = max(0, alpha - 180) g = alpha b = alpha screen.draw.filled_circle(pos, 4, (r, g, b)) screen.draw.filled_circle((brush_x, brush_y), 7, "white") state = "ON" if painting else "OFF" screen.draw.text(f"SPACE=paint {state} C=clear", topleft=(8, 8), fontsize=18, color="grey") def update(): global brush_x, brush_y if keyboard.right: brush_x += SPEED if keyboard.left: brush_x -= SPEED if keyboard.up: brush_y -= SPEED if keyboard.down: brush_y += SPEED brush_x = max(0, min(WIDTH, brush_x)) brush_y = max(0, min(HEIGHT, brush_y)) if painting: trail.append((brush_x, brush_y)) if len(trail) > MAX_TRAIL: trail.pop(0) # remove oldest dot def on_key_down(key): global painting if key == keys.C: trail.clear() elif key == keys.SPACE: painting = not painting pgzrun.go()
The brightness of each dot is scaled by its index position — older dots are dimmer. trail.pop(0) removes the oldest entry to keep the list capped at MAX_TRAIL.
Recap
3 minSky Painter stores each brush position as a tuple inside a list. draw() replays all stored positions every frame — making the trail visible. trail.clear() wipes the canvas. Limiting the list length with pop(0) or slicing creates a natural fade effect.
Vocabulary Card
- trail list
- A list of
(x, y)tuples that records every brush position so it can be redrawn each frame. - list.append(item)
- Adds a new item to the end of the list — used every frame to extend the trail.
- list.pop(0)
- Removes the oldest (first) item from the list — keeps the trail capped at a maximum length.
- on_key_down(key)
- Pygame Zero hook called once when a key is pressed. Use
keys.C,keys.SPACEetc. to check which key.
Homework
4 minExtend Sky Painter with a colour cycle. Press the number keys 1, 2, 3 to switch the trail colour between three colours of your choice (e.g. cyan, hotpink, lime). The colour should change only for new dots — old ones keep their original colour. Save as sky_painter_colours.py and bring a screenshot next class.
Hint: store each dot as a tuple (x, y, colour) and use the third element in draw().
Sample · sky_painter_colours.py
# sky_painter_colours.py — colour-cycling trail import pgzrun WIDTH = 600 HEIGHT = 400 SPEED = 3 COLOURS = ["cyan", "hotpink", "lime"] brush_x = 300 brush_y = 200 trail = [] # each entry is (x, y, colour) current_colour = COLOURS[0] def draw(): screen.fill("black") for x, y, col in trail: screen.draw.filled_circle((x, y), 5, col) screen.draw.filled_circle((brush_x, brush_y), 8, "white") screen.draw.text(f"Colour: {current_colour} 1/2/3=change C=clear", topleft=(8, 8), fontsize=18, color="grey") def update(): global brush_x, brush_y moved = False if keyboard.right: brush_x += SPEED moved = True if keyboard.left: brush_x -= SPEED moved = True if keyboard.up: brush_y -= SPEED moved = True if keyboard.down: brush_y += SPEED moved = True if moved: trail.append((brush_x, brush_y, current_colour)) def on_key_down(key): global current_colour if key == keys.K_1: current_colour = COLOURS[0] elif key == keys.K_2: current_colour = COLOURS[1] elif key == keys.K_3: current_colour = COLOURS[2] elif key == keys.C: trail.clear() pgzrun.go()
Storing the colour with each dot means old marks keep their colour when you switch. Your three colours and key choices may differ.