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.
Learn to make games
Section titled “Learn to make games”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"]
Complete games
Section titled “Complete games”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:
uv run --with pyzed-ui python examples/games/neon_invaders.pypython3 -m venv .venvsource .venv/bin/activatepython -m pip install pyzed-uipython examples/games/neon_invaders.pypy -m venv .venv.venv\Scripts\Activate.ps1python -m pip install pyzed-uipython examples\games\neon_invaders.pypython3 examples/games/neon_invaders.pypy examples\games\neon_invaders.pyThe building blocks
Section titled “The building blocks”| 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. |
The loop
Section titled “The loop”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 pzfrom 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 heldMouse 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.
Sprites
Section titled “Sprites”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)HUD and menus are just widgets
Section titled “HUD and menus are just widgets”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)Performance tips
Section titled “Performance tips”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.rectsdraws many rectangles of one color as a single shape (that is whatgame.spriteuses). - 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.memocan cache expensive ones. - Test game logic without a window: call
tick(1 / 60)in a loop and assert on the state, astests/test_game.pydoes.App.screenshotrenders any moment of the game to a PNG.







