# A game loop

> Write a small real-time game with Pyzed: a frame loop with pz.frames, held keys, collisions, glow, particles and screen shake, and a HUD made of ordinary widgets.

Source: https://albakore.github.io/pyzed/tutorials/advanced/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](https://albakore.github.io/pyzed/tutorials/intermediate/navigation/) |

## What you'll learn

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

## Steps

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:

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

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

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

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

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

```python
play = pz.stack([self.board(), self.overlay()], width=W, height=H)
```

7. **Restart.** `on_key` gets single key presses; <kbd>Enter</kbd> resets the state and, since `over` is false
   again, `subscriptions()` returns the loop and the game starts over.

**Make it yours:**

Change one number at a time and see what happens: the paddle speed (380), the gravity of the sparks
(`Particles(gravity=300)`), the spawn interval, or give the blocks a `rotation` with `draw.group`. Run it with
`pyzed dev examples/tutorial/dodge.py` to see each change when you save.

**A fixed step or a frame loop?:**

Grid games such as Snake move one cell at a time at a steady pace: use `pz.every(seconds, callback)`. Action
games that move smoothly use `pz.frames`. The [Games](https://albakore.github.io/pyzed/games/) section has one of each.

## Full code

`examples/tutorial/dodge.py`:

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

## Events in this tutorial

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

### Try more events

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

## Next

- [Making games](https://albakore.github.io/pyzed/games/): Snake, Breakout and Neon Invaders, step by step.
- [A custom shader graph](https://albakore.github.io/pyzed/tutorials/advanced/shader-graph/) for a GPU background.
