Game Development with Ruby: Getting Started with Gosu

Ruby works for games. Seriously. It is not C++ or Unity, but for 2D games, prototypes, and game jam entries, it gets the job done fast. You can go from idea to playable demo in a single afternoon.

Why Ruby for Game Development?

You already know Ruby. That is the main reason. No need to learn a new language just to make a simple game. Ruby's syntax is clean, so your game code stays readable even when the project grows. Object-oriented design maps naturally to game entities -- players, enemies, projectiles, and items all become classes with clear responsibilities.

Ruby also shines during rapid prototyping. You can test gameplay ideas without fighting a compiler or wading through boilerplate. The REPL (irb) lets you experiment with math, physics formulas, and data structures before wiring them into your game.

The Tools

Three libraries worth knowing:

Gosu -- The go-to for 2D games in Ruby. Handles window creation, graphics rendering, keyboard and mouse input, and audio playback. The API is small enough to learn in a day. Works on Mac, Windows, and Linux.

Ruby2D -- A simpler alternative focused on ease of use. Great for quick prototypes and learning, but less flexible than Gosu for larger projects.

DragonRuby -- Commercial toolkit built on mruby. Fast, cross-platform, exports to consoles and mobile. Costs money but saves time if you plan to ship commercially.

We will use Gosu here. It is free, well-documented, and battle-tested.

Installation

gem install gosu

If you have never installed a gem before, our guide on building your first Ruby gem covers the ecosystem basics. On Mac you might need SDL2:

brew install sdl2

On Ubuntu/Debian, install the build dependencies first:

sudo apt-get install build-essential libsdl2-dev libgl1-mesa-dev \
  libopenal-dev libgmp-dev libfontconfig1-dev
gem install gosu

Your First Game Window

require 'gosu'

class Game < Gosu::Window
  def initialize
    super(640, 480)
    self.caption = "My Game"
  end
end

Game.new.show

Run this. You get a black window. That is your canvas. The super(640, 480) call sets the window to 640 pixels wide and 480 pixels tall. Pass true as a third argument to go fullscreen: super(640, 480, fullscreen: true).

Understanding the Game Loop

Every game runs a loop: read input, update state, draw the frame, repeat. Gosu handles this loop for you and calls two methods every frame:

  • update -- runs 60 times per second by default. All game logic goes here: movement, physics, collision checks, score tracking.
  • draw -- runs after each update. Render sprites, text, and shapes here. Never modify game state in draw -- it should be a pure read of the current state.

This separation matters. By keeping logic in update and rendering in draw, your code stays organized and bugs are easier to track down.

require 'gosu'

class Game < Gosu::Window
  def initialize
    super(640, 480)
    self.caption = "My Game"
    @x = 0.0
    @y = 0.0
  end

  def update
    @x += 2
    @x = 0 if @x > 640
  end

  def draw
    Gosu.draw_rect(@x, 200, 32, 32, Gosu::Color::GREEN)
  end
end

Game.new.show

This draws a green square that moves across the screen and wraps back to the left edge. The update method moves it 2 pixels per frame, which is 120 pixels per second at 60 FPS.

Loading and Drawing Sprites

def initialize
  super(640, 480)
  @player = Gosu::Image.new("player.png")
  @x = 100.0
  @y = 100.0
end

def draw
  @player.draw(@x, @y, 0)
end

The third argument to draw is the Z-order. Higher numbers draw on top of lower numbers. Use this to layer backgrounds (z=0), game objects (z=1), and UI elements (z=2).

You can also load a sprite sheet and split it into individual frames for animation:

@walk_frames = Gosu::Image.load_tiles("spritesheet.png", 32, 32)
@current_frame = 0

Then cycle through frames in update and draw the current one:

def update
  @current_frame = (Gosu.milliseconds / 150) % @walk_frames.size
end

def draw
  @walk_frames[@current_frame].draw(@x, @y, 1)
end

This switches frames every 150 milliseconds, giving you a smooth walk animation without any external animation library.

Handling Input

Check for held keys in update for continuous actions like movement:

def update
  @x -= 5 if Gosu.button_down?(Gosu::KB_LEFT)
  @x += 5 if Gosu.button_down?(Gosu::KB_RIGHT)
  @y -= 5 if Gosu.button_down?(Gosu::KB_UP)
  @y += 5 if Gosu.button_down?(Gosu::KB_DOWN)
end

Arrow keys now move your sprite. 5 pixels per frame at 60 FPS = 300 pixels per second.

For one-shot actions like shooting or pausing, override button_down instead. This fires once per key press, not every frame:

def button_down(id)
  close! if id == Gosu::KB_ESCAPE
  shoot if id == Gosu::KB_SPACE
end

You can also check the mouse position with mouse_x and mouse_y, and detect clicks with Gosu::MS_LEFT.

Collision Detection with AABB

Most 2D games need collision detection. The simplest approach is AABB (Axis-Aligned Bounding Box) -- check if two rectangles overlap. Here is a reusable method:

def collide?(x1, y1, w1, h1, x2, y2, w2, h2)
  x1 < x2 + w2 && x1 + w1 > x2 &&
    y1 < y2 + h2 && y1 + h1 > y2
end

Use it in update to check if the player touches an enemy or a collectible:

def update
  @coins.reject! do |coin|
    if collide?(@px, @py, 32, 32, coin[:x], coin[:y], 16, 16)
      @score += 1
      true
    end
  end
end

This removes coins from the array when the player overlaps them and bumps the score. Simple, efficient, and good enough for most 2D games.

Complete Example: Coin Collector

Here is a fully working game. The player moves with arrow keys and collects randomly placed coins. No external images needed -- everything is drawn with colored rectangles.

require 'gosu'

class CoinCollector < Gosu::Window
  def initialize
    super(640, 480)
    self.caption = "Coin Collector"

    @px = 304.0
    @py = 224.0
    @speed = 4
    @score = 0
    @font = Gosu::Font.new(24)

    @coins = 10.times.map do
      { x: rand(20..588), y: rand(20..448) }
    end
  end

  def update
    @px -= @speed if Gosu.button_down?(Gosu::KB_LEFT)
    @px += @speed if Gosu.button_down?(Gosu::KB_RIGHT)
    @py -= @speed if Gosu.button_down?(Gosu::KB_UP)
    @py += @speed if Gosu.button_down?(Gosu::KB_DOWN)

    @px = @px.clamp(0, 608)
    @py = @py.clamp(0, 448)

    @coins.reject! do |coin|
      if collide?(@px, @py, 32, 32, coin[:x], coin[:y], 16, 16)
        @score += 1
        true
      end
    end

    spawn_coins if @coins.empty?
  end

  def draw
    Gosu.draw_rect(@px, @py, 32, 32, Gosu::Color::CYAN, 1)

    @coins.each do |coin|
      Gosu.draw_rect(coin[:x], coin[:y], 16, 16, Gosu::Color::YELLOW, 1)
    end

    @font.draw_text("Score: #{@score}", 10, 10, 2, 1, 1, Gosu::Color::WHITE)
  end

  def button_down(id)
    close! if id == Gosu::KB_ESCAPE
  end

  private

  def collide?(x1, y1, w1, h1, x2, y2, w2, h2)
    x1 < x2 + w2 && x1 + w1 > x2 &&
      y1 < y2 + h2 && y1 + h1 > y2
  end

  def spawn_coins
    @coins = 10.times.map do
      { x: rand(20..588), y: rand(20..448) }
    end
  end
end

CoinCollector.new.show

Save this as coin_collector.rb and run it with ruby coin_collector.rb. You get a cyan square (the player), yellow squares (coins), and a score counter. Collect all ten coins and a new batch spawns. Press Escape to quit.

Rendering Text and UI

Gosu makes text rendering straightforward with Gosu::Font:

@font = Gosu::Font.new(20)

def draw
  @font.draw_text("HP: #{@health}", 10, 10, 2)
  @font.draw_text("Score: #{@score}", 10, 40, 2)
end

The arguments after the string are x, y, z-order, x-scale, y-scale, and color. For centered text, use draw_text_rel with 0.5 for the relative origin:

@font.draw_text_rel("GAME OVER", 320, 240, 2, 0.5, 0.5)

Adding Sound

def initialize
  super(640, 480)
  @jump_sound = Gosu::Sample.new("jump.wav")
  @music = Gosu::Song.new("background.ogg")
  @music.play(true)
end

def button_down(id)
  if id == Gosu::KB_SPACE
    @jump_sound.play
  end
end

Gosu::Sample is for short sound effects. Gosu::Song is for background music -- pass true to play for looping. Only one Song can play at a time, but you can layer multiple Samples over it. For a deeper dive into programmatic audio, see making music with Ruby and Sonic Pi.

Delta Time for Smooth Movement

Hardcoded pixel-per-frame movement works, but frame rate drops cause stuttering. For smoother results, track the time between frames:

def initialize
  super(640, 480)
  @last_time = Gosu.milliseconds
  @px = 320.0
  @speed = 200.0
end

def update
  current_time = Gosu.milliseconds
  dt = (current_time - @last_time) / 1000.0
  @last_time = current_time

  @px += @speed * dt if Gosu.button_down?(Gosu::KB_RIGHT)
  @px -= @speed * dt if Gosu.button_down?(Gosu::KB_LEFT)
end

Now @speed is in pixels per second instead of pixels per frame. The player moves at a consistent rate regardless of frame drops.

What Next

Start small. Make Pong -- it teaches you collision, scoring, and basic AI. Then build a simple platformer with gravity. Then add enemies with patrol patterns.

Gosu has good docs at libgosu.org. The wiki has tutorials for tilemaps, particle effects, and networking.

For structuring larger games, extract your entities into separate classes. A Player class, an Enemy class, a Level class. Applying Ruby design patterns like Strategy or Observer can help keep game logic organized. Gosu does not force any architecture on you, so you can organize your code however makes sense.

Ruby games will not run on phones or consoles. For that, look at DragonRuby. But for desktop games, game jams, and learning how games work under the hood, Gosu with Ruby is hard to beat.

Build something. Ship it. That is how you learn.