The Snake Game

Don't you think that it's only appropriate to discover Python with a snake game?

avatar

Sébastien Boisgérault
Associate Professor, ITN Mines Paris – PSL

Boa constrictor by Jan Kopřiva
Attribution

The first version of this project was created by Aurélien Noce (aka @ushu).

Introduction

In this project you will create a program which is a classic video game: the 🐍 snake game! You will use Python and the Pyxel library. What matters is the learning experience: you will discover how to design and implement a complete application, by starting with a simple program and adding features one by one.

Your code is ugly!

At this stage, we won’t focus at all on the aesthestics of the code, so don’t worry if you find that our solutions do not “look good” (they don’t). But they work! However, code readability & maintainability are important issues and we will come back to this issue in the next session.

By the end of this project, you will have something like this:

🎮

Inside a square arena, you control a 🐍 snake:

  • 🏃 which relentlessly moves forward,

  • ➡️ but can be directed using the arrow keys,

  • 🍓 which grows by eating fruits that respawn at random locations,

  • 🥴 shrinks when he hits an obstacle (either a wall, a rock, or its own body).

Getting started

Let’s start with a message display whose color changes over time.

The code of this application is:

import pyxel

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()

def draw():
    pyxel.cls(0)
    color = pyxel.frame_count % 16
    pyxel.text(56, 54, "Hello, Snake!", color)

pyxel.init(160, 120)
pyxel.run(update, draw)

Now we are going to slightly tweak this program until we understand what’s going on. When it’s necessary, search into Pyxel’s user’s guide and reference documentation.

Colors

Let’s tweak the colors a little:

  • Display a white background (instead of the black one).

  • Display the “Hello Snake!” message in black (not flickering).

Solution

In the spirit of retro gaming, Pyxel can only display 16 colors. The standard palette from which you must pick your color is described here.

Pyxel's color palette

The index of black is 0 and there is no pure white, but the closest is color 7. Therefore, the changes we need to perform are:

def draw():
    pyxel.cls(7)
    pyxel.text(56, 54, "Hello, Snake!", 0)

Frame rate

To get a feel of the rythm at which the Pyxel application is running:

  • Measure the time elapsed between two calls to the draw function of Pyxel.

  • Print the number of frames per second (FPS) in the window top left corner.

Time in the Python standard library

The time function, from the time module, returns the time elapsed in seconds since January 1st, 1970 at noon (the “Unix epoch”).

>>> import time
>>> time.time()
1692980870.0990813
>>> time.time()
1692980871.2445116
>>> time.time()
1692980872.3245282
Solution
import pyxel
import time

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()

t = 0.0

def draw():
    global t
    t_new = time.time()
    dt = t_new - t
    t = t_new
    fps = 1.0 / dt
    fps = round(fps)
    pyxel.cls(0)
    color = pyxel.frame_count % 16
    pyxel.text(56, 54, "Hello, Snake!", color)
    pyxel.text(0, 0, f"fps: {fps}", 7)

The Game Board

Let’s build a game board

  • made of a square of 30x30 cells,

  • with each cell is 1 pixel large.

But 1 pixel is too small!

Yes, it certainly would be if we were talking about real pixels! But Pyxel automatically scales the display to fit the screen size. Since we use a small 30x30 display, it will zoom it quite a bit and we will be able to see every “virtual pixel” clearly.

To check that everything’s ok, draw a checkerboard pattern like this:

A 30x30 checkerboard

(You may keep this checkerboard in the background as “training wheels” for a while, and remove it when you’re ready.)

Solution
import pyxel

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()

def draw():
    pyxel.cls(13)
    for i in range(30):
        for j in range(30):
            if (i+j) % 2 == 0:
                pyxel.pset(i, j, 7)

pyxel.init(30, 30)
pyxel.run(update, draw)

The Forbidden Fruit

Display a fruit at a random location on top of the checkerboard. A fruit is a simple 1x1 rectangle (a pixel!). Pick a color you like! When we say “random”, we want the fruit location to be different each time you restart the program.

A fruit at a random location
Solution
import pyxel

pyxel.init(30, 30)

fruit = [
    pyxel.rndi(0, 29),
    pyxel.rndi(0, 29)
]

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()

def draw():
    pyxel.cls(13)
    for i in range(30):
        for j in range(30):
            if (i+j) % 2 == 0:
             pyxel.pset(i, j, 7)
    pyxel.pset(fruit[0], fruit[1], 8)

pyxel.run(update, draw)

A Restin’ Snake

In the next step, we will represent the snake, as a sequence of pixels. Let’s snake with the following simple sequence

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

Let’s say that the last list item (here [12, 15]) represents the snake head. Use a dark green color to represent the snake body and a light green for its head.

A resting snake
Solution
import pyxel

pyxel.init(30, 30)

fruit = [
    pyxel.rndi(0, 29),
    pyxel.rndi(0, 29)
]

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()

def draw():
    pyxel.cls(13)
    for i in range(30):
        for j in range(30):
            if (i+j) % 2 == 0:
                pyxel.pset(i, j, 7)
    pyxel.pset(fruit[0], fruit[1], 8)
    for x, y in snake_geometry[:-1]:
        pyxel.pset(x, y, 3)
    snake_head = snake_geometry[-1]
    pyxel.pset(snake_head[0], snake_head[1], 11)

pyxel.run(update, draw)

Events

Modify the program so that when the user presses the arrow keys, the program displays (with the print function) the characters ←, ↑, → or ↓ in the terminal.

Solution
import pyxel

pyxel.init(30, 30)

fruit = [
    pyxel.rndi(0, 29),
    pyxel.rndi(0, 29)
]

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

arrow_keys = [
    pyxel.KEY_UP,
    pyxel.KEY_DOWN,
    pyxel.KEY_LEFT,
    pyxel.KEY_RIGHT
]

def update():
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()
    arrow_keys_pressed = []
    for key in arrow_keys:
        if pyxel.btnp(key):
            arrow_keys_pressed.append(key)
    for key in arrow_keys_pressed:
        if key == pyxel.KEY_UP:
            print("↑")
        elif key == pyxel.KEY_DOWN:
            print("↓")
        elif key == pyxel.KEY_LEFT:
            print("←")
        elif key == pyxel.KEY_RIGHT:
            print("→")

def draw():
    pyxel.cls(7)

    pyxel.pset(fruit[0], fruit[1], 8)
    for x, y in snake_geometry[:-1]:
        pyxel.pset(x, y, 3)
    snake_head = snake_geometry[-1]
    pyxel.pset(snake_head[0], snake_head[1], 11)

pyxel.run(update, draw)

It’s Aliiiiiiive!

We are finally going to make the snake move!

  • We create a (global) vector snake_direction whose initial value is [1, 0].

  • At each update, we move the head of the snake in this direction ; the rest of its body follows.

  • Pressing an arrow key changes the direction of the snake.

OMG it's so fast!

You may consider slowing down the game at this point … at least while you are developing and testing it! Technically, you are searching to decrease the frame rate.

Solution
import pyxel

pyxel.init(30, 30, fps=10)

fruit = [
    pyxel.rndi(0, 29),
    pyxel.rndi(0, 29)
]

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

snake_direction = [1, 0]

arrow_keys = [
    pyxel.KEY_UP,
    pyxel.KEY_DOWN,
    pyxel.KEY_LEFT,
    pyxel.KEY_RIGHT
]

def update():
    global snake_geometry, snake_direction
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()
    arrow_keys_pressed = []
    for key in arrow_keys:
        if pyxel.btnp(key):
            arrow_keys_pressed.append(key)
    for key in arrow_keys_pressed:
        if key == pyxel.KEY_UP:
            snake_direction = [0, -1]
        elif key == pyxel.KEY_DOWN:
            snake_direction = [0, 1]
        elif key == pyxel.KEY_LEFT:
            snake_direction = [-1, 0]  
        elif key == pyxel.KEY_RIGHT:
            snake_direction = [1, 0]
    snake_head = snake_geometry[-1]
    new_snake_head = [
        snake_head[0] + snake_direction[0],
        snake_head[1] + snake_direction[1]
    ]
    snake_geometry = snake_geometry[1:] + [new_snake_head]

def draw():
    pyxel.cls(7)
    pyxel.pset(fruit[0], fruit[1], 8)
    for x, y in snake_geometry[:-1]:
        pyxel.pset(x, y, 3)
    snake_head = snake_geometry[-1]
    pyxel.pset(snake_head[0], snake_head[1], 11)

pyxel.run(update, draw)

I am starving!

So far the fruit and the snake don’t interact. Let’s change that! Make sure that:

  • when the snake head reaches the fruit, the fruit disappears,

  • the snake grows by one pixel and

  • a new fruit appears at a random location.

Solution
import pyxel

pyxel.init(30, 30, fps=10)

def spawn_new_fruit():
    global fruit
    fruit = [
        pyxel.rndi(0, 29),
        pyxel.rndi(0, 29)
    ]

spawn_new_fruit()

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

snake_direction = [1, 0]

arrow_keys = [
    pyxel.KEY_UP,
    pyxel.KEY_DOWN,
    pyxel.KEY_LEFT,
    pyxel.KEY_RIGHT
]

def update():
    global snake_geometry, snake_direction
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()
    arrow_keys_pressed = []
    for key in arrow_keys:
        if pyxel.btnp(key):
            arrow_keys_pressed.append(key)
    for key in arrow_keys_pressed:
        if key == pyxel.KEY_UP:
            snake_direction = [0, -1]
        elif key == pyxel.KEY_DOWN:
            snake_direction = [0, 1]
        elif key == pyxel.KEY_LEFT:
            snake_direction = [-1, 0]
        elif key == pyxel.KEY_RIGHT:
            snake_direction = [1, 0]
    snake_head = snake_geometry[-1]
    new_snake_head = [
      snake_head[0] + snake_direction[0],
      snake_head[1] + snake_direction[1]
    ]
    if new_snake_head == fruit:
        snake_geometry = snake_geometry + [new_snake_head]
        spawn_new_fruit()
    else:
        snake_geometry = snake_geometry[1:] + [new_snake_head]

def draw():
    pyxel.cls(7)
    pyxel.pset(fruit[0], fruit[1], 8)
    for x, y in snake_geometry[:-1]:
        pyxel.pset(x, y, 3)
    snake_head = snake_geometry[-1]
    pyxel.pset(snake_head[0], snake_head[1], 11)

pyxel.run(update, draw)

Epilog

Obviously, there are a couple of things missing here with respect to the initial demo:

  • the snake should not be able to go through itself,

  • the snake cannot get out of the arena and

  • the snake should not be able to go through the rocks (yes, there are rocks now!).

Since we are not too cruel, the snake doesn’t die when such a collision occurs. Instead, he loses one segment of it body (by frame), until only the head remains.

Implements these changes!

Solution
import pyxel

pyxel.init(30, 30, fps=10)

snake_geometry = [
    [10, 15],
    [11, 15],
    [12, 15],
]

snake_direction = [1, 0]

rocks = []
for i in range(30):
    for j in range(30):
        if (i+j) % 5 == 0 and (i-j) % 11 == 0:
            rocks.append([i, j])

def spawn_new_fruit():
    global fruit
    while True:
        fruit = [pyxel.rndi(0, 29), pyxel.rndi(0, 29)]
        if fruit not in snake_geometry and fruit not in rocks:
            break

spawn_new_fruit()

arrow_keys = [
    pyxel.KEY_UP, 
    pyxel.KEY_DOWN, 
    pyxel.KEY_LEFT, 
    pyxel.KEY_RIGHT
]

def update():
    global snake_geometry, snake_direction
    if pyxel.btnp(pyxel.KEY_Q):
        pyxel.quit()
    arrow_keys_pressed = []
    for key in arrow_keys:
        if pyxel.btnp(key):
            arrow_keys_pressed.append(key)
    for key in arrow_keys_pressed:
        if key == pyxel.KEY_UP:
            snake_direction = [0, -1]
        elif key == pyxel.KEY_DOWN:
            snake_direction = [0, 1]
        elif key == pyxel.KEY_LEFT:
            snake_direction = [-1, 0]
        elif key == pyxel.KEY_RIGHT:
            snake_direction = [1, 0]
    snake_head = snake_geometry[-1]
    new_snake_head = [
        snake_head[0] + snake_direction[0],
        snake_head[1] + snake_direction[1],
    ]
    if (
        new_snake_head in snake_geometry
        or new_snake_head in rocks
        or (
        new_snake_head[0] < 0
        or new_snake_head[0] > 29
        or new_snake_head[1] < 0
        or new_snake_head[1] > 29
        )
    ):
        snake_geometry = snake_geometry[1:-1] + [snake_head]
    elif new_snake_head == fruit:
        snake_geometry = snake_geometry + [new_snake_head]
        spawn_new_fruit()
    else:
        snake_geometry = snake_geometry[1:] + [new_snake_head]

def draw():
    pyxel.cls(7)
    pyxel.pset(fruit[0], fruit[1], 8)
    for x, y in rocks:
        pyxel.pset(x, y, 0)
    for x, y in snake_geometry[:-1]:
        pyxel.pset(x, y, 3)
    snake_head = snake_geometry[-1]
    pyxel.pset(snake_head[0], snake_head[1], 11)

pyxel.run(update, draw)