Snake Game in Python

Do not miss this exclusive book on Binary Tree Problems. Get it now for free.

In this article, we have developed Snake Game in Python Programming Language and developed the GUI for it as well. You shall follow this guide and develop your own version. This will be a strong addition to SDE Portfolio.

What we shall build

In this game, there is a continously moving snake, the snake is directed (using arrow keys) by the player in attempt to eat a fruit spawned somewhere on the screen. The motive of the player should be to score as highly as possible without meeting any of the following conditions:

  • The snake should not collide with the walls.
  • The snake should not feed on itself.

With every fruit eaten by the snake, the player is awarded 10 game points.

This article presumes knowledge of python OOP and pygame. The good news is, the pygame documentation is well organised, easy to follow and understand and besides that, only a couple of pygame modules have been used in the game.

Source code

The source code for the game can be found at this github repository. I recommend going through the source code yourself before continuing. And if you find everything comprehensible, you dont have to read past this point.

I will pat myself on the back and congratulate myself upon writing code that a human can understand 😄.

Installing dependencies

  1. Installing pygame

    pip install pygame

  2. Installing pygame-widgets

    pip install pygame-widgets

Game Folder Structure

  • fonts: Habours the fonts used in the game.
  • game_objects.py: There are basically two game objects we have in the snake game, the snake and the fruit, and this file has the implementation of those two objects.
  • services.py: There are only two services I considered here i.e FontService, for drawing text on screen and the GameSoundService, for playing game sound
  • snake.py: It connects all the components together into a functioning game.
  • sounds: It has the sounds used in the game.
  • utils.py: These are basically utilities.

utils.py

class Position:
def __init__(self, x: int, y: int):
    self.x = x
    self.y = y

The uitilities file only has a single class (Position) that will be used to represent positions.

game_objects.py

import pygame

from utils import Position


class Snake:
    def __init__(self, position: Position, size: int = 10):
        self.__position = Position(position.x, position.y)

        self.__size = size
        self.__body_color: pygame.Color = pygame.Color("green")
        self.__body = [[position.x, position.y], [position.x - size, 50], [position.x - 2 * size, 50],
                    [position.x - 3 * size, 50]]

    def get_position(self):
        return self.__position

    def move(self, dx: int, dy: int) -> None:
        self.__position.x += dx
        self.__position.y += dy
        self.__body.insert(0, [self.__position.x, self.__position.y])
        self.__body.pop()

    def grow(self) -> None:
        self.__body.insert(0, [self.__position.x, self.__position.y])

    def draw(self, surface: pygame.Surface) -> None:
        for block in self.__body:
            block_rect = pygame.Rect(block[0], block[1], self.__size, self.__size)
            pygame.draw.rect(surface, self.__body_color, block_rect)

    def has_eaten_itself(self):
        for i in range(1, len(self.__body)):
            block = self.__body[i]
            if block[0] == self.__position.x and block[1] == self.__position.y:
                return True
        return False


class Fruit:
    def __init__(self, position: Position, size: int = 10):
        self.__position = position
        self.__size = size
        self.__color = pygame.Color("red")
        self.__fruit = pygame.Rect(self.__position.x, self.__position.y, self.__size, self.__size)

    def draw(self, surface: pygame.Surface) -> None:
        pygame.draw.rect(surface, self.__color, self.__fruit)

    def move(self, dx: int, dy: int) -> None:
        self.__position.x += dx
        self.__position.y += dy
        self.__fruit.move_ip(self.__position.x, self.__position.y)

    def get_position(self) -> Position:
        return self.__position

Snake Class

The snake is represented by small square blocks of a given size(still a rectangle with equal sides) and initially, the snake is just three blocks long.

The first block is the head and the rest of the blocks shall be considered as tail blocks.The head's position is always reflective of the current position of the snake.

The position and size attributes are private because the position will only be manipulated by the move() method and the size attribute is not subject to change once the snake object has been instantiated or created.

How the snake moves

The move() method updates the current position, creates a new block with the current position, then places the newly created block at the front of the snake body(new head). The last block of the snake body is then removed in order not to have the snake grow(increase in length).

This implies, in each frame, a block with an outdated position is removed and a block with the current position is added to the front. As frames are drawn, a simulation of motion is created.

When does the snake feed on itself

The condition to which the snake is said to have eaten itself is when the head(the first block) gets into contact with any of the other blocks. In other words the head's position is equal to any of the tail blocks' positions.

How the snake grows

The snake growth mechanism is not different from the moving mechanism except that, the last block of the snake body is not removed since we are interested in the increase in length.

Fruit Class

The fruit is represented by a square block of predefined size.

This class has all its properties marked as private for the same simple reason: Once the fruit is created or instantiated, the properties are not subject to change during game play. Rather new ones can be created with the desired properties.

The move method repositions (in place) the fruit using the move_ip() method of the pygame.Rect class

NOTE: I have not used the move() method of the fruit class in game play, since I have followed the approach of spawning new fruits at the desired position rather than modifying their positions.

services.py

    import pygame

    from utils import Position


    class FontService:
        def __init__(self, filename: str, font_size: int):
            self.__font = pygame.font.Font(filename, font_size)
            self.__font_size = font_size

        def draw_text(self, text: str, surface: pygame.Surface, position: Position, color: pygame.Color) -> None:
            text_surface = self.__font.render(text, True, color)
            surface.blit(text_surface, (position.x, position.y))

        def draw_text_at_center(self, text: str, surface: pygame.Surface, color: pygame.Color) -> None:
            surface_width, surface_height = surface.get_size()
            text_surface = self.__font.render(text, True, color)
            text_surface_height = text_surface.get_size()[1]
            text_rect = text_surface.get_rect()
            text_rect.midtop = (surface_width // 2, surface_height // 2 - text_surface_height)
            surface.blit(text_surface, text_rect)

        def draw_text_at_bottom_center(self, text: str, surface: pygame.Surface, color: pygame.Color) -> None:
            surface_width, surface_height = surface.get_size()
            text_surface = self.__font.render(text, True, color)
            text_surface_height = text_surface.get_size()[1]
            text_rect = text_surface.get_rect()
            text_rect.midtop = (surface_width // 2, surface_height - text_surface_height)
            surface.blit(text_surface, text_rect)

        def get_font(self):
            return self.__font


    class SmallFontService(FontService):
        def __init__(self, filename: str = "./fonts/KidpixiesRegular.ttf"):
            super().__init__(filename, font_size=18)


    class LargeFontService(FontService):
        def __init__(self, filename: str = "./fonts/KidpixiesRegular.ttf"):
            super().__init__(filename, font_size=44)


    class GameSoundService:
        def __init__(self) -> None:
            self.fruit_eaten_sound_channel = pygame.mixer.Channel(0)
            self.fruit_eaten_sound_channel.set_volume(0.9)
            self.main_channel = pygame.mixer.Channel(1)
            self.main_channel.set_volume(0.6)

            self.background_music_sound = pygame.mixer.Sound("./sounds/background.ogg")
            self.fruit_eaten_sound = pygame.mixer.Sound("./sounds/fruit_eaten.wav")
            self.game_over_sound = pygame.mixer.Sound("./sounds/game_over.wav")
            self.snake_hissing_sound = pygame.mixer.Sound("./sounds/snake-hissing-sound.mp3")

        def play_background_music(self) -> None:
            self.main_channel.play(self.background_music_sound, loops=-1)

        def play_fruit_eaten_sound(self) -> None:
            self.fruit_eaten_sound_channel.play(self.fruit_eaten_sound)

        def play_game_over_sound(self) -> None:
            self.main_channel.play(self.game_over_sound)

        def play_snake_hissing_sound(self) -> None:
            self.main_channel.play(self.snake_hissing_sound, loops=-1)

The services file exists to isolate certain prevalent functions such as drawing text on the screen. It is simply a wrapper of the few prevalent features desirable in the snake game that you would rather not repeat yourself incase that particular feature is needed.

I am afraid to say, there is already a better place that explains what is going on in this file, please go ahead and read the pygame documentation. Dont shy away

There is just one subtle thing to note about the GameSoundService, two channels have been used to play game sound, simply because certain sounds are desired to be played simultaneously. Forexample, as the background music is playing, you might want to play the fruit_eaten.wav sound without stopping playback of the background music.

snake.py

This is the file that glues all the rest together.

    import sys
    import random
    import os

    import pygame
    import pygame.gfxdraw
    import pygame_widgets
    from pygame_widgets.button import Button

    sys.path.insert(0, os.path.dirname("."))

    from game_objects import Snake, Fruit
    from services import LargeFontService, SmallFontService, GameSoundService
    from utils import Position


    class Game:
        def __init__(self):
            self.window_fill_color = pygame.Color("black")
            self.window_top_margin = 30
            self.window_width = 500
            self.window_height = 500
            self.window_dimensions = (self.window_width, self.window_height)
            self.window_caption = "Snake Game By Kirabo Ibrahim <3"
            self.window = pygame.display.set_mode(self.window_dimensions)
            self.window.fill(self.window_fill_color)
            pygame.display.set_icon(pygame.image.load("./images/icon.png"))
            pygame.display.set_caption(self.window_caption)

            self.snake = None
            self.starting_snake_position = Position(100, 50)
            self.snake_crawl_unit_size = 10
            self.snake_displacement = (self.snake_crawl_unit_size, 0)
            self.snake_direction = "R"
            self.snake_size = self.snake_crawl_unit_size
            self.fruit = None
            self.eaten_fruit_reward = 10
            self.player_score = 0

            self.font_small = SmallFontService()
            self.font_large = LargeFontService()
            self.game_sound_service = GameSoundService()

            self.frame_rate = 10
            self.clock = pygame.time.Clock()

        def start(self):
            self.game_sound_service.play_background_music()
            self.spawn_snake()
            self.spawn_fruit()

            quit_game, game_over = False, False
            while not quit_game and not game_over:
                self.clear_screen()
                self.draw_score_board()
                self.draw_game_panel_separator()

                events = pygame.event.get()
                for event in events:
                    if self.is_quit_event(event):
                        quit_game = True

                    self.snake_displacement = self.get_displacement(event)

                self.snake_direction = self.get_snake_direction()
                self.snake.move(self.snake_displacement[0], self.snake_displacement[1])
                self.snake.draw(self.window)

                if self.is_game_over():
                    game_over = True
                else:
                    if self.has_snake_eaten_fruit():
                        self.update_player_score()
                        self.snake.grow()
                        self.game_sound_service.play_fruit_eaten_sound()
                        self.spawn_fruit()
                    else:
                        # Fruit has not been eaten by the snake, re draw it at the same position
                        self.re_draw_fruit()

                pygame.display.update()
                self.clock.tick(self.frame_rate)
            if game_over:
                self.draw_game_over_screen()
            self.quit()

        def update_player_score(self) -> None:
            self.player_score += self.eaten_fruit_reward

        def draw_score_board(self) -> None:
            player_score_position = Position(10, 5)
            self.font_small.draw_text("Score: {}".format(self.player_score), self.window, player_score_position,
                                    pygame.Color("white"))

        def draw_game_panel_separator(self) -> None:
            pygame.gfxdraw.hline(self.window, 0, self.window_width, self.window_top_margin,
                                pygame.Color("white"))

        def spawn_fruit(self) -> None:
            fruit_position = self.generate_fruit_position()
            self.fruit = Fruit(fruit_position)
            self.fruit.draw(self.window)

        def generate_fruit_position(self) -> Position:
            """
            Movement of the snake is increments of snake_crawl_unit_size, so the position of the fruit should
            be a multiple of crawl size in order to avoid misalignment btn the snake body and the fruit
            """
            position_x = random.randint(1, self.window_width // self.snake_crawl_unit_size) * self.snake_crawl_unit_size
            position_y = random.randint(self.window_top_margin // self.snake_crawl_unit_size,
                                        self.window_height // self.snake_crawl_unit_size - self.snake_size) * \
                        self.snake_crawl_unit_size
            return Position(position_x, position_y)

        def re_draw_fruit(self) -> None:
            self.fruit.draw(self.window)

        def spawn_snake(self) -> None:
            self.snake = Snake(self.starting_snake_position, self.snake_size)
            self.snake.draw(self.window)

        def has_snake_eaten_fruit(self) -> bool:
            fruit_position = self.fruit.get_position()
            snake_position = self.snake.get_position()
            if fruit_position.x == snake_position.x and fruit_position.y == snake_position.y:
                return True
            return False

        def is_game_over(self) -> bool:
            if self.has_snake_collided_with_walls() or self.snake.has_eaten_itself():
                return True
            return False

        def has_snake_collided_with_walls(self) -> bool:
            snake_position = self.snake.get_position()
            if snake_position.x < 0 or snake_position.x > self.window_width - self.snake_size:
                return True
            if snake_position.y < self.window_top_margin or \
                    snake_position.y > self.window_height - self.snake_size:
                return True
            return False

        def draw_game_over_screen(self) -> None:
            self.clear_screen()
            self.game_sound_service.play_game_over_sound()
            self.draw_score_board()
            self.draw_game_panel_separator()
            self.font_large.draw_text_at_center("Game Over :(", self.window, pygame.Color("red"))
            self.font_small.draw_text("Press [SPACE] to restart game", self.window,
                                    Position(110, 300), pygame.Color("white"))

            quit_game, restart_game = False, False
            while not quit_game and not restart_game:
                events = pygame.event.get()
                for event in events:
                    if self.is_quit_event(event):
                        quit_game = True
                    if self.is_space_bar_key_event(event):
                        restart_game = True
                pygame.display.update()

            if restart_game:
                self.restart()
            self.quit()

        @staticmethod
        def is_space_bar_key_event(event):
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_SPACE:
                    return True
            return False

        def clear_screen(self) -> None:
            self.window.fill(self.window_fill_color)

        def get_snake_direction(self) -> str:
            """ Get the snake's current direction of movement,
            The snake is either moving left, right, up or down
            """
            displacement_x = self.snake_displacement[0]
            displacement_y = self.snake_displacement[1]
            if displacement_x > 0:
                return "R"
            elif displacement_x < 0:
                return "L"
            if displacement_y > 0:
                return "D"
            elif displacement_y < 0:
                return "U"

        def get_displacement(self, event: pygame.event.Event) -> tuple[int, int]:
            if self.is_arrow_key_pressed_event(event):
                if event.key == pygame.K_LEFT and self.snake_direction != "R":
                    return -1 * self.snake_crawl_unit_size, 0
                elif event.key == pygame.K_RIGHT and self.snake_direction != "L":
                    return self.snake_crawl_unit_size, 0
                elif event.key == pygame.K_UP and self.snake_direction != "D":
                    return 0, -1 * self.snake_crawl_unit_size
                elif event.key == pygame.K_DOWN and self.snake_direction != "U":
                    return 0, self.snake_crawl_unit_size
            return self.snake_displacement

        def restart(self) -> None:
            self.reset_game()
            self.start()

        def reset_game(self) -> None:
            self.player_score = 0
            self.snake_displacement = (self.snake_crawl_unit_size, 0)
            self.snake_direction = "R"

        @staticmethod
        def is_arrow_key_pressed_event(event: pygame.event.Event) -> bool:
            if event.type == pygame.KEYDOWN:
                return (event.key == pygame.K_LEFT) or (event.key == pygame.K_UP) or (event.key == pygame.K_DOWN) or \
                    (event.key == pygame.K_RIGHT)
            return False

        @staticmethod
        def quit() -> None:
            pygame.quit()
            sys.exit()

        @staticmethod
        def is_quit_event(event: pygame.event.Event) -> bool:
            return event.type == pygame.QUIT

        def draw_startup_screen(self):
            startup_image = pygame.image.load("./images/background.png")
            self.window.blit(startup_image, startup_image.get_rect())
            self.draw_start_button()
            self.draw_exit_button()
            self.game_sound_service.play_snake_hissing_sound()

            quit_game = False
            while not quit_game:
                events = pygame.event.get()
                for event in events:
                    if self.is_quit_event(event):
                        quit_game = True

                pygame_widgets.update(events)
                pygame.display.update()
            self.quit()

        def draw_start_button(self) -> None:
            start_button_width, start_button_height = 200, 100
            start_button_position_x, start_button_position_y = self.window_width // 2 - 100, self.window_height // 2 - 100
            start_button = Button(self.window, start_button_position_x, start_button_position_y, start_button_width,
                                start_button_height, text="START", font=self.font_large.get_font(),
                                textColour=(255, 255, 255), inactiveColour=(97, 118, 75), radius=20, onClick=self.start
                                )

        def draw_exit_button(self) -> None:
            exit_button_width, exit_button_height = 200, 100
            exit_button_position_x, exit_button_position_y = self.window_width // 2 - 100, self.window_height // 2 + 90
            exit_button = Button(self.window, exit_button_position_x, exit_button_position_y, exit_button_width,
                                exit_button_height, text="EXIT", font=self.font_large.get_font(),
                                textColour=(255, 255, 255), inactiveColour=(97, 118, 75), radius=20, onClick=self.quit
                                )

        def draw_restart_button(self) -> None:
            restart_button_width, restart_button_height = 200, 100
            restart_button_position_x, restart_button_position_y = self.window_width // 2 - 100, self.window_height // 2 + 90
            restart_button = Button(self.window, restart_button_position_x, restart_button_position_y, restart_button_width,
                                    restart_button_height, text="RESTART", font=self.font_large.get_font(),
                                    textColour=(255, 255, 255), inactiveColour=(97, 118, 75), radius=20, onClick=self.start
                                    )


    if __name__ == "__main__":
        pygame.init()
        try:
            game = Game()
            game.draw_startup_screen()
        except KeyboardInterrupt:
            pygame.quit()
            sys.exit()

The Game class has a few attributes worthy taking note of.

snake_direction: The default direction is "R". This attribute is in place to prevent reverse movements of the snake.

snake_displacement: Represents how many units to move from the snake position in both x and y direction. Initially, the snake displacement points in the "R" direction and its quite not suprising for the snake_direction to also be pointing in the "R" direction.

window_top_margin: Marks the start of the screen height available during game play since some space at the top of the window is reserved for drawing the player's score.

frame_rate: Determines how fast frames are drawn to the screen. You can get the right value for this by trial and error.

Game flow

draw_startup_screen()

    def draw_startup_screen(self):
                startup_image = pygame.image.load("./images/background.png")
                self.window.blit(startup_image, startup_image.get_rect())
                self.draw_start_button()
                self.draw_exit_button()
                self.game_sound_service.play_snake_hissing_sound()

                quit_game = False
                while not quit_game:
                    events = pygame.event.get()
                    for event in events:
                        if self.is_quit_event(event):
                            quit_game = True

                    pygame_widgets.update(events)
                    pygame.display.update()
                self.quit()

This method starts the game. It draws the start and exit button on the game window. When the player clicks the start button, control is delegated to the start() method.

start() method

    def start(self):
                self.game_sound_service.play_background_music()
                self.spawn_snake()
                self.spawn_fruit()

                quit_game, game_over = False, False
                while not quit_game and not game_over:
                    self.clear_screen()
                    self.draw_score_board()
                    self.draw_game_panel_separator()

                    events = pygame.event.get()
                    for event in events:
                        if self.is_quit_event(event):
                            quit_game = True

                        self.snake_displacement = self.get_displacement(event)

                    self.snake_direction = self.get_snake_direction()
                    self.snake.move(self.snake_displacement[0], self.snake_displacement[1])
                    self.snake.draw(self.window)

                    if self.is_game_over():
                        game_over = True
                    else:
                        if self.has_snake_eaten_fruit():
                            self.update_player_score()
                            self.snake.grow()
                            self.game_sound_service.play_fruit_eaten_sound()
                            self.spawn_fruit()
                        else:
                            # Fruit has not been eaten by the snake, re draw it at the same position
                            self.re_draw_fruit()

                    pygame.display.update()
                    self.clock.tick(self.frame_rate)
                if game_over:
                    self.draw_game_over_screen()
                self.quit()

The startup() method is the core of the Game class. Before the game loop, screen is cleard to have a blank window, fruit and snake are drawn onto the screen.

With in the game loop, we listen to the quit and arrow events. From the arrow events, we deduce the displacement of the snake(How many units should the snake move in the x and y from the current position).

Regarding the movement of the snake, direction of the snake is important, imagine the player pressing the up arrow key when the snake is currently moving downwards, the snake reverses i.e moves upwards. This isn't allowed. As a workaround, the snake_direction attribute keeps track of the snake's current direction, so as not to permit movments in the reverse direction.

If game over conditons aren't met yet and the snake's position is equal to that of the fruit(fruit eaten), player is awarded 10 points, snake grows, fruit eaten sound is played and another fruit is spawned in a random position on the screen. If the snake hasn't eaten the fruit, the fruit is redrawn at the same position.

When the game ends(Game over conditions met), game over screen is drawn, which simply listens for quit and space bar key event. In response to pressing the space bar key, the game is restarted and the whole process starts again.

Challenges

I am a newbie in game development and not to mention, this has been my first time to use the pygame package. However, the pygame documentation is one of the most comprehensible documentation I have ever read. With the pygame alienness out of my way, I was closest to successfully completing the game.

Before writing my own version of snake game, I had to go through a couple of other developers's version of snake. Even after reviewing other developer's code, moving and growth mechanism of the snake was obscure. There are simply two reasons for this:

  • The code(other developer's versions of snake) never documented it. The comments were literally verbatim.
  • I was imagining it the wrong way.

For the first I had no choice but to figure it out on my own, and the solution to the second is what led me there. I just had to start thinking in terms of frames to get everything right.

I hope, I have not created another fuddle of code.

Happy learning!

Sign up for FREE 3 months of Amazon Music. YOU MUST NOT MISS.