Skip to content

A game loop

Apps wait for the user; games do not. A game updates the world many times per second, whether or not you press a key. This tutorial builds Dodge: blocks fall, you slide left and right, you collect stars and the game speeds up. About 130 lines, and every piece is reusable.

Goal Time Level You need
A playable arcade game 40 minutes Advanced · Python A multi-view app
  • Run code every frame with pz.frames(callback) and move things by speed × dt.
  • Read the keys held down (game.axis) instead of single key presses.
  • Detect collisions with game.Rect.
  • Draw on a pz.canvas and add glow, particles and screen shake.
  • Stack a HUD and a Game over card, made of normal widgets, over the canvas.
Dodge: red blocks falling toward a cyan paddle, score badges on top
Dodge, a few seconds in.
  1. The loop. Return pz.frames(self.tick) from subscriptions() and Pyzed calls tick(dt) before every frame (60, 120 or 144 times a second, in sync with the screen). dt is the time since the last frame, in seconds. When the game is over, return nothing and the loop stops, so a paused game uses no CPU:

    def subscriptions(self):
    return [] if self.over else [pz.frames(self.tick)]
  2. Move by speed × dt. Speeds are in pixels per second. Multiplying by dt makes the game run at the same speed on any screen:

    self.x += game.axis(("left", "a"), ("right", "d")) * 380 * dt # -1, 0 or 1 × speed × time
    self.x = game.clamp(self.x, 0, W - PLAYER_W)

    game.axis reads the keys held right now: hold the arrow and the paddle keeps moving.

  3. Spawn things on a timer. A countdown in seconds, reset when it reaches zero. It shrinks over time, so the game gets harder:

    self.spawn -= dt
    if self.spawn <= 0:
    self.spawn = max(0.25, 0.8 - self.time * 0.01)
    size = self.random.uniform(24, 60)
    self.blocks.append(game.Rect(self.random.uniform(0, W - size), -size, size, size))
  4. Collisions. game.Rect is a box with overlaps, contains and center:

    player = game.Rect(self.x, PLAYER_Y, PLAYER_W, PLAYER_H)
    if any(block.overlaps(player) for block in self.blocks):
    self.over = True
    self.shake.kick(10) # screen shake
    self.sparks.burst(*player.center, count=40, color=["#f87171", "#fca5a5"])
  5. Draw the world. A list of shapes from pyzed.draw on a pz.canvas. game.glow adds soft light, sparks.shapes() draws the particles and shake.shapes() moves everything a little after a hit:

    shapes = [draw.background("#0b0e14"), *self.shake.shapes()]
    shapes += [block.shape(fill="#ef4444", radius=6) for block in self.blocks]
    for x, y in self.stars:
    shapes += game.glow(x, y, 6, "#fbbf24", layers=3)
    shapes.append(draw.rect(self.x, PLAYER_Y, PLAYER_W, PLAYER_H, radius=7, fill="#22d3ee"))
    shapes += self.sparks.shapes(glow=True)
    return pz.canvas(shapes, width=W, height=H)
  6. HUD and Game over. These are not drawn on the canvas: they are widgets. pz.stack layers the Game over card (a glass panel with a button) over the canvas, and the score badges sit above it:

    play = pz.stack([self.board(), self.overlay()], width=W, height=H)
  7. Restart. on_key gets single key presses; Enter resets the state and, since over is false again, subscriptions() returns the loop and the game starts over.

examples/tutorial/dodge.py
"""Dodge: a tiny real-time game built with the game loop of ``pyzed.game``.
python examples/tutorial/dodge.py
Move with the arrows or A/D, dodge the falling blocks, collect the stars. Enter starts again.
- ``pz.frames(self.tick)`` in ``subscriptions()`` calls ``tick(dt)`` once per displayed frame.
- ``game.axis("left", "right")`` reads the keys held down: -1, 0 or 1.
- ``game.Rect.overlaps`` checks collisions; ``game.glow``, ``game.Particles`` and ``game.Shake``
add light, sparks and screen shake.
- The score and the "Game over" card are ordinary widgets stacked over the canvas.
Walkthrough: https://albakore.github.io/pyzed/tutorials/advanced/game-loop/
"""
import random
import pyzed as pz
from pyzed import draw, game, ui
W, H = 480, 560
PLAYER_W, PLAYER_H, PLAYER_Y = 56, 14, H - 48
class Dodge(pz.App):
title = "Dodge"
size = (W + 32, H + 88)
theme = "One Dark"
def __init__(self, seed=None):
self.random = random.Random(seed)
self.sparks = game.Particles(gravity=300, seed=seed)
self.shake = game.Shake(seed=seed)
self.best = 0
self.reset()
def reset(self):
self.x = W / 2 - PLAYER_W / 2
self.blocks = [] # game.Rect objects falling down
self.stars = [] # (x, y) of the stars to collect
self.spawn = 0.0 # seconds until the next block
self.time = 0.0 # seconds survived
self.bonus = 0 # points from stars
self.over = False
@property
def score(self):
return int(self.time * 10) + self.bonus
# -- the loop -------------------------------------------------------------------------
def subscriptions(self):
return [] if self.over else [pz.frames(self.tick)]
def tick(self, dt):
dt = min(dt, 0.05) # never jump too far after a pause
self.time += dt
speed = 220 + self.time * 8 # blocks fall faster over time
# 1. input: move the player by speed x dt, so it goes as fast at 60 Hz as at 144 Hz
self.x = game.clamp(self.x + game.axis(("left", "a"), ("right", "d")) * 380 * dt, 0, W - PLAYER_W)
player = game.Rect(self.x, PLAYER_Y, PLAYER_W, PLAYER_H)
# 2. spawn new things now and then
self.spawn -= dt
if self.spawn <= 0:
self.spawn = max(0.25, 0.8 - self.time * 0.01)
size = self.random.uniform(24, 60)
self.blocks.append(game.Rect(self.random.uniform(0, W - size), -size, size, size))
if self.random.random() < 0.3:
self.stars.append((self.random.uniform(16, W - 16), -16.0))
# 3. move everything and check collisions
for block in self.blocks:
block.y += speed * dt
self.blocks = [b for b in self.blocks if b.y < H]
self.stars = [(x, y + speed * 0.8 * dt) for x, y in self.stars if y < H]
for x, y in list(self.stars):
if player.overlaps(game.Rect(x - 12, y - 12, 24, 24)): # a star is a 24 px box
self.stars.remove((x, y))
self.bonus += 50
self.sparks.burst(x, y, count=18, color=["#fde68a", "#fbbf24"])
if any(block.overlaps(player) for block in self.blocks):
self.over = True
self.best = max(self.best, self.score)
self.shake.kick(10)
self.sparks.burst(*player.center, count=40, color=["#f87171", "#fca5a5"])
# 4. effects
self.sparks.update(dt)
self.shake.update(dt)
def on_key(self, combo):
if combo == "enter" and self.over:
self.reset()
# -- drawing --------------------------------------------------------------------------
def board(self):
shapes = [draw.background("#0b0e14"), *self.shake.shapes()]
for block in self.blocks:
shapes.append(block.shape(fill="#ef4444", radius=6))
for x, y in self.stars:
shapes += game.glow(x, y, 6, "#fbbf24", layers=3)
shapes += game.glow(self.x + PLAYER_W / 2, PLAYER_Y + PLAYER_H / 2, 4, "#22d3ee", layers=4, core=False)
shapes.append(draw.rect(self.x, PLAYER_Y, PLAYER_W, PLAYER_H, radius=7, fill="#22d3ee"))
shapes += self.sparks.shapes(glow=True)
return pz.canvas(shapes, width=W, height=H)
def overlay(self):
if not self.over:
return None
card = pz.container(pz.column([
ui.heading("Game over", level=2),
ui.muted(f"Score {self.score} · Best {self.best}"),
ui.button("Play again", self.reset, variant="solid", icon="rotate-ccw"),
], spacing=10, align="center"), padding=24).style(glass=True, radius=14)
return pz.container(card, width="fill", height="fill", align_x="center", align_y="center")
def view(self):
t = self.resolved_theme()
hud = pz.row([ui.label("Dodge", weight="semibold"), pz.hspace(),
ui.badge(f"Score {self.score}", icon="star"),
ui.badge(f"Best {self.best}", tint="warning", icon="trophy")], spacing=8, align="center")
play = pz.stack([self.board(), self.overlay()], width=W, height=H)
return pz.container(
pz.column([hud, pz.container(play, radius=10, clip=True),
ui.muted("Arrows / A-D move · Enter restarts", size="sm")], spacing=10),
padding=16, width="fill", height="fill", bg=t.background,
)
if __name__ == "__main__":
pz.run(Dodge)
Windows, macOS and Linux
uv run --with pyzed-ui python examples/tutorial/dodge.py
Source Event Handler
subscriptions() every displayed frame pz.frames(self.tick): tick(dt) gets the seconds since the last frame.
The keyboard keys held game.axis(("left", "a"), ("right", "d")) reads them inside tick.
The app on_key Enter starts again after a crash.

A game reads the keys that are held in every frame instead of reacting to each press; on_key is for one-off actions.

def on_key(self, combo):
if combo == "enter" and self.over:
self.reset()
elif combo == "p": # pause: stop returning the frame subscription
self.paused = not self.paused
def subscriptions(self):
return [] if self.over or self.paused else [pz.frames(self.tick)]
def board(self):
...
return pz.canvas(shapes, width=W, height=H,
on_press=lambda x, y: self.reset() if self.over else None) # click to restart