How I Made a Simple Snake Game with Ruby 2D
Tags: ruby, tutorial, codenewbie, beginners
Published: 2020-07-16 16:53:28

#Snake
Everybody loves [Snake](https://en.wikipedia.org/wiki/Snake_(video_game_genre)). 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](https://www.youtube.com/watch?v=2UVhYHBT_1o) 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](https://www.ruby2d.com/), 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):
```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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.

```ruby
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:
1. Run `brew install mruby`, `brew tap simple2d/tap`, and `brew install simple2d` in the terminal.
2. In the same directory as your Ruby file for the game, run `ruby2d build --macos <your_game_file_name.rb>`.

On Linux:
1. Run `sudo apt install mruby libmruby-dev` in the terminal.
2. 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.