The final project

Day 30 Project: Snake

Welcome to the graduation project for the final day of the 30 Days of Python series. Today we're building Snake using pygame.

If you haven't checked out the preparation post, I'd definitely recommend you do so, as I talk about how to use pygame there.

The brief

The brief today is fairly simple. Your task to create the classic Snake game.

If you're not familiar with the game, you play as a snake that is trying to gather up little pieces of food. Every time the snake's head passes through a square containing food, you get a point, and the snake also grows by a certain amount.

If you hit your own body, or the walls of the play area, you die, and the game ends.

The aim of the game is to score as many points as you can.

For this implementation, the game should be controlled by keyboard keys. The arrow keys make a lot of sense, but you can choose whichever keys you like. Pressing these different keys should set a new course for the snake.

A single piece of food should be in the play area at a time, and when the snake eats a piece of food, another piece should be randomly placed within the play area. This should not occupy the same space as any of the snake's segments.

Finally a running total of the user's score should be displayed in the top left of the screen.

Our solution

To start, let's do all the standard setup that we need to do when working with pygame.

app.py
import pygame

WINDOW_HEIGHT = 840
WINDOW_WIDTH = 800
WINDOW_DIMENSIONS = WINDOW_WIDTH, WINDOW_HEIGHT

pygame.init()
pygame.display.set_caption("Snake")

clock = pygame.time.Clock()
screen = pygame.display.set_mode(WINDOW_DIMENSIONS)

def play_game():
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        clock.tick(30)

play_game()

I'm also going to be defining a second file called colours.py which will contain all of the colour constants we want to use as part of the game.

There's nothing complicated in this file, so we don't need to dwell too much on the details.

colours.py
from collections import namedtuple

Colour = namedtuple("Colour", ["r", "g", "b"])

BACKGROUND = Colour(r=1, g=22, b=39)
SNAKE = Colour(r=255, g=0, b=25)
FOOD = Colour(r=255, g=253, b=65)
TEXT = Colour(r=255, g=255, b=255)

Here I've defined a Colour tuple and I've used it to make an RGB representation of every colour I plan to use in the application.

Let's import this into my app.py file and set the background colour for the application, which is a dark blue.

app.py
import pygame
import colours

WINDOW_HEIGHT = 840
WINDOW_WIDTH = 800
WINDOW_DIMENSIONS = WINDOW_WIDTH, WINDOW_HEIGHT

pygame.init()
pygame.display.set_caption("Snake")

clock = pygame.time.Clock()
screen = pygame.display.set_mode(WINDOW_DIMENSIONS)

def play_game():
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        screen.fill(colours.BACKGROUND)
        pygame.display.update() 

        clock.tick(30)

play_game()

I've put the fill call inside the loop, because we want to run this for every tick of the clock to cover up any old drawings. We also have to remember to called the update function so that the changes are reflected on the surface we want to draw on—the screen in this case.

At this point we should stop and think about how we're actually going to implement some of the logic here. For example, how are we going to handle moving the snake?

I think a good approach would be to keep track of the position of various snake segments, since we can use those positions to draw the various squares that make up the snake's body.

When we move the snake, I'm going to add a new segment to the head of the snake, and I'm going to remove the last element in the tail. This is going to ensure the snake stays the same length, and it means our snake is constantly moving in the direction we want.

We're going to need some logic to map directions onto various keys, so that we know which way the snake should move next, and we're also going to need to keep track of the current direction, since the user may press nothing between ticks.

For the actual movement of the head, I'm going to move in steps equal to the size of a single segment. This is going to make collision detection much easier. Because we're going to be using the segment size fairly often, we should probably also add this as a constant.

Let's add this constant and draw some initial shapes onto the screen for the snake and food.

app.py
import colours
import pygame

WINDOW_HEIGHT = 840
WINDOW_WIDTH = 800
WINDOW_DIMENSIONS = WINDOW_WIDTH, WINDOW_HEIGHT

SEGMENT_SIZE  =  20

pygame.init()
pygame.display.set_caption("Snake")

clock = pygame.time.Clock()
screen = pygame.display.set_mode(WINDOW_DIMENSIONS)

def draw_objects(snake_positions, food_position):
    pygame.draw.rect(screen, colours.FOOD, [food_position, (SEGMENT_SIZE, SEGMENT_SIZE)])

    for x, y in snake_positions:
        pygame.draw.rect(screen, colours.SNAKE, [x, y, SEGMENT_SIZE, SEGMENT_SIZE])

def play_game():
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = (160, 160)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        pygame.display.update() 

        clock.tick(30)

play_game()

In order to draw the shapes, I've defined another function, because I don't want to clutter up the play_game function too much.

I've also set a starting position for the snake segments and the food inside play_game. We pass both of these into draw_objects to draw all of the required shapes.

The actual drawing is fairly straightforward. We just create some rectangles using the rect function, and we use the positions we passed into draw_objects  as arguments for these rect calls.

At the moment, our food position is always the same, but I want this to be random later on, and I think it would be good to have a random starting position for the food as well.

Let's define a new function to set a new food position.

app.py
def set_new_food_position(snake_positions):
    while True:
        x_position = randint(0, 39) * SEGMENT_SIZE
        y_position = randint(2, 41) * SEGMENT_SIZE
        food_position = (x_position, y_position)

        if food_position not in snake_positions:
            return food_position

This may look a little bit strange, but let's walk through what I've done.

Because we can't have the food in the same square as any of the snake segments, we may have to make several attempts at generating the position of the food. For this reason, I've placed all the code in a while True loop.

Inside the loop we use the randint function from the random module to generate a random number, which we then multiply by the SEGMENT_SIZE constant. In effect, what we're doing here is selecting a square from the grid, and then we're converting this to a coordinate using the SEGMENT_SIZE. This ensures that we always end up with the food in a space a segment will occupy.

One important thing to remember here is that the the position of a rectangle is based on the top left corner, so we should generate our random numbers accordingly. We don't want to put the food outside of the play area by mistake.

You'll also note that the y_position numbers are different from the x_position ones. This is so that we have room for the score.

Let's plug this new function into our other code and import random.

app.py
import colours
import pygame

from random import randint

WINDOW_HEIGHT = 840
WINDOW_WIDTH = 800
WINDOW_DIMENSIONS = WINDOW_WIDTH, WINDOW_HEIGHT

SEGMENT_SIZE  =  20

pygame.init()
pygame.display.set_caption("Snake")

clock = pygame.time.Clock()
screen = pygame.display.set_mode(WINDOW_DIMENSIONS)

def draw_objects(snake_positions, food_position):
    pygame.draw.rect(screen, colours.FOOD, [food_position, (SEGMENT_SIZE, SEGMENT_SIZE)])

    for x, y in snake_positions:
        pygame.draw.rect(screen, colours.SNAKE, [x, y, SEGMENT_SIZE, SEGMENT_SIZE])

def set_new_food_position(snake_positions):
    while True:
        x_position = randint(0, 39) * SEGMENT_SIZE
        y_position = randint(2, 41) * SEGMENT_SIZE
        food_position = (x_position, y_position)

        if food_position not in snake_positions:
            return food_position

def play_game():
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        pygame.display.update() 

        clock.tick(30)

play_game()

Now if we run the program, the food should appear in a random location.

Next, I'm going to tackle the score, since this is quite an easy step. We just have to add a starting score variable to play_game and we need to render and blit the text to the screen surface.

app.py
...

def play_game():
    score = 0

    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        font = pygame.font.Font(None, 28)
        text = font.render(f"Score: {score}", True, colours.TEXT)
        screen.blit(text, (10, 10))

        pygame.display.update() 

        clock.tick(30)

play_game()

Next, I'm going to handle moving the snake. Since we need quite a bit of logic here, because we need to check for the snake's direction, I'm going to make another function.

app.py
def move_snake(snake_positions, direction):
    head_x_position, head_y_position = snake_positions[0]

    if direction == "Left":
        new_head_position = (head_x_position - SEGMENT_SIZE, head_y_position)
    elif direction == "Right":
        new_head_position = (head_x_position + SEGMENT_SIZE, head_y_position)
    elif direction == "Down":
        new_head_position = (head_x_position, head_y_position + SEGMENT_SIZE)
    elif direction == "Up":
        new_head_position = (head_x_position, head_y_position - SEGMENT_SIZE)

    snake_positions.insert(0, new_head_position)
    del snake_positions[-1]

The first thing I did here was split the head position into x and y coordinates. This saves us constantly referring to indices when modifying the head position.

The bulk of the function body is taken up by a large conditional statement, where we check the current direction of the snake. We then create the next head position based on which direction the snake is heading in.

Once we've constructed this new set of coordinates, we use the insert method to add it to the front of the snake_positions list, and then we delete the tail, since the tail will now occupy the old penultimate position.

We can now set and initial direction for the snake inside play_game and call this function to get the snake moving.

app.py
...

def play_game():
    score = 0

    current_direction = "Right"
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        font = pygame.font.Font(None, 28)
        text = font.render(f"Score: {score}", True, colours.TEXT)
        screen.blit(text, (10, 10))

        pygame.display.update()

        move_snake(snake_positions, current_direction)

        clock.tick(30)

The problem is, the snake currently goes flying right off the screen, so we need to fix a couple of things. First, we need to let the user control the direction of the snake, and second, we need to end the game if the snake collides with the "walls" of the play area.

To let the user control the snake, we need to do a few things. First, we need to listen for keypress events so that we can see what keys they're pressing. Once we have hold of the key, we need to figure out what it actually means. For this, I'm going to create a dictionary to map direction keys to directions like "Up" and "Down".

I'm also going to write a function which going to set a new direction if the user pressed an appropriate key, but not if this direction is the direct opposite of the snake's current direction. We don't want the snake being able to eat itself by folding, so we're just going to ignore those keypresses.

Let's start with handling the event in the loop.

app.py
def play_game():
    score = 0

    current_direction = "Right"
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return
            elif event.type == pygame.KEYDOWN:
                current_direction = on_key_press(event, current_direction)

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        font = pygame.font.Font(None, 28)
        text = font.render(f"Score: {score}", True, colours.TEXT)
        screen.blit(text, (10, 10))

        pygame.display.update()

        move_snake(snake_positions, current_direction)

        clock.tick(30)

play_game()

All we need to do here is add an elif clause to the conditional statements which triggers when we get a pygame.KEYDOWN event. We're then going to call our soon-to-be-created on_key_press function, which will handle the actual processing of this key.

However, before that function can do its thing, we need to create our mapping of key codes to directions, which will look like this:

app.py
KEY_MAP = {
    273: "Up",
    274: "Down",
    275: "Right",
    276: "Left"
}

You can derive these numbers by just printing out the event.__dict__ to see what happens when you press different directional keys.

With that, we can create the on_key_press function, which is going to look like this:

app.py
def on_key_press(event, current_direction):
    key = event.__dict__["key"]
    new_direction = KEY_MAP.get(key)

    all_directions = ("Up", "Down", "Left", "Right")
    opposites = ({"Up", "Down"}, {"Left", "Right"})

    if (new_direction in all_directions
    and {new_direction, current_direction} not in opposites):
        return new_direction

    return current_direction

First things first, we grab the actual key code from the event, and we see if that code is in our KEY_MAP dictionary. If it isn't, then we assign None to new_direction, which means we're going to end up returning the current direction from the function. In effect, nothing will change.

If we do get a valid direction, we see see if the set containing the current and new directions matches the sets of opposites we constructed.

Since sets don't care about order, all that matters for this condition to be met is that we have directions which are opposites. This would result in the snake eating itself, so we ignore the key press in this case and just return the current direction.

If we got a valid direction, and the new and old directions aren't opposites, we return the new direction, which is then assigned to current_direction inside play_game.

With that, there are only a couple of things we need to do. First, we need to check for collisions with the walls, and second, we need to check for collisions with the food.

Here is how I'm going to check for collisions with the walls.

app.py
def check_collisions(snake_positions):
    head_x_position, head_y_position = snake_positions[0]

    return (
        head_x_position in (-20, WINDOW_WIDTH )
        or head_y_position in (20, WINDOW_HEIGHT)
        or (head_x_position, head_y_position) in snake_positions[1:]
    )

I've used a series of or operators here to combine several conditions. Since they all return a Boolean value, we're going to get True or False returned, where True indicates that a condition did indeed occur.

This happens when we hit any of the walls, or where the head occupies the same space as one of the other segments.

We're going to call check_collisions inside of play_game once every tick, and we're going to return from play_game if check_collisions returns True. This will end the game.

app.py
...

def play_game():
    score = 0

    current_direction = "Right"
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return
            elif event.type == pygame.KEYDOWN:
                current_direction = on_key_press(event, current_direction)

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        font = pygame.font.Font(None, 28)
        text = font.render(f"Score: {score}", True, colours.TEXT)
        screen.blit(text, (10, 10))

        pygame.display.update()

        move_snake(snake_positions, current_direction)

        if check_collisions(snake_positions):
            return

        clock.tick(30)

play_game()

The final piece of the puzzle checking for food collisions, which is as simple as checking whether the food coordinates and the head segment coordinates are the same.

app.py
def check_food_collision(snake_positions, food_position):
    if snake_positions[0] == food_position:
        snake_positions.append(snake_positions[-1])

        return True

If the condition is met, we're going to duplicate the tail segment so that the snake grows by one segment during the next move.

We then return True, because we need to do some things back in play_game as well, such as generating a new food position, and increasing the score.

The final code therefore looks like this:

app.py
import colours
import pygame

from random import randint

WINDOW_HEIGHT = 840
WINDOW_WIDTH = 800
WINDOW_DIMENSIONS = WINDOW_WIDTH, WINDOW_HEIGHT

SEGMENT_SIZE  =  20

KEY_MAP = {
    273: "Up",
    274: "Down",
    275: "Right",
    276: "Left"
}

pygame.init()
pygame.display.set_caption("Snake")

clock = pygame.time.Clock()
screen = pygame.display.set_mode(WINDOW_DIMENSIONS)

def check_collisions(snake_positions):
    head_x_position, head_y_position = snake_positions[0]

    return (
        head_x_position in (-20, WINDOW_WIDTH )
        or head_y_position in (20, WINDOW_HEIGHT)
        or (head_x_position, head_y_position) in snake_positions[1:]
    )

def check_food_collision(snake_positions, food_position):
    if snake_positions[0] == food_position:
        snake_positions.append(snake_positions[-1])

        return True

def draw_objects(snake_positions, food_position):
    pygame.draw.rect(screen, colours.FOOD, [food_position, (SEGMENT_SIZE, SEGMENT_SIZE)])

    for x, y in snake_positions:
        pygame.draw.rect(screen, colours.SNAKE, [x, y, SEGMENT_SIZE, SEGMENT_SIZE])

def move_snake(snake_positions, direction):
    head_x_position, head_y_position = snake_positions[0]

    if direction == "Left":
        new_head_position = (head_x_position - SEGMENT_SIZE, head_y_position)
    elif direction == "Right":
        new_head_position = (head_x_position + SEGMENT_SIZE, head_y_position)
    elif direction == "Down":
        new_head_position = (head_x_position, head_y_position + SEGMENT_SIZE)
    elif direction == "Up":
        new_head_position = (head_x_position, head_y_position - SEGMENT_SIZE)

    snake_positions.insert(0, new_head_position)
    del snake_positions[-1]

def on_key_press(event, current_direction):
    key = event.__dict__["key"]
    new_direction = KEY_MAP.get(key)

    all_directions = ("Up", "Down", "Left", "Right")
    opposites = ({"Up", "Down"}, {"Left", "Right"})

    if (new_direction in all_directions
    and {new_direction, current_direction} not in opposites):
        return new_direction

    return current_direction

def set_new_food_position(snake_positions):
    while True:
        x_position = randint(0, 39) * SEGMENT_SIZE
        y_position = randint(2, 41) * SEGMENT_SIZE
        food_position = (x_position, y_position)

        if food_position not in snake_positions:
            return food_position

def play_game():
    score = 0

    current_direction = "Right"
    snake_positions = [(100, 100), (80, 100), (60, 100)]
    food_position = set_new_food_position(snake_positions)

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return
            elif event.type == pygame.KEYDOWN:
                current_direction = on_key_press(event, current_direction)

        screen.fill(colours.BACKGROUND)
        draw_objects(snake_positions, food_position)

        font = pygame.font.Font(None, 28)
        text = font.render(f"Score: {score}", True, colours.TEXT)
        screen.blit(text, (10, 10))

        pygame.display.update()

        move_snake(snake_positions, current_direction)

        if check_collisions(snake_positions):
            return

        if check_food_collision(snake_positions, food_position):
            food_position = set_new_food_position(snake_positions)
            score += 1

        clock.tick(30)

play_game()

This isn't the end though! There are plenty of things we can do to make this better, and I'd encourage you to try to extend this project on your own.

For example, you could add a start and end of game screen. You could keep track of high scores. You could add music! The possibilities are endless.

I hope you enjoyed this final project, and the series as a whole.

Good luck with your future projects, and happy coding!