Design Patterns in Ruby - The Abstract Factory

In my last article I introduced this series and here's the first pattern: the Abstract Factory

The app

At first I didn't know exactly what to build but, thankfully, the authors of Design Patterns furnished me with a good idea from their own example: a text based game where we traverse randomly generated dungeons. The player will input their commands from a list of options and will traverse different rooms that might have traps, treasures, enchantments, and whatever other nicities our hearts might desire.

For starters, I just want to be able to create a ruby module where I can say DungeonMaker.new_dungeon and it will give me a pristine new dungueon for my player to just enter and begin his adventure.

Not all rooms are created equal

So, clearly I have the following situation: I want to generate, in a random manner, different kinds of rooms that will have, on each side (for now, we deal with four) either a wall or a door, also these of different kinds.

We need to be more explicit, though. What are all possible kinds of rooms, doors and walls? For now I'll go with three:

  • Trapped
  • Enchanted
  • Ordinary

A trapped entity, when activated, will inflict a certain negative effect (ideally a random effect picked from a list of possible effects). An enchanted one will cast a spell, which can either be good or bad. Finally, ordinary entities have a chance at hidden items or treasure.

I'll first try to do it in a straightforward fashion, so that the problem will manifest itself. It is when the dragon rears it's ugly head that we must strike. Once we see what the problem is, then I'll present the solution.

Finding the dragon's lair

So, first, I want my API to be simple. I want whoever uses my code (which will be a gem) to be able to just say DungeonMaker.new_dungeon and they'll get a new dungeon, fully set up with random rooms and the whole deal. Optionally, they can pass the number of rooms they want in their dungeon (default will be 10).

So I start by creating my dungeon_maker gem:

$ bundler gem dungeon_maker

After this, in order to be able to build the gem with bundler, you'll need to configure some required options in the .gemspec file. I won't go through it here since this information is readily accessible on the internet. Either way, once you cd into the project, this is what the file tree will look like:

Initial file tree

Now, what I'll I want to do is loop through the number of rooms and, for each room, instantiate a room of a random type and give for each of it's sides either a wall or a door of a random type as well. Seems quite convoluted, and it is. I bet there are maybe better algorithms than this, but I just want to illustrate the problem of instantiating different types of objects, not the best algorithm for assembling randomly generated dungeons. Not for now, at least.

So, first order of business is to add our new_dungeon method to our DungeonMaker module in dungeon_maker.rb:

# frozen_string_literal: true

require_relative "dungeon_maker/version"

module DungeonMaker
  def self.new_dungeon(number_of_rooms: 10)
  end
end

Now we need a way to store our types of objects so that they can be retrieved in a random fashion when we want. Ruby's Array class has a #sample method that is perfect for this, so I'll just make a simple array of the object types I want and create the dungeon object while I'm at it:

# frozen_string_literal: true

require_relative "dungeon_maker/version"

module DungeonMaker
  def self.new_dungeon(number_of_rooms: 10)
    types = [:trapped, :enchanted, :ordinary]
    dungeon = Dungeon.new(number_of_rooms)
  end
end

You might be asking yourself, like I did, "Hey! Where'd you get that Dungeon class, mister!? That's cheating!". Not quite, Timmy. Whenever I can I write my code this way, I do, because it helps me ignore matters of how to implement things and focus on what I want my program to do. I'll worry about the how tos and the why fors later.

I might need to change these constructors, but I don't want implementation detail to guide me on how my algorithm works. Rather, I want my algorithm to inform (in the original sense of the word) my implementation.

Moving on.

So now I want to loop through the dungeon's rooms and make them be of any random type:

NOTE: I use #map! because I think it's more readable. It's not usually the preferred way of doing things but, since this is the only place I modify this dungeon, it should be fine

# frozen_string_literal: true

require_relative "dungeon_maker/version"

module DungeonMaker
  def self.new_dungeon(number_of_rooms: 10)
    types = [:trapped, :enchanted, :ordinary]
    dungeon = Dungeon.new(number_of_rooms)

    dungeon.rooms.map! do |room|
      type = types.sample

      room = case type
        when :trapped
          TrappedRoom.new
        when :enchanted
          EnchantedRoom.new
        when :ordinary
          OrdinaryRoom.new
        else
          OrdinaryRoom.new
        end
    end
  end
end

Pretty straightforward code, right? I sample one of the types, match against it and instantiate the kind of room that I want. The else clause is there for completeness, but it should never be run. If it is, however, I just make an OrdinaryRoom. One possibility is to make some BuggyRoom type just so you know this weirdness is going on. Wouldn't know if players would like it, but hey, there are no bugs, just happy little features.

Finally, we loop through the rooms' sides and do the same deal as before, except we want to randomly assign a wall or a door. Thankfully, Ruby's got our back again with Kernel#rand, so we get:

# frozen_string_literal: true

require_relative "dungeon_maker/version"

module DungeonMaker
  def self.new_dungeon(number_of_rooms: 10)
    types = [:trapped, :enchanted, :ordinary]
    dungeon = Dungeon.new(number_of_rooms)

    dungeon.rooms.map! do |room|
      type = types.sample

      room = case type
        when :trapped
          TrappedRoom.new
        when :enchanted
          EnchantedRoom.new
        when :ordinary
          OrdinaryRoom.new
        else
          OrdinaryRoom.new
        end

      room.sides.map! do |side|
        make_wall = rand() >= 0.5

        if make_wall
          type = types.sample
          side = case type
            when :trapped
              TrappedWall.new
            when :enchanted
              EnchantedWall.new
            when :ordinary
              OrdinaryWall.new
            else
              OrdinaryWall.new
            end
        else
          type = types.sample
          side = case type
            when :trapped
              TrappedDoor.new
            when :enchanted
              EnchantedDoor.new
            when :ordinary
              OrdinaryDoor.new
            else
              OrdinaryDoor.new
            end
        end
      end

      room
    end

    dungeon
  end
end

Done. Well, of course, we still need to actually write all those classes, but we don't need to write them to spot the dragon that has emerged from the depths called complexity.

First problem is this code isn't as readable as it could be due to all of the case statements. Second, and this is the actual problem, suppose one day we decide to add 3 new types of doors, 7 new types of walls, 2 new types of rooms and, God forbid, we decide we want our rooms to have any number of sides between 3 and 8 and that sides can have both doors and walls. You'd have cases within if else statements nested under more cases. The dragon is real and is rearing it's head. Time to pull out our sword engraved with the powerful Abstract Factory enchantment.

NOTE: The next code snippets will already have the changes needed due to the addition of the necessary Door, Wall, and Room classes. If you want to see how these were added and required, checkout the commits for the project

How our sword is forged

So the problem is laid before us: we have different kinds of objects that need to be instantiated and we want our program to be able to, at runtime, randomly select a type of object to be created without polluting our algorithm's logic with how these objects are instantiated.

The pattern that solves this pickle is the Abstract Factory. It will define a common interface (methods that our program can call) for our DungeonMaker to interact with and we'll move the logic concerning which kind of objects to instantiate elsewhere.

On the other side of the Abstract Factory we have our concrete implementations. So, in our case, Abstract Factory can be a class that defines methods for making rooms, doors and walls and we create three different concrete factories, one for each type of object we want, that will implement each of these methods:

  • TrappedDungeonFactory
  • EnchantedDungeonFactory
  • OrdinaryDungeonFactory

Classically, and if you read the Design Patterns book they illustrate it this way, we'd inject the concrete factory we want at runtime and our program would use that implementation only. In our case, we have an extra twist where we want to mix and match the different possible kinds (Hmm... wonder if there's a pattern for that...) so that we don't just get either Ordinary Dungeons or Enchanted Dungeons, but a dungeon that can have rooms, doors and walls of any kind.

Smiting our foe

So we'd like our abstract factory to have methods that allow us to create rooms, doors and walls of each kind. Technically, I could just make a RandomDungeonFactory that'll randomly pick one of the types and just return the new object, but I want some flexibility. Maybe, in the future, I want to give my gem's user the ability to generate a purely enchanted maze, who knows? It's best to have the randomization logic seperated from my factories.

Returning to the methods our abstract factory should have. I can identify 3 at the very least: #make_door, #make_wall, #make_room. Here we hit a design decision particular to Ruby. If this were in languages like Java, I'd declare an abstract class, or maybe a simple interface, that my concrete factories would have to extend or implement. Ruby being Ruby though, I have 2 options:

  1. I can create an AbstractFactory class which my concrete implementations will inherit and override
  2. I can use the so called "duck typing" available to me due to how Ruby does it's typing: if it quacks like a factory, it is a factory. With this option, there's no need to create a seperate AbstractFactory class. I just need to create the Factories I want and make sure they all have the 3 methods I want

I'll go with the second option because I feel that adding an extra class just to be overriden seems unnecessary.

Also, I need to change my DungeonMaker#new_dungeon method to use these factories. I'll also keep the randomization logic in it since I don't see a reason to move it elsewhere yet. Therefore, our classes should look like:

trapped_dungeon_factory.rb

require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"

module DungeonMaker
  module Factories
    class TrappedDungeonFactory
      def make_door
        Doors::TrappedDoor.new
      end

      def make_wall
        Walls::TrappedWall.new
      end

      def make_room
        Rooms::TrappedRoom.new
      end
    end
  end
end

enchanted_dungeon_factory.rb

require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"

module DungeonMaker
  module Factories
    class EnchantedDungeonFactory
      def make_door
        Doors::EnchantedDoor.new
      end

      def make_wall
        Walls::EnchantedWall.new
      end

      def make_room
        Rooms::EnchantedRoom.new
      end
    end
  end
end

ordinary_dungeon_factory.rb

require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"

module DungeonMaker
  module Factories
    class OrdinaryDungeonFactory
      def make_door
        Doors::OrdinaryDoor.new
      end

      def make_wall
        Walls::OrdinaryWall.new
      end

      def make_room
        Rooms::OrdinaryRoom.new
      end
    end
  end
end

And our new_dungeon method now looks like this:

require "dungeon_maker/dungeon"
require "dungeon_maker/factories"

module DungeonMaker
  def self.new_dungeon(number_of_rooms: 10)
    factories = [Factories::TrappedDungeonFactory.new,
                 Factories::EnchantedDungeonFactory.new, Factories::OrdinaryDungeonFactory.new]
    dungeon = Dungeon.new(number_of_rooms)

    dungeon.rooms.map! do
      factory = factories.sample

      room = factory.make_room
      room.sides.map! do
        factory = factories.sample
        make_wall = rand >= 0.5

        make_wall ? factory.make_wall : factory.make_door
      end

      room
    end

    dungeon
  end
end

So much better. And just to prove it works, I also have console output:

New dungeon output

There's much more that needs to be done to get our game working, but I believe we're off to a good start. Maybe not all the patterns in the book I'll implement into this game, but it will definitely come up in future articles.