3. Collisions and physics
You don’t need a physics engine to make a platformer. A few lines give you velocity (how fast something moves), gravity (a constant pull down), and collisions with boxes. This step builds Jump Lab, a playground where a box runs and jumps between platforms while balls bounce around, with a debug view that shows the hitboxes, the velocity and the arc of every jump.

| Goal | Time | Level | You need |
|---|---|---|---|
| A box that runs, jumps and lands on platforms, and balls that bounce | 40 minutes | Intermediate | 2. Sprites and animation |
What you’ll learn
Section titled “What you’ll learn”- Velocity and gravity: position changes by velocity × dt, velocity changes by gravity × dt.
- AABB collisions (axis-aligned bounding boxes) with
game.Rect.overlaps, resolved one axis at a time. - Circles against boxes with
game.circle_rect, which tells you which side was hit. - Game feel: acceleration, variable jump height, coyote time and jump buffering.
- A debug view you can switch on and off.
uv run --with pyzed-ui python examples/games/jump_lab.pypython3 -m venv .venvsource .venv/bin/activatepython -m pip install pyzed-uipython examples/games/jump_lab.pypy -m venv .venv.venv\Scripts\Activate.ps1python -m pip install pyzed-uipython examples\games\jump_lab.pypython3 examples/games/jump_lab.pypy examples\games\jump_lab.py
-
Numbers that feel right. All the physics is a handful of constants, in pixels and seconds. Changing them is how you tune the feel:
RUN = 250 # top running speed, px/sACCEL = 2200 # px/s² on the ground (how fast you reach RUN)AIR_ACCEL = 1300 # less control in the airGRAVITY = 1900 # px/s², added to vy every secondJUMP_SPEED = 660 # vy right after a jump (upwards, so negative)LOW_JUMP = 2.6 # gravity multiplier while rising without the jump key: short hopsMAX_FALL = 900COYOTE = 0.10 # seconds after leaving a ledge when a jump still worksBUFFER = 0.12 # seconds a jump press is remembered before landingWith these, the highest jump is
JUMP_SPEED² / (2 × GRAVITY)≈ 115 pixels. The test suite checks that. -
Velocity and gravity.
vxandvyare speeds in pixels per second (ygrows downwards, so up is negative). Instead of settingvxto the target speed at once, it moves toward it by at mostACCEL × dtper frame, which gives a short, smooth start and stop:move = game.axis(("left", "a"), ("right", "d"), held=self.held)jump_held = game.is_pressed(*JUMP_KEYS, held=self.held)target = move * RUNaccel = (ACCEL if self.on_ground else AIR_ACCEL) * dtself.vx += game.clamp(target - self.vx, -accel, accel)g = GRAVITY * (LOW_JUMP if self.vy < 0 and not jump_held else 1)self.vy = min(self.vy + g * dt, MAX_FALL)The second line of gravity is variable jump height: while rising, letting go of the key makes gravity stronger, so a tap is a hop and a long press is a full jump.
-
Move one axis at a time. Move horizontally and push out of anything you now overlap; then do the same vertically. Resolving each axis on its own is what lets the box slide along a wall while falling, and land exactly on a floor:
def move_x(self, dx):self.player.x += dxfor i, p in self.solids_hit(self.player):self.player.x = p.left - self.player.w if dx > 0 else p.rightself.vx = 0.0def move_y(self, dy):self.player.y += dyfor i, p in self.solids_hit(self.player):if dy > 0: # falling: land on topself.player.y = p.top - self.player.hself.on_ground = Trueelse: # rising: bump the headself.player.y = p.bottomself.vy = 0.0# in tick:self.move_x(self.vx * dt)self.on_ground = Falseself.move_y(self.vy * dt)on_groundis only true when the vertical move hit something below, so it is always up to date. -
Coyote time and jump buffering. Two tiny timers make jumps forgiving. Coyote time lets you still jump for 0.1 s after running off a ledge (like the cartoon coyote who only falls when he looks down). The jump buffer remembers a press made just before landing, so it is not lost:
def press_jump(self): # called by on_key for Space, Up and Wself.buffer = BUFFER# in tick:self.coyote = COYOTE if self.on_ground else max(0.0, self.coyote - dt)self.buffer = max(0.0, self.buffer - dt)if self.buffer > 0 and self.coyote > 0:self.vy = -JUMP_SPEEDself.buffer = self.coyote = 0.0Players never notice these, but they notice when they’re missing: the game feels like it “eats” jumps.
-
Balls bounce off boxes.
game.circle_rect(cx, cy, r, rect)returnsNone, or the direction from the box to the circle along the side it hit:(0, -1)from above,(1, 0)from the right. Flip the speed to point that way, push the ball out, and lose a little energy:normal = game.circle_rect(ball.x, ball.y, ball.r, p)if normal:nx, ny = normalif nx:ball.vx = abs(ball.vx) * nxball.x = (p.right + ball.r) if nx > 0 else (p.left - ball.r)if ny:ball.vy = abs(ball.vy) * ny * 0.82ball.y = (p.bottom + ball.r) if ny > 0 else (p.top - ball.r) -
A debug view. Drawing what the code “sees” saves hours. With H the game outlines the hitboxes, draws the velocity as a line, keeps the last 60 positions as fading dots (the jump arc) and prints the numbers:
self.trail.append(self.player.center)del self.trail[:-60]...if self.debug:shapes.append(p.shape(stroke="#34d399", width=1.5))shapes.append(draw.line(cx, cy, cx + self.vx * 0.15, cy + self.vy * 0.15, stroke="#f472b6", width=2.5))
Try this
Section titled “Try this”- Add a moving platform: a
Rectwhosexfollows a sine wave; when the player stands on it, add its movement to the player’sx. - Add a double jump: allow one extra jump in the air, reset when landing.
- Tune
GRAVITYandJUMP_SPEEDto make the jump floatier or snappier, and watch the trail change.
Full code
Section titled “Full code”"""Jump Lab: velocity, gravity, box collisions and a jump that feels good.
python examples/games/jump_lab.py
Arrows or A/D run, Space (or Up/W) jumps, hold it to jump higher, H hides the debug view.A box runs and jumps between platforms while three balls bounce around. The debug viewdraws the hitboxes, the velocity and the trail of the last second, so you can see the jumparc. Shows:
- **Velocity and gravity**: ``vx``/``vy`` in pixels per second; gravity adds to ``vy`` every frame, and the position moves by ``velocity x dt``.- **AABB collisions** (axis-aligned bounding boxes, ``game.Rect.overlaps``), resolved one axis at a time so the box slides along walls and lands on floors.- **Circle against box** with ``game.circle_rect``, which says which side was hit, to bounce.- **Game feel**: acceleration, coyote time, jump buffering and variable jump height.
Tutorial: https://albakore.github.io/pyzed/games/collisions-physics/"""
import random
import pyzed as pzfrom pyzed import draw, game, ui
W, H = 720, 400RUN = 250 # top running speed, px/sACCEL = 2200 # px/s² on the ground (how fast you reach RUN)AIR_ACCEL = 1300 # less control in the airGRAVITY = 1900 # px/s², added to vy every secondJUMP_SPEED = 660 # vy right after a jump (upwards, so negative)LOW_JUMP = 2.6 # gravity multiplier while rising without the jump key: short hopsMAX_FALL = 900COYOTE = 0.10 # seconds after leaving a ledge when a jump still worksBUFFER = 0.12 # seconds a jump press is remembered before landingJUMP_KEYS = ("space", "up", "w")
PLATFORMS = [ game.Rect(0, H - 36, W, 36), # the floor game.Rect(120, 280, 140, 16), game.Rect(330, 214, 130, 16), game.Rect(520, 150, 150, 16), game.Rect(560, H - 116, 40, 80), # a wall to bump into game.Rect(0, 0, 12, H), game.Rect(W - 12, 0, 12, H), # side walls]
class Ball: def __init__(self, x, y, vx, vy, r, color): self.x, self.y, self.vx, self.vy, self.r, self.color = x, y, vx, vy, r, color
class JumpLab(pz.App): title = "Jump Lab" size = (W + 32, H + 92) theme = "One Dark"
def __init__(self, seed=None): rnd = random.Random(seed) self.held = None # the keys held down; None reads the keyboard self.debug = True self.player = game.Rect(60, H - 36 - 34, 26, 34) self.vx = self.vy = 0.0 self.on_ground = False self.coyote = 0.0 # time left to jump after walking off a ledge self.buffer = 0.0 # time left of a remembered jump press self.trail = [] # the player's center, one point per frame self.hits = {} # platform index -> seconds to keep it highlighted self.balls = [Ball(rnd.uniform(100, 600), rnd.uniform(40, 120), rnd.choice((-1, 1)) * rnd.uniform(120, 220), 0.0, r, color) for r, color in ((10, "#f472b6"), (14, "#a78bfa"), (8, "#34d399"))]
# -- input ------------------------------------------------------------------------------ def on_key(self, combo): if combo in JUMP_KEYS: self.press_jump() elif combo == "h": self.debug = not self.debug
def press_jump(self): self.buffer = BUFFER # remember the press for a moment (jump buffering)
# -- update ----------------------------------------------------------------------------- def subscriptions(self): return [pz.frames(self.tick)]
def tick(self, dt): dt = min(dt, 1 / 30) # big steps would let things pass through walls move = game.axis(("left", "a"), ("right", "d"), held=self.held) jump_held = game.is_pressed(*JUMP_KEYS, held=self.held)
# Horizontal: accelerate toward the target speed instead of jumping to it. target = move * RUN accel = (ACCEL if self.on_ground else AIR_ACCEL) * dt self.vx += game.clamp(target - self.vx, -accel, accel)
# Vertical: gravity, stronger while rising without the key held (variable jump height). g = GRAVITY * (LOW_JUMP if self.vy < 0 and not jump_held else 1) self.vy = min(self.vy + g * dt, MAX_FALL)
# Coyote time and jump buffer: both are small timers counting down. self.coyote = COYOTE if self.on_ground else max(0.0, self.coyote - dt) self.buffer = max(0.0, self.buffer - dt) if self.buffer > 0 and self.coyote > 0: self.vy = -JUMP_SPEED self.buffer = self.coyote = 0.0
# Move one axis at a time and push out of whatever we hit. self.move_x(self.vx * dt) self.on_ground = False self.move_y(self.vy * dt)
self.trail.append(self.player.center) del self.trail[:-60] self.hits = {i: t - dt for i, t in self.hits.items() if t - dt > 0} for ball in self.balls: self.bounce(ball, dt)
def solids_hit(self, rect): return [(i, p) for i, p in enumerate(PLATFORMS) if rect.overlaps(p)]
def move_x(self, dx): self.player.x += dx for i, p in self.solids_hit(self.player): self.player.x = p.left - self.player.w if dx > 0 else p.right self.vx = 0.0 self.hits[i] = 0.2
def move_y(self, dy): self.player.y += dy for i, p in self.solids_hit(self.player): if dy > 0: # falling: land on top self.player.y = p.top - self.player.h self.on_ground = True else: # rising: bump the head self.player.y = p.bottom self.vy = 0.0 self.hits[i] = 0.2
def bounce(self, ball, dt): ball.vy += GRAVITY * 0.5 * dt ball.x += ball.vx * dt ball.y += ball.vy * dt for i, p in enumerate(PLATFORMS): normal = game.circle_rect(ball.x, ball.y, ball.r, p) if normal: nx, ny = normal if nx: ball.vx = abs(ball.vx) * nx # reflect the speed on that side ball.x = (p.right + ball.r) if nx > 0 else (p.left - ball.r) if ny: ball.vy = abs(ball.vy) * ny * 0.82 # lose a little energy on each bounce ball.y = (p.bottom + ball.r) if ny > 0 else (p.top - ball.r) if ny < 0 and abs(ball.vy) < 120: # nearly still: kick it again ball.vy = -620 self.hits[i] = 0.2
# -- draw ------------------------------------------------------------------------------- def board(self): shapes = [draw.background("#0f141c")] for i, p in enumerate(PLATFORMS): lit = i in self.hits and self.debug shapes.append(p.shape(fill="#2a3446", radius=3)) shapes.append(draw.rect(p.x, p.y, p.w, 3, fill="#4b5d7c" if not lit else "#fbbf24")) if self.debug: shapes.append(p.shape(stroke="#fbbf24" if lit else "#ffffff1f", width=1.5 if lit else 1)) for ball in self.balls: shapes += game.glow(ball.x, ball.y, ball.r, ball.color, layers=2, strength=0.35) if self.debug: shapes.append(draw.line(ball.x, ball.y, ball.x + ball.vx * 0.08, ball.y + ball.vy * 0.08, stroke=game.rgba(ball.color, 0.7), width=1.5))
if self.debug and len(self.trail) > 1: # the trail: fading dots, one per frame n = len(self.trail) for i, (x, y) in enumerate(self.trail[::2]): a = (i * 2 + 1) / n shapes.append(draw.circle(x, y, 2.2, fill=game.rgba("#22d3ee", a * 0.8)))
p = self.player squash = 1 + game.clamp(abs(self.vy) / MAX_FALL, 0, 1) * 0.15 if not self.on_ground else 1 w, h = p.w / squash, p.h * squash # stretch while flying fast shapes.append(draw.rect(p.center[0] - w / 2, p.bottom - h, w, h, radius=6, fill="#22d3ee")) eye = 1 if self.vx >= 0 else -1 shapes.append(draw.rect(p.center[0] + eye * 4 - 3, p.bottom - h + 9, 6, 7, radius=2, fill="#0b0e14"))
if self.debug: cx, cy = p.center shapes.append(p.shape(stroke="#34d399", width=1.5)) shapes.append(draw.line(cx, cy, cx + self.vx * 0.15, cy + self.vy * 0.15, stroke="#f472b6", width=2.5, cap="round")) info = (f"vx {self.vx:6.0f} vy {self.vy:6.0f} ground {'yes' if self.on_ground else 'no '} " f"coyote {self.coyote:.2f} buffer {self.buffer:.2f}") shapes.append(draw.rect(16, 16, 520, 24, radius=6, fill="#00000088")) shapes.append(draw.text(26, 21, info, size=12, font="mono", color="#cbd5e1")) return pz.canvas(shapes, width=W, height=H)
def view(self): t = self.resolved_theme() hud = pz.row([ui.label("Jump Lab", weight="semibold"), pz.hspace(), ui.badge("on the ground" if self.on_ground else "in the air", icon="footprints" if self.on_ground else "wind", tint="success" if self.on_ground else "accent"), ui.badge("debug on" if self.debug else "debug off", icon="bug", tint="text_muted")], spacing=8, align="center") return pz.container(pz.column([hud, pz.container(self.board(), radius=10, clip=True), ui.muted("←/→ run · Space jumps (hold for higher) · H toggles the debug view", size="sm")], spacing=10), padding=16, width="fill", height="fill", bg=t.background)
if __name__ == "__main__": pz.run(JumpLab)