Tutorial: Snake
Snake moves on a grid, one cell at a time, so it doesn’t need a frame loop: a timer that
fires a few times a second is enough. You will learn how to keep the game state in the
app, step it with pz.every, steer with on_key, draw the board on a canvas and put a
menu on top with pz.stack.
| Goal | Time | Level | You need |
|---|---|---|---|
| Classic Snake: a grid, a timer, a key queue and a game-over card | 30 minutes | Intermediate | The To-do list tutorial, or a few Pyzed apps of your own |
Play it first:
uv run --with pyzed-ui python examples/games/snake.pypython3 -m venv .venvsource .venv/bin/activatepython -m pip install pyzed-uipython examples/games/snake.pypy -m venv .venv.venv\Scripts\Activate.ps1python -m pip install pyzed-uipython examples\games\snake.pypython3 examples/games/snake.pypy examples\games\snake.py-
The state. The snake is a list of cells, head first. The direction is a
(dx, dy)pair, andturnsqueues key presses until the next step (two quick presses between steps would otherwise lose the first one):CELL = 22COLS, ROWS = 24, 18class Snake(pz.App):title = "Snake"size = (COLS * CELL + 32, ROWS * CELL + 96)def __init__(self, seed=None):self.random = random.Random(seed) # a seed makes games reproducible in testsself.best = 0self.reset()def reset(self):middle = (COLS // 2, ROWS // 2)self.body = [middle, (middle[0] - 1, middle[1]), (middle[0] - 2, middle[1])]self.direction = (1, 0)self.turns = []self.food = self.free_cell()self.score = 0self.paused = self.over = False -
One step. Move the head; if it lands on the food the snake grows (the tail stays), otherwise the tail moves too. Walls wrap around with
%, and biting yourself ends the game:def step(self):if self.turns:self.direction = self.turns.pop(0)dx, dy = self.directionx, y = self.body[0]head = ((x + dx) % COLS, (y + dy) % ROWS)if head in self.body[:-1]:self.over = Trueself.best = max(self.best, self.score)returnself.body.insert(0, head)if head == self.food:self.score += 1self.food = self.free_cell()else:self.body.pop() -
The timer.
subscriptions()is re-read after every update, so the game decides when the clock runs. Putting the speed in thekeyrestarts the timer when the snake speeds up, and returning nothing pauses it:@propertydef speed(self):return min(8 + self.score // 3, 20) # steps per seconddef subscriptions(self):if self.over or self.paused:return []return [pz.every(1 / self.speed, self.step, key=f"snake-{self.speed}")] -
Steering.
on_keyreceives every key press as a string. Turning back on yourself (or pressing the same direction twice) is ignored:DIRECTIONS = {"up": (0, -1), "w": (0, -1), "down": (0, 1), "s": (0, 1),"left": (-1, 0), "a": (-1, 0), "right": (1, 0), "d": (1, 0)}def on_key(self, combo):if combo in DIRECTIONS:last = self.turns[-1] if self.turns else self.directionnew = DIRECTIONS[combo]if new != last and (new[0] + last[0], new[1] + last[1]) != (0, 0):self.turns.append(new)elif combo == "space" and not self.over:self.paused = not self.pausedelif combo == "enter" and self.over:self.reset() -
The board. A canvas is a list of shapes from
pyzed.draw. The checkerboard is a singledraw.rectsshape (fast even with hundreds of cells), the food gets a soft halo from a translucent circle and the body is rounded rectangles:def board(self, t):shapes = [draw.background(t.editor)]shapes.append(draw.rects(((x * CELL, y * CELL, CELL, CELL)for x in range(COLS) for y in range(ROWS) if (x + y) % 2),fill=t.surface))fx, fy = self.foodshapes.append(draw.circle(fx * CELL + CELL / 2, fy * CELL + CELL / 2, CELL / 2 - 3,fill="#d07277"))for i, (x, y) in enumerate(self.body):shapes.append(draw.rect(x * CELL + 2, y * CELL + 2, CELL - 4, CELL - 4, radius=6,fill=t.accent if i == 0 else "#a1c181"))return pz.canvas(shapes, width=COLS * CELL, height=ROWS * CELL) -
HUD and game over. The score is a row of
ui.badges. The game-over card is a normal container withglass=True(a blurred, tinted panel), stacked over the board:def overlay(self, t):if not self.over:return Nonecard = 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):game = pz.stack([self.board(t), self.overlay(t)], width=COLS * CELL, height=ROWS * CELL)
Full code
Section titled “Full code”"""Snake: the classic, in about 150 lines of Pyzed.
python examples/games/snake.py
Arrows or WASD steer, Space pauses, Enter starts again. Shows a fixed-rate game loopwith ``pz.every``, steering from ``on_key`` (with a queue so quick turns are not lost),a ``pz.canvas`` board and a HUD made of ordinary widgets stacked on top of it.Tutorial: https://albakore.github.io/pyzed/games/snake/"""
import random
import pyzed as pzfrom pyzed import draw, ui
CELL = 22COLS, ROWS = 24, 18DIRECTIONS = { "up": (0, -1), "w": (0, -1), "down": (0, 1), "s": (0, 1), "left": (-1, 0), "a": (-1, 0), "right": (1, 0), "d": (1, 0),}
class Snake(pz.App): title = "Snake" size = (COLS * CELL + 32, ROWS * CELL + 96) theme = "One Dark"
def __init__(self, seed=None): self.random = random.Random(seed) self.best = 0 self.reset()
# -- game state ----------------------------------------------------------- def reset(self): middle = (COLS // 2, ROWS // 2) self.body = [middle, (middle[0] - 1, middle[1]), (middle[0] - 2, middle[1])] self.direction = (1, 0) self.turns = [] # key presses waiting for the next step self.food = self.free_cell() self.score = 0 self.paused = False self.over = False
def free_cell(self): while True: cell = (self.random.randrange(COLS), self.random.randrange(ROWS)) if cell not in self.body: return cell
@property def speed(self): """Steps per second: starts at 8 and speeds up as the snake grows.""" return min(8 + self.score // 3, 20)
def step(self): if self.turns: self.direction = self.turns.pop(0) dx, dy = self.direction x, y = self.body[0] head = ((x + dx) % COLS, (y + dy) % ROWS) # walls wrap around if head in self.body[:-1]: self.over = True self.best = max(self.best, self.score) return self.body.insert(0, head) if head == self.food: self.score += 1 self.food = self.free_cell() else: self.body.pop()
# -- input and timing ----------------------------------------------------- def subscriptions(self): if self.over or self.paused: return [] # The key includes the speed so the timer restarts when it changes. return [pz.every(1 / self.speed, self.step, key=f"snake-{self.speed}")]
def on_key(self, combo): if combo in DIRECTIONS: last = self.turns[-1] if self.turns else self.direction new = DIRECTIONS[combo] # Ignore turning back on itself and repeated presses. if new != last and (new[0] + last[0], new[1] + last[1]) != (0, 0) and len(self.turns) < 3: self.turns.append(new) elif combo == "space" and not self.over: self.paused = not self.paused elif combo == "enter" and self.over: self.reset()
# -- drawing -------------------------------------------------------------- def board(self, t): shapes = [draw.background(t.editor)] # A subtle checkerboard makes the grid readable without lines. shapes.append(draw.rects(((x * CELL, y * CELL, CELL, CELL) for x in range(COLS) for y in range(ROWS) if (x + y) % 2), fill=t.surface)) fx, fy = self.food shapes.append(draw.circle(fx * CELL + CELL / 2, fy * CELL + CELL / 2, CELL * 0.62, fill=(0.82, 0.45, 0.47, 0.18))) shapes.append(draw.circle(fx * CELL + CELL / 2, fy * CELL + CELL / 2, CELL / 2 - 3, fill="#d07277")) for i, (x, y) in enumerate(self.body): inset = 2 if i else 1 shapes.append(draw.rect(x * CELL + inset, y * CELL + inset, CELL - 2 * inset, CELL - 2 * inset, radius=6 if i else 7, fill=t.accent if i == 0 else "#a1c181")) # Eyes looking where the snake goes. hx, hy = self.body[0] dx, dy = self.direction cx, cy = hx * CELL + CELL / 2 + dx * 4, hy * CELL + CELL / 2 + dy * 4 for side in (-1, 1): shapes.append(draw.circle(cx + dy * 5 * side, cy + dx * 5 * side, 2.2, fill=t.editor)) return pz.canvas(shapes, width=COLS * CELL, height=ROWS * CELL)
def overlay(self, t): if self.over: title, hint = "Game over", "Press Enter to play again" elif self.paused: title, hint = "Paused", "Press Space to continue" else: return None card = pz.container(pz.column([ ui.heading(title, level=2), ui.muted(f"Score {self.score} · Best {self.best}"), ui.button(hint.split(" to ")[1].capitalize(), self.reset if self.over else (lambda: setattr(self, "paused", False)), variant="solid", icon="rotate-ccw" if self.over else "play"), ui.muted(hint, size="sm"), ], spacing=10, align="center"), padding=24).style(glass=True, radius=14, border=(1, "border")) return pz.container(card, width="fill", height="fill", align_x="center", align_y="center")
def view(self): t = self.resolved_theme() hud = pz.row([ pz.icon("worm", size=18, color=t.accent), ui.label("Snake", weight="semibold"), pz.hspace(), ui.badge(f"Score {self.score}", icon="apple"), ui.badge(f"Best {self.best}", tint="warning", icon="trophy"), ui.badge(f"{self.speed}/s", tint="text_muted", icon="gauge"), ], spacing=8, align="center") game = pz.stack([self.board(t), self.overlay(t)], width=COLS * CELL, height=ROWS * CELL) return pz.container( pz.column([hud, pz.container(game, radius=10, clip=True), ui.muted( "Arrows / WASD steer · Space pauses · Enter restarts", size="sm")], spacing=10), padding=16, width="fill", height="fill", bg=t.background, )
if __name__ == "__main__": pz.run(Snake)