Gosu Basics

By now Gosu should be installed and ready for a spin. But before we rush into building our game, we have to get acquainted with our library. We will go through several simple examples, familiarize ourselves with Gosu architecture and core principles, and take a couple of baby steps towards understanding how to put everything together.

To make this chapter easier to read and understand, I recommend watching Writing Games With Ruby talk given by Mike Moore at LA Ruby Conference 2014. In fact, this talk pushed me towards rethinking this crazy idea of using Ruby for game development, so this book wouldn’t exist without it. Thank you, Mike.

Hello World

To honor the traditions, we will start by writing “Hello World” to get a taste of what Gosu feels like. It is based on Ruby Tutorial that you can find in Gosu Wiki.

01-hello/hello_world.rb


 1 require 'gosu'
 2 
 3 class GameWindow < Gosu::Window
 4   def initialize(width=320, height=240, fullscreen=false)
 5     super
 6     self.caption = 'Hello'
 7     @message = Gosu::Image.from_text(
 8       self, 'Hello, World!', Gosu.default_font_name, 30)
 9   end
10 
11   def draw
12     @message.draw(10, 10, 0)
13   end
14 end
15 
16 window = GameWindow.new
17 window.show

Run the code:

$ ruby 01-hello/hello_world.rb

You should see a neat small window with your message:

Hello World

Hello World

See how easy that was? Now let’s try to understand what just happened here.

We have extended Gosu::Window with our own GameWindow class, initializing it as 320x240 window. super passed width, height and fullscreen initialization parameters from GameWindow to Gosu::Window.

Then we defined our window’s caption, and created @message instance variable with an image generated from text "Hello, World!" using Gosu::Image.from_text.

We have overridden Gosu::Window#draw instance method that gets called every time Gosu wants to redraw our game window. In that method we call draw on our @message variable, providing x and y screen coordinates both equal to 10, and z (depth) value equal to 0.

Screen Coordinates And Depth

Just like most conventional computer graphics libraries, Gosu treats x as horizontal axis (left to right), y as vertical axis (top to bottom), and z as order.

Screen coordinates and depth

Screen coordinates and depth

x and y are measured in pixels, and value of z is a relative number that doesn’t mean anything on it’s own. The pixel in top-left corner of the screen has coordinates of 0:0.

z order in Gosu is just like z-index in CSS. It does not define zoom level, but in case two shapes overlap, one with higher z value will be drawn on top.

Main Loop

The heart of Gosu library is the main loop that happens in Gosu::Window.

TODO write more about main loop

Moving Things With Keyboard

We will modify our “Hello, World!” example to learn how to move things on screen. The following code will print coordinates of the message along with number of times screen was redrawn. It also allows exiting the program by hitting Esc button.

01-hello/hello_movement.rb


 1 require 'gosu'
 2 
 3 class GameWindow < Gosu::Window
 4   def initialize(width=320, height=240, fullscreen=false)
 5     super
 6     self.caption = 'Hello Movement'
 7     @x = @y = 10
 8     @draws = 0
 9     @buttons_down = 0
10   end
11 
12   def update
13     @x -= 1 if button_down?(Gosu::KbLeft)
14     @x += 1 if button_down?(Gosu::KbRight)
15     @y -= 1 if button_down?(Gosu::KbUp)
16     @y += 1 if button_down?(Gosu::KbDown)
17   end
18 
19   def button_down(id)
20     close if id == Gosu::KbEscape
21     @buttons_down += 1
22   end
23 
24   def button_up(id)
25     @buttons_down -= 1
26   end
27 
28   def needs_redraw?
29     @draws == 0 || @buttons_down > 0
30   end
31 
32   def draw
33     @draws += 1
34     @message = Gosu::Image.from_text(
35       self, info, Gosu.default_font_name, 30)
36     @message.draw(@x, @y, 0)
37   end
38 
39   private
40 
41   def info
42     "[x:#{@x};y:#{@y};draws:#{@draws}]"
43   end
44 end
45 
46 window = GameWindow.new
47 window.show

Run the program and try pressing arrow keys:

$ ruby 01-hello/hello_movement.rb

The message will move around as long as you keep arrow keys pressed.

Use arrow keys to move the message around

Use arrow keys to move the message around

We could write a shorter version, but the point here is that if we wouldn’t override needs_redraw? this program would be slower by order of magnitude, because it would create @message object every time it wants to redraw the window, even though nothing would change.

Here is a screenshot of top displaying two versions of this program. Second screen has needs_redraw? method removed. See the difference?

Redrawing only when necessary VS redrawing every time

Redrawing only when necessary VS redrawing every time

Ruby is slow, so you have to use it wisely.

Images And Animation

It’s time to make something more exciting. Our game will have to have explosions, therefore we need to learn to animate them. We will set up a background scene and trigger explosions on top of it with our mouse.

01-hello/hello_animation.rb


 1 require 'gosu'
 2 
 3 def media_path(file)
 4   File.join(File.dirname(File.dirname(
 5     __FILE__)), 'media', file)
 6 end
 7 
 8 class Explosion
 9   FRAME_DELAY = 10 # ms
10   SPRITE = media_path('explosion.png')
11 
12   def self.load_animation(window)
13     Gosu::Image.load_tiles(
14       window, SPRITE, 128, 128, false)
15   end
16 
17   def initialize(animation, x, y)
18     @animation = animation
19     @x, @y = x, y
20     @current_frame = 0
21   end
22 
23   def update
24     @current_frame += 1 if frame_expired?
25   end
26 
27   def draw
28     return if done?
29     image = current_frame
30     image.draw(
31       @x - image.width / 2.0,
32       @y - image.height / 2.0,
33       0)
34   end
35 
36   def done?
37     @done ||= @current_frame == @animation.size
38   end
39 
40   private
41 
42   def current_frame
43     @animation[@current_frame % @animation.size]
44   end
45 
46   def frame_expired?
47     now = Gosu.milliseconds
48     @last_frame ||= now
49     if (now - @last_frame) > FRAME_DELAY
50       @last_frame = now
51     end
52   end
53 end
54 
55 class GameWindow < Gosu::Window
56   BACKGROUND = media_path('country_field.png')
57 
58   def initialize(width=800, height=600, fullscreen=false)
59     super
60     self.caption = 'Hello Animation'
61     @background = Gosu::Image.new(
62       self, BACKGROUND, false)
63     @animation = Explosion.load_animation(self)
64     @explosions = []
65   end
66 
67   def update
68     @explosions.reject!(&:done?)
69     @explosions.map(&:update)
70   end
71 
72   def button_down(id)
73     close if id == Gosu::KbEscape
74     if id == Gosu::MsLeft
75       @explosions.push(
76         Explosion.new(
77           @animation, mouse_x, mouse_y))
78     end
79   end
80 
81   def needs_cursor?
82     true
83   end
84 
85   def needs_redraw?
86     !@scene_ready || @explosions.any?
87   end
88 
89   def draw
90     @scene_ready ||= true
91     @background.draw(0, 0, 0)
92     @explosions.map(&:draw)
93   end
94 end
95 
96 window = GameWindow.new
97 window.show

Run it and click around to enjoy those beautiful special effects:

$ ruby 01-hello/hello_animation.rb
Multiple explosions on screen

Multiple explosions on screen

Now let’s figure out how it works. Our GameWindow initializes with @background Gosu::Image and @animation, that holds array of Gosu::Image instances, one for each frame of explosion. Gosu::Image.load_tiles handles it for us.

Explosion::SPRITE points to “tileset” image, which is just a regular image that contains equally sized smaller image frames arranged in ordered sequence. Rows of frames are read left to right, like you would read a book.

Explosion tileset

Explosion tileset

Given that explosion.png tileset is 1024x1024 pixels big, and it has 8 rows of 8 tiles per row, it is easy to tell that there are 64 tiles 128x128 pixels each. So, @animation[0] holds 128x128 Gosu::Image with top-left tile, and @animation[63] - the bottom-right one.

Gosu doesn’t handle animation, it’s something you have full control over. We have to draw each tile in a sequence ourselves. You can also use tiles to hold map graphics The logic behind this is pretty simple:

  1. Explosion knows it’s @current_frame number. It begins with 0.
  2. Explosion#frame_expired? checks the last time when @current_frame was rendered, and when it is older than Explosion::FRAME_DELAY milliseconds, @current_frame is increased.
  3. When GameWindow#update is called, @current_frame is recalculated for all @explosions. Also, explosions that have finished their animation (displayed the last frame) are removed from @explosions array.
  4. GameWindow#draw draws background image and all @explosions draw their current_frame.
  5. Again, we are saving resources and not redrawing when there are no @explosions in progress. needs_redraw? handles it.

It is important to understand that update and draw order is unpredictable, these methods can be called by your system at different rate, you can’t tell which one will be called more often than the other one, so update should only be concerned with advancing object state, and draw should only draw current state on screen if it is needed. The only reliable thing here is time, consult Gosu.milliseconds to know how much time have passed.

Rule of the thumb: draw should be as lightweight as possible. Prepare all calculations in update and you will have responsive, smooth graphics.

Music And Sound

Our previous program was clearly missing a soundtrack, so we will add one. A background music will be looping, and each explosion will become audible.

01-hello/hello_sound.rb


  1 require 'gosu'
  2 
  3 def media_path(file)
  4   File.join(File.dirname(File.dirname(
  5     __FILE__)), 'media', file)
  6 end
  7 
  8 class Explosion
  9   FRAME_DELAY = 10 # ms
 10   SPRITE = media_path('explosion.png')
 11 
 12   def self.load_animation(window)
 13     Gosu::Image.load_tiles(
 14       window, SPRITE, 128, 128, false)
 15   end
 16 
 17   def self.load_sound(window)
 18     Gosu::Sample.new(
 19       window, media_path('explosion.mp3'))
 20   end
 21 
 22   def initialize(animation, sound, x, y)
 23     @animation = animation
 24     sound.play
 25     @x, @y = x, y
 26     @current_frame = 0
 27   end
 28 
 29   def update
 30     @current_frame += 1 if frame_expired?
 31   end
 32 
 33   def draw
 34     return if done?
 35     image = current_frame
 36     image.draw(
 37       @x - image.width / 2.0,
 38       @y - image.height / 2.0,
 39       0)
 40   end
 41 
 42   def done?
 43     @done ||= @current_frame == @animation.size
 44   end
 45 
 46   def sound
 47     @sound.play
 48   end
 49 
 50   private
 51 
 52   def current_frame
 53     @animation[@current_frame % @animation.size]
 54   end
 55 
 56   def frame_expired?
 57     now = Gosu.milliseconds
 58     @last_frame ||= now
 59     if (now - @last_frame) > FRAME_DELAY
 60       @last_frame = now
 61     end
 62   end
 63 end
 64 
 65 class GameWindow < Gosu::Window
 66   BACKGROUND = media_path('country_field.png')
 67 
 68   def initialize(width=800, height=600, fullscreen=false)
 69     super
 70     self.caption = 'Hello Animation'
 71     @background = Gosu::Image.new(
 72       self, BACKGROUND, false)
 73     @music = Gosu::Song.new(
 74       self, media_path('menu_music.mp3'))
 75     @music.volume = 0.5
 76     @music.play(true)
 77     @animation = Explosion.load_animation(self)
 78     @sound = Explosion.load_sound(self)
 79     @explosions = []
 80   end
 81 
 82   def update
 83     @explosions.reject!(&:done?)
 84     @explosions.map(&:update)
 85   end
 86 
 87   def button_down(id)
 88     close if id == Gosu::KbEscape
 89     if id == Gosu::MsLeft
 90       @explosions.push(
 91         Explosion.new(
 92           @animation, @sound, mouse_x, mouse_y))
 93     end
 94   end
 95 
 96   def needs_cursor?
 97     true
 98   end
 99 
100   def needs_redraw?
101     !@scene_ready || @explosions.any?
102   end
103 
104   def draw
105     @scene_ready ||= true
106     @background.draw(0, 0, 0)
107     @explosions.map(&:draw)
108   end
109 end
110 
111 window = GameWindow.new
112 window.show

Run it and enjoy the cinematic experience. Adding sound really makes a difference.

$ ruby 01-hello/hello_sound.rb

We only added couple of things over previous example.

72 @music = Gosu::Song.new(
73   self, media_path('menu_music.mp3'))
74 @music.volume = 0.5
75 @music.play(true)

GameWindow creates Gosu::Song with menu_music.mp3, adjusts the volume so it’s a little more quiet and starts playing in a loop.

16 def self.load_sound(window)
17   Gosu::Sample.new(
18     window, media_path('explosion.mp3'))
19 end

Explosion has now got load_sound method that loads explosion.mp3 sound effect Gosu::Sample. This sound effect is loaded once in GameWindow constructor, and passed into every new Explosion, where it simply starts playing.

Handling audio with Gosu is very straightforward. Use Gosu::Song to play background music, and Gosu::Sample to play effects and sounds that can overlap.