Learning Goals
3 minBy the end of this lesson you can:
- Store a list of frame image names and use a frame counter to index into it.
- Change
actor.imageeach time the counter reaches a threshold to play an animation loop. - Run a complete animated idle or walk cycle inside Pygame Zero's
update()hook.
Warm-Up · The Modulo Trick
5 minAnimation cycling relies on one maths trick: the modulo operator %. Predict what this loop prints:
frames = ["idle_0", "idle_1", "idle_2", "idle_3"] for tick in range(12): frame_idx = (tick // 3) % len(frames) print(tick, "->", frames[frame_idx])
Show the answer
Output
0 -> idle_0 1 -> idle_0 2 -> idle_0 3 -> idle_1 4 -> idle_1 5 -> idle_1 6 -> idle_2 7 -> idle_2 8 -> idle_2 9 -> idle_3 10 -> idle_3 11 -> idle_3
Each frame image shows for 3 ticks before advancing. After idle_3 the index wraps back to idle_0 via %.
A tick counter + % len(frames) = an animation loop that repeats forever with a controlled speed.
New Concept · Frame Counter Animation
12 minThink of animation as a film reel. The reel has several frames; a projector shows them one at a time. Your frame counter is the projector position — you advance it every few game updates, and modulo makes it loop back to frame 0.
The pattern
FRAMES = ["walk_0", "walk_1", "walk_2", "walk_3"] FRAME_SPEED = 6 # game ticks per frame image hero = Actor(FRAMES[0]) hero.pos = (300, 200) tick = 0 def update(): global tick tick += 1 hero.image = FRAMES[(tick // FRAME_SPEED) % len(FRAMES)]
Naming convention for frame files
Save frames as images/walk_0.png, images/walk_1.png, etc. Pygame Zero expects all images in the images/ folder next to the script. Actor images are referenced without the extension.
Free art
Kenney.nl has free sprite sheets (top-down characters, side-scrollers). Slice them into individual PNG files named walk_0, walk_1… Any image editor works; even MS Paint can crop and save.
Why it matters
Without animation sprites look frozen. A four-frame walk cycle turns a static actor into a character the player cares about — and it costs almost no code.
Worked Example · Animated Character
12 minThe story
Iman wants a character that walks across the screen playing a four-frame walk cycle. When the character reaches the right edge it reappears on the left. Save as walk_cycle.py.
No walk frames yet? Replace the Actor with a coloured circle that changes shade — see the "stand-in" version below.
With real images
# walk_cycle.py — four-frame walk cycle import pgzrun WIDTH = 600 HEIGHT = 300 TITLE = "Walk Cycle" WALK_FRAMES = ["walk_0", "walk_1", "walk_2", "walk_3"] FRAME_SPEED = 6 hero = Actor(WALK_FRAMES[0]) hero.pos = (80, 150) tick = 0 def draw(): screen.fill("skyblue") screen.draw.filled_rect(Rect((0, 210), (WIDTH, 90)), "forestgreen") hero.draw() screen.draw.text(f"Frame: {WALK_FRAMES[(tick // FRAME_SPEED) % 4]}", topleft=(8, 8), fontsize=24, color="black") def update(): global tick tick += 1 hero.image = WALK_FRAMES[(tick // FRAME_SPEED) % len(WALK_FRAMES)] hero.x += 2 if hero.x > WIDTH + 40: hero.x = -40 pgzrun.go()
Stand-in version (no image files needed)
# walk_cycle_standin.py — coloured circle stand-in import pgzrun WIDTH = 600 HEIGHT = 300 TITLE = "Walk Cycle (stand-in)" COLOURS = ["deepskyblue", "royalblue", "navy", "royalblue"] FRAME_SPEED = 6 tick = 0 cx = 80 cy = 150 def draw(): screen.fill("skyblue") screen.draw.filled_rect(Rect((0, 210), (WIDTH, 90)), "forestgreen") colour = COLOURS[(tick // FRAME_SPEED) % len(COLOURS)] screen.draw.filled_circle((int(cx), cy), 24, colour) screen.draw.text(f"Tick: {tick}", topleft=(8, 8), fontsize=24, color="black") def update(): global tick, cx tick += 1 cx += 2 if cx > WIDTH + 30: cx = -30 pgzrun.go()
What you'll see
Try It Yourself
13 minChange the example so the character stands still (remove hero.x += 2) but still plays through its frames. Add a second list IDLE_FRAMES with different image names (or colours) and use it when the player is not pressing any key.
IDLE_FRAMES = ["idle_0", "idle_1", "idle_2"] WALK_FRAMES = ["walk_0", "walk_1", "walk_2", "walk_3"] def update(): global tick tick += 1 moving = keyboard.right or keyboard.left frames = WALK_FRAMES if moving else IDLE_FRAMES hero.image = frames[(tick // FRAME_SPEED) % len(frames)]
Allow the player to press F to toggle between slow animation (FRAME_SPEED = 12) and fast (FRAME_SPEED = 3). Display the current speed on screen.
frame_speed = 6 def on_key_down(key): global frame_speed if key == keys.F: frame_speed = 3 if frame_speed == 12 else 12 def update(): global tick tick += 1 hero.image = WALK_FRAMES[(tick // frame_speed) % len(WALK_FRAMES)]
Mini-Challenge · Debug the Frozen Sprite
8 minNurul's character is supposed to animate but the sprite never changes — it is stuck on the first frame. Find the two bugs.
# nurul_anim.py — buggy
import pgzrun
WIDTH = 500
HEIGHT = 300
FRAMES = ["run_0", "run_1", "run_2", "run_3"]
FRAME_SPEED = 6
hero = Actor(FRAMES[0])
hero.pos = (250, 150)
tick = 0
def draw():
screen.fill("white")
hero.draw()
def update():
tick += 1 # BUG 1
idx = (tick // FRAME_SPEED) % 4
hero.image = FRAMES[idx] # BUG 2: hero never moves
pgzrun.go()It works if…
the sprite image changes every 6 ticks and the character moves across the screen
Show the fix
def update(): global tick # fix 1: declare tick as global before assigning tick += 1 idx = (tick // FRAME_SPEED) % len(FRAMES) hero.image = FRAMES[idx] hero.x += 2 # fix 2: actually move the hero each frame if hero.x > WIDTH + 40: hero.x = -40
Bug 1: assigning tick without global tick raises UnboundLocalError. Bug 2: the sprite was never moved, so it looked frozen even if animation had worked.
Recap
3 minFrame cycling animation needs three things: a list of image names, a tick counter that increments each update() call, and (tick // FRAME_SPEED) % len(frames) to turn the counter into a looping index. Set actor.image to the current frame name and the sprite comes alive.
Vocabulary Card
- frame list
- A list of image name strings representing each pose in the animation cycle, e.g.
["walk_0", "walk_1", "walk_2"]. - tick counter
- An integer that increases by 1 every
update()call. Dividing it byFRAME_SPEEDslows down the animation. - FRAME_SPEED
- How many game ticks each frame image is shown for. Higher = slower animation. Typical range: 4–12.
- actor.image
- The name of the image file (without extension) the actor currently displays. Assigning a new name instantly swaps the sprite.
Homework
4 minCreate a "blinking eye" animation without any image files. Draw a filled circle as the eye, and every 60 ticks make it squish flat for 8 ticks (draw a very flat ellipse or tiny rect) before going round again. Use a frame counter and a list of states like ["open", "blink"] with different draw calls for each state. Save as blink_eye.py and bring a screenshot.
Sample · blink_eye.py
# blink_eye.py — blinking eye animation import pgzrun WIDTH = 400 HEIGHT = 300 TITLE = "Blinking Eye" tick = 0 BLINK_START = 60 # open for 60 ticks BLINK_LEN = 8 # closed for 8 ticks CYCLE = BLINK_START + BLINK_LEN def draw(): screen.fill("lightyellow") phase = tick % CYCLE cx, cy = WIDTH // 2, HEIGHT // 2 # white of the eye screen.draw.filled_circle((cx, cy), 50, "white") screen.draw.circle((cx, cy), 50, "black") if phase < BLINK_START: # open: draw iris and pupil screen.draw.filled_circle((cx, cy), 28, "saddlebrown") screen.draw.filled_circle((cx, cy), 14, "black") screen.draw.filled_circle((cx - 8, cy - 8), 5, "white") else: # blink: draw closed eyelid as a thin rect screen.draw.filled_rect( Rect((cx - 50, cy - 5), (100, 10)), "peachpuff") screen.draw.line((cx - 50, cy), (cx + 50, cy), "black") def update(): global tick tick += 1 pgzrun.go()
The key insight: tick % CYCLE resets to 0 every 68 ticks. When the remainder is < 60 draw the open eye; otherwise draw the closed lid. Your style can differ.