In last week's post we started work on a recreation of the popular arcade game, Snake, using Tkinter's Canvas
widget. In this earlier post we set up the application window, loaded our assets, and put everything on the Canvas
. This week we're going to be adding all of the logic needed to actually play the game, and we should have a working version of snake by the end of this post!
If you missed our earlier post, you can find it here, and we also have a number of other posts on GUI development if you're just starting out. You can find those at the link below:
https://blog.tecladocode.com/tag/gui-development/
We also have a complete video walk through of the code in this post, and the previous post in the series, which you can find below:
The code so far
import tkinter as tk
from random import randint
from PIL import Image, ImageTk
class Snake(tk.Canvas):
def __init__(self):
super().__init__(
width=600, height=620, background="black", highlightthickness=0
)
self.snake_positions = [(100, 100), (80, 100), (60, 100)]
self.food_position = (200, 200)
self.load_assets()
self.create_objects()
self.pack()
def load_assets(self):
try:
self.snake_body_image = Image.open("./assets/snake.png")
self.snake_body = ImageTk.PhotoImage(self.snake_body_image)
self.food_image = Image.open("./assets/food.png")
self.food = ImageTk.PhotoImage(self.food_image)
except IOError as error:
print(error)
root.destroy()
def create_objects(self):
self.create_text(
35, 12, text=f"Score: {self.score}", tag="score", fill="#fff", font=10
)
for x_position, y_position in self.snake_positions:
self.create_image(
x_position, y_position, image=self.snake_body, tag="snake"
)
self.create_image(*self.food_position, image=self.food, tag="food")
self.create_rectangle(7, 27, 593, 613, outline="#525d69")
root = tk.Tk()
root.title("Snake")
root.resizable(False, False)
root.tk.call("tk", "scaling", 4.0)
board = Snake()
root.mainloop()
At this stage, our Snake app looks the part, but it doesn't really do anything. We have a game board, a score counter, and all the assets we need loaded on the Canvas, but we can't get play the game.
Let's start implementing the game logic!
Moving the snake
Before we do anything else, I think it's a good idea to actually animate our snake. If we can't even move the snake, the rest of the functionality is a little irrelevant after all.
We're going to define a new method called move_snake
inside of our Snake
class.
def move_snake(self):
pass
At the moment, we have the positions of all of our snake segments in a property called self.snake_positions
. The value of this property is a list of tuples, where the first value of each tuple is an x coordinate, and the second value is a y coordinate.
The way our move_snake
is going to work, is that we're going to find out where the head of the snake should be, and then we're going have each snake segment take the old position of the segment in front.
First then, we need to know where the head is:
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
I've used destructuring here to avoid using nested indices. If you don't know about destructuring, check out this post: https://blog.tecladocode.com/destructuring-in-python/
Now that we have the current head position, we're going to modify the coordinates so that they represent where the snake's head should be. This involves creating a new tuple, since tuples are immutable.
To start with, we're just going to have the snake move right.
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
new_head_position = (head_x_position + 20, head_y_position)
I've used a value of 20px here, because that's how big our snake segments are. This is a value we're going to be using a lot, however, so we should extract it to a constant at the top of our app.py
.
MOVE_INCREMENT = 20
We can now use this constant in our move_snake
method:
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
new_head_position = (head_x_position + MOVE_INCREMENT, head_y_position)
As I said earlier, the other snakes are going to inherit their positions from the snake segments that come before. In order to achieve this we're going to take a slice of the original snake positions, cutting off the last value. We can then append this to the new_head_position
, which will give us a new list of the same length as the old one.
If you're not familiar with slicing, we have a couple of posts on this that will fill you in:
https://blog.tecladocode.com/python-slices/
https://blog.tecladocode.com/python-slices-part-2/
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
new_head_position = (head_x_position + MOVE_INCREMENT, head_y_position)
self.snake_positions = [new_head_position] + self.snake_positions[:-1]
The final step is to update the positions of all the segments.
We're going to be using zip
to combine the snake segments with the self.snake_positions
list. We can get a list of all of our snake segments using the find_withtag
method, which will allow us to get all of the canvas elements with the tag, "snake"
.
If zip
is something new to you, once again we have you covered: https://blog.tecladocode.com/python-zip/
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
new_head_position = (head_x_position + MOVE_INCREMENT, head_y_position)
self.snake_positions = [new_head_position] + self.snake_positions[:-1]
for segment, position in zip(self.find_withtag("snake"), self.snake_positions):
self.coords(segment, position)
The coords
method we called here has two jobs in Tkinter. If we call it with just a reference to an object on the Canvas
, it will return a set of coordinates for that item. However, if we pass in new coordinates as a second argument, we can replace the existing set of coordinates with those new ones.
This allows us to "move" the items by updating their coordinates.
So that's it right? The snake can move now?
Well, not exactly. While the logic is written that will allow our snake to move, we currently don't call our move_snake
method anywhere. We also don't really know how we're going to call it yet, since we need it to run several times per second.
We're actually going to use a built in method called after
to trigger our move_snake
method. after
takes a numerical value as its first argument, which represents a period of time to wait. The second argument is a function or method to call. We're not going to call move_snake
directly, however, as we want a number of things to happen at the end of each period. Instead, we're going to make a method called perform_actions
which is going to call all of the relevant methods after each cycle.
So, inside our __init__
method we need to write:
self.after(75, self.perform_actions)
So after 75 milliseconds, we're going to call self.perform_action
. Note that we only pass in the method name to after
.
Now we're going create out perform_actions
method like this:
def perform_actions(self):
self.move_snake()
self.after(75, self.perform_actions)
Note that we also call after
inside of perform_actions
, which means we can repeatedly call the function every 75 milliseconds and do everything we need to.
However, we have this 75 milliseconds in a couple of places, so let's extract this to some constants:
MOVES_PER_SECOND = 15
GAME_SPEED = 1000 // MOVES_PER_SECOND
Now we can just set how many moves we want to happen per turn using MOVES_PER_SECOND
, and we can pass in GAME_SPEED
in place of a millisecond value. Feel free to play around with that value to find a speed you like.
With that, our snake should move!
Collision detection
So, we got our snake to move, which is very exciting. However, it quickly slithers off the right hand side of the screen, never to be seen again. So long, snake!
We need to make sure it can't leave the board, so let's look at implementing some collision detection. There are a couple of things we need to check for at the moment. We need to check that the snake doesn't bite itself, and we need to check whether the snake hit the edge of the board.
In either case, we want to stop the game.
Since this is something we want to check after each move, we're going to create a new method called check_collisions
, and we're going to call it inside of perform_actions
.
The check_collisions
method is actually quite simple. We once again find the head of our snake from our self.snake_positions
list, and then we can use some conditional logic.
Instead of a series of if statements, we're going to use the or
boolean operator. We want to check 3 conditions, and if any of them are True
, we want to return True
. We can therefore write something like this:
def check_collisions(self):
head_x_position, head_y_position = self.snake_positions[0]
return (
head_x_position in (0, 600)
or head_y_position in (20, 620)
or (head_x_position, head_y_position) in self.snake_positions[1:]
)
Our first condition checks that the head_x_position
is neither 0
, nor 600
. These are the horizontal boundaries of our board.
The second condition checks that head_y_position
is not 20
or 620
, which are the vertical boundaries of our board. We have a score above the playable area, so the vertical limit isn't at 0
.
Finally we check that the complete coordinates for the head are not the same as any other segment coordinates.
If any of these conditions are met, the method returns True
, and we can check the return value in our perform_actions
method:
def perform_actions(self):
if self.check_collisions():
return
self.move_snake()
self.after(GAME_SPEED, self.perform_actions)
Now if the snake hits any of the boundary walls, the game just stops.
Controlling the snake
While we can make the snake segments move right along the screen, and we can end the game when the snake bashes into the wall, I think it's fair to say that this isn't a game right now. We can't even control which direction the snake moves!
So, how do we fix that? It's actually a multi-stage process.
First, we need some way to keep track of the current direction of the snake. The user doesn't move the snake once square at a time by pressing the buttons every round. The snake moves on its own in the same direction until we tell it to change.
Once we have somewhere to store the snake's direction, we need to listen for the user's key presses and determines what they mean. We can then use this information to set a new direction for the snake.
Finally, we need to update our move_snake
definition so that we can change the coordinates of the head in different ways depending on which direction the snake is moving in.
Let's start with listening for the keys.
Listening for key presses
Tkinter actually comes with a very handle pair of methods called bind
and bind_all
, which allow us to listen for keyboard or mouse events, among other things, and perform some action when these events occur.
We're therefore going to be adding two new properties to our __init__
method:
self.direction = "Right"
self.bind_all("<Key>", self.on_key_press)
Here we've set the starting direction for our snake, which is "Right"
, and we've created a listener using bind_all
, which is going to activate any time any key gets pressed.
The action we perform when this event fires is to call self.on_key_press
, which is a method which doesn't yet exist. So let's make it.
Handling key press events
Our on_key_press
method is going to be responsible for setting the self.direction
property whenever a relevant key is pressed. We're going to catch information about the triggering event in a parameter called e
, which is passed in by the bind_all
method when it calls self.on_key_press
.
One of the neat things we can do with this event is get a keysym
out of it. This is a special name that Tkinter uses for certain keybindings, and they happen to be very readable in our case.
For example, the up arrow has a keysym
of "Up"
. The first line of our new method is therefore going to find out what the keysym
of the fired key is.
def on_key_press(self, e):
new_direction = e.keysym
We're now going to define a tuple containing all of the possible directions on our board, so that we can filter out key presses we don't care about. For example the f key doesn't do anything for us, so we'll just ignore it.
In addition to the collection of directions, we're also going to define the opposites of those directions as a pair of sets. This will allow us to avoid killing off the snake when players accidentally press the opposite direction to where they are currently going.
def on_key_press(self, e):
new_direction = e.keysym
all_directions = ("Up", "Down", "Left", "Right")
opposites = ({"Up", "Down"}, {"Left", "Right"})
Now that we have all of this set up, we can use an if statement to filter out the key presses we want to ignore, and set a new direction when a valid key gets pressed.
def on_key_press(self, e):
new_direction = e.keysym
all_directions = ("Up", "Down", "Left", "Right")
opposites = ({"Up", "Down"}, {"Left", "Right"})
if (
new_direction in all_directions
and {new_direction, self.direction} not in opposites
):
self.direction = new_direction
The first part of the condition is fairly simple: we just check that the keysym
we assigned to new_direction
is one of the directions that we defined. The second condition is a little tricky, however.
First we define a new set made up of the new direction and the current direction. A couple of interesting properties of sets are that they don't preserve order, and they don't allow for duplicate entries.
This means that the set {"Up", "Down"}
is the same as the set {"Down", "Up"}
.
When we perform our check for membership in opposites
, we therefore get a match any time the current and new direction are opposed, which causes the condition to fail. The self.direction
property therefore doesn't get updated.
Since both conditions must evaluate to True
to satisfy the and
operator, we only end up updating the direction when we have a direction key pressed, and that direction is not opposite to the current direction.
Great! So we can now update the self.direction
property, but we don't currently use it to determine the movement direction of the snake. We need to update our move_snake
method.
Accounting for direction in move_snake
Our move_snake
method can mostly stay as it is. We just need to add some conditions so that we can change how we modify the snake's head coordinates depending on which direction the snake is meant to be moving.
When the snake is moving left, we need to subtract the MOVE_INCREMENT
from the x coordinate or the current head position, since the lower x-axis values are on the left of the board.
When moving right, we just add the MOVE_INCREMENT
instead.
For down and up, we need to add and subtract the MOVE_INCREMENT
from the y coordinate values respectively.
Our finished method looks like this:
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
if self.direction == "Left":
new_head_position = (head_x_position - MOVE_INCREMENT, head_y_position)
elif self.direction == "Right":
new_head_position = (head_x_position + MOVE_INCREMENT, head_y_position)
elif self.direction == "Down":
new_head_position = (head_x_position, head_y_position + MOVE_INCREMENT)
elif self.direction == "Up":
new_head_position = (head_x_position, head_y_position - MOVE_INCREMENT)
self.snake_positions = [new_head_position] + self.snake_positions[:-1]
for segment, position in zip(self.find_withtag("snake"), self.snake_positions):
self.coords(segment, position)
Feeding the snake
So, our snake is slithering around all over the place now, but it's not touching its food. We need to implement a method to handle collisions with the food items on the Canvas
.
Naturally we're going to define another method here, this time called check_food_collisions
.
The actual logic is a single if block, where we initially check whether the snake's head position is the same as the current food position.
Remember that we have that set in a property called self.food_position
, so we don't need to go looking for it on the Canvas
.
If the condition evaluates to False
, we just immediately return the default None
, and we carry on. If the condition evaluates to True
, however, we need to do a quite a few things.
First of all, we need to increment the score, and add a new segment to the snake:
def check_food_collision(self):
if self.snake_positions[0] == self.food_position:
self.score += 1
self.snake_positions.append(self.snake_positions[-1])
Here, we just duplicate the coordinates of the final snake segment, as the snake is going to immediately move, leaving the new tail segment where the old one was.
Of course, we also have to put it on the Canvas
, so we need to call the create_image
method on our Canvas
widget:
def check_food_collision(self):
if self.snake_positions[0] == self.food_position:
self.score += 1
self.snake_positions.append(self.snake_positions[-1])
self.create_image(
*self.snake_positions[-1], image=self.snake_body, tag="snake"
)
We use *
unpacking here to get the x and y coordinates out of the tuple, and pass them in as separate arguments.
While we're updating things on the Canvas
, we also need to update the score text. So far we've just updated the property.
def check_food_collision(self):
if self.snake_positions[0] == self.food_position:
self.score += 1
self.snake_positions.append(self.snake_positions[-1])
self.create_image(
*self.snake_positions[-1], image=self.snake_body, tag="snake"
)
score = self.find_withtag("score")
self.itemconfigure(score, text=f"Score: {self.score}", tag="score")
Finally, we need to check for food collisions inside our perform_actions
method:
def perform_actions(self):
if self.check_collisions():
return
self.check_food_collision()
self.move_snake()
self.after(GAME_SPEED, self.perform_actions)
We're not quite done yet, as we also want the food to move to a new random spot. However, we haven't implemented anything to do this yet.
Randomising the position of the food item
In order to randomise the position of the food, we're going to create yet another method, this one called set_new_food_position
.
Inside the method, I'm actually going to start with a while loop, as I want to keep randomly selecting a place for the food until we find a place not occupied by the snake.
We're going to use the randint
function from the random
module to generate a random integer within a given range, and then we're going to multiply them by the MOVE_INCREMENT
so that we don't end up with values outside of our 20px grid. These values are going to be the coordinates for our food object.
Note that you will have to import the random
module, which we did like this:
from random import randint
Once we have a pair of random numbers, we're going to construct a tuple for the coordinates, and check if those coordinates are in self.snake_positions
. If they are, we start the loop over, but if we found a space not occupied by the snake, we return the coordinates we generated, which will end the loop.
def set_new_food_position(self):
while True:
x_position = randint(1, 29) * MOVE_INCREMENT
y_position = randint(3, 30) * MOVE_INCREMENT
food_position = (x_position, y_position)
if food_position not in self.snake_positions:
return food_position
Now that we have this method, we can use it to set a random starting position for the food, and we can also use it inside of check_food_collision
to move the food after it gets consumed.
The property now looks like this:
self.food_position = self.set_new_food_position()
And we add a couple of new lines to the check_food_collision
method:
def check_food_collision(self):
if self.snake_positions[0] == self.food_position:
self.score += 1
self.snake_positions.append(self.snake_positions[-1])
self.create_image(
*self.snake_positions[-1], image=self.snake_body, tag="snake"
)
self.food_position = self.set_new_food_position()
self.coords(self.find_withtag("food"), *self.food_position)
score = self.find_withtag("score")
self.itemconfigure(score, text=f"Score: {self.score}", tag="score")
Adding an end game screen
If we wanted to, we could just stop here. The game totally works, and we've implemented all of the vital functionality.
However, I think it would be nice to clear the Canvas
at the end of the game and tell the user their score. We're therefore going to implement one final method called end_game
.
The end_game
method is quite straightforward. We start by deleting everything from the Canvas
, which can be done with the delete
method, passing in tk.ALL
, which selects all of the Canvas
objects.
We then place a single new item on the Canvas
: a single line of text, which tells the player that the game has ended, and what they scored.
def end_game(self):
self.delete(tk.ALL)
self.create_text(
self.winfo_width() / 2,
self.winfo_height() / 2,
text=f"Game over! You scored {self.score}!",
fill="#fff",
font=("", 14)
)
We can now call this method in our perform_actions
method instead of just returning when a collision is detected:
def perform_actions(self):
if self.check_collisions():
self.end_game()
self.check_food_collision()
self.move_snake()
self.after(GAME_SPEED, self.perform_actions)
The finished code
With that, we're done!
At the end of it all, my version of the code looks something like this:
import tkinter as tk
from random import randint
from PIL import Image, ImageTk
MOVE_INCREMENT = 20
MOVES_PER_SECOND = 15
GAME_SPEED = 1000 // MOVES_PER_SECOND
class Snake(tk.Canvas):
def __init__(self):
super().__init__(
width=600, height=620, background="black", highlightthickness=0
)
self.snake_positions = [(100, 100), (80, 100), (60, 100)]
self.food_position = self.set_new_food_position()
self.direction = "Right"
self.score = 0
self.load_assets()
self.create_objects()
self.bind_all("<Key>", self.on_key_press)
self.pack()
self.after(GAME_SPEED, self.perform_actions)
def load_assets(self):
try:
self.snake_body_image = Image.open("./assets/snake.png")
self.snake_body = ImageTk.PhotoImage(self.snake_body_image)
self.food_image = Image.open("./assets/food.png")
self.food = ImageTk.PhotoImage(self.food_image)
except IOError as error:
print(error)
root.destroy()
def create_objects(self):
self.create_text(
35, 12, text=f"Score: {self.score}", tag="score", fill="#fff", font=(10)
)
for x_position, y_position in self.snake_positions:
self.create_image(
x_position, y_position, image=self.snake_body, tag="snake"
)
self.create_image(*self.food_position, image=self.food, tag="food")
self.create_rectangle(7, 27, 593, 613, outline="#525d69")
def check_collisions(self):
head_x_position, head_y_position = self.snake_positions[0]
return (
head_x_position in (0, 600)
or head_y_position in (20, 620)
or (head_x_position, head_y_position) in self.snake_positions[1:]
)
def check_food_collision(self):
if self.snake_positions[0] == self.food_position:
self.score += 1
self.snake_positions.append(self.snake_positions[-1])
self.create_image(
*self.snake_positions[-1], image=self.snake_body, tag="snake"
)
self.food_position = self.set_new_food_position()
self.coords(self.find_withtag("food"), *self.food_position)
score = self.find_withtag("score")
self.itemconfigure(score, text=f"Score: {self.score}", tag="score")
def end_game(self):
self.delete(tk.ALL)
self.create_text(
self.winfo_width() / 2,
self.winfo_height() / 2,
text=f"Game over! You scored {self.score}!",
fill="#fff",
font=("", 14)
)
def move_snake(self):
head_x_position, head_y_position = self.snake_positions[0]
if self.direction == "Left":
new_head_position = (head_x_position - MOVE_INCREMENT, head_y_position)
elif self.direction == "Right":
new_head_position = (head_x_position + MOVE_INCREMENT, head_y_position)
elif self.direction == "Down":
new_head_position = (head_x_position, head_y_position + MOVE_INCREMENT)
elif self.direction == "Up":
new_head_position = (head_x_position, head_y_position - MOVE_INCREMENT)
self.snake_positions = [new_head_position] + self.snake_positions[:-1]
for segment, position in zip(self.find_withtag("snake"), self.snake_positions):
self.coords(segment, position)
def on_key_press(self, e):
new_direction = e.keysym
all_directions = ("Up", "Down", "Left", "Right")
opposites = ({"Up", "Down"}, {"Left", "Right"})
if (
new_direction in all_directions
and {new_direction, self.direction} not in opposites
):
self.direction = new_direction
def perform_actions(self):
if self.check_collisions():
self.end_game()
self.check_food_collision()
self.move_snake()
self.after(GAME_SPEED, self.perform_actions)
def set_new_food_position(self):
while True:
x_position = randint(1, 29) * MOVE_INCREMENT
y_position = randint(3, 30) * MOVE_INCREMENT
food_position = (x_position, y_position)
if food_position not in self.snake_positions:
return food_position
root = tk.Tk()
root.title("Snake")
root.resizable(False, False)
root.tk.call("tk", "scaling", 4.0)
board = Snake()
root.mainloop()
I'd actually encourage you to modify the code, and expand on the game. Perhaps you could make a short menu where users can control the speed of the game, or change the colour scheme. Maybe you could add the option to replay the game as well.
There's also a popular version of snake where you can pass through the walls of the map and you end up on the opposite side. Implementing that might also be a fun challenge!
Wrapping up
I hope you've enjoyed creating this little game with me, and I hope it's given you some ideas about how to work with Tkinter's Canvas
widget.
If you've enjoyed this and want to learn more about GUI development, we have a brand new course which goes into a lot more depth on how Tkinter works, and we'll be making a tonne of other cool applications throughout the course. If that excites you, check out the course: GUI Development with Python and Tkinter.
Hope to see you there!