# 3. Collisions and physics

> Simple game physics in Python with Pyzed: velocity and gravity, AABB box collisions resolved one axis at a time, circles bouncing off boxes, and a platformer jump with coyote time and jump buffering.

Source: https://albakore.github.io/pyzed/games/collisions-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](https://albakore.github.io/pyzed/games/sprites-animation/) |

## 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.

1. **Numbers that feel right.** All the physics is a handful of constants, in pixels and seconds. Changing
   them is how you *tune* the feel:

```python
RUN = 250            # top running speed, px/s
ACCEL = 2200         # px/s² on the ground (how fast you reach RUN)
AIR_ACCEL = 1300     # less control in the air
GRAVITY = 1900       # px/s², added to vy every second
JUMP_SPEED = 660     # vy right after a jump (upwards, so negative)
LOW_JUMP = 2.6       # gravity multiplier while rising without the jump key: short hops
MAX_FALL = 900
COYOTE = 0.10        # seconds after leaving a ledge when a jump still works
BUFFER = 0.12        # seconds a jump press is remembered before landing
```

   With these, the highest jump is `JUMP_SPEED² / (2 × GRAVITY)` ≈ 115 pixels. The test suite checks that.

2. **Velocity and gravity.** `vx` and `vy` are speeds in pixels per second (`y` grows downwards, so up is
   negative). Instead of setting `vx` to the target speed at once, it moves toward it by at most `ACCEL × dt`
   per frame, which gives a short, smooth start and stop:

```python
move = game.axis(("left", "a"), ("right", "d"), held=self.held)
jump_held = game.is_pressed(*JUMP_KEYS, held=self.held)

target = move * RUN
accel = (ACCEL if self.on_ground else AIR_ACCEL) * dt
self.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.

3. **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:

```python
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

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

# in tick:
self.move_x(self.vx * dt)
self.on_ground = False
self.move_y(self.vy * dt)
```

   `on_ground` is only true when the vertical move hit something below, so it is always up to date.

4. **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:

```python
def press_jump(self):           # called by on_key for Space, Up and W
    self.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_SPEED
    self.buffer = self.coyote = 0.0
```

   Players never notice these, but they notice when they're missing: the game feels like it "eats" jumps.

5. **Balls bounce off boxes.** `game.circle_rect(cx, cy, r, rect)` returns `None`, 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:

```python
normal = game.circle_rect(ball.x, ball.y, ball.r, p)
if normal:
    nx, ny = normal
    if nx:
        ball.vx = abs(ball.vx) * nx
        ball.x = (p.right + ball.r) if nx > 0 else (p.left - ball.r)
    if ny:
        ball.vy = abs(ball.vy) * ny * 0.82
        ball.y = (p.bottom + ball.r) if ny > 0 else (p.top - ball.r)
```

6. **A debug view.** Drawing what the code "sees" saves hours. With <kbd>H</kbd> 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:

```python
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))
```

**Tunneling:**

If something moves more than its own size in one frame, it can pass through a thin wall without ever
overlapping it. Jump Lab caps `dt` at 1/30 s; for fast objects, split the move into smaller steps, as
[Breakout](https://albakore.github.io/pyzed/games/breakout/) does for its ball (`steps = ceil(speed × dt / radius)`).

**When to use what:**

Boxes (`Rect.overlaps`) for characters, platforms, bullets and pickups; circles (`math.hypot`) for round things
and quick "is it close enough" checks; `circle_rect` when a ball must bounce off a box. Most 2D games need
nothing else.

## Try this

- Add a moving platform: a `Rect` whose `x` follows a sine wave; when the player stands on it, add its
  movement to the player's `x`.
- Add a double jump: allow one extra jump in the air, reset when landing.
- Tune `GRAVITY` and `JUMP_SPEED` to make the jump floatier or snappier, and watch the trail change.

## Full code

`examples/games/jump_lab.py`:

```py
"""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 view
draws the hitboxes, the velocity and the trail of the last second, so you can see the jump
arc. 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 pz
from pyzed import draw, game, ui

W, H = 720, 400
RUN = 250            # top running speed, px/s
ACCEL = 2200         # px/s² on the ground (how fast you reach RUN)
AIR_ACCEL = 1300     # less control in the air
GRAVITY = 1900       # px/s², added to vy every second
JUMP_SPEED = 660     # vy right after a jump (upwards, so negative)
LOW_JUMP = 2.6       # gravity multiplier while rising without the jump key: short hops
MAX_FALL = 900
COYOTE = 0.10        # seconds after leaving a ledge when a jump still works
BUFFER = 0.12        # seconds a jump press is remembered before landing
JUMP_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)
```

## Next

- [4. A platformer](https://albakore.github.io/pyzed/games/platformer/): A tile map, a camera that follows you, enemies to stomp, coins and a goal.
