Initial Snake Prototype: Part 2
Previously, we had gotten the snake to move around correctly and eat apples correctly. However, we still have some work to do in ordre to get a functioning snake. Let’s add the ability for the snake to die. First, let’s get a refresh on the code we had last time.
import pygame
from enum import Enum
from random import randint
from math import floor
class Game():
def __init__(self, fps, width, height):
pygame.init()
self.done = False
self.fps = fps
self.clock = pygame.time.Clock()
self.screen = pygame.display.set_mode((width, height))
self.snake = Snake(self, 40)
self.food = Food(self, 40)
def play(self):
timer = 0
speed = 10
while not self.done:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.done = True
if event.type == pygame.KEYDOWN:
self.userInput(event.key)
if timer * speed > 1:
timer = 0
self.snake.move()
self.snake.checkEat()
self.screen.fill((0, 0, 0))
pygame.draw.rect(self.screen, Snake.color, self.snake)
pygame.draw.rect(self.screen, Food.color, self.food)
for block in self.snake.tail:
pygame.draw.rect(self.screen, Block.color, block)
pygame.display.flip()
timer += self.clock.tick(self.fps) / 1000
def userInput(self, key):
if key == pygame.K_UP:
self.snake.changeDirection(Direction.UP)
elif key == pygame.K_DOWN:
self.snake.changeDirection(Direction.DOWN)
elif key == pygame.K_LEFT:
self.snake.changeDirection(Direction.LEFT)
elif key == pygame.K_RIGHT:
self.snake.changeDirection(Direction.RIGHT)
else:
return
class Block(pygame.Rect):
color = (0, 128, 255) # blue
def __init__(self, size, x, y):
self.width = size
self.height = size
self.x = x
self.y = y
self.dim = size
class Direction(Enum):
UP = [0, -40]
DOWN = [0, 40]
LEFT = [-40, 0]
RIGHT = [40, 0]
class Snake(Block):
color = (124, 252, 0)
def __init__(self, game, size):
Block.__init__(self, size,
floor((pygame.display.get_window_size()
[0]/size - 1)/2) * size,
floor((pygame.display.get_window_size()
[1]/size - 1)/2) * size)
self.direction = Direction.UP
self.game = game
self.tail = []
def move(self):
if self.tail:
self.tail = self.tail[1:]
self.tail.append(Block(self.dim, self.x, self.y))
self.x += self.direction.value[0]
self.y += self.direction.value[1]
self.checkEat()
self.hitWall()
def changeDirection(self, newDirection):
self.direction = newDirection
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
self.growTail()
def growTail(self):
self.tail.append(Block(
self.dim, self.x - self.direction.value[0], self.y - self.direction.value[1]))
def hitWall(self):
if (self.x < 0 or self.y < 0
or self.x > pygame.display.get_window_size()[0] - self.dim
or self.y > pygame.display.get_window_size()[1] - self.dim
):
# Push back
self.x -= self.direction.value[0]
self.y -= self.direction.value[1]
class Food(Block):
color = (255, 0, 100)
def __init__(self, game, size):
Block.__init__(self, size, 0, 0)
self.game = game
self.relocate()
def relocate(self):
cols = floor(pygame.display.get_window_size()[0]/self.dim - 1)
rows = floor(pygame.display.get_window_size()[1]/self.dim - 1)
self.x, self.y = randint(0, cols) * \
self.dim, randint(0, rows) * self.dim
if not self.isSafe():
self.relocate()
def isSafe(self):
if self.colliderect(self.game.snake):
return False
else:
return True
def main():
game = Game(60, 800, 600)
game.play()
if __name__ == "__main__":
main()
Since we want the game to end, let’s add a gameOver()
method for the Game
class.
# In Game
def gameOver(self):
print(f"You have died! You ate {len(self.snake.tail)} pieces of food!")
self.done = True
As of right now, the only way for the snake to die is if he hits a wall, so let’s add a check after every movement.
This means we also need to edit out Snake.hitWall()
function to edit the instance variable hit_wall
.
def hitWall(self):
if (self.x < 0 or self.y < 0
or self.x > pygame.display.get_window_size()[0] - self.dim
or self.y > pygame.display.get_window_size()[1] - self.dim
):
self.hit_wall = True
# Push back
self.x -= self.direction.value[0]
self.y -= self.direction.value[1]
else:
self.hit_wall = False
Now if we run our code, the snake can die! You may not be able to tell from the video, but if you run it yourself the window will close right after the snake hits the wall.
The snake should also die when it hits one of it’s tail. Let’s add a hitSelf()
function and hit_self
instance variable.
def hitSelf(self):
for block in self.tail:
if block.colliderect(self):
self.hit_self = True
return
self.hit_self = False
We now have to call this function in the move()
function. It can just be added to the end self.hitSelf(()
.
Let’s add a method to check if the snake has died.
This changes the check after movment as follows:
#Old Method
if self.snake.hit_wall:
self.gameOver()
#New Method
if self.snake.checkDead():
self.gameOver()
Now we can see that the snake will die if it hits itself or if it hits a wall. One issue is that the snake can reverse directions and go from Direction.LEFT to Direction.RIGHT, but that type of movement should be impossible. To fix this, we need to set the changeDirection()
function to filter out those inputs.
def changeDirection(self, newDirection):
if newDirection.value == [-x for x in self.direction.value]:
return
self.direction = newDirection
Now, no matter how hard you try, you can’t go back into yourself.
One issue that is persistant is if you press two keys such as up and left at the same time while going right, the snake will appear to switch directions. This is because we check for user input every frame, but we only mnove once the timer has reached a certain time. The way I decided to fix this is to introduce a buffer that will hold any extra user input while also allowing a user to add input at any time.
while not self.done:
#For loop:
if event.type == pygame.KEYDOWN:
if moved:
self.userInput(event.key)
moved = False
else:
input_buffer.append(event.key)
if input_buffer and moved:
self.userInput(input_buffer.pop(0))
moved = False
Now no matter how fast we press the buttons, we can’t flip directions.
Finally, let’s add a score to the game so we can see how we’re doing in game.
class Score():
def __init__(self, screen, location):
self.font = pygame.font.Font(None, 36)
self.value = 0
self.location = location
self.screen = screen
def draw(self):
text = self.font.render(f"Score: {self.value}", 1, (255, 255, 255))
self.screen.blit(text, self.location)
def changeScore(self, change):
self.value += change
def reset(self):
self.value = 0
Now we have to create an instance in Game
Now we have to draw it in play()
. Since I want the score to be visible at all times and not get covered up by the snake, it should be drawn first.
#In Game.play():
#In while:
self.score.draw()
pygame.draw.rect(self.screen, Snake.color, self.snake)
pygame.draw.rect(self.screen, Food.color, self.food)
In order to get the score to update, we can add a line to the code that executes when the snake eats because that is when the score should change.
#In Snake.checkEat()
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
self.growTail()
self.game.score.changeScore(1)
Now we have a functional snake game! The final code is posted below. The next step will be to refactor the code where applicable.
import pygame
from enum import Enum
from random import randint
from math import floor
class Game():
def __init__(self, fps, width, height):
pygame.init()
self.done = False
self.fps = fps
self.clock = pygame.time.Clock()
self.screen = pygame.display.set_mode((width, height))
self.snake = Snake(self, 40)
self.food = Food(self, 40)
self.score = Score(self.screen, (10, 10))
def play(self):
timer = 0
speed = 10
input_buffer = []
moved = False
while not self.done:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.done = True
if event.type == pygame.KEYDOWN:
if moved:
self.userInput(event.key)
moved = False
else:
input_buffer.append(event.key)
if input_buffer and moved:
self.userInput(input_buffer.pop(0))
moved = False
if timer * speed > 1:
timer = 0
self.snake.move()
if self.snake.checkDead():
self.gameOver()
moved = True
self.snake.checkEat()
self.screen.fill((0, 0, 0))
self.score.draw()
pygame.draw.rect(self.screen, Snake.color, self.snake)
pygame.draw.rect(self.screen, Food.color, self.food)
for block in self.snake.tail:
pygame.draw.rect(self.screen, Block.color, block)
pygame.display.flip()
timer += self.clock.tick(self.fps) / 1000
def userInput(self, key):
if key == pygame.K_UP:
self.snake.changeDirection(Direction.UP)
elif key == pygame.K_DOWN:
self.snake.changeDirection(Direction.DOWN)
elif key == pygame.K_LEFT:
self.snake.changeDirection(Direction.LEFT)
elif key == pygame.K_RIGHT:
self.snake.changeDirection(Direction.RIGHT)
elif key == pygame.K_SPACE:
self.pause = ~self.pause
def gameOver(self):
print(f"You have died! You ate {len(self.snake.tail)} pieces of food!")
self.done = True
class Block(pygame.Rect):
color = (0, 128, 255) # blue
def __init__(self, size, x, y):
self.width = size
self.height = size
self.x = x
self.y = y
self.dim = size
class Direction(Enum):
UP = [0, -40]
DOWN = [0, 40]
LEFT = [-40, 0]
RIGHT = [40, 0]
class Snake(Block):
color = (124, 252, 0)
def __init__(self, game, size):
Block.__init__(self, size,
floor((pygame.display.get_window_size()
[0]/size - 1)/2) * size,
floor((pygame.display.get_window_size()
[1]/size - 1)/2) * size)
self.direction = Direction.NONE
self.game = game
self.tail = []
def move(self):
if self.tail:
self.tail = self.tail[1:]
self.tail.append(Block(self.dim, self.x, self.y))
self.x += self.direction.value[0]
self.y += self.direction.value[1]
self.checkEat()
self.hitWall()
self.hitSelf()
def changeDirection(self, newDirection):
if newDirection.value == [-x for x in self.direction.value]:
return
self.direction = newDirection
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
self.growTail()
self.game.score.changeScore(1)
def growTail(self):
self.tail.append(Block(
self.dim, self.x - self.direction.value[0], self.y - self.direction.value[1]))
def hitWall(self):
if (self.x < 0 or self.y < 0
or self.x > pygame.display.get_window_size()[0] - self.dim
or self.y > pygame.display.get_window_size()[1] - self.dim
):
self.hit_wall = True
# Push back
self.x -= self.direction.value[0]
self.y -= self.direction.value[1]
else:
self.hit_wall = False
def hitSelf(self):
for block in self.tail:
if block.colliderect(self):
self.hit_self = True
return
self.hit_self = False
def checkDead(self):
if self.hit_self or self.hit_wall:
return True
else:
return False
class Food(Block):
color = (255, 0, 100)
def __init__(self, game, size):
Block.__init__(self, size, 0, 0)
self.game = game
self.relocate()
def relocate(self):
cols = floor(pygame.display.get_window_size()[0]/self.dim - 1)
rows = floor(pygame.display.get_window_size()[1]/self.dim - 1)
self.x, self.y = randint(0, cols) * \
self.dim, randint(0, rows) * self.dim
if not self.isSafe():
self.relocate()
def isSafe(self):
if self.colliderect(self.game.snake):
return False
else:
return True
class Score():
def __init__(self, screen, location):
self.font = pygame.font.Font(None, 36)
self.value = 0
self.location = location
self.screen = screen
def draw(self):
text = self.font.render(f"Score: {self.value}", 1, (255, 255, 255))
self.screen.blit(text, self.location)
def changeScore(self, change):
self.value += change
def reset(self):
self.value = 0
def main():
game = Game(60, 800, 600)
game.play()
if __name__ == "__main__":
main()