Refactoring The Prototype

At this point you may be thinking where to go next. We want to implement enemies, collision detection and AI, but design of current prototype is already limiting. Code is becoming tightly coupled, there is no clean separation between different domains.

If we were to continue building on top of our prototype, things would get ugly quickly. Thus we will untangle the spaghetti and rewrite some parts from scratch to achieve elegance.

Game Programming Patterns

I would like to tip my hat to Robert Nystrom, who wrote this amazing book called Game Programming Patterns. The book is available online for free, it is a relatively quick read - I’ve devoured it with pleasure in roughly 4 hours. If you are guessing that this chapter is inspired by that book, you are absolutely right.

Component pattern is especially noteworthy. We will be using it to do major housekeeping, and it is great time to do so, because we haven’t implemented much of the game yet.

What Is Wrong With Current Design

Until this point we have been building the code in monolithic fashion. Tank class holds the code that:

  1. Loads all ground unit sprites. If some other class handled it, we could reuse the code to load other units.
  2. Handles sound effects.
  3. Uses Gosu::Song for moving sounds. That limits only one tank movement sound per whole game. Basically, we abused Gosu here.
  4. Handles keyboard and mouse. If we were to create AI that controls the tank, we would not be able to reuse Tank class because of this.
  5. Draws graphics on screen.
  6. Calculates physical properties, like speed, acceleration.
  7. Detects movement collisions.

Bullet is not perfect either:

  1. It renders it’s graphics.
  2. It handles it’s movement trajectories and other physics.
  3. It treats Explosion as part of it’s own lifecycle.
  4. Draws graphics on screen.
  5. Handles sound effects.

Even the relatively small Explosion class is too monolithic:

  1. It loads it’s graphics.
  2. It handles rendering, animation and frame skipping
  3. It loads and plays it’s sound effects.

Decoupling Using Component Pattern

Best design separates concerns in code so that everything has it’s own place, and every class handles only one thing. Let’s try splitting up Tank class into components that handle specific domains:

Decoupled Tank

Decoupled Tank

We will introduce GameObject class will contain shared functionality for all game objects (Tank, Bullet, Explosion), each of them would have it’s own set of components. Every component will have it’s parent object, so it will be able to interact with it, change it’s attributes, or possibly invoke other components if it comes to that.

Game objects and their components

Game objects and their components

All these objects will be held within ObjectPool, which would not care to know if object is a tank or a bullet. Purpose of ObjectPool is a little different in Ruby, since GC will take care of memory fragmentation for us, but we still need a single place that knows about every object in the game.

Object Pool

Object Pool

PlayState would then iterate through @object_pool.objects and invoke update and draw methods.

Now, let’s begin by implementing base class for GameObject:

05-refactor/entities/game_object.rb


 1 class GameObject
 2   def initialize(object_pool)
 3     @components = []
 4     @object_pool = object_pool
 5     @object_pool.objects << self
 6   end
 7 
 8   def components
 9     @components
10   end
11 
12   def update
13     @components.map(&:update)
14   end
15 
16   def draw(viewport)
17     @components.each { |c| c.draw(viewport) }
18   end
19 
20   def removable?
21     @removable
22   end
23 
24   def mark_for_removal
25     @removable = true
26   end
27 
28   protected
29 
30   def object_pool
31     @object_pool
32   end
33 end

When GameObject is initialized, it registers itself with ObjectPool and prepares empty @components array. Concrete GameObject classes should initialize Components so that array would not be empty.

update and draw methods would cycle through @components and delegate those calls to each of them in a sequence. It is important to update all components first, and only then draw them. Keep in mind that @components array order has significance. First elements will always be updated and drawn before last ones.

We will also provide removable? method that would return true for objects that mark_for_removal was invoked on. This way we will be able to weed out old bullets and explosions and feed them to GC.

Next up, base Component class:

05-refactor/entities/components/component.rb


 1 class Component
 2   def initialize(game_object = nil)
 3     self.object = game_object
 4   end
 5 
 6   def update
 7     # override
 8   end
 9 
10   def draw(viewport)
11     # override
12   end
13 
14   protected
15 
16   def object=(obj)
17     if obj
18       @object = obj
19       obj.components << self
20     end
21   end
22 
23   def x
24     @object.x
25   end
26 
27   def y
28     @object.y
29   end
30 
31   def object
32     @object
33   end
34 end

It registers itself with GameObject#components, provides some protected methods to access parent object and it’s most often called properties - x and y.

Refactoring Explosion

Explosion was probably the smallest class, so we will extract it’s components first.

05-refactor/entities/explosion.rb


 1 class Explosion < GameObject
 2   attr_accessor :x, :y
 3 
 4   def initialize(object_pool, x, y)
 5     super(object_pool)
 6     @x, @y = x, y
 7     ExplosionGraphics.new(self)
 8     ExplosionSounds.play
 9   end
10 end

It is much cleaner than before. ExplosionGraphics will be a Component that handles animation, and ExplosionSounds will play a sound.

05-refactor/entities/components/explosion_graphics.rb


 1 class ExplosionGraphics < Component
 2   FRAME_DELAY = 16.66 # ms
 3 
 4   def initialize(game_object)
 5     super
 6     @current_frame = 0
 7   end
 8 
 9   def draw(viewport)
10     image = current_frame
11     image.draw(
12       x - image.width / 2 + 3,
13       y - image.height / 2 - 35,
14       20)
15   end
16 
17   def update
18     now = Gosu.milliseconds
19     delta = now - (@last_frame ||= now)
20     if delta > FRAME_DELAY
21       @last_frame = now
22     end
23     @current_frame += (delta / FRAME_DELAY).floor
24     object.mark_for_removal if done?
25   end
26 
27   private
28 
29   def current_frame
30     animation[@current_frame % animation.size]
31   end
32 
33   def done?
34     @done ||= @current_frame >= animation.size
35   end
36 
37   def animation
38     @@animation ||=
39     Gosu::Image.load_tiles(
40       $window, Utils.media_path('explosion.png'),
41       128, 128, false)
42   end
43 end

Everything that is related to animating the explosion is now clearly separated. mark_for_removal is called on the explosion after it’s animation is done.

05-refactor/entities/components/explosion_sounds.rb


 1 class ExplosionSounds
 2   class << self
 3     def play
 4       sound.play
 5     end
 6 
 7     private
 8 
 9     def sound
10       @@sound ||= Gosu::Sample.new(
11         $window, Utils.media_path('explosion.mp3'))
12     end
13   end
14 end

Since explosion sounds are triggered only once, when it starts to explode, ExplosionSounds is a static class with play method.

Refactoring Bullet

Now, let’s go up a little and reimplement our Bullet:

05-refactor/entities/bullet.rb


 1 class Bullet < GameObject
 2   attr_accessor :x, :y, :target_x, :target_y, :speed, :fired_at
 3 
 4   def initialize(object_pool, source_x, source_y, target_x, target_y)
 5     super(object_pool)
 6     @x, @y = source_x, source_y
 7     @target_x, @target_y = target_x, target_y
 8     BulletPhysics.new(self)
 9     BulletGraphics.new(self)
10     BulletSounds.play
11   end
12 
13   def explode
14     Explosion.new(object_pool, @x, @y)
15     mark_for_removal
16   end
17 
18   def fire(speed)
19     @speed = speed
20     @fired_at = Gosu.milliseconds
21   end
22 end

All physics, graphics and sounds are extracted into individual components, and instead of managing Explosion, it just registers a new Explosion with ObjectPool and marks itself for removal in explode method.

05-refactor/entities/components/bullet_physics.rb


 1 class BulletPhysics < Component
 2   START_DIST = 20
 3   MAX_DIST = 300
 4 
 5   def initialize(game_object)
 6     super
 7     object.x, object.y = point_at_distance(START_DIST)
 8     if trajectory_length > MAX_DIST
 9       object.target_x, object.target_y = point_at_distance(MAX_DIST)
10     end
11   end
12 
13   def update
14     fly_speed = Utils.adjust_speed(object.speed)
15     fly_distance = (Gosu.milliseconds - object.fired_at) * 0.001 * fly_speed
16     object.x, object.y = point_at_distance(fly_distance)
17     object.explode if arrived?
18   end
19 
20   def trajectory_length
21     d_x = object.target_x - x
22     d_y = object.target_y - y
23     Math.sqrt(d_x * d_x + d_y * d_y)
24   end
25 
26   def point_at_distance(distance)
27     if distance > trajectory_length
28       return [object.target_x, object.target_y]
29     end
30     distance_factor = distance.to_f / trajectory_length
31     p_x = x + (object.target_x - x) * distance_factor
32     p_y = y + (object.target_y - y) * distance_factor
33     [p_x, p_y]
34   end
35 
36   private
37 
38   def arrived?
39     x == object.target_x && y == object.target_y
40   end
41 end

BulletPhysics is where the most of Bullet ended up at. It does all the calculations and triggers Bullet#explode when ready. When we will be implementing collision detection, the implementation will go somewhere here.

05-refactor/entities/components/bullet_graphics.rb


 1 class BulletGraphics < Component
 2   COLOR = Gosu::Color::BLACK
 3 
 4   def draw(viewport)
 5     $window.draw_quad(x - 2, y - 2, COLOR,
 6                       x + 2, y - 2, COLOR,
 7                       x - 2, y + 2, COLOR,
 8                       x + 2, y + 2, COLOR,
 9                       1)
10   end
11 
12 end

After pulling away Bullet graphics code, it looks very small and elegant. We will probably never have to edit anything here again.

05-refactor/entities/components/bullet_sounds.rb


 1 class BulletSounds
 2   class << self
 3     def play
 4       sound.play
 5     end
 6 
 7     private
 8 
 9     def sound
10       @@sound ||= Gosu::Sample.new(
11         $window, Utils.media_path('fire.mp3'))
12     end
13   end
14 end

Just like ExplosionSounds, BulletSounds are stateless and static. We could make it just like a regular component, but consider it our little optimization.

Refactoring Tank

Time to take a look at freshly decoupled Tank:

05-refactor/entities/tank.rb


 1 class Tank < GameObject
 2   SHOOT_DELAY = 500
 3   attr_accessor :x, :y, :throttle_down, :direction, :gun_angle, :sounds, :physics
 4 
 5   def initialize(object_pool, input)
 6     super(object_pool)
 7     @input = input
 8     @input.control(self)
 9     @physics = TankPhysics.new(self, object_pool)
10     @graphics = TankGraphics.new(self)
11     @sounds = TankSounds.new(self)
12     @direction = @gun_angle = 0.0
13   end
14 
15   def shoot(target_x, target_y)
16     if Gosu.milliseconds - (@last_shot || 0) > SHOOT_DELAY
17       @last_shot = Gosu.milliseconds
18       Bullet.new(object_pool, @x, @y, target_x, target_y).fire(100)
19     end
20   end
21 end

Tank class was reduced over 5 times. We could go further and extract Gun component, but for now it’s simple enough already. Now, the components.

05-refactor/entities/components/tank_physics.rb


 1 class TankPhysics < Component
 2   attr_accessor :speed
 3 
 4   def initialize(game_object, object_pool)
 5     super(game_object)
 6     @object_pool = object_pool
 7     @map = object_pool.map
 8     game_object.x, game_object.y = @map.find_spawn_point
 9     @speed = 0.0
10   end
11 
12   def can_move_to?(x, y)
13     @map.can_move_to?(x, y)
14   end
15 
16   def moving?
17     @speed > 0
18   end
19 
20   def update
21     if object.throttle_down
22       accelerate
23     else
24       decelerate
25     end
26     if @speed > 0
27       new_x, new_y = x, y
28       shift = Utils.adjust_speed(@speed)
29       case @object.direction.to_i
30       when 0
31         new_y -= shift
32       when 45
33         new_x += shift
34         new_y -= shift
35       when 90
36         new_x += shift
37       when 135
38         new_x += shift
39         new_y += shift
40       when 180
41         new_y += shift
42       when 225
43         new_y += shift
44         new_x -= shift
45       when 270
46         new_x -= shift
47       when 315
48         new_x -= shift
49         new_y -= shift
50       end
51       if can_move_to?(new_x, new_y)
52         object.x, object.y = new_x, new_y
53       else
54         object.sounds.collide if @speed > 1
55         @speed = 0.0
56       end
57     end
58   end
59 
60   private
61 
62   def accelerate
63     @speed += 0.08 if @speed < 5
64   end
65 
66   def decelerate
67     @speed -= 0.5 if @speed > 0
68     @speed = 0.0 if @speed < 0.01 # damp
69   end
70 end

While we had to rip player input away from it’s movement, we got ourselves a benefit - tank now both accelerates and decelerates. When directional buttons are no longer pressed, tank keeps moving in last direction, but quickly decelerates and stops. Another addition that would have been more difficult to implement on previous Tank is collision sound. When Tank abruptly stops by hitting something (for now it’s only water), collision sound is played. We will have to fix that, because metal bang is not appropriate when you stop on the edge of a river, but we now did it for the sake of science.

05-refactor/entities/components/tank_graphics.rb


 1 class TankGraphics < Component
 2   def initialize(game_object)
 3     super(game_object)
 4     @body = units.frame('tank1_body.png')
 5     @shadow = units.frame('tank1_body_shadow.png')
 6     @gun = units.frame('tank1_dualgun.png')
 7   end
 8 
 9   def draw(viewport)
10     @shadow.draw_rot(x - 1, y - 1, 0, object.direction)
11     @body.draw_rot(x, y, 1, object.direction)
12     @gun.draw_rot(x, y, 2, object.gun_angle)
13   end
14 
15   private
16 
17   def units
18     @@units = Gosu::TexturePacker.load_json(
19       $window, Utils.media_path('ground_units.json'), :precise)
20   end
21 end

Again, graphics are neatly packed and separated from everything else. Eventually we should optimize draw to take viewport into consideration, but it’s good enough for now, especially when we have only one tank in the game.

05-refactor/entities/components/tank_sounds.rb


 1 class TankSounds < Component
 2   def update
 3     if object.physics.moving?
 4       if @driving && @driving.paused?
 5         @driving.resume
 6       elsif @driving.nil?
 7         @driving = driving_sound.play(1, 1, true)
 8       end
 9     else
10       if @driving && @driving.playing?
11         @driving.pause
12       end
13     end
14   end
15 
16   def collide
17     crash_sound.play(1, 0.25, false)
18   end
19 
20   private
21 
22   def driving_sound
23     @@driving_sound ||= Gosu::Sample.new(
24       $window, Utils.media_path('tank_driving.mp3'))
25   end
26 
27   def crash_sound
28     @@crash_sound ||= Gosu::Sample.new(
29       $window, Utils.media_path('crash.ogg'))
30   end
31 end

Unlike Explosion and Bullet, Tank sounds are stateful. We have to keep track of tank_driving.mp3, which is no longer Gosu::Song, but Gosu::Sample, like it should have been.

When Gosu::Sample#play is invoked, Gosu::SampleInstance is returned, and we have full control over it. Now we are ready to play sounds for more than one tank at once.

05-refactor/entities/components/player_input.rb


 1 class PlayerInput < Component
 2   def initialize(camera)
 3     super(nil)
 4     @camera = camera
 5   end
 6 
 7   def control(obj)
 8     self.object = obj
 9   end
10 
11   def update
12     d_x, d_y = @camera.target_delta_on_screen
13     atan = Math.atan2(($window.width / 2) - d_x - $window.mouse_x,
14                       ($window.height / 2) - d_y - $window.mouse_y)
15     object.gun_angle = -atan * 180 / Math::PI
16     motion_buttons = [Gosu::KbW, Gosu::KbS, Gosu::KbA, Gosu::KbD]
17 
18     if any_button_down?(*motion_buttons)
19       object.throttle_down = true
20       object.direction = change_angle(object.direction, *motion_buttons)
21     else
22       object.throttle_down = false
23     end
24 
25     if Utils.button_down?(Gosu::MsLeft)
26       object.shoot(*@camera.mouse_coords)
27     end
28   end
29 
30   private
31 
32   def any_button_down?(*buttons)
33     buttons.each do |b|
34       return true if Utils.button_down?(b)
35     end
36     false
37   end
38 
39   def change_angle(previous_angle, up, down, right, left)
40     if Utils.button_down?(up)
41       angle = 0.0
42       angle += 45.0 if Utils.button_down?(left)
43       angle -= 45.0 if Utils.button_down?(right)
44     elsif Utils.button_down?(down)
45       angle = 180.0
46       angle -= 45.0 if Utils.button_down?(left)
47       angle += 45.0 if Utils.button_down?(right)
48     elsif Utils.button_down?(left)
49       angle = 90.0
50       angle += 45.0 if Utils.button_down?(up)
51       angle -= 45.0 if Utils.button_down?(down)
52     elsif Utils.button_down?(right)
53       angle = 270.0
54       angle -= 45.0 if Utils.button_down?(up)
55       angle += 45.0 if Utils.button_down?(down)
56     end
57     angle = (angle + 360) % 360 if angle && angle < 0
58     (angle || previous_angle)
59   end
60 end

We finally come to a place where keyboard and mouse input is handled and converted to Tank commands. We could have used Command pattern to decouple everything even further.

Refactoring PlayState

05-refactor/game_states/play_state.rb


 1 require 'ruby-prof' if ENV['ENABLE_PROFILING']
 2 class PlayState < GameState
 3   attr_accessor :update_interval
 4 
 5   def initialize
 6     @map = Map.new
 7     @camera = Camera.new
 8     @object_pool = ObjectPool.new(@map)
 9     @tank = Tank.new(@object_pool, PlayerInput.new(@camera))
10     @camera.target = @tank
11   end
12 
13   def enter
14     RubyProf.start if ENV['ENABLE_PROFILING']
15   end
16 
17   def leave
18     if ENV['ENABLE_PROFILING']
19       result = RubyProf.stop
20       printer = RubyProf::FlatPrinter.new(result)
21       printer.print(STDOUT)
22     end
23   end
24 
25   def update
26     @object_pool.objects.map(&:update)
27     @object_pool.objects.reject!(&:removable?)
28     @camera.update
29     update_caption
30   end
31 
32   def draw
33     cam_x = @camera.x
34     cam_y = @camera.y
35     off_x =  $window.width / 2 - cam_x
36     off_y =  $window.height / 2 - cam_y
37     viewport = @camera.viewport
38     $window.translate(off_x, off_y) do
39       zoom = @camera.zoom
40       $window.scale(zoom, zoom, cam_x, cam_y) do
41         @map.draw(viewport)
42         @object_pool.objects.map { |o| o.draw(viewport) }
43       end
44     end
45     @camera.draw_crosshair
46   end
47 
48   def button_down(id)
49     if id == Gosu::KbQ
50       leave
51       $window.close
52     end
53     if id == Gosu::KbEscape
54       GameState.switch(MenuState.instance)
55     end
56   end
57 
58   private
59 
60   def update_caption
61     now = Gosu.milliseconds
62     if now - (@caption_updated_at || 0) > 1000
63       $window.caption = 'Tanks Prototype. ' <<
64         "[FPS: #{Gosu.fps}. " <<
65         "Tank @ #{@tank.x.round}:#{@tank.y.round}]"
66       @caption_updated_at = now
67     end
68   end
69 end

Implementation of PlayState is now also a little simpler. It doesn’t update @tank or @bullets individually anymore. Instead, it uses ObjectPool and does all object operations in bulk.

Other Improvements

05-refactor/main.rb


 1 #!/usr/bin/env ruby
 2 
 3 require 'gosu'
 4 
 5 root_dir = File.dirname(__FILE__)
 6 require_pattern = File.join(root_dir, '**/*.rb')
 7 @failed = []
 8 
 9 # Dynamically require everything
10 Dir.glob(require_pattern).each do |f|
11   next if f.end_with?('/main.rb')
12   begin
13     require_relative f.gsub("#{root_dir}/", '')
14   rescue
15     # May fail if parent class not required yet
16     @failed << f
17   end
18 end
19 
20 # Retry unresolved requires
21 @failed.each do |f|
22   require_relative f.gsub("#{root_dir}/", '')
23 end
24 
25 $window = GameWindow.new
26 GameState.switch(MenuState.instance)
27 $window.show

Finally, we made some improvements to main.rb - it now recursively requires all *.rb files within same directory, so we don’t have to worry about it in other classes.

05-refactor/utils.rb


 1 module Utils
 2   def self.media_path(file)
 3     File.join(File.dirname(File.dirname(
 4       __FILE__)), 'media', file)
 5   end
 6 
 7   def self.track_update_interval
 8     now = Gosu.milliseconds
 9     @update_interval = (now - (@last_update ||= 0)).to_f
10     @last_update = now
11   end
12 
13   def self.update_interval
14     @update_interval ||= $window.update_interval
15   end
16 
17   def self.adjust_speed(speed)
18     speed * update_interval / 33.33
19   end
20 
21   def self.button_down?(button)
22     @buttons ||= {}
23     now = Gosu.milliseconds
24     now = now - (now % 150)
25     if $window.button_down?(button)
26       @buttons[button] = now
27       true
28     elsif @buttons[button]
29       if now == @buttons[button]
30         true
31       else
32         @buttons.delete(button)
33         false
34       end
35     end
36   end
37 end

Another notable change is renaming Game module into Utils. The name finally makes more sense, I have no idea why I put utility methods into Game module in the first place. Also, Utils received button_down? method, that solves the issue of changing tank direction when button is immediately released. It made very difficult to stop at diagonal angle, because when you depressed two buttons, 16 ms was enough for Gosu to think “he released W, and S is still pressed, so let’s change direction to S”. Utils#button_down? gives a soft 150 ms window to synchronize button release. Now controls feel more natural.