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 gosuIf 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 sdl2On 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 gosuYour First Game Window
require 'gosu'
class Game < Gosu::Window
def initialize
super(640, 480)
self.caption = "My Game"
end
end
Game.new.showRun 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 eachupdate. Render sprites, text, and shapes here. Never modify game state indraw-- 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.showThis 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)
endThe 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 = 0Then 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)
endThis 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)
endArrow 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
endYou 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
endUse 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
endThis 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.showSave 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)
endThe 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
endGosu::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)
endNow @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.