1. Technology
You can opt-out at any time. Please refer to our privacy policy for contact information.

Scripting

By

Scripting
Making a Text Adventure Game in Ruby

Scripting

This article is part of the series Making Text Adventure Games in Ruby. The example code for this article can be downloaded here.

Thus far, our players have been living in a static world. Things simply exist. They can be moved around and their state changed slightly (containers can be opened and closed), but there's nothing really interesting in any of that. Some games can be fun using only a simple game mechanic, but text adventures aren't one of those games. Text adventure games need scripting, or else they don't really do anything at all.

Recall that one of the goals of the project was to make a text adventure game that can be serialized easily. Your first idea may be to implement scripting with blocks or Proc objects. Just store them in the nodes and call them when they are to be triggered. That would be great, except Proc objects can't be serialized.

Why can't Proc objects be serialized? When a Proc object is instantiated, it's tied to a binding. This binding represents the current execution context, with a pointer to self, local variables, etc. This most certainly can't be serialized, as there's no way to guarantee the state of a binding when the object is un-serialized. There may be some tricks to get around this, but there is a simpler and very straightforward solution: just store your scripts in strings and use eval. That sounds kind of silly, why use such a primitive method in such a dynamic language like Ruby? Simply because that's the only reasonable solution.

Hooks

The text adventure engine will look for scripts on a node at certain times. If the script exists, it will be called. For certain scripts, the script's return value will be examined and an action will be taken depending on the boolean value of the return value. In other words, before some things are done the script will tell the engine if it's OK to do it.

It's easy to get carried away here, to put scripts on every single thing that could possibly happen to a node. It's also important to think about at what level the engine should even care about scripting. Should it be up to the higher level Player code, or part of the lower-level Node code? There are two philosophies here, but I've chosen to put the script calls in Player, and Node just shouldn't care about scripting. The scripts themselves are stored as strings, often using heredocs to allow for easy multi-line strings, in the scripts_* keys.

May I Do That?

Let's take a look at how the scripts will be called. First, the Node#script method. This is the only method we'll be adding to the Node class, it simply looks that the scripts the node has and calls the script if there is one. Since we like default sane behaviors in Ruby, we'll also make the method return true so the default behavior for all scripts is to allow the action.


class Node < OpenStruct
  def script(key, *args)
    if respond_to?("script_#{key}")
      return eval(self.send("script_#{key}"))
    else
      return true
    end
  end
end

Let's take a look at how scripts are called. We first modify the Player#do_go method (which is called when a player wants to go from one room to another) to call the script on the node the player wants to move to before attempting to go between rooms. If the script returns false, the player won't go. Also remember that the scripts don't just return true or false, they can also print messages to the player, move objects on their own or do anything they want really.


class Player < Node
  def do_go(direction, *a)
    dest = get_room.send("exit_#{direction}")

    if dest.nil?
      puts "You can't go that way"
    else
      dest = get_root.find(dest)

      if dest.script('enter', direction)
        get_root.move(self, dest)
      end
    end
  end
end

And the script itself, part of the world definition.


  room(:hall) do
    self.exit_west = :living_room

    self.script_enter = <<-SCRIPT
      puts "A forcefield stops you from entering the hall"
      return false
    SCRIPT
  end

Pretty simple, print a message informing the user there's a forcefield preventing them from entering the hall (probably a sleeping dog, they never want to move) and return false. This script is about as simple as it gets, and takes no arguments. Scripts can take arguments, and those will be available via the args array from within the script. You can't pass any arguments to an eval, but it does execute in the current context with the current binding, so it has direct access to the args array to the script method.

Let's look at something a little more interesting. If you try to pick up the cat, it will refuse to be picked up. In addition, it will throw up the dead mouse if it hasn't already. So, we first modify Player#do_take and then throw the script into the cat node.


class Player < Node
  def do_take(*thing)
    thing = get_room.find(thing)
    return if thing.nil?

    if thing.script('take')
      puts 'Taken.' if get_root.move(thing, self)
    end
  end
  alias_method :do_get, :do_take
end

And the script.


  item(:cat, 'cat', 'sleeping', 'fuzzy') do
    self.script_take = <<-SCRIPT
      if get_room.find(:cat).find(:dead_mouse)
        puts "The cat makes a horrifying noise and throws up a dead mouse"
        get_room.move(:dead_mouse, get_room, false)
      end

      puts "The cat refused to be picked up (how degrading!)"
      return false
    SCRIPT

    item(:dead_mouse, 'mouse', 'dead', 'eaten')
  end

Again, it's nothing too complicated. But there are two things you should notice. First, we called Node#move with the third argument. Normally it would refuse to move the mouse since it's a hidden object. We don't care about that, so we'll ignore those checks. And again, be careful to watch what context the scripts are executing from. Scripts are always executed in the context of the object the script belongs to, not the object that called the script. So, from within the script, the self keyword refers to the cat.

You Want to Put What, Where?

Can you put the ham sandwich in the remote? No, that makes no sense. Can you put the new batteries in the remote? Yes, but only if there are no batteries in there already. So, implementing this is rather straightforward once we have scripting in place. In the do_put method, simply call the accept script with the item you want to put inside it as as argument. If it succeeds, you can put it there. If it fails, you can't.


class Player < Node
  def do_put(*words)
    prepositions = [' in ', ' on ']

    prep_regex = Regexp.new("(#{prepositions.join('|')})")
    item_words, _, cont_words = words.join(' ').split(prep_regex)

    if cont_words.nil?
      puts "You want to put what where?"
      return
    end

    item = get_room.find(item_words)
    container = get_room.find(cont_words)

    return if item.nil? || container.nil?

    if container.script('accept', item)
      get_room.move(item, container)
    end
  end
end

And an example of how this script is used so that only the old batteries or new batteries can be put in the remote, but not at the same time.


    item(:remote_control, 'remote', 'control') do
      self.script_accept = <<-SCRIPT
        if [:new_batteries, :old_batteries].include?(args[0].tag) &&
            children.empty?
          return true
        elsif !children.empty?
          puts "There are already batteries in the remote"
          return false
        else
          puts "That won't fit into the remote"
          return false
        end
      SCRIPT

      item(:dead_batteries, 'batteries', 'dead', 'AA')
    end

Using Objects

Now that we have scripting working, we can now "use" objects. What "use" means is completely up to the items being used and what they're being used with. For instance use knife with turkey clearly means you want to carve the thanksgiving turkey. However, use knife with hideous drooling goblin means something a bit different. The format of this verb is very similar to the put verb, a verb with two objects separated by a preposition.


class Player < Node
  def do_use(*words)
    prepositions = %w{ in on with }
    prepositions.map!{|p| " #{p} " }

    prep_regex = Regexp.new("(#{prepositions.join('|')})")
    item1_words, _, item2_words = words.join(' ').split(prep_regex)

    if item2_words.nil?
      puts "I don't quite understand you"
      return
    end

    item1 = get_room.find(item1_words)
    item2 = get_room.find(item2_words)
    return if item1.nil? || item2.nil?

    item1.script('use', item2)
  end
end

And we'll implement a script so the player can use remote control on sleeping cat.


    item(:remote_control, 'remote', 'control') do
      self.script_accept = <<-SCRIPT
        if [:new_batteries, :dead_batteries].include?(args[0].tag) &&
            children.empty?
          return true
        elsif !children.empty?
          puts "There are already batteries in the remote"
          return false
        else
          puts "That won't fit into the remote"
          return false
        end
      SCRIPT

      self.script_use = <<-SCRIPT
        if !find(:new_batteries)
          puts "The remote doesn't seem to work"
          return
        end

        if args[0].tag == :cat
          args[0].script('control')
          return
        else
          puts "The remote doesn't seem to work with that"
          return
        end
      SCRIPT

      item(:dead_batteries, 'batteries', 'dead', 'AA')
    end

And you may notice that the script fired another script on the cat, one with a custom (not a hook called by the player) name. So the definition of the cat becomes this.


    item(:cat, 'cat', 'sleeping', 'fuzzy') do
      self.script_take = <<-SCRIPT
        if find(:dead_mouse)
          puts "The cat makes a horrifying noise and throws up a dead mouse"
          get_room.move(:dead_mouse, get_room, false)
        end

        puts "The cat refused to be picked up (how degrading!)"
        return false
      SCRIPT

      self.script_control = <<-SCRIPT
        puts "The cat sits upright, awaiting your command"
        return true
      SCRIPT

      item(:dead_mouse, 'mouse', 'dead', 'eaten')
    end

Things are really starting to shape up now. In fact, we have everything we need to implement a complete text adventure game. In the next article, we're going to look at implementing saving and loading (which is trivial) and adding a few missing pieces.

  1. About.com
  2. Technology
  3. Ruby
  4. Tutorials
  5. Text Adventure Games
  6. Scripting

©2014 About.com. All rights reserved.