DEV Community

Cover image for How I Made a Simple Snake Game with Ruby 2D
JoaoCardoso193
JoaoCardoso193

Posted on

How I Made a Simple Snake Game with Ruby 2D

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (7)

Collapse
 
araslanove profile image
Araslanov Eugene

Great, is there on github?

Collapse
 
joaocardoso193 profile image
JoaoCardoso193

Sorry for the late reply, I somehow missed this comment. But yes, I have a repo for it: github.com/JoaoCardoso193/Snake

Collapse
 
araslanove profile image
Araslanov Eugene

Thanks

Collapse
 
sylwiavargas profile image
Sylwia Vargas

Thank you for this blog post! It's so good — I never heard about Ruby2D. I'll definitely check it out.

Collapse
 
joaocardoso193 profile image
JoaoCardoso193

Thank you Sylwia :)! This is just scratching the surface of what this gem can do, so definitely check it out

Collapse
 
mxldevs profile image
MxL Devs

I wonder if there's a way to wrap this for mobile or web.

Collapse
 
joaocardoso193 profile image
JoaoCardoso193 • Edited

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 using ruby2d build --web <your_game_file_name.rb>. However, the web feature is currently disabled as it's being upgraded.