Let's Build a Text Adventure - Part 2
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:
- A getter and setter for the player's name
- A getter and setter for the session mode
- A way to get a room from the rooms list by ID
- A way to add a room class to the rooms list
- A way to see if a room name already exists
- A way to get all the rooms
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.
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