Learning Goals
3 minBy the end of this lesson you can:
- Organise frame lists into a dictionary keyed by direction string (
"right","left","up","down"). - Update a
directionvariable based on which movement key is held and select the matching frame list. - Combine direction-based frame selection with the tick-counter cycle from PZ-24 into a smooth four-way animated character.
Warm-Up · Dictionary Lookup Review
5 minLast lesson you used a list for frames. Today you use a dictionary of lists. Predict what this prints:
anim = { "right": ["run_r0", "run_r1", "run_r2"], "left": ["run_l0", "run_l1", "run_l2"], } direction = "left" tick = 7 FRAME_SPEED = 3 frames = anim[direction] print(frames[(tick // FRAME_SPEED) % len(frames)])
Show the answer
Output
run_l1
tick // FRAME_SPEED = 2. 2 % 3 = 2. anim["left"][2] = "run_l2". Wait — actually index 2 is "run_l2". Let's recheck: 7 // 3 = 2, 2 % 3 = 2, anim["left"][2] = "run_l2".
Corrected output
run_l2
A dictionary maps direction → frame list. Change the key and the whole animation switches instantly — no if/elif chain needed in update().
New Concept · Direction Dictionary
12 minThink of your character as an actor who has a different costume for each direction it faces. The wardrobe is a dictionary: give it the direction string, get back the right costume (frame list).
Setting up the animation dictionary
ANIM = { "right": ["hero_r0", "hero_r1", "hero_r2", "hero_r3"], "left": ["hero_l0", "hero_l1", "hero_l2", "hero_l3"], "up": ["hero_u0", "hero_u1", "hero_u2", "hero_u3"], "down": ["hero_d0", "hero_d1", "hero_d2", "hero_d3"], } IDLE = { "right": ["hero_idle_r"], "left": ["hero_idle_l"], "up": ["hero_idle_u"], "down": ["hero_idle_d"], }
Single-item lists work fine for idle poses — the "cycle" just shows the same image forever.
Updating direction from input
direction = "right" # starting direction moving = False def update(): global direction, moving moving = False if keyboard.right: hero.x += 4 direction = "right" moving = True if keyboard.left: hero.x -= 4 direction = "left" moving = True if keyboard.up: hero.y -= 4 direction = "up" moving = True if keyboard.down: hero.y += 4 direction = "down" moving = True
Picking the frame
frames = ANIM[direction] if moving else IDLE[direction] hero.image = frames[(tick // FRAME_SPEED) % len(frames)]
Worked Example · Four-Way Animated Hero
12 minThe story
Priya is building a top-down adventure. Her hero should face the direction of movement and freeze in that direction when no key is pressed. Save as four_way.py. The stand-in version draws a coloured circle with a direction indicator so you can test without images.
Stand-in version (no images required)
# four_way.py — direction-based animation (stand-in) import pgzrun WIDTH = 500 HEIGHT = 400 TITLE = "Four-Way Hero" SPEED = 4 FRAME_SPEED = 6 cx, cy = 250, 200 direction = "down" moving = False tick = 0 ARROW = {"right": (1, 0), "left": (-1, 0), "up": (0, -1), "down": (0, 1)} COLOURS = ["deepskyblue", "royalblue", "navy", "royalblue"]
def draw(): screen.fill("beige") screen.draw.filled_circle((int(cx), int(cy)), 22, COLOURS[tick // FRAME_SPEED % 4]) dx, dy = ARROW[direction] ax = int(cx + dx * 28) ay = int(cy + dy * 28) screen.draw.line((int(cx), int(cy)), (ax, ay), "white") screen.draw.filled_circle((ax, ay), 5, "white") screen.draw.text(f"dir: {direction} moving: {moving}", topleft=(8, 8), fontsize=22, color="black")
def update(): global cx, cy, direction, moving, tick tick += 1 moving = False if keyboard.right and cx < WIDTH - 25: cx += SPEED direction = "right" moving = True if keyboard.left and cx > 25: cx -= SPEED direction = "left" moving = True if keyboard.up and cy > 25: cy -= SPEED direction = "up" moving = True if keyboard.down and cy < HEIGHT - 25: cy += SPEED direction = "down" moving = True pgzrun.go()
What you'll see
Try It Yourself
13 minWhen the player is not moving, show the circle in white instead of cycling through COLOURS. Change the draw call to use "white" when moving is False.
def draw(): # ... colour = COLOURS[tick // FRAME_SPEED % 4] if moving else "white" screen.draw.filled_circle((int(cx), int(cy)), 22, colour)
Download a free top-down character sprite sheet from kenney.nl. Slice 4 frames for each direction (16 files total), name them hero_r0 to hero_r3, hero_l0 … etc., and place them in an images/ folder. Replace the circle draw with an Actor that uses the ANIM dictionary from the concept section.
hero = Actor(ANIM["down"][0]) hero.pos = (250, 200) def draw(): screen.fill("beige") hero.draw() def update(): # ... direction logic ... frames = ANIM[direction] if moving else IDLE[direction] hero.image = frames[(tick // FRAME_SPEED) % len(frames)]
Mini-Challenge · Debug the Backwards Walker
8 minFaridah's character walks the right way but faces the wrong direction — pressing Left shows the right-facing frames, and vice versa. She also gets a KeyError when pressing Up. Fix both bugs.
# faridah_dir.py — buggy
import pgzrun
WIDTH = 500
HEIGHT = 400
ANIM = {
"right": ["hero_r0", "hero_r1", "hero_r2"],
"left": ["hero_l0", "hero_l1", "hero_l2"],
"up": ["hero_u0", "hero_u1", "hero_u2"],
}
hero = Actor(ANIM["right"][0])
hero.pos = (250, 200)
direction = "right"
tick = 0
FRAME_SPEED = 6
def draw():
screen.fill("lightgrey")
hero.draw()
def update():
global tick, direction
tick += 1
if keyboard.right:
hero.x += 4
direction = "left" # BUG 1: should be "right"
if keyboard.left:
hero.x -= 4
direction = "right" # BUG 1: should be "left"
if keyboard.up:
hero.y -= 4
direction = "up"
if keyboard.down: # BUG 2: no "down" key in ANIM!
hero.y += 4
direction = "down"
frames = ANIM[direction]
hero.image = frames[(tick // FRAME_SPEED) % len(frames)]
pgzrun.go()It works if…
pressing Right shows right-facing frames; pressing Down does not crash; all four directions animate correctly
Show the fix
# Fix 1: swap the direction strings if keyboard.right: hero.x += 4 direction = "right" # corrected if keyboard.left: hero.x -= 4 direction = "left" # corrected # Fix 2: add "down" frames to the dictionary ANIM = { "right": ["hero_r0", "hero_r1", "hero_r2"], "left": ["hero_l0", "hero_l1", "hero_l2"], "up": ["hero_u0", "hero_u1", "hero_u2"], "down": ["hero_d0", "hero_d1", "hero_d2"], # added }
Bug 1 is a simple string swap — the character was moving correctly but looking the wrong way. Bug 2 is a missing dictionary key: ANIM["down"] raised KeyError because "down" was never added.
Recap
3 minDirection-based animation stores frame lists in a dictionary keyed by direction string. In update(), check which key is pressed, update the direction variable, then pick ANIM[direction] as the active frame list. Combine with the tick-counter cycle from PZ-24 and your sprite looks exactly where it moves — no flipping maths, no extra conditions.
Vocabulary Card
- direction variable
- A string (
"right","left","up","down") that remembers the last key pressed and persists between frames. - animation dictionary
- A dict mapping direction strings to lists of frame image names. Indexing it with
directionreturns the correct walk cycle instantly. - idle frames
- Separate (often single-image) frame lists used when no movement key is held. Keeps the sprite pointing the right way while standing still.
- KeyError
- Raised when you try to look up a key that does not exist in a dictionary. Ensure every direction the player can travel has a matching entry.
Homework
4 minExtend the four-way hero from today's worked example into a small scene. Add a "goal" object (a coloured circle at a fixed position). When the hero reaches the goal, display "Berjaya!" ("Success!" in Malay) and teleport the goal to a new random position. The hero must animate correctly the whole time. Save as four_way_goal.py and bring a screenshot showing the success message.
Sample · four_way_goal.py
# four_way_goal.py — direction animation + goal import pgzrun import random WIDTH = 500 HEIGHT = 400 TITLE = "Four-Way Goal" SPEED = 4 FRAME_SPEED = 6 cx, cy = 250, 200 direction = "down" moving = False tick = 0 score = 0 flash_timer = 0 ARROW = {"right": (1, 0), "left": (-1, 0), "up": (0, -1), "down": (0, 1)} COLOURS = ["deepskyblue", "royalblue", "navy", "royalblue"] gx = random.randint(40, WIDTH - 40) gy = random.randint(40, HEIGHT - 40) def draw(): screen.fill("beige") screen.draw.filled_circle((gx, gy), 18, "gold") colour = COLOURS[tick // FRAME_SPEED % 4] if moving else "white" screen.draw.filled_circle((int(cx), int(cy)), 22, colour) dx, dy = ARROW[direction] screen.draw.line((int(cx), int(cy)), (int(cx + dx * 28), int(cy + dy * 28)), "black") screen.draw.text(f"Score: {score}", topleft=(8, 8), fontsize=26, color="black") if flash_timer > 0: screen.draw.text("Berjaya!", center=(WIDTH // 2, HEIGHT // 2), fontsize=52, color="green") def update(): global cx, cy, direction, moving, tick, score, gx, gy, flash_timer tick += 1 moving = False if keyboard.right and cx < WIDTH - 25: cx += SPEED direction = "right" moving = True if keyboard.left and cx > 25: cx -= SPEED direction = "left" moving = True if keyboard.up and cy > 25: cy -= SPEED direction = "up" moving = True if keyboard.down and cy < HEIGHT - 25: cy += SPEED direction = "down" moving = True dist = ((cx - gx) ** 2 + (cy - gy) ** 2) ** 0.5 if dist < 40: score += 1 flash_timer = 60 gx = random.randint(40, WIDTH - 40) gy = random.randint(40, HEIGHT - 40) if flash_timer > 0: flash_timer -= 1 pgzrun.go()
The flash timer counts down from 60 (one second at 60 fps) to show the "Berjaya!" message before hiding it automatically. Your colours and layout can differ.