Skip to content

2. Sprites and animation

Things in a game rarely just slide: characters walk, blink and land, coins bob and pop. Two techniques do almost all of it. Frame animation shows a series of pictures one after another, like a flip book. Tweening moves a number (a position, a size, an opacity) from one value to another over time, shaped by an easing curve. This step builds Walker, a pixel-art hero that uses both.

A small pixel-art hero walks right and left over grass, jumps to grab glowing coins that pop with a rising +1, while a strip of frames in the corner highlights the one on screen
Walker: the strip at the top left is the animation's sprite sheet; the highlighted frame is the one being drawn.
Goal Time Level You need
An animated character with walk, idle and jump states 35 minutes Beginner 1. Your first game
  • Draw pixel art from text with game.sprite, and mirror it with flip=True.
  • Keep a sprite sheet (a list of frames) and play it at a few frames per second.
  • Switch animations with states (idle, walk, jump).
  • Write a tiny Tween class and use easing curves for the jump, a landing squash, a “+1” and a title.
Windows, macOS and Linux
uv run --with pyzed-ui python examples/games/walker.py
  1. A sprite is text. Each string is a row of pixels; each character picks a color from a palette, and any character that is not in the palette (like .) is transparent. game.sprite turns it into a handful of shapes, one per color, however big the sprite is:

    PALETTE = {"h": "#7c4a2d", "s": "#f5c99b", "S": "#e9b385", "e": "#1b1f27", "b": "#3b82f6",
    "B": "#60a5fa", "p": "#334155", "k": "#111827"}
    HEAD = [
    "...hhhh...",
    "..hhhhhh..",
    "..hhssss..",
    "..hsssess.",
    "..hsssssS.",
    "...ssss...",
    ]
    BODY = ["..bbBBbb..", ".sbbBBbbs.", ".sbbbbbbs.", "..bbbbbb.."]
    game.sprite(HEAD + BODY + LEGS["stand"], PALETTE, x, y, scale=4) # every pixel becomes 4x4
  2. A sprite sheet is a list of frames. The walk cycle reuses the head and body and changes the legs; the idle animation is the same frame six times and a blink once, so the hero blinks every couple of seconds:

    LEGS = {
    "stand": ["..pp..pp..", "..pp..pp..", "..kk..kk.."],
    "apart": ["..pp..pp..", ".pp....pp.", ".kk.....kk"],
    "pass": ["...pp.pp..", "...pp.pp..", "...kkkkk.."],
    "air": ["..pp..pp..", "..ppp.ppp.", "....k...k."],
    }
    IDLE = [HEAD + BODY + LEGS["stand"]] * 6 + [BLINK + BODY + LEGS["stand"]]
    WALK = [HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"],
    HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"]]
    JUMP = [HEAD + BODY + LEGS["air"]]
  3. Play the frames. An animation only needs to count time; the frame to show is time × frames per second, wrapped around with % when it loops:

    class FrameAnimation:
    def __init__(self, frames, fps, loop=True):
    self.frames, self.fps, self.loop = frames, fps, loop
    self.time = 0.0
    def update(self, dt):
    self.time += dt
    @property
    def index(self):
    i = int(self.time * self.fps)
    return i % len(self.frames) if self.loop else min(i, len(self.frames) - 1)
    @property
    def frame(self):
    return self.frames[self.index]
  4. Pick the animation from the state. Every frame the hero is idle, walking or jumping. When the state changes, the new animation starts from its first frame, and the facing direction mirrors the sprite:

    self.animations = {"idle": FrameAnimation(IDLE, 3), "walk": FrameAnimation(WALK, 10),
    "jump": FrameAnimation(JUMP, 1)}
    def set_state(self, state):
    if state != self.state:
    self.state = state
    self.animations[state].time = 0.0
    # in tick(dt):
    move = game.axis(("left", "a"), ("right", "d"), held=self.held)
    if move:
    self.facing = move
    self.set_state("jump" if self.jump else "walk" if move else "idle")
    self.animation.update(dt)
    # in board():
    game.sprite(self.animation.frame, PALETTE, x, y, scale=PIXEL, flip=self.facing < 0)
  5. Tweens: from A to B over time. A tween remembers when it started and computes its value from the progress (0 to 1), passed through an easing function. Easing is what makes motion feel natural: things rarely start and stop at full speed.

    def ease_out_cubic(t):
    return 1 - (1 - t) ** 3
    def ease_out_back(t): # goes a little past the end, then settles
    c = 1.70158
    return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2
    class Tween:
    def __init__(self, start, end, duration, ease=ease_out_cubic, delay=0.0):
    self.start, self.end, self.duration, self.ease = start, end, duration, ease
    self.time = -delay
    def update(self, dt):
    self.time += dt
    @property
    def progress(self):
    return game.clamp(self.time / self.duration, 0.0, 1.0)
    @property
    def done(self):
    return self.time >= self.duration
    @property
    def value(self):
    return game.lerp(self.start, self.end, self.ease(self.progress))
    Four glowing dots race from left to right in the same second: linear moves evenly, ease_out_cubic starts fast and slows down, ease_in_out_sine speeds up then slows, ease_out_back overshoots and comes back
    The same tween with four easing curves. The fading dots behind each one show its speed.
  6. A jump is a tween too. The jump’s progress goes from 0 to 1 in JUMP_TIME seconds; the height is a parabola, 4t(1 − t), which is 0 at both ends and 1 in the middle:

    def start_jump(self):
    if not self.jump:
    self.jump = Tween(0.0, 1.0, JUMP_TIME, linear)
    @property
    def height(self):
    if not self.jump:
    return 0.0
    t = self.jump.value
    return 4 * t * (1 - t) * JUMP_HEIGHT

    In step 3 you will replace it with real gravity, which can land on platforms; a tween is perfect when the motion is always the same.

  7. Squash and stretch. When the jump ends, a second tween goes from 1 to 0 in a quarter of a second. While it runs, the sprite is drawn wider and shorter around its feet, with a scale transform:

    s = self.squash.value if self.squash else 0.0
    sx, sy = 1 + 0.25 * s, 1 - 0.2 * s
    feet_x, feet_y = self.x + SPRITE_W / 2, GROUND - self.height
    shapes += [draw.save(), draw.translate(feet_x, feet_y), draw.scale(sx, sy),
    *game.sprite(self.animation.frame, PALETTE, -SPRITE_W / 2, -SPRITE_H, scale=PIXEL,
    flip=self.facing < 0),
    draw.restore()]

    Moving the origin to the feet first (translate) makes the sprite squash onto the ground instead of toward its top-left corner.

  8. Little tweens everywhere. The “+1” over a coin rises 40 pixels and fades out in 0.7 s; the title slides in with ease_out_back 0.2 s after the game starts; the coins bob with a sine wave:

    self.popups.append([cx, cy, Tween(0.0, 1.0, 0.7, ease_out_cubic)])
    ...
    for x, y, tween in self.popups:
    shapes.append(draw.text(x, y - 40 * tween.value, "+1", size=18, weight="bold",
    color=game.rgba("#fde68a", 1 - tween.progress), align_x="center"))
  • Add a run animation (6 frames at 14 fps) used when Shift is held, with a higher WALK_SPEED.
  • Make the coins spin by squashing their width with abs(math.cos(time)), as Coin Chase does.
  • Give the title an ease_in_out_sine bob after it lands.
examples/games/walker.py
"""Walker: sprites, frame animation and tweening.
python examples/games/walker.py
Arrows or A/D walk, Space jumps, F shows or hides the sprite sheet. A pixel-art hero
walks, blinks, jumps and squashes when it lands; coins bob and pop with a "+1" that
eases up and fades. Shows:
- **Sprites from text** with ``game.sprite`` (one string per row, one character per pixel),
flipped with ``flip=True`` when the hero faces left.
- **Frame animation**: a list of frames (a sprite sheet) played at a few frames per second
by ``FrameAnimation``, which only counts time.
- **Tweening**: a value going from A to B over a duration with an easing curve (``Tween``),
for the jump, the landing squash, the "+1" and the title that slides in.
Tutorial: https://albakore.github.io/pyzed/games/sprites-animation/
"""
import math
import pyzed as pz
from pyzed import draw, game, ui
W, H = 640, 300
GROUND = 236
PIXEL = 4
WALK_SPEED = 170
JUMP_HEIGHT = 70
JUMP_TIME = 0.55
PALETTE = {"h": "#7c4a2d", "s": "#f5c99b", "S": "#e9b385", "e": "#1b1f27", "b": "#3b82f6", "B": "#60a5fa",
"p": "#334155", "k": "#111827"} # "." (or any character not here) is transparent
# -- the sprite sheet: every frame is a list of rows, every character a pixel ---------------
HEAD = [
"...hhhh...",
"..hhhhhh..",
"..hhssss..",
"..hsssess.",
"..hsssssS.",
"...ssss...",
]
BLINK = HEAD[:3] + ["..hsssshs."] + HEAD[4:] # the eye closed for a frame
BODY = [
"..bbBBbb..",
".sbbBBbbs.",
".sbbbbbbs.",
"..bbbbbb..",
]
LEGS = {
"stand": ["..pp..pp..", "..pp..pp..", "..kk..kk.."],
"apart": ["..pp..pp..", ".pp....pp.", ".kk.....kk"],
"pass": ["...pp.pp..", "...pp.pp..", "...kkkkk.."],
"air": ["..pp..pp..", "..ppp.ppp.", "....k...k."],
}
IDLE = [HEAD + BODY + LEGS["stand"]] * 6 + [BLINK + BODY + LEGS["stand"]] # blink once in a while
WALK = [HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"],
HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"]]
JUMP = [HEAD + BODY + LEGS["air"]]
SPRITE_W = len(HEAD[0]) * PIXEL
SPRITE_H = len(IDLE[0]) * PIXEL
# -- easing: how a value moves from 0 to 1 ----------------------------------------------------
def linear(t):
return t
def ease_out_cubic(t):
return 1 - (1 - t) ** 3
def ease_out_back(t):
c = 1.70158
return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2
def ease_in_out_sine(t):
return -(math.cos(math.pi * t) - 1) / 2
class Tween:
"""A number going from ``start`` to ``end`` in ``duration`` seconds, shaped by ``ease``."""
def __init__(self, start, end, duration, ease=ease_out_cubic, delay=0.0):
self.start, self.end, self.duration, self.ease = start, end, duration, ease
self.time = -delay
def update(self, dt):
self.time += dt
@property
def progress(self):
return game.clamp(self.time / self.duration, 0.0, 1.0)
@property
def done(self):
return self.time >= self.duration
@property
def value(self):
return game.lerp(self.start, self.end, self.ease(self.progress))
class FrameAnimation:
"""Plays a list of frames at ``fps`` frames per second, looping or not."""
def __init__(self, frames, fps, loop=True):
self.frames, self.fps, self.loop = frames, fps, loop
self.time = 0.0
def update(self, dt):
self.time += dt
@property
def index(self):
i = int(self.time * self.fps)
return i % len(self.frames) if self.loop else min(i, len(self.frames) - 1)
@property
def frame(self):
return self.frames[self.index]
class Walker(pz.App):
title = "Walker"
size = (W + 32, H + 92)
theme = "One Dark"
def __init__(self, seed=None):
self.held = None # the keys held down; None reads the keyboard
self.show_sheet = True
self.x = 120.0
self.facing = 1 # 1 right, -1 left
self.clock = 0.0
self.animations = {"idle": FrameAnimation(IDLE, 3), "walk": FrameAnimation(WALK, 10),
"jump": FrameAnimation(JUMP, 1)}
self.state = "idle"
self.jump = None # a Tween from 0 to 1 while in the air
self.squash = None # a Tween from 1 back to 0 after landing
self.coins = [{"x": 260 + i * 70, "high": i % 2 == 1, "taken": None} for i in range(5)]
self.popups = [] # [x, y, Tween] "+1" labels
self.title_in = Tween(-80, 18, 0.8, ease_out_back, delay=0.2)
self.score = 0
@property
def animation(self):
return self.animations[self.state]
def set_state(self, state):
if state != self.state:
self.state = state
self.animations[state].time = 0.0 # start the new animation from its first frame
# -- update -------------------------------------------------------------------------------
def subscriptions(self):
return [pz.frames(self.tick)]
def tick(self, dt):
self.clock += dt
self.title_in.update(dt)
move = game.axis(("left", "a"), ("right", "d"), held=self.held)
if move:
self.facing = move
self.x = game.clamp(self.x + move * WALK_SPEED * dt, 10, W - SPRITE_W - 10)
if self.held is not None and "space" in self.held:
self.start_jump()
if self.jump:
self.jump.update(dt)
if self.jump.done:
self.jump = None
self.squash = Tween(1.0, 0.0, 0.25, ease_out_cubic) # land: squash, then recover
if self.squash:
self.squash.update(dt)
if self.squash.done:
self.squash = None
self.set_state("jump" if self.jump else "walk" if move else "idle")
self.animation.update(dt)
self.collect()
for popup in self.popups:
popup[2].update(dt)
self.popups = [p for p in self.popups if not p[2].done]
def start_jump(self):
if not self.jump:
self.jump = Tween(0.0, 1.0, JUMP_TIME, linear)
@property
def height(self):
"""How high the hero is: a parabola, 4t(1-t), peaks at JUMP_HEIGHT halfway through."""
if not self.jump:
return 0.0
t = self.jump.value
return 4 * t * (1 - t) * JUMP_HEIGHT
def collect(self):
hero = game.Rect(self.x + 8, GROUND - SPRITE_H - self.height, SPRITE_W - 16, SPRITE_H)
for coin in self.coins:
if coin["taken"] is not None:
if self.clock - coin["taken"] > 3: # coins come back after three seconds
coin["taken"] = None
continue
cx, cy = coin["x"], self.coin_y(coin)
if hero.overlaps(game.Rect(cx - 9, cy - 9, 18, 18)):
coin["taken"] = self.clock
self.score += 1
self.popups.append([cx, cy, Tween(0.0, 1.0, 0.7, ease_out_cubic)])
def coin_y(self, coin):
base = GROUND - (110 if coin["high"] else 40)
return base + math.sin(self.clock * 3 + coin["x"] * 0.05) * 5 # bob with a sine wave
def on_key(self, combo):
if combo in ("space", "up", "w"):
self.start_jump()
elif combo == "f":
self.show_sheet = not self.show_sheet
# -- draw ---------------------------------------------------------------------------------
def board(self):
shapes = [draw.background("#101521")]
for i, (color, y) in enumerate((("#1a2236", 170), ("#202a40", 196))): # far hills
shapes += [draw.circle(80 + j * 190 + i * 90, y + 70, 110, fill=color) for j in range(4)]
shapes.append(draw.rect(0, GROUND, W, H - GROUND, fill="#1f2a1d"))
shapes.append(draw.rect(0, GROUND, W, 6, fill="#4d7c3a"))
for coin in self.coins:
if coin["taken"] is None:
cx, cy = coin["x"], self.coin_y(coin)
shapes += game.glow(cx, cy, 8, "#fbbf24", layers=3, strength=0.45)
shapes.append(draw.circle(cx, cy, 4, fill="#fde68a"))
# shadow: smaller and fainter the higher the hero is
k = 1 - self.height / JUMP_HEIGHT * 0.5
shapes.append(draw.rect(self.x + SPRITE_W / 2 - 18 * k, GROUND - 3, 36 * k, 6, radius=3,
fill=game.rgba("#000000", 0.35 * k)))
# squash and stretch: wider and shorter right after landing, around the feet
s = self.squash.value if self.squash else 0.0
sx, sy = 1 + 0.25 * s, 1 - 0.2 * s
feet_x, feet_y = self.x + SPRITE_W / 2, GROUND - self.height
shapes += [draw.save(), draw.translate(feet_x, feet_y), draw.scale(sx, sy),
*game.sprite(self.animation.frame, PALETTE, -SPRITE_W / 2, -SPRITE_H, scale=PIXEL,
flip=self.facing < 0),
draw.restore()]
for x, y, tween in self.popups:
shapes.append(draw.text(x, y - 40 * tween.value, "+1", size=18, weight="bold",
color=game.rgba("#fde68a", 1 - tween.progress), align_x="center"))
shapes.append(draw.text(W / 2, self.title_in.value, "WALKER", size=26, weight="bold", color="#e2e8f0",
align_x="center"))
if self.show_sheet:
shapes += self.sheet()
return pz.canvas(shapes, width=W, height=H)
def sheet(self):
"""The current animation's frames, small, with the one on screen highlighted."""
frames = self.animation.frames
unique = []
for f in frames:
if not unique or unique[-1] is not f:
unique.append(f)
scale, gap = 2, 6
fw, fh = len(HEAD[0]) * scale, len(IDLE[0]) * scale
x0, y0 = 12, 12
shapes = [draw.rect(x0 - 6, y0 - 6, max(len(frames) * (fw + gap) + 6, 160), fh + 30, radius=8,
fill="#00000066")]
for i, frame in enumerate(frames):
x = x0 + i * (fw + gap)
current = i == self.animation.index
shapes.append(draw.rect(x - 2, y0 - 2, fw + 4, fh + 4, radius=3,
stroke="#fbbf24" if current else "#ffffff22", width=1.5 if current else 1))
shapes += game.sprite(frame, PALETTE, x, y0, scale=scale)
shapes.append(draw.text(x0, y0 + fh + 8, f"{self.state} · frame {self.animation.index + 1}/{len(frames)}",
size=11, color="#cbd5e1", font="mono"))
return shapes
def view(self):
t = self.resolved_theme()
hud = pz.row([ui.label("Walker", weight="semibold"), pz.hspace(),
ui.badge(self.state, icon="film", tint="accent"),
ui.badge(f"{self.score} coins", icon="coins", tint="warning")], spacing=8, align="center")
return pz.container(pz.column([hud, pz.container(self.board(), radius=10, clip=True),
ui.muted("←/→ or A/D walk · Space jumps · F shows the sprite sheet",
size="sm")], spacing=10),
padding=16, width="fill", height="fill", bg=t.background)
if __name__ == "__main__":
pz.run(Walker)