Initial Snake Prototype: Part 1
The main goal of the first part of this instruction is to get familiar with the Pygame environment and functions. After the first half there will be lots of code refactoring to get actual snake behavior, but the first half is to get used to coding with Pygame.
Now that we have our environment set up we can go ahead and start creating our Snake. If you look at the typical Pygame loop, it looks something like this:
import pygame
pygame.init()
done = False
clock = pygame.time.Clock()
screen = pygame.display.set_mode((800, 600))
while not done:
for event in pygame.event.get():
if event.type == pygame.QUIT:
done = True
pygame.display.flip() #update display
clock.tick(60)
At first, some variables are set from the Pygame library. We get a copy of the internal clock and the screen object with a width of 800px and a height of 600px. The while
loop will check if the game is done then iterate x times every second until the end of the game. Here, the x represents the parameter passed to the clock.tick()
function. In this case, every 60 seconds the game will check for a Pygame event, check if it is the user wanting to quit, then if not it will update the display.
This will be the basic outline from which I create the Snake game. Currently, only a blank screen will pop up.
Step one is to create a Game
class to encapsulate the game. The game should have a done
instance variable, and a main game method.
import pygame
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))
def play(self):
while not self.done:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.done = True
pygame.display.flip()
self.clock.tick(self.fps)
def main():
game = Game(60, 800, 600)
game.play()
if __name__ == "__main__":
main()
Here is a basic outline of the class. It has one parameter passed into it, fps
which is passed into the internal clock of the game, retrieved from pygame.time.clock
and stored in self.clock
. This class will continually be updated as we add members to the game.
We also need a main method in order to create our game and play it, that’s what the last two 6 lines are doing.
Now we can move on to the actual Snake. Let’s start with getting a square on the screen. All of these changed will take place in the play
method.
while not self.done:
#for loop
pygame.draw.rect(self.screen, (124, 252, 0), pygame.Rect(400, 300, 30, 30))
pygame.display.flip()
self.clock.tick(self.fps)
#rest of the code
Running this, you should end up with a green square on your screen like below.
What is happening is we are telling Pygame we want to draw a rectangle with the rgb values (124, 252, 0)
onto the screen. The rectangle will be at coordinates x -- 400
, x -- 300
, size of 30x30
or square.
We don’t have any movement yet, it’s just the same square being redrawn over and over again. Let’s bring this out into a class for the snake and add some movement.
class Snake(pygame.rect):
color = (124, 252, 0)
def __init__(self):
self.size = 30
self.x = 400
self.y = 300
def move(self):
self.x += 3
Adding this into the main game loop we get a total script of
import pygame
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()
def play(self):
while not self.done:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.done = True
self.snake.move()
pygame.draw.rect(self.screen, Snake.color, self.snake)
pygame.display.flip()
self.clock.tick(self.fps)
class Snake(pygame.Rect):
color = (124, 252, 0)
def __init__(self):
self.size = 30
self.x = 400
self.y = 300
def move(self):
self.x += 3 #move across the x axis 3 pixels per frame
def main():
game = Game(60, 800, 600)
game.play()
if __name__ == "__main__":
main()
This code should lead to something like this.
You may notice that something isn’t quite right. This is because we aren’t overwriting the previous place of the square. The quick fix is to refill the screen as black every iteration. Adding the following line should fix this.
self.screen.fill((0, 0, 0)) #value for black
This just needs to be placed inside the loop BEFORE the other objects are drawn.
Perfect. Now let’s add the ability to control the direction of the snake.
First we should define direction. I decided on defining it as an enumeration
from enum import Enum
class Direction(Enum):
UP = [0, -3]
DOWN = [0, 3]
LEFT = [-3, 0]
RIGHT = [3, 0]
Next, we should define user input. Pygame has an event KEYDOWN
that fires when a key is pressed. The key is held in the event
variable from the for loop in the main game loop. First, let’s make a function for the snake that takes in a Direction and updates it’s current direction. We’ll also add a Direction instance variable.
#In class Snake
def changeDirection(self, newDirection):
self.direction = newDirection
def move(self):
self.x += self.direction.value[0]
self.y += self.direction.value[1]
That was simple. Now let’s write a function that takes a key and adjusts the direction of the snake accordingly.
#In class Game
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
Finally, we add the code in the game loop to call the functions we have created.
#In play()
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)
#Rest of the function
We have movement! We’re starting to get a snake game.
Now we can get to work on the food that the snake will eat. The food will be similar to the snake, it will be a rectangle, and will be able to relocate once it is eaten, and it will have a different color – pink.
class Food(pygame.Rect):
color = (255, 0, 100)
def __init__(self, size, game):
self.width = size
self.height = size
self.relocate()
def relocate(self):
self.x, self.y = randint(0, 800), randint(0, 600) #from random import randint
if not self.isSafe():
self.relocate()
def isSafe(self):
if self.colliderect(self.game.snake):
return False
else:
return True
Now we can define a basic eating method for the snake, when the snake hits the food, the food should relocate.
#In Snake
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
Now we have to call checkEat()
in the main game loop, right after we move. We also have to draw the food on the screen.
So far we have
import pygame
from enum import Enum
from random import randint
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, 30)
self.food = Food(self, 30)
def play(self):
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)
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)
pygame.display.flip()
self.clock.tick(self.fps)
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 Direction(Enum):
UP = [0, -3]
DOWN = [0, 3]
LEFT = [-3, 0]
RIGHT = [3, 0]
class Snake(pygame.Rect):
color = (124, 252, 0)
def __init__(self, game, size):
self.x = 400
self.y = 300
self.height = size
self.width = size
self.direction = Direction.UP
self.game = game
def move(self):
self.x += self.direction.value[0]
self.y += self.direction.value[1]
def changeDirection(self, newDirection):
self.direction = newDirection
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
class Food(pygame.Rect):
color = (255, 0, 100)
def __init__(self, game, size):
self.width = size
self.height = size
self.game = game
self.relocate()
def relocate(self):
self.x, self.y = randint(0, 800), randint(0, 600)
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()
The above code leads to
However, we have a few issues with the design. The food spawns outside of the display sometimes, the snake can go outside of the board, the snake moves in continuous steps and not in blocks, and the snake doesn’t grow.
Let’s tackle one issue at a time, starting with moving in blocks. In order to split the window into a grid, we need to divide the width and height of the window into equal size blocks. Our width is 800 and our height is 600. If we divide each by 40 we will get our rows and columns of the grid. This will involve changes to the sizes of the squares, the Direction
enum and the relocate
method to align the food on the grid.
class Direction(enum):
UP = [0, -40]
DOWN = [0, 40]
LEFT = [-40, 0]
RIGHT = [40, 0]
#Later in Food class...
def relocate(self):
cols = floor(self.game.screen.get_size()[0]/self.width - 1) #from math import floor
rows = floor(self.game.screen.get_size()[1]/self.width - 1)
#Note, the -1 is because we don't want the food to be drawn at the edge column because
#shapes are drawn from the top left meaning if the top left was at the edge of the screen
#it would be drawn off the screen
self.x, self.y = randint(0, cols) * self.width, randint(0, rows) * self.width
if not self.isSafe():
relocate()
#In __init__ of Snake, to start the snake in the middle of the screen
self.x = floor((pygame.display.get_window_size()[0]/size -1)/2) * size
self.y = floor((pygame.display.get_window_size()[1]/size -1)/2) * size
#In __init__ of Game
self.snake = Snake(self, 40)
self.food = Food(self, 40)
Now we have a new problem.
The snake is moving entirely too fast. That is because every frame of the game (60 times per second) the snake is moving by 40 pixels. The snake starts at y = 300 and is going up, meaning after 15 iterations the snake will be at the top of the screen. That should take 1/4 of a second at 60 fps. In reality, we would rather the snake only move once per second. In order to control this we can add in a variable and a check in our main game loop.
#in play()
timer = 0
speed = 10
while not self.done:
#for loop
if timer * speed > 1:
timer = 0
self.snake.move()
#draw
timer += self.clock.tick(self.fps) / 1000 #returns number of miliseconds since last call
Now we’re looking good. We just need to add walls and have the snake grow. The walls are going to be a simple check if the snake is trying to go past 0 on either axis or past the window width on the x axis or height on the y axis.
#In Snake class
def hitWall(self):
if (self.x < 0 or self.y < 0
or self.x > pygame.display.get_window_size()[0] - self.width
or self.y > pygame.display.get_window_size()[1] - self.height
):
#Push back
self.x -= self.direction.value[0]
self.y -= self.direction.value[1]
#Editing move
def move(self):
self.x += self.direction.value[0]
self.y += self.direction.value[1]
self.hitWall()
The only thing left for our basic snake is for the tail to grow. For this, we will create a new class for our rectangles. We could refactor all of our current squares into this class as well.
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 #faster way to look up without a confusing name
The init of our Snake and Food classes would change
class Snake(Block): #Was class Snake(pygame.Rect)
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.tail = [] #Init our tail, will be a list of Blocks
#rest of the code
#Later...
class Food(Block): #Was class Food(pygame.Rect)
def __init__(self):
Block.__init__(self, size, 0, 0)
#rest of the code
Don’t forget to replace any code mentioning self.width
or self.height
, because size is clearer.
Now we need an eat method. We already check if the squares collide, now we can add a method that grows the snake.
#In Snake
def growTail(self):
self.tail.append(Block(self.dim, self.x, self.y))
#Calling the above from the checkEat() method
def checkEat(self):
if self.colliderect(self.game.food):
self.game.food.relocate()
self.growTail()
We also can’t forget to draw the tail, we just go back to our main game loop where we draw everything else
#in while loop
for block in self.snake.tail:
pygame.draw.rect(self.screen, Block.color, block)
Almost, but the tail doesn’t follow the snake. There are a few ways to deal with this issue. I decided to shift the list of the tail to the left and let the first element fall off. Then add a new Block to the tail that has the previous spot of the head.
#In Snake
def move(self):
if self.tail:
self.tail.append(Block(self.dim, self.x, self.y))
self.tail = self.tail[1:]
self.x += self.direction.value[0]
self.y += self.direction.value[1]
self.checkEat()
self.hitWall()
Looks pretty good, but there are a couple more issues that we’ll focus on tackling next time. These include the snake being able to go back into itself, and it can’t die.