MakeThingsWork.dev

Let's Build a Text Adventure - Part 2

<- Previous Post: Part 1

Time to get crazy. We're going to build some disgusting spaghetti code that just works today and make sure we like how it operates. In the following post we'll work on cleaning things up and do a little meta-programming.

So we got a little crazy last time and sketched out the kind of "models" we'd have in the game and how they all relate to each other. Today we want to get the initial part of our builder GameBuilder class working, and we're not going to worry too much about doing it "correctly"

Where Were We?

We had just created the placeholders for the build_process and it's related methods:

#...
  def build_process
    action = p.select("What would you like to do?") do |menu|
      menu.choice name: "Add a Room",  value: :add_room
      menu.choice name: "List Rooms", value: :list_rooms
      menu.choice name: "Exit Game Builder",  value: :exit_builder
    end

    case action
    when :add_room
      add_room
    when :list_rooms
      list_rooms
    when :exit_builder
      exit_builder
    end
  end

  def add_room
    p.say("We're working on this part".colorize(:yellow))
    build_process
  end

  def list_rooms
    if DATA[:game][:rooms].empty?
      p.say("No rooms have been added yet.".colorize(:red))
    else
      p.ok("We'll do this later.")
    end
    build_process
  end

  def exit_builder
    p.say("Exiting Game Builder...".colorize(:light_blue))
  end
#...

So we need to make all of these functional, but first, I think constantly accessing the DATA global is going to be ugly and cumbersome. So let's whip up a little GameData class.

GameData Class

This can be a pretty simple wrapper since we have a pretty simple JSON object but we want to think ahead a bit.

As a reminder here is what our DATA object looks like:

DATA = {
  player: {
    name: nil,
  },
  session_mode: nil,
  game: {
    name: nil,
    rooms: []
  }
}

We probably want the following:

This isn't too complicated:

class GameData
  class << self
    def player_name
      DATA[:player][:name]
    end

    def player_name=(name)
      DATA[:player][:name] = name
    end

    def session_mode
      DATA[:session_mode]
    end

    def session_mode=(mode)
      DATA[:session_mode] = mode
    end

    def get_room(id)
      DATA[:game][:rooms].find { |room| room.id == id }
    end

    # Expects an instance of Room
    def add_room(room)
      DATA[:game][:rooms] << room
    end

    def room_name_exists?(name)
      DATA[:game][:rooms].any? { |room| room.name.downcase == name.downcase }
    end

    def all_rooms
      DATA[:game][:rooms]
    end
  end

For now I want to store an actual Room class that we'll build in a minute. Eventually when we go to "save" this file we can make sure that all the game object classes (Room, Monster, etc) to have a method to convert them to JSON, but at the moment I think it'll just be easier to work with objects.

The Room (The Class, Not The Movie)

Let's start a simple model that can represent a Room for us:

class Room
  attr_accessor :id, :name, :description

  def initialize
    @id = SecureRandom.uuid
    @name = nil
    @description = nil
  end
end

Super simple. I think it'll be more complicated later.

Let's go back to our add_room method in our GameBuilder class and prompt the user to set the room name and description:

  def add_room
    room = Room.new
    room.name = p.ask("Enter the name of the room:".colorize(:light_blue))
    room.description = p.ask("Enter a description for the room:".colorize(:light_blue))
  end

Simple enough. But let's use our cool new GameData class to make sure the room name doesn't already exist (that'll be acting as our "primary key") and maybe give the user some feedback on what they entered.

  def add_room
    room = Room.new

    room.name = p.ask("Enter the name of the room:".colorize(:light_blue))
    if GameData.room_name_exists?(room.name)
      p.say("A room with that name already exists.".colorize(:red))
      room = nil
      create_room
    end
    p.say("Room name set to '#{room.name}'.".colorize(:green))

    room.description = p.ask("Enter a description for the room:".colorize(:light_blue))
    p.say("Room description set to '#{room.description}'.".colorize(:green))
  end

Here is how the loop looks with the above code in place.

add_room_1_large

Success!

Adding Rooms and Other Things

Moving onwardly, we're going to change the name of this method to create_room because it's annoying me, and add a line that actually adds it to the GameData.

  def create_room
    room = Room.new

    room.name = p.ask("Enter the name of the room:".colorize(:light_blue))
    if GameData.room_name_exists?(room.name)
      p.say("A room with that name already exists.".colorize(:red))
      room = nil
      create_room
    end
    p.say("Room name set to '#{room.name}'.".colorize(:green))

    room.description = p.ask("Enter a description for the room:".colorize(:light_blue))
    p.say("Room description set to '#{room.description}'.".colorize(:green))

    GameData.add_room(room)
    p.ok("Room '#{room.name}' created successfully!")
    newline
  end

Oh! Almost forgot, to make the main GameBuilder loop continue, I wrapped the select and case statement in the build_process method in a while loop. This way, whenever we finish performing an action, we get sent to the start of the process.

#...
  def build_process
    action = nil
    while action != :exit_builder
      action = p.select("What would you like to do?") do |menu|
        menu.choice name: "Add a Room",  value: :create_room
        menu.choice name: "List Rooms", value: :list_rooms
        menu.choice name: "Exit Game Builder",  value: :exit_builder
      end

      case action
      when :create_room
        create_room
      when :list_rooms
        list_rooms
      when :exit_builder
        exit_builder
      end
    end
  end
#...

Okay, now let's take a big step and, while we're creating rooms, give the user the option to add an item. First let's change the Room model:

class Room
  attr_accessor :id, :name, :description, :items

  def initialize
    @id = SecureRandom.uuid
    @name = nil
    @description = nil
    @items = [] # adding this array
  end
end

Alright, then let's create an Item class based off our initial data mockup:

class Item
  attr_accessor :id, :name, :description, :room_id, :inventory_item

  def initialize
    @id = SecureRandom.uuid
    @name = nil
    @description = nil
    @room_id = nil
    @inventory_item = false
  end
end

And now let's make it work. Just like the build_process loop, I want the user to be able to add multiple items to a room while it's being created, so let's make a loop:

    #...
    add_items = true
    while add_items
      add_items = p.yes?("Would you like to add items to this room?")
      if add_items
        item = Item.new
        item.room_id = room.id
        item.name = p.ask("Enter the name of the item:".colorize(:light_blue))
        p.say("Item name set to '#{item.name}'.".colorize(:green))
        item.description = p.ask("Enter a description for the item:".colorize(:light_blue))
        p.say("Item description set to '#{item.description}'.".colorize(:green))
        room.items << item
        p.ok("Item '#{item.name}' added to room '#{room.name}'.")
        newline
      end
    end

    GameData.add_room(room)
    p.ok("Room '#{room.name}' created successfully!")
    newline
    #...

This allows us to add as many items to a room as we want while we create it and add them to the data structure.

Now let's do Monsters and Doors. First the classes.

#...
class Monster
  attr_accessor :id, :name, :description, :health, :attack_power, :room_id

  def initialize
    @id = SecureRandom.uuid
    @name = nil
    @description = nil
    @health = 100
    @attack_power = 10
    @room_id = nil
  end

  def self.create_for_room(room_id)
    monster = new
    monster.room_id = room_id
    monster.name = "Default Monster"
    monster.description = "A fearsome creature."
    monster.health = 100
    monster.attack_power = 10
    monster
  end
end

class Door
  attr_accessor :direction, :description, :destination_room_id,

  def initialize
    @direction = nil
    @description = nil
    @destination_room_id = nil
  end
end
#...

And then we can just add them to the loop:

#...
    add_monsters = true
    while add_monsters
      add_monsters = p.yes?("Would you like to add monsters to this room?")
      if add_monsters
        monster = Monster.new
        monster.room_id = room.id
        monster.name = p.ask("Enter the name of the monster:".colorize(:light_blue))
        p.say("Monster name set to '#{monster.name}'.".colorize(:green))
        monster.description = p.ask("Enter a description for the monster:".colorize(:light_blue))
        p.say("Monster description set to '#{monster.description}'.".colorize(:green))
        monster.health = p.ask("Enter the monster's health (default 100):".colorize(:light_blue)) do |q|
          q.default = "100"
          q.convert :int
        end
        p.say("Monster health set to '#{monster.health}'.".colorize(:green))
        monster.attack_power = p.ask("Enter the monster's attack power (default 10):".colorize(:light_blue)) do |q|
          q.default = "10"
          q.convert :int
        end
        p.say("Monster attack power set to '#{monster.attack_power}'.".colorize(:green))
        room.add_monster(monster)
        p.ok("Monster '#{monster.name}' added to room '#{room.name}'.")
        newline
      end
    end

    add_doors = true
    while add_doors
      add_doors = p.yes?("Would you like to add doors to this room?")
      if add_doors
        door = Door.new

        door.direction = p.select("Select the direction of the door:".colorize(:light_blue)) do |menu|
          menu.choice name: "North", value: "north"
          menu.choice name: "South", value: "south"
          menu.choice name: "East", value: "east"
          menu.choice name: "West", value: "west"
        end
        p.say("Door direction set to '#{door.direction}'.".colorize(:green))
        door.description = p.ask("Enter a description for the door (optional):".colorize(:light_blue))
        p.say("Door description set to '#{door.description}'.".colorize(:green))
        room.add_door(door)
        p.ok("Door to the '#{door.direction}' added to room '#{room.name}'.")
        newline
      end
    end
#...

It's a lot of messy spaghetti code but remember we are hammering out details right now on how we want things to work for later.

Now we can add all kinds of things to our rooms! Here's a video:

https://supertextadventure.com/sta_part2.mp4

I used ClaudeCode and asked it to take what we have so far and make a little html file for it. You can check it out here:

https://supertextadventure.com/cli.html

Until Next Time

That's a good stopping point! What fun we've had so far!

As a reminder this code is up in the supertextadventure-core repository.

In the next one we'll get a little more meta and try to wrap up most of the game builder stuff.

I'm always interested in your thoughts! Shoot me an email: rick [at] makethingswork [dot] dev

-- Rick

#ruby #supertextadventure