Snake
Everybody loves Snake. Although the original concept of the game dates back to 1976, this classic game won many hearts when it came preloaded on Nokia phones since 1998. I have many fond childhood memories of playing this classic game on my mom's phone, and I'm sure I'm not the only one whose childhood was bettered by this simple yet brilliant piece of entertainment.
Making Snake
When I decided I wanted to make a Snake game in Ruby, I searched the internet for many tutorials to get me started. By far the best tutorial I found was this one by Mario Visic on Youtube. All credit for this blog post goes to him, I simply followed his method and incorporated a few more things.
In order to re-create this classic game in Ruby, we need the help of Ruby 2D, a wonderful gem which allows you to draw visualizations for your Ruby programs with ease. This gem has a lot of functionality to really bring your programs to life, so it's a no-brainer when it comes to making 2D games in Ruby. Additionally, it has a quite comprehensive and very user-friendly documentation. To install this gem, simply run gem install ruby2d
, and then add require 'ruby2d'
at the top of your Ruby file.
The Initial Set-Up
Once Ruby 2d is installed and required, we have to set a background color, fps_cap (this determines how many frames the game will render per second), and grid size (this determines how many pixels wide each square on the grid will be):
require 'ruby2d'
set background: 'navy'
set fps_cap: 20
GRID_SIZE = 20 #grid size is 20 pixels
#for default window size of 480px * 640px, width is 32 (640/20) and height is 24 (480/20) at grid size = 20 pixels
GRID_WIDTH = Window.width / GRID_SIZE
GRID_HEIGHT = Window.height / GRID_SIZE
These values can be changed if you desire a different grid size, a different background color, or a different game speed.
The Snake Class
All of the logic for the snake itself was encapsulated inside the snake class:
class Snake
attr_writer :direction
attr_reader :positions
def initialize
@positions = [[2, 0], [2,1], [2,2], [2,3]] #first coordinate is x and the second is y, starting from top left corner
@direction = 'down'
@growing = false
end
def draw
@positions.each do |position|
Square.new(x: position[0] * GRID_SIZE, y: position[1] * GRID_SIZE, size: GRID_SIZE - 1, color: 'olive')
end
end
def move
if !@growing
@positions.shift
end
case @direction
when 'down'
@positions.push(new_coords(head[0], head[1] + 1))
when 'up'
@positions.push(new_coords(head[0], head[1] - 1))
when 'left'
@positions.push(new_coords(head[0] - 1, head[1]))
when 'right'
@positions.push(new_coords(head[0] + 1, head[1]))
end
@growing = false
end
#Preventing snake from moving backwards into itself
def can_change_direction_to?(new_direction)
case @direction
when 'up' then new_direction != 'down'
when 'down' then new_direction != 'up'
when 'left' then new_direction != 'right'
when 'right' then new_direction != 'left'
end
end
def x
head[0]
end
def y
head[1]
end
def grow
@growing = true
end
def snake_hit_itself?
@positions.uniq.length != @positions.length #this checks if there are any duplicate positions in the snake (self-collision)
end
private
#This method uses the modulus operator to make the
#snake appear on the other side of the screen when it goes over the edge
def new_coords(x,y)
[x % GRID_WIDTH, y % GRID_HEIGHT]
end
def head
@positions.last
end
end
Here, the snake is initialized as 4 squares on the top left corner of the window, heading downwards, and not growing. This is how the snake will appear every time the game is started.
The draw
method is used to convert the snake's @positions
array to actual squares on the grid, using Ruby 2D's Square
method.
The move
method moves the snake on the screen by using .shift
on the array of positions the snake occupies, which removes the first element of the array (which actually corresponds to the snake's tail, or last square). After the snake's tail is removed, .push
(which appends to the end of the array, corresponding to the snake's head) is called to redraw the snake's head 1 square away in the direction of movement. The position of the snake's head can be accessed by calling on the head
helper method. The redrawing of the snake's head also uses another helper method, .new_coords
, which makes the snake reappear on the other side of the screen if it goes over the edge. This encompasses pretty much all of the snake's basic movement.
The way in which the direction of movement is determined will become apparent later on in the code, but for now a can_change_direction_to?
method is required to prevent the snake from going backwards into itself.
Then, two simple x
and y
methods are require to simply return the coordinates of the snake's head (these will be needed later). A simple grow
method is also required to set the snake's @growing
condition to true. This will be triggered when the snake eats food and then @growing
will be set to false again after the snake moves.
Finally, a snake_hit_itself?
method is required to check if the snake has crashed into itself, which will finish the game. This is done quite cleverly by just checking if the @positions
array has any duplicate coordinates, meaning that the snake has crashed into itself. If this is the case, the length of @positions
and @positions.uniq
will be different (.uniq
removes any duplicates), and the method will return true.
*If you want to test the game so far, skip ahead to the the Game Loop and Key-Mapping sections, so you can run and interact with the game before proceeding to the next section. If you skip ahead, make sure to not include any references to the Game
class or game
instance anywhere as the Game class hasn't been defined yet.
The Game Class
Now, it's time to make the class that will encompass all of the game's mechanics:
class Game
def initialize(snake)
@snake = snake
@score = 0
initial_coords = draw_ball
@ball_x = initial_coords[0]
@ball_y = initial_coords[1]
@finished = false
@paused = false
end
def draw_ball
available_coords = []
for x in (0..GRID_WIDTH-1)
for y in (0..GRID_HEIGHT-1)
available_coords.append([x, y])
end
end
selected = available_coords.select{|coord| @snake.positions.include?(coord) == false}
selected.sample
end
def draw
unless finished?
Square.new(x: @ball_x * GRID_SIZE, y: @ball_y * GRID_SIZE, size: GRID_SIZE, color: 'yellow')
end
Text.new(text_message, color: 'white', x: 10, y: 10, size: 25)
end
def snake_hit_ball?(x, y)
@ball_x == x && @ball_y == y
end
def record_hit
@score += 1
ball_coords = draw_ball
@ball_x = ball_coords[0]
@ball_y = ball_coords[1]
end
def finish
@finished = true
end
def finished?
@finished
end
def pause
@paused = true
end
def unpause
@paused = false
end
def paused?
@paused
end
private
def text_message
if finished?
"Game over, score: #{@score}. Press 'R' to restart, 'Q' to quit."
elsif paused?
"Game paused, score: #{@score}. Press 'P' to resume."
else
"Score: #{@score}"
end
end
end
Here, the game is initialized with a score of 0, and the @finished
and @paused
conditions set to false. My code differs a bit from the video tutorial I followed in the way in which the ball (food) is drawn, as it calls on a helper method draw_ball
.
I wrote this helper method to check that the ball isn't drawn inside the snake. In order to do this, this helper method requires access to the snake's position, so I initialized the Game class with an instance of the Snake class. With access to the snake's position, draw_ball
finds the available coordinates to draw the ball in by selecting all the coordinates on the grid which are not currently being occupied by the snake. Then, this method selects a random sample from all those available coordinates and returns it. Props to my instructor Sylwia Vargas for helping me debug my old method which wasn't working!
draw
, again, converts the ball's position array to actual squares on the grid and it also draws a text message on the top left to display information, as long as the game isn't finished. This text message itself is delegated to a helper method text_message
. This helper method displays the current score and information about the game's state, changing accordingly if the game is paused or finished.
The snake_hit_ball?
method just checks if the snake has come into contact with the ball. This will be called in the game loop later.
The record_hit
method adds 1 point to the score and redraws the ball every time it's called.
Finally, the finish
, finished?
, pause
, unpase
and paused?
methods set and return the game's state accordingly.
The Game Loop
Now, with our main classes finished, it's time to put them to use inside our game loop, which will run every new frame:
update do
clear
unless game.finished? or game.paused?
snake.move
end
snake.draw
game.draw
if game.snake_hit_ball?(snake.x, snake.y)
game.record_hit
snake.grow
end
if snake.snake_hit_itself?
game.finish
end
end
Every frame, this loop starts by clearing the screen, moving the snake (unless the game is paused or finished), redrawing the snake, and redrawing the rest of the game.
Then, the loop checks if the snake has hit the ball, and if so it records a hit and makes the snake grow accordingly.
Finally, the loop checks if the snake has hit itself, in which case it finishes the game.
Key-Mapping
Our game is looking great so far, but it can't really be interacted with without key-mappings, so let's add that. The main key-mappings will allow us to control the snake's direction as well as pause, reset, and quit the game.
on :key_down do |event|
if ['up', 'down', 'left', 'right'].include?(event.key)
if snake.can_change_direction_to?(event.key)
snake.direction = event.key
end
elsif event.key == 'r' or event.key == 'R' #resetting game
snake = Snake.new
game = Game.new(snake)
elsif event.key == 'q' or event.key == 'Q' #quitting game
exit()
elsif event.key == 'p' or event.key == 'P' #pausing/unpausing game
if game.paused?
game.unpause
else
game.pause
end
end
end
show
We start with an event-listener which will detect whenever a key is pressed. Then, we check the key pressed against multiple conditions.
If the key was 'up', 'down', 'left', or, 'right', change the snake's direction accordingly, as long as that's allowed by snake.can_change_direction_to?
.
If the key was 'r', 'q', or 'p', reset, quit, or pause/unpause the game accordingly.
Finally, we call show
to actually render the game's window. This is the very last line in our code as everything else about the game must be executed first before displaying.
Exporting the Game
Congrats! If you've made this far your game should be working perfectly whenever it's run in the terminal.
Although the game is looking great, it's a bit of a hassle to have to open up the terminal and run ruby snake.rb
every time we want to play it. So let's fix that.
On MacOS:
- Run
brew install mruby
,brew tap simple2d/tap
, andbrew install simple2d
in the terminal. - In the same directory as your Ruby file for the game, run
ruby2d build --macos <your_game_file_name.rb>
.
On Linux:
- Run
sudo apt install mruby libmruby-dev
in the terminal. - In the same directory as your Ruby file for the game, run
ruby2d build --native <your_game_file_name.rb>
These steps will generate a build
directory with your game inside.
Congrats! Now you can enjoy this great classic game by simply pressing on the executable file in the new folder.
Top comments (7)
Great, is there on github?
Sorry for the late reply, I somehow missed this comment. But yes, I have a repo for it: github.com/JoaoCardoso193/Snake
Thanks
Thank you for this blog post! It's so good — I never heard about Ruby2D. I'll definitely check it out.
Thank you Sylwia :)! This is just scratching the surface of what this gem can do, so definitely check it out
I wonder if there's a way to wrap this for mobile or web.
The Ruby 2D Package allows you to export it to iOS using
ruby2d build --ios <your_game_file_name.rb>
. It also allows you to export to a Javascript and HTML package to be deployed on the web usingruby2d build --web <your_game_file_name.rb>
. However, the web feature is currently disabled as it's being upgraded.