Game Tutorial: SSSnake!
I put together a little Snake game based on the arcade game Blockade (created in 1976!) and thought I'd write a tutorial on how the game works. (This code is based on work by Ahira Patel.) Feel free to fork the REPL and add to it!
The code is broken up into four main files:
main.py. The first file manages drawing the game board,
constants.py sets up nice enums for our board colors and symbols,
snake.py handles drawing the snake itself, and the last file,
main.py, ties everything together to run our game.
constants.py is the simplest, we'll start there. The entire file looks like this:
All we're doing is setting up some nicer syntax so we can use
Symbols.FOOD.value throughout our code. This also makes it easier for us to change things throughout our program by centralizing logic in one place—for example, we can change the "food" symbol from
+ by just changing one character.
Next, let's take a look at
board.py. This file is a little longer, so we won't reproduce it here, but feel free to follow along in the REPL as we move from function to function.
All our functions are inside a
Board class, which keeps track of the symbols we use in the terminal for the various pieces of our board (such as walls and empty squares). (Note that functions inside classes are called methods, so we'll call them that throughout this tutorial.) We also do some arithmetic to build the board from rows and columns, which are in turn dictated by the dimensions of our terminal. (Feel free to play around with different terminal sizes! You can find these values in the
get_terminal_dimensions function in
Next, we define getters and setters,
get method gives us the symbol associated with a particular pair of terminal coordinates (that is, what symbol is located at a particular row and column), and the
set method lets us change the symbol located at a set of coordinates. We also define
height to give us the number of columns and rows, respectively, as well as an
is_valid_coord method that checks if a proposed row/column coordinate pair is within the bounds of our terminal.
Our last two methods handle drawing the board itself. The
draw_initial_board method does exactly that: it goes through and draws the board row-by-row (including the outer walls). Finally, the
draw method goes to the provided coordinates on the board and draws the provided symbol (for example, drawing the food symbol at a random spot on the board). Note that both methods use
sys.stdout.flush() to ensure the changes we've made are written to the screen (since content we write to the terminal is buffered; you can read more about data buffers in here). You can think of
writeing as telling the program how the terminal content is going to change, and
flushing makes those changes appear in the terminal.
Moving on, we get to the
Snake class in our
snake.py file. In our
__init__ method, we keep track of the snake's current direction, head, tail, and what the snake has eaten, as well as a bit of bookkeeping to help us draw the snake. (A discussion of threading in Python is beyond the scope of this tutorial, but you can Google "Python threading" to find some more information on the topic, such as this WikiBook.) Most of our snake's functionality can be found in the
move method, which controls the snake's movement across the board: we acquire the lock (ensuring no other threads can change the game state while we're in the middle of moving), move the head to its new position, remove the tail, and then signal that we're done moving by updating our
movement_processed flag and releasing the thread lock. The result? Our snake moves one cell at a time across the board!
consume method handles the snake's behavior when it eats something: when it successfully eats the food symbol, we grow the snake by one unit. The
is_hungry method helps us figure out whether the snake has recently eaten any food, and
is_dead handles the two conditions that make us lose the game: if we "eat" a wall or "eat" our own tail (that is, collide with a wall or ourselves). The
set_movement method allows the snake to change direction (note that it prevents a snake that's more than one unit long from "reversing"—that is, the snake can't immediately go down while it's going up, left while it's going right, and so on).
Last but not least, the
get_old_tail methods are helpers that make it easier for us to get ahold of the snake's head and tail while updating its state on the board.
Finally, we'll tie everything together in a
Game class inside our
main.py file. Most of our game logic lives here, so we'll spend a bit more time unpacking this code.
__init__ialize our class with a board (
self.board), how many pieces of food the player has to eat to win (
self.num_food), and the snake (
self.snake), as well as some bookkeeping to keep track of whether the game is over (
self.over) and, if so, how the player signalled they wanted to quit the game (
self.sig_quit). Next, we have our
play method, which
sleeps for two seconds to give the player time to switch over to the REPL, then does the following inside a loop:
- Ends the game as needed (if the player crashes into a wall/themselves, eats all the food, or quits);
- Updates the game board state;
- Draws the updated board on the terminal;
- Briefly sleeps to allow the user time to react.
spawn_new_food method does what you'd expect: it generates a random coordinate, tries to place the food at that spot, and retries if it's not successful (for example, if the snake is already occupying that spot on the board). The
update_game_board are also fairly straightforward: the first method updates the board (and the snake as well, as long as it's still hungry), and the second updates the game state by moving the snake and updating the board, allowing the snake to consume whatever's in the current square (growing the snake if it eats food and ending the game if the snake bites the wall or itself).
Outside of our
Game class, we have a handful of helper functions that allow us to handle user input and nicely transition the terminal from "game mode" back to the regular REPL (and vice-versa). These can be a little confusing, since they have more to do with the particulars of the terminal than the mechanics of our game, so we'll go over them one by one:
movement_listenerlistens for user input and sets the snake's direction via
snake.set_movement(). (It also does some nice things to ensure the terminal looks good; these are described in the comments.)
get_terminal_dimensionsis just a nice helper function for getting the height and width of our terminal. (We actually do some subtraction to make the dimensions slightly smaller than the actual terminal, in order to ensure the entire game is visible on-screen.)
end_alternate_screenare pretty much as-advertised: they handle the switch to the terminal game from the REPL and back again.
exit_as_neededis also pretty straightforward: it handles the four ways our game can end (the user loses by crashing the snake, the user wins by getting all the food, or the user quits the game by either explicitly quitting or stopping the game by sending
signal_handleris a callback function we provide to
signal.signalto tell Python what to do if it gets an interrupt (
SIGINT) or terminate (
SIGTERM) signal from the user: in either case, we quit the game.
Trueand cleans up the game terminal, restoring our settings to the way they were before the game started.
The last few lines of code in
main.py set up our game for us by getting the terminal dimensions, showing a countdown timer for the game to begin, handle the switch from the "regular" REPL to the game (lines 181 to 183), and finally, creating a new game and starting it.
...and that's it! I hope you enjoyed this tutorial, and feel free to fork the REPL and add more to the game.