A snake game – a must if you do Python!
Apr 032019As part of an voluntary introductory course the students and me made the decision to create a game. They have only one programming course in Matlab, studying mechanical engineering. The whole effort took two and half lectures, but the result was worth it and I hope students have learned something that will be regarded as a valuable experience.
What library to use? Setup environment. Example.
Pyglet, because I like responsive applications, I want to be able to scale and rotate sprites. Also the installation was easy. Students do not have permission to install software, therefore we created a virtualenv. Another totally new thing for them. Using PyCharm really helps, most students are not comfortable with using a shell.
As example application I have prepared a rotating trollface which moved unexpectedly when user hit arrow keys. Example involved loading a PNG image with alpha channel, handling on_draw and on_key_press, scheduling periodical update, and drawing text label with computing FPS. Then we played around with the code a little, changing colours of the trollface randomly when moved, then adding sine waves to the colors depending on time.
So we need only things from pyglet and random.
import pyglet
from pyglet.window import key, mouse
from pyglet.gl import *
import random
Graphics + sound = assets
No blocky quiet boring snake this time! So after some searching the web I found:
OpenGameArt.org Snake sprites & sound
Now this proved to be quite a challange for our first year students. I might swamped them a little with all this – cutting out sprites from one file, sound and the snake has nice turn sprites.
We had a problem with sprites having black borders when we scaled them. I knew setting GL_NEAREST shoud do the trick. After pyglet.texture.Texture constructor mislead me a little, the solution have been found. Just put glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) in on_show event handler.
Application structure
The game_state object of SnakeGame class have all the need methods, holds a window and reacts to user input. on_show only sets the magnifying filter for textures to nearest neighbor.
class SnakeGame:
def __init__(self):
pass
def reset_snake(self):
pass
def place_rabbit(self, r=None, c=None):
pass
def update(self, dt):
pass
def on_show(self):
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER,
GL_NEAREST)
def on_key_press(self, symbol, modifiers):
pass
def on_mouse_press(self, x, y, b, mod):
pass
game_state = SnakeGame() # gamestate with window
pyglet.app.run() # run main event loop
Initialize everything
First we create a window and register SnakeGame methods as handlers for the window’s events. Then we set periodical update (the interval is not guaranteed to be exactly 1/60 s). Also we setup labels to display frames per second and length of the snake and load sound. __init__ method looks like this:
self.frame_counter = 0 # for FPS label
self.one_second_counter = 0 # for FPS label
# pyglet objects
self.main_window = pyglet.window.Window(1200, 800)
self.main_window.push_handlers(self.on_key_press,
self.on_draw,
self.on_show,
self.on_mouse_press)
pyglet.clock.schedule_interval(self.update, 1 / 60)
self.fps_label = pyglet.text.Label('??? FPS',
font_name='Arial',
font_size=20,
x=10, y=self.main_window.height,
anchor_x='left', anchor_y='top')
self.len_label = pyglet.text.Label('Length',
font_name='Arial',
font_size=20,
x=10, y=self.main_window.height-35,
anchor_x='left', anchor_y='top')
# sound
self.eat_sound = pyglet.media.load('data/eat.wav', streaming=False)
self.die_sound = pyglet.media.load('data/die.wav', streaming=False)
The graphics is in one PNG file which we will cut with get_region method. Also we set a zoom (scale factor) and compute how many sprites we can cram into our window and prepare background (not bothering with batch drawing which pyglet supports).
# graphics
snake_png = pyglet.resource.image("data/Snake.png")
s_w = self.snake_png.width // 4
s_h = self.snake_png.height // 4
self.images = {
'head_up': snake_png.get_region(0, 3*s_h, s_w, s_h),
'head_right': snake_png.get_region(s_w, 3*s_h, s_w, s_h),
'head_down': snake_png.get_region(2*s_w, 3 * s_h, s_w, s_h),
'head_left': snake_png.get_region(3*s_w, 3 * s_h, s_w, s_h),
'tail_up': snake_png.get_region(0, 2 * s_h, s_w, s_h),
'tail_right': snake_png.get_region(s_w, 2 * s_h, s_w, s_h),
'tail_down': snake_png.get_region(2 * s_w, 2 * s_h, s_w, s_h),
'tail_left': snake_png.get_region(3 * s_w, 2 * s_h, s_w, s_h),
'turn_1': snake_png.get_region(0, 1 * s_h, s_w, s_h),
'turn_2': snake_png.get_region(s_w, 1 * s_h, s_w, s_h),
'turn_3': snake_png.get_region(2 * s_w, 1 * s_h, s_w, s_h),
'turn_4': snake_png.get_region(3 * s_w, 1 * s_h, s_w, s_h),
'vertical': snake_png.get_region(0, 0, s_w, s_h),
'horizontal': snake_png.get_region(s_w, 0, s_w, s_h),
'rabbit': snake_png.get_region(2 * s_w, 0, s_w, s_h),
'grass': snake_png.get_region(3 * s_w, 0, s_w, s_h),
}
self.sprite_zoom = 5
self.columns = self.main_window.width // (self.sprite_zoom * s_w)
self.rows = self.main_window.height // (self.sprite_zoom * s_h)
self.sprite_w = s_w
self.sprite_h = s_h
self.background_sprites = []
for i in range(self.columns):
for j in range(self.rows):
sprite = pyglet.sprite.Sprite(self.images['grass'])
sprite.scale = self.sprite_zoom
sprite.position = (i * s_w * self.sprite_zoom,
j * s_h * self.sprite_zoom)
self.background_sprites.append(sprite)
We store snake parts in a list, where index 0 is head and last index is tail. Every snake part is a tuple (row, col, direction, sprite). We also exploit that tuple being immutable, image of the sprite can still be changed. I do consider using immutable types where possible a good practice of defensive programming. Rabbit is a list, because we want to change it’s row and column placement.
self.snake = [] # parts of snake (list)
self.snake_dir = 'right' # current direction of snake
self.snake_dir_next = 'right' # direction where snake moves
self.reset_snake()
self.snake_move_t = 0.2 # interval of snake move
# current remaining time until snake moves
self.snake_move_t_rem = self.snake_move_t
self.rabbit = [0, 0, pyglet.sprite.Sprite(self.images['rabbit'])]
self.rabbit[2].scale = self.sprite_zoom
self.place_rabbit()
At the beginning of each game, we reset the snake speed and place a snake of length 3 into upper bottom third of the screen, facing right.
def reset_snake(self):
self.snake_move_t = 0.2
self.snake.clear() # delete old snake
row = self.rows // 3 # head position - row
col = self.columns // 3 # head position - column
head_sprite = pyglet.sprite.Sprite(self.images['head_right'])
head_sprite.scale = self.sprite_zoom
head_sprite.position = (col * self.sprite_w * self.sprite_zoom,
row * self.sprite_h * self.sprite_zoom)
body_sprite = pyglet.sprite.Sprite(self.images['horizontal'])
body_sprite.scale = self.sprite_zoom
body_sprite.position = ((col-1) * self.sprite_w * self.sprite_zoom,
row * self.sprite_h * self.sprite_zoom)
tail_sprite = pyglet.sprite.Sprite(self.images['tail_right'])
tail_sprite.scale = self.sprite_zoom
tail_sprite.position = ((col-2) * self.sprite_w * self.sprite_zoom,
row * self.sprite_h * self.sprite_zoom)
self.snake.append((row, col, 'right', head_sprite))
self.snake.append((row, col-1, 'right', body_sprite))
self.snake.append((row, col-2, 'right', tail_sprite))
self.snake_dir = 'right'
self.snake_dir_next = 'right'
Then we need to place the rabbit. Here I wanted to show sets, so we create a set A of all possible positions and set S of snake positions. Therefore A ∖ S is a set of free positions where we can place the rabbit. We also leave the caller option to assign the position with arguments r, c (for our mouse cheat).
def place_rabbit(self, r=None, c=None):
if r is None or c is None:
A = set((row, col)
for row in range(self.rows)
for col in range(self.columns))
S = set((s[0], s[1]) for s in self.snake)
a = random.choice(list(A-S))
else:
a = (r, c)
self.rabbit[0] = a[0] # row
self.rabbit[1] = a[1] # col
self.rabbit[2].position = (a[1] * self.sprite_w * self.sprite_zoom,
a[0] * self.sprite_h * self.sprite_zoom)
Update state and react to the user
Updating the state begins with computing remaining time to move the snake. If the snake should move, it will and update graphics. The snake wraps around the scene with modulo operation. At the beginning a new head is placed.
def update(self, dt):
self.snake_move_t_rem -= dt
if self.snake_move_t_rem <= 0:
self.snake_move_t_rem = self.snake_move_t
self.snake_dir = self.snake_dir_next
row = self.snake[0][0]
col = self.snake[0][1]
if self.snake_dir == 'left':
col -= 1 # col = col - 1
elif self.snake_dir == 'right':
col += 1
elif self.snake_dir == 'up':
row += 1
elif self.snake_dir == 'down':
row -= 1
row %= self.rows
col %= self.columns
head_img = self.images['head_'+ self.snake_dir]
head_sprite = pyglet.sprite.Sprite(head_img)
head_sprite.scale = self.sprite_zoom
head_sprite.position = (col * self.sprite_w * self.sprite_zoom,
row * self.sprite_h * self.sprite_zoom)
self.snake.insert(0, (row, col, self.snake_dir, head_sprite))
The part which used to be a head must have the image changed. Two options for straight line and eight options for turns.
H = self.snake_dir # head direction
S = self.snake[1][2] # second snake part direction
if H == S and (H == 'left' or H == 'right'):
self.snake[1][3].image = self.images['horizontal']
elif H == S and (H == 'down' or H == 'up'):
self.snake[1][3].image = self.images['vertical']
elif S == 'down':
if H == 'left':
self.snake[1][3].image = self.images['turn_4']
else: # right
self.snake[1][3].image = self.images['turn_1']
elif S == 'up':
if H == 'left':
self.snake[1][3].image = self.images['turn_3']
else: # right
self.snake[1][3].image = self.images['turn_2']
elif S == 'left':
if H == 'up':
self.snake[1][3].image = self.images['turn_1']
else: # down
self.snake[1][3].image = self.images['turn_2']
elif S == 'right':
if H == 'up':
self.snake[1][3].image = self.images['turn_4']
else: # down
self.snake[1][3].image = self.images['turn_3']
When the rabbit is eaten as a “reward” we increase the speed and play the sound. If it is not eaten, the last part of snake is removed and appropriate tail image is set to the sprite – the direction must be used from the part before the tail itself.
rabbit_eaten = row == self.rabbit[0] and col == self.rabbit[1]
if not rabbit_eaten:
self.snake.pop() # podobne: del self.snake[-1]
tail = self.snake[-1]
tail[3].image = self.images['tail_'+self.snake[-2][2]]
else: # rabbit_eaten
self.snake_move_t -= 0.005
self.place_rabbit()
self.eat_sound.play()
for s in self.snake[1:]:
if row == s[0] and col == s[1]:
self.die_sound.play()
self.reset_snake()
self.place_rabbit()
self.snake_move_t_rem = 3
Finally we update FPS label and snake length label.
self.frame_counter += 1
self.one_second_counter += dt
if self.one_second_counter >= 1:
fps = self.frame_counter/self.one_second_counter
self.fps_label.text = f'{fps:.2f} FPS'
self.frame_counter = 0
self.one_second_counter -= 1
self.len_label.text = f'Length: {len(self.snake)}'
Only the arrow keys for changing snake directions are used. We forbid to change the direction to to direct opposite.
To show how mouse events are processed we added a little cheat; you can place the rabbit where you want with left mouse click! :-)
def on_key_press(self, symbol, modifiers):
print('A key was pressed, code: ', symbol)
if symbol == key.LEFT:
if self.snake_dir != 'right':
self.snake_dir_next = 'left'
elif symbol == key.RIGHT:
if self.snake_dir != 'left':
self.snake_dir_next = 'right'
elif symbol == key.UP:
if self.snake_dir != 'down':
self.snake_dir_next = 'up'
elif symbol == key.DOWN:
if self.snake_dir != 'up':
self.snake_dir_next = 'down'
def on_mouse_press(self, x, y, b, mod):
if b == mouse.LEFT:
print('left mouse clicked at:', x, y)
col = x // (self.sprite_w * self.sprite_zoom)
row = y // (self.sprite_h * self.sprite_zoom)
self.place_rabbit(row, col)
Finally we can draw everything.
def on_draw(self):
self.main_window.clear() # vymazani okna
# draw background
for sprite in self.background_sprites:
sprite.draw()
# draw rabbit
self.rabbit[2].draw()
# draw snake
for s in self.snake:
s[3].draw()
self.fps_label.draw()
self.had_label.draw()