MakeThingsWork.dev

Let's Build a Text Adventure - Part 1

<- Previous Post: Intro | Next Post: Part 2 ->

So far we've set up our premise and started writing some basic ruby code that will be the foundation for our single-file implementation of our SuperTextAdventure app! In this part we'll greatly flesh out the functionality and start designing details on how our world-builder will work!

Hey! You came back! That's awesome. Or perhaps you're new here... if that's true then I'd encourage you to check out the previous posts. As a reminder, the repo we're working from is here and changes from this post will be up at the time of posting this:

supertextadventure-core

Encapsulation

Right now our single-file program's important parts looks like this (I did some tweaking since the last article:

prompt = TTY::Prompt.new

prompt.ok intro

player_name = prompt.ask("What is your name, adventurer?".colorize(:light_blue))

DATA[:player][:name] = player_name

newline

prompt.ok("Welcome, #{DATA[:player][:name]}!")

newline
wait

choices = { "build" => "Build an Adventure", "play" => "Play a Game"}
selection = prompt.select("What would you like to do?", choices.invert)

prompt.say("You chose to #{choices[selection]}.", color: :yellow)

newline
wait

This is nice and simple. I had never actually used invert before, though. I wanted to easily lookup the "display" text of the choices after the user made a selection and my original attempt, with the keys and values flipped, required some like this:

choices.find { _2 == selection }.first

Which is fine but gross.

In any case, our code is going to rapidly get stringy so let's build some classes that can help us. First, let's create a class that encapsulates the process of this introduction bit.

1. Modify The DATA Object

We want to store the user's selection of what type of "mode" they want to be in for this session: Building or Playing.

DATA = {
  player: {
    name: nil,
  },
  session_mode: nil, # <- added
  game: {
    rooms: []
  }
}

Now we can set the DATA[:session_mode] and use it to determine what block of logic to run

2. Base Class

Now, let's talk about classes. Ideally, we want to keep each section of the app separate so it can do the following:

  1. Be called by the user
  2. Run and display text/options for the user
  3. Accept user input and modify the DATA object accordingly
  4. Loop as needed, or quit and move on to the next part

I'm imagining a few steps ahead where we build the part that prompts the user to create a room. It would start with asking the user what the name of the room is, what objects are in the room, what the exits are, what other rooms it connects to, and then looping back to ask the user "Create another room?" and either starting over or stopping depending on the answer.

So the more re-usable these blocks of logic are the better. It'll be easier to read, easier to modify, and easier to test.

So I think we'll start with a Base class that defines the prompt. Other classes can inherit from this so we only have to instantiate the TTY::Prompt once. We can also create a helper so that we don't have to write "prompt." a thousand times:

class Base
  attr_accessor :prompt
 
  def initialize
    @prompt = TTY::Prompt.new
  end

  def p
    @prompt
  end
end

There. Now we can write "p." a thousand times!

Now I wanted to make it even easier and use method_missing so that we could just write say and ok:

 def method_missing(m, *args, &block)
    @prompt.send(m, *args, &block)
  end

This worked for some, but the select method is not undefined and would have to be overridden in the base class and then passed to the @prompt instance variable and at that point I felt like the universe was trying to tell me something. I might change my mind later.

3. Intro Class

Let's move our intro code we wrote into a new class called Intro that inherits from Base and basically just does what our loose script did earlier:

class Intro < Base

  CHOICES = {
    "build" => "Build an Adventure",
    "play" => "Play a Game"
  }

  def self.run
    new.run
  end

  def run
    p.ok(INTRO)

    player_name = p.ask("What is your name, adventurer?".colorize(:light_blue))

    DATA[:player][:name] = player_name

    newline

    p.ok("Welcome, #{DATA[:player][:name]}!")

    newline
    wait

    selection = p.select("What would you like to do?", CHOICES.invert)

    DATA[:session_mode] = selection

    p.say("You chose to #{CHOICES[selection]}.".colorize(:yellow))

    newline
    wait
  end
end

Alrighty. Now we can just add this at the bottom of the file:

Intro.run

puts DATA

Let's also dump our DATA object at the end of the run to see what was loaded into it.

What would you like to do? Build an Adventure
You chose to Build an Adventure.

{player: {name: "Rick"}, session_mode: "build", game: {rooms: []}}

Nice! Okay we're now that we have more structure we can move forward and create classes as we need to build different sets of functionality.

4. Let's Get Complicated - Game Builder Design

Okay let's mentally sketch this out a bit...

                                                         
                                                         
                    GameBuilder                          
   ┌─────────┐           │                               
   │ Select  │           │                               
   │ A       │    ┌──────▼───────┐                       
   │ Room    │    │     Room     │                       
   │         │    │              │                       
   └────▲────┘    │ -> Name      │                       
        │         │ -> Desc.     │      ┌──────────┐     
        │         │ -> Items?----┼─────►│   Item   │     
        │         │ -> Monster┐  │      │          │     
   ┌────┴─────┐   │ -> Doors┐ │  │      │ -  Name  │     
   │Direction │   └─────────│─┼──┘      │ -> Type  │     
   │          │◄────────────┘ │         │ -> Desc. │     
   │ N S E W  │               │         └──────────┘     
   │          │         ┌─────▼─────┐                    
   └──────────┘         │  Monster  │                    
                        │           │                    
                        │ -> Name   │                    
                        │ -> Desc   │                    
                        │ -> HP     │                    
                        └───────────┘                    

This is pretty rudimentary (thanks ASCIIFlow) but the idea is we have the game builder act as a "wizard" that asks a bunch of guided questions that helps a user create a world. We'll start with the concept of a "Room" as the basic building block and things will get added around that.

Now, quick aside. Let's step over here by the snack table. This will be a super tedious way to build a world. I plan for the final version to have a nice UI where folks can lay out a map with multiple floors and "see" the world they are building all at once. But for now, this helps us design the basic building blocks and forces us to think about the data and structure in detail so that when we're done we have all of the logic we need. Alright, let's get back out there.

So in a nutshell: let's build a main class that asks the user for data about the world and can loop as many times as needed.

5. GameBuilder Class

Let's build the primary GameBuilder class that will be the entry point. I moved the self.run method to the Base class so it won't have to be duplicated in every class.

class GameBuilder < Base
  def run
    p.ok("Starting Game Builder...".colorize(:yellow))
    newline
    wait

    p.say("Let's build your adventure, #{DATA[:player][:name]}!".colorize(:light_blue))
    newline

    name_game if DATA[:game][:name].nil?

    build_process
  end
  
  def name_game;end
  def build_process;end
end
Naming the Game

This is all up for changing later but I set the initial method up to check to see if the DATA[:game][:name] has a value, and if not, we'll call a separate method that prompts them to set it.

Let's write that method first:

#...
  def name_game
    game_name = p.ask("What is the name of your game?".colorize(:light_blue))

    DATA[:game][:name] = game_name.strip
    p.ok("Game name set to '#{DATA[:game][:name]}'.")
    newline
  end
#...

That gives us a result like this: game_name_sample

Building a Loop to Add Data

Let's flesh out our build_process method now. The plan for this is to ask the user what they want to do, redirect them to that process, and when they're done loop them back.

#...
  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 as you can see the add_room and list_rooms don't do much but the main thing is they return back to build_process. exit_builder just exits.

How does it look?

builder_example

Next Steps

That's enough for now. We've got a good design going and a solid plan for the future.

In the next installment we'll start adding actual rooms and items and build some classes for each of those things.

As always, please shoot me an email:

rick [at] makethingswork [dot] dev

Or email about the SuperTextAdventure project:

hub [at] supertextadventure [dot] com

Thanks for sticking with me, see you soon!

-- Rick

<- Previous Post: Intro

#ruby #supertextadventure