Design Patterns in Ruby - The Builder

In the last part of this series, we left our little maze game gem generating random mazes that had random kinds of rooms using the abstract factory pattern.

While that was good enough, in the case of the our maze game, it turns out mazes can be pretty complex objects, being a collection of rooms, doors and walls of many types. And even though I didn't add too much variety of these components, things could get pretty convoluted.

It turns out there's a pattern just for these cases: the Builder Pattern.

Why, though?

Before I start changing our implementation, it's worth understing why it would be beneficial to use this pattern here.

According to the Design Patterns book we should use the Builder pattern when:

  • The algorithm for creating a complex object should be independent of the parts that make up the object and how they're assembled.
  • The construction process must allow different representations for the object that's constructed.

Now, to the first point, the parts that make up a maze in our game are, currently, Room, Door and Wall. Each of these, although I haven't added all of them, can be one of 2 varieties, Bombed or Enchanted. The way we assemble these parts is by setting doors and walls to the sides of rooms.

Now the question: should the algorithm for creating a maze be separate from how we put these parts together? Seems like one could argue either way, but I tend to believe that it should, so that whomever is building the maze can simply tell the program: "Make a room. Make another room. Make a door between those rooms." and so on. We might let them pick the type of the assembled parts, even, but even then, they really shouldn't care about how these parts are glued together.

As for the second point, for sure it is interesting to us that our construction process allows for different representations of our mazes. The term "representation" in our case is the type of maze we get. Our game is simple and we only get to have a RandomMaze, but I do wish to be able to customize this a bit more and allow for a maze to be of one type only, or mayby have a builder that can allow us to mix and match types more precisely.

Seems like our case is pretty strong for using a Builder.

What's in a Builder?

There are 2 main components to the pattern: the Builder class itself and what the authors of the book call a Director. The Director is the class responsible for building our object (the Product) using the Builder's interface. It isn't necessary that the client of our gem be the Director, although that is possible. In our case, they'll be seperate. We want our clients to just ask for a maze and we take care of giving them a maze.

Aside from these 2 there are also the ConcreteBuilder and the Product. The Product, as mentioned above, is the object we want to build (in our case, a Maze), the ConcreteBuilder would be the concrete implementation of the Builder. Now, in our case, that is not relevant because I decided that, instead of making a class that I need every ConcreteBuilder to override, I went with just making the ConcreteBuilder classes and ensuring that they all implement the same interface.

The reason is that, in Ruby, there isn't really a standard way to enforce the implementation of an interface and I just decided to go with what we call "duck typing": if it acts like a Builder, it's a Builder.

Not to mention, for now I just want the one builder, so if I do feel like I need some abstract class that'll raise exceptions if I don't have the full implementations, I'll add it later.

Where we left off

So, currently, our CreationalMaze module looks like this:

module CreationalMaze
  DIRECTIONS = {
    north: 1,
    east: 2,
    west: 3,
    south: 4
  }

  NUMBER_OF_ROOMS = 10

  def self.new_maze(maze_factory:)
    maze = maze_factory.make_maze

    rooms = []
    for i in 1..NUMBER_OF_ROOMS do
      rooms.push maze_factory.make_room room_number: i
    end

    rooms.each do |room|
      maze.add_room room: room
    end

    maze.rooms.each do |room|
      DIRECTIONS.each do |direction|
        # randomely choose if make wall or add door
        wall = rand >= 0.5
        map_site = if wall
          maze_factory.make_wall
        else
          maze_factory.make_door room_1: room, room_2: rooms.sample
        end

        room.set_side direction: direction, map_site: map_site
      end
    end

    maze
  end

  def self.new_level
    bombed_maze_factory = BombedMazeFactory.new
    enchanted_maze_factory = EnchantedMazeFactory.new
    maze_factory_array = [bombed_maze_factory, enchanted_maze_factory]

    new_maze(maze_factory: maze_factory_array.sample)
  end
end

We want to add now a Builder to our project that will allow us to build mazes in random fashion, as the method above does. I shall call it the RandomMazeBuilder.

I just want to first share that I did a lot of reorganization of the code behind the scenes: moved modules out of the CreationalMaze module, added modules that were needed, changed function calls, etc. I intend to later share this code on github, so you'll be able to see all that, but for the purposes of this article, these changes are not relevant, so don't worry if you notice that some classes suddenly are in other modules that didn't exist in the previous article.

So now, for organization's sake, I'm going to add a Builders module, which will name space all builders that I decide to create. Thus I add a builders.rb in the creational_maze folder with the following content:

module Builders
end

Then I add a builders folder and in there I create a file called random_maze_builder.rb:

module Builders
  class RandomMazeBuilder
  end
end

Now, following the book, the builder class needs any methods necessary to build the maze and one method to give back the built maze. They mention even some private utility methods, but, at least in my case, they were not needed. So stubbing out the methods like in the book:

module Builders
  class RandomMazeBuilder
    def build_maze; end

    def build_room(room_no); end

    def build_door(from_no, to_no); end
  end
end

Implementing build_maze

This one is dead simple. We just need to initialize our maze here and set an attribute on the class, so that our other methods can modify it.

One worthy mention here is that should you want to be able to do method chaining, all methods must return the Maze instance. It's an interesting idea. I won't be doing that here, though.

Thus, our method should look like:

module Builders
  class RandomMazeBuilder
    attr_accessor :current_maze

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no); end

    def build_door(from_no, to_no); end
  end
end

Now we can start working on our other methods.

Implementing build_room

Here we have our first run in with some validations and some dependencies.

The Builder I'm making will randomly generate different types of rooms and add them to the maze. Here, I'm going to rely on the pattern we worked on last time, the AbstractFactory.

What I want is for the method to randomly get one factory and request that factory to give it a room of that type. To do that, our Builder needs to hold references to all the different types of factories we might have. Therefore, first thing to do is add an initializer that will provide these factories for us:

module Builders
  class RandomMazeBuilder
    attr_accessor :current_maze
    attr_accessor :factories

    def initializer
      @factories = [
        Factories::BombedMazeFactory.new,
        Factories::EnchantedMaze.new
      ]
    end

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no); end

    def build_door(from_no, to_no); end
  end
end

Now, in our build_room we want to get a random factory from our @factories attribute and ask it to give us a room and add that room to our maze:

module Builders
  class RandomMazeBuilder
    attr_accessor :current_maze
    attr_accessor :factories

    def initializer
      @factories = [
        Factories::BombedMazeFactory.new,
        Factories::EnchantedMaze.new
      ]
    end

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no)
      factory = @factories.sample
      room = factory.make_room(room_no)
      @current_maze.add_room(room)
    end

    def build_door(from_no, to_no); end
  end
end

Awesome! Ou room has no walls though, so we should add them. And, since we do want every component to be randomly picked, we need to sample the factories array again.

In the interest of simplicity, the walls will be all of a kind, but later I'll make them be of random types as well. Here we also need to add the Directions module to our class. The walls of the room are set according to the 4 directions of the compass which are given by the symbold :north, :east, :west and :south. I just moved them to a seperate module because we'll also need some auxiliary methods related to them:

module Builders
  class RandomMazeBuilder
    include Directions

    attr_accessor :current_maze
    attr_accessor :factories

    def initializer
      @factories = [
        Factories::BombedMazeFactory.new,
        Factories::EnchantedMaze.new
      ]
    end

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no)
      factory = @factories.sample
      room = factory.make_room(room_no)
      @current_maze.add_room(room)

      factory = @factories.sample
      room.set_side(Directions::NORTH, factory.make_wall)
      room.set_side(Directions::SOUTH, factory.make_wall)
      room.set_side(Directions::EAST, factory.make_wall)
      room.set_side(Directions::WEST, factory.make_wall)
    end

    def build_door(from_no, to_no); end
  end
end

And that's the happy path for you. There is the issue of validation in this method. However, I'm going to leave that for yet another blog post. How to handle errors is well worth it. In the mean time, you can just check the repo for the gem to see what I added.

Implementing build_door

The last method we need to take care of in our Builder is the build_door method.

Now, as with most doors, the doors in our maze should lead from room A to room B. So the first order of business is to grab those rooms. Naturally, the Director calling this method is responsible for telling the Builder which rooms to fetch:

module Builders
  class RandomMazeBuilder
    include Directions

    attr_accessor :current_maze
    attr_accessor :factories

    def initializer
      @factories = [
        Factories::BombedMazeFactory.new,
        Factories::EnchantedMaze.new
      ]
    end

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no)
      factory = @factories.sample
      room = factory.make_room(room_no)
      @current_maze.add_room(room)

      factory = @factories.sample
      room.set_side(Directions::NORTH, factory.make_wall)
      room.set_side(Directions::SOUTH, factory.make_wall)
      room.set_side(Directions::EAST, factory.make_wall)
      room.set_side(Directions::WEST, factory.make_wall)
    end

    def build_door(from_no, to_no)
      room1 = @current_maze.room_no(from_no)
      room2 = @current_maze.room_no(to_no)
    end
  end
end

Now we just instantiate a new Door:

    def build_door(from_no, to_no)
      room1 = @current_maze.room_no(from_no)
      room2 = @current_maze.room_no(to_no)
      door = Doors::Door.new(room1, room2)
    end
  end
end

Finally, we want to set the door to one of the sides of each room. And here lies the tricky part: we want the door to be on the common side of each room. So, for example, if we make a door between rooms 1 and 2, if we set it to the north side of room 1, we must necessarily have that door set to the southern side of room 2.

I'll just present the code and later explain the details:

    def build_door(from_no, to_no)
      room1 = @current_maze.room_no(from_no)
      room2 = @current_maze.room_no(to_no)
      door = Doors::Door.new(room1, room2)

      door_direction = all_directions.sample

      room1.set_side(door_direction, door)
      room2.set_side(opposite_direction(door_direction), door)
    end
  end
end

The first method we call is the all_directions method. Remember how we added an include Directions at the top of the file? Well, that's precisely where this method is coming from. Below is the full content of the Directions module:

module Directions
  NORTH = :north
  SOUTH = :south
  EAST = :east
  WEST = :west

  def all_directions
    [:north, :south, :east, :west]
  end

  def opposite_direction(direction)
    case direction
    when :south
      :north
    when :east
      :west
    when :west
      :east
    when :north
      :south
    else
      raise ArgumentError.new("The only valid directions are :north, :south, :east or :west")
    end
  end
end

As you can see, all_directions merely returns an array with all four directions available. The opposite_direction method we'll cover in a bit.

Back to the Builder code:

    def build_door(from_no, to_no)
      room1 = @current_maze.room_no(from_no)
      room2 = @current_maze.room_no(to_no)
      door = Doors::Door.new(room1, room2)

      door_direction = all_directions.sample

      room1.set_side(door_direction, door)
      room2.set_side(opposite_direction(door_direction), door)
    end
  end
end

After grabbing some random direction, we immediately set the door to that side of room1. Here is where we call opposite_direction. As the name implies, it gives back the direction opposite to the one we pass to it. So, if door_direction is :north, it'll give us :south, and so forth. The implementation, as you saw above, is pretty straightforward.

And that is all for our Builder. Now, we must turn to our Director class.

The Director

The last piece to this pattern is implementing our Director. According to the book, this is the class responsible for calling our Builder and grabbing the finished product. It knows what it wants, it doesn't care how it gets done.

Implementing this class is pretty straightforward as well. In our case, I'll make it a module, because I want to have the Director in this pattern be something separate from the actual class that creates a new game.

The module is none other than our CreationalMaze module, from the previous articles. Now that I have a builder, I can change the new_maze method to take an instance of a Builder and to have it create the maze using this Builder:

# frozen_string_literal: true

require "creational_maze/version"
require "creational_maze/maze"
require "creational_maze/rooms"
require "creational_maze/directions"
require "creational_maze/doors"
require "creational_maze/walls"
require "creational_maze/error"
require "creational_maze/builders"
require "creational_maze/factories"

module CreationalMaze
  NUMBER_OF_ROOMS = 6

  def new_maze(maze_builder)
    maze_builder.build_maze

    (1..NUMBER_OF_ROOMS).each do |room_number|
      maze_builder.build_room room_number
    end

    rooms = maze_builder.get_maze.rooms

    rooms.each do |room|
      room.sides.each do |_direction, _map_site|
        door_odds = rand
        if door_odds >= 0.5
          room2 = rooms.sample
          maze_builder.build_door(room.room_number, room2.room_number)
        end
      end
    end

    maze_builder.get_maze
  end
end

Like before, we merely establish how many rooms we want, loop through that number and simply add the rooms by calling maze_builder.build_room. Finally we add doors (i.e.: we connect our rooms) by looping around each room, and then each side and randomly adding a door, with a 50% chance of not adding a door at all.

Conclusion

This still doesn't give us a usable maze. Despite that, this pattern seems to be more aligned to the needs at hand, which is to say, abstract the complexities of building the objects that compose a maze. I still intend to revisit this application and make it truly generate a maze that we can interact with in a defined manner, but the objective of this article was achieved: to show how to implement the Builder Pattern in Ruby.

The main issue is with the algorithm I came up with to build and assemble the maze. But this isn't a problem that can be solved by the Builder Pattern. I intend to explore this issue in future articles before coming back to this gem and making it actually usable.