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
-
Installing pygame
pip install pygame
-
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.