1. Computing

Saving, Loading and Cleaning Up

By

Saving, Loading and Cleaning Up
Making a Text Adventure Game in Ruby

Saving and Loading

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

One of the design goals for this project was to have a game that's easily serialized and de-serialized with YAML. Take a look at all of the objects in our game. Every single object in the game is a Node object, or a child class of Node in the case of the player. In addition, no instance variables are used, everything that is stored is stored as OpenStruct members. That's it. If you try to serialize this with YAML, everything should just fine.

Loading is even more interesting. You don't even need the definition file anymore, you can simply load the text adventure library and load the YAML. Everything is in there including the room data and scripts. You load it and it just works.

Using YAML

Using YAML is very simple. All you have to remember is that you convert any object (such as our root node, and hence all nodes it points to) by calling to_yaml on that object. To load an object from a file, it's the YAML::load_file method. In the following example, assume that root is a root node for a text adventure game.


root = Node.root do
  # …
end

File.open( 'save.yaml', 'w+' ) do|f|
  f.puts root.to_yaml
end

# Later on, or even in a totally different program
root = YAML::load_file( 'save.yaml' )

It's just that simple. Or it would be, if it weren't for a very confusing bug and a small stipulation imposed on your object constructors.

This stipulation is that objects must be able to be constructed with a zero-argument constructor. This is simply because YAML doesn't know any other way to construct your object before loading and setting all of the instance variables. So, we have to modify our Node#initialize method slightly to have default values for all of the arguments. It doesn't change how anything works, and since it's never called directly (instead, it's called by the helper methods like Node#room and Node#player), there's little risk of it being misused.


class Node < OpenStruct
  def initialize(parent=nil, tag=nil, defaults={}, &block)
    super()
    defaults.each {|k,v| send("#{k}=", v) }

    self.parent = parent
    self.parent.children << self unless parent.nil?
    self.tag = tag
    self.children = []

    instance_eval(&block) unless block.nil?
  end
end

And now for the utterly confusing bug. When YAML serializes and de-serializes an object, it saves and restores all the of the object's instance variables. For many objects, that's fine. However, the OpenStruct class keeps part of the state of the object in the object's defined methods. If you were to call some_node.open = true to open that object, the OpenStruct code defines open and open= methods for that object. This is not serialized by YAML, so the methods are not there after the object is de-serialized.

But why is this a problem? Shouldn't method_missing catch it and the same code that created the methods in the first place create them again? In the case of open, not if it's called from within the class. Since the Object class includes Kernel, an open method comes with it as a private method. So if you were to call open without first calling open=, the wrong method will be called and an exception will be raised because there aren't enough arguments.

This is a short program that demonstrates this problem. The solution to the problem is the init_with method, called by the YAML engine when constructing objects. It will set the instance variables as normal, but also call new_ostruct_member for each of the keys. The solution to this problem involved a bit of reading in the Psych documentation (psych is the library that handles yaml) and the ostruct.rb file in Ruby's directory. This isn't a particularly good solution either, it breaks encapsulation and future versions of OpenStuct may stop working with this code.


#!/usr/bin/env ruby
require "yaml"
require "ostruct"

class TestClass < OpenStruct
  def print_open_value
    # Should call TestClass#open, but it will
    # call Kernel#open after it's loaded from
    # YAML
    puts open
  end

  # Uncomment this method to see the
  # solution in action.
=begin
  def init_with(c)
    c.map.keys.each do|k|
      instance_variable_set("@#{k}", c.map[k])
    end

    @table.keys.each do|k|
      new_ostruct_member(k)
    end
  end
=end
end

# This will work
t1 = TestClass.new
t1.open = "A test value"
t1.print_open_value

# This will fail without init_with
t2 = YAML::load( t1.to_yaml )
t2.print_open_value

Once these two little road bumps are navigated successfully, loading and saving works just fine. You won't even need the world definition file anymore since the entire game state is saved in the YAML. We'll implement loading and saving as class methods, and implement our own REPL to handle special verbs like load and save.


class Node < OpenStruct
  def init_with(c)
    c.map.keys.each do|k|
      instance_variable_set("@#{k}", c.map[k])
    end

    @table.keys.each do|k|
      new_ostruct_member(k)
    end
  end

  def self.save(node, file='save.yaml')
    File.open(file, 'w+') do|f|
      f.puts node.to_yaml
    end
  end

  def self.load(file='save.yaml')
    YAML::load_file(file)
  end
end

And in our world file, after the world definition, our custom REPL.


root = Node.root do
  # …
end

loop do
  player = root.find(:player)
  player.do_look
  
  print "What now? "
  input = gets.chomp
  verb = input.split(' ').first

  case verb
  when "load"
    root = Node.load
    puts "Loaded"
  when "save"
    Node.save(root)
    puts "Saved"
  when "quit"
    puts "Goodbye!"
    exit
  else
    player.command(input)
  end
end

Room Descriptions and More

Until now, we've neglected room descriptions, item descriptions, etc. We've also neglected any sort of formatting for the screen. To make a complete game, both of these are going to be necessary.

Room descriptions are trivial to implement, but not so easy to use. One problem you'll face is that, in order to keep a clean-looking world definition file, the room descriptions are going to have to live in here-docs. They'll also be indented, and generally not formatted well for simply feeding to puts.

To accommodate this, we're going to implement a Node#puts method that will override calls to puts from within Node methods (much like the bug described above). It will word wrap the output to 80 columns, the typical size for terminal windows and fullscreen text mode terminals (if those even exist anymore). This will involve taking the string we aim to display apart, strip off unneeded whitespace, perform the word wrapping and put it back together.

We could have implemented this with a monkeypatch, simply overwriting Kernel#puts with our method, but I don't think monkeypatching is a very good idea. By putting our method in the Node class, we limit the scope of the monkeypatching to just our methods. And if you ever need to print something without the mangling, simply call Kernel.puts. We will, however, add a String#word_wrap method for convenience.

Let's start with String#word_wrap. It's implemented as a series of String#gsub calls, each one massaging the string a bit more into what we really need. It is explained step by step in the comments.


class String
  def word_wrap(width=80)
    # Replace newlines with spaces
    gsub(/\n/, ' ').   
    
    # Replace more than one space with a single space
    gsub(/\s+/, ' ').

    # Replace spaces at the beginning of the
    # string with nothing
    gsub(/^\s+/, '').

    # This one is hard to read.  Replace with any amount
    # of space after it with that punctuation and two
    # spaces
    gsub(/([\.\!\?]+)(\s+)?/, '\1  ').

    # Similar to the call above, except replace commas
    # with a comma and one space
    gsub(/\,(\s+)?/, ', ').

    # The meat of the method, replace between 1 and width
    # characters followed by whitespace or the end of the
    # line with that string and a newline.  This works
    # because regular expression engines are greedy,
    # they'll take as many characters as they can.
    gsub(%r[(.{1,#{width}})(?:\s|\z)], "\\1\n")
  end
end

And the puts method.


class Node < OpenStruct
  def puts(*s)
    STDOUT.puts( s.join(' ').word_wrap )
  end
end

Now that we have that in place, we can go ahead and start adding descriptions to rooms. These are simply stored in the desc keys. In text adventure games, when you come to a room you've visited before, it doesn't display the full description, just a short one like "You are in the kitchen." These will be stored in the short_desc key. We'll also add a described key that will remember if that room has been described, a described? method that will help us out a bit and a describe method that will choose the long or short description.


class Node < OpenStruct
  def described?
    if respond_to?(:described)
      self.described
    else
      false
    end
  end

  def describe
    if !described? && respond_to?(:desc)
      self.described = true
      puts desc
    elsif respond_to?(:short_desc)
      puts short_desc
    else
      # Just make something up
      puts "You are in #{tag}"
    end
  end
end

Now when we want to print where the player is, we can simply say root.find(:player).get_room.describe.

Item Descriptions and Presence

Now that we can describe the rooms, we need to describe the items. And not only that, but items need to inject themselves into the room descriptions. If there's cat sleeping on the couch, it should say that in the room description. But if the cat has gone off to find another mouse, it shouldn't appear in the room description.

To handle this, we'll give items desc and short_desc tags. The desc will be used in the new examine verb, and short_desc in the inventory verb. There will also be a presence key that gets appended to room descriptions when that item is the child of the room.

First, the updated cat.


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

  self.desc = <<-DESC
    A pumpkin-colored long-haired cat.  He is well-groomed
    and certainly a house cat and seems perfectly content
    to sleep the day away on the couch.
  DESC

  self.short_desc = <<-DESC
    A pumpkin-colored long-haired cat.
  DESC

  self.presence = <<-PRES
    A cat dozes lazily here.
  PRES

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

The describe method now takes presence into account.


class Node < OpenStruct
  def describe
    base = if !described? && respond_to?(:desc)
      self.described = true
      desc
    elsif respond_to?(:short_desc)
      short_desc
    else
      # Just make something up
      "You are in #{tag}"
    end

    # Append presence of children nodes if open
    if open
      children.each do|c|
        base << (c.presence || '')
      end
    end

    puts base
  end
end

The new examine verb doesn't hold any surprises. Just describe an item that's in view.


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

    item.described = false
    item.describe
  end
end

And the inventory verb has changed slightly.


class Player < Node
  def do_inventory(*a)
    puts "You are carrying:"

    if children.empty?
      puts " * Nothing"
    else
      children.each do|c|
        puts " * #{c.short_description} (#{c.words.join(' ')})"
      end
    end
  end
  alias_method :do_inv, :do_inventory
  alias_method :do_i, :do_inventory
end

The Engine is Complete

And that completes the text adventure game engine. Of course, this is simply the engine, the real work comes in making an enjoyable game. And, as always, there will always be thing to clean up, tweak and change to your liking. No text adventure game is the same and each has their own engine requirements.

If you've made it this far, give yourself a pat on the back. Feel free to use the example code from this series in any projects you wish. And, as always, this is Ruby, so the possibilities are endless. This code can easily be integrated into a web application (serialize the game to YAML and store in a database or the user's session), it should work on JRuby so your games can be distributed that way, or you could use this as a seed for something a bit more graphical. I used to play a game called Pawn that had graphics for each room, it really added a lot to the "feel" of the game. This code is just the seed, what you do with it is up to you!

©2014 About.com. All rights reserved.