Skip to content

Showcase

Making games

Learn to make 2D games in Python with Pyzed, from your first game loop to complete platformers, shooters and puzzles: a step-by-step learning path with animated examples, tips and three finished games.

Pyzed is a UI library, but everything a 2D game needs is already in it: a GPU-drawn pz.canvas, a loop that runs once per displayed frame, the keys held down right now, and the whole widget library to build menus and HUDs on top of the action. Every game on these pages is a complete, playable program in examples/games/, tested by the test suite on every change.

A path from zero to complete games. Each step is a tutorial with a goal, the time it takes, what you need to know first, the full code and ideas to keep going. If you have never written a game, start at 1; if you have, jump to what you need.

flowchart LR
    A["1 · Loop and keys"] --> B["2 · Sprites and animation"] --> C["3 · Collisions and physics"]
    C --> D["4 · Platformer"]
    C --> E["5 · Asteroids"]
    B --> F["6 · Puzzle: 2048"]
    D --> G["7 · Menus, HUD, settings"]
    E --> G
    F --> G
    G --> H["8 · Saves and sound"]

Three bigger games to read, play and take apart. They grow in difficulty:

Run any game from the repository folder; change the file name for the others:

Windows, macOS and Linux
uv run --with pyzed-ui python examples/games/neon_invaders.py
What API Used for
Frame loop pz.frames(callback) in subscriptions() callback(dt) once per frame (in sync with the screen); dt is the seconds since the last one.
Fixed-rate timer pz.every(seconds, callback) Turn-based or grid games that step at a set pace (Snake).
Key presses on_key(combo) One-off actions: jump, pause, restart ("space", "enter", "ctrl-r").
Key releases on_key_up(key) Charge-and-release mechanics, stopping a sound.
Held keys pz.pressed(), pz.is_pressed("left", "a") Continuous movement and auto-fire, polled every frame.
Movement game.axis(neg, pos), game.vector() -1/0/1 from two key groups; a normalized direction from arrows/WASD.
Drawing pz.canvas([...]) with pyzed.draw Rectangles, circles, paths, text, transforms.
Sprites draw.image(src, x, y, w, h) and game.sprite(rows, palette) Image files (decoded once and cached) or pixel art written as text.
Collisions game.Rect, game.circle_rect Box overlaps; where a ball hits a box, to bounce it.
Effects game.glow, game.Particles, game.Shake Neon lights, explosions and sparks, screen shake.
HUD and menus pz.stack([canvas, widgets]) Scores, health bars and pause screens with glass, bloom and animations.

A game is a regular pz.App. Return pz.frames(self.tick) from subscriptions() and the runtime calls tick(dt) before every frame. Move things by speed × dt so they go just as fast at 60, 120 or 144 Hz:

import pyzed as pz
from pyzed import draw, game
class Ship(pz.App):
size = (480, 320)
def __init__(self):
self.x, self.y = 240, 160
def subscriptions(self):
return [pz.frames(self.tick)]
def tick(self, dt):
dx, dy = game.vector() # arrows / WASD, diagonals normalized
self.x += dx * 300 * dt
self.y += dy * 300 * dt
def view(self):
return pz.canvas([draw.background("#0b0e14"),
*game.glow(self.x, self.y, 14, "#22d3ee")], width=480, height=320)
pz.run(Ship)

dt is capped at a quarter of a second, so dragging the window or hitting a breakpoint never makes the game jump ahead. Where the display does not wait for vsync (software rendering), max_fps (240 by default) keeps the loop from spinning the CPU.

on_key is for actions that happen once per press; pz.pressed() is for things that happen while a key is down. Both see every key, whatever widget has focus.

def on_key(self, combo): # "space", "p", "shift-r"...
if combo == "p":
self.paused = not self.paused
def tick(self, dt):
if pz.is_pressed("space") and self.cooldown <= 0:
self.fire() # auto-fire while Space is held

Mouse input comes from the canvas itself: on_move(x, y), on_press(x, y), on_release(), on_right_press(x, y) and on_scroll(dx, dy), in canvas coordinates.

draw.image draws a PNG/JPEG (or raw RGBA bytes) into a box, optionally rotated and faded. It is decoded once per source and cached, so drawing the same sprite hundreds of times per frame is cheap. Use pixelated=True for pixel art scaled up.

draw.image("assets/ship.png", self.x - 16, self.y - 16, 32, 32, rotation=self.angle)

For retro games you don’t even need files: game.sprite turns text into pixel art, with one shape per color however big the sprite is.

INVADER = ["..#.....#..",
"...#...#...",
"..#######..",
".##.###.##.",
"###########"]
game.sprite(INVADER, {"#": "#a3e635"}, x, y, scale=4)

Stack widgets over the canvas with pz.stack. Scores, health bars, badges and pause menus are ordinary Pyzed components, so they get themes, glass panels, bloom, and entrance animations for free — the hard part of game UI is already done.

hud = pz.container(pz.row([score, pz.hspace(), ui.badge(f"Wave {self.wave}")]),
padding=(8, 14)).style(glass=True, radius=12)
return pz.stack([self.scene(), hud], width=W, height=H)

More in Game dev tips.

  • Keep the per-frame work in Python small: plain lists and numbers, no allocations of big structures every frame.
  • Batch shapes: draw.rects draws many rectangles of one color as a single shape (that is what game.sprite uses).
  • Cap particles (game.Particles(limit=...)) and drop bullets that leave the screen.
  • Only the canvas changes every frame; widgets in the HUD are cheap to rebuild, but pz.memo can cache expensive ones.
  • Test game logic without a window: call tick(1 / 60) in a loop and assert on the state, as tests/test_game.py does. App.screenshot renders any moment of the game to a PNG.