One Tree to Rule Them All
One Tree to Rule Them All
This article is the first article in the series Making Text Adventure Games in Ruby. The example code for this article can be downloaded here.
Before we start coding, let's take some time and analyze the structure of a text adventure game. Once we have a data structure defined, we can start doing things in a very predictable manner. Without such a data structure, the number of edge cases to consider grows very quickly and implementing a text adventure game becomes like untangling spaghetti without breaking it.
Consider a typical text adventure game scenario. You are in a messy living room. There is a TV, a remote control, a few pieces of furniture and a sleeping cat. You're carrying a ham sandwich. Inside the remote control there are some dead batteries and inside the cat there is a dead mouse. To the north is a kitchen, and to the east is a hallway which presumably have objects of their own. What type of data structure could you use to represent this situation?
Your first instinct might be to make an array of the rooms, and in each of the rooms an array of the items. However, in doing that you've introduced a situation where there will be a large number of edge cases. When moving an item was the player holding it, was it inside of another item or inside of a room? A better question would be why should that even matter?
The data structure chosen for representing the text adventure is a tree. The tree has a single root node and nodes have any number of child nodes. The entire representation is based on one thing: everything is a node.
Also, since each node has its own attributes (the batteries are dead, you can go north from the living room to the kitchen), each node is also a key/value store. So, what we've described here is a tree of hashes and, to make things simpler down the road, the children are stored in an array in the hash itself. Also, to make the syntax a little more friendly, we'll be using OpenStruct (which is just a hash underneath).
A few other values are stored in the nodes in addition to the children. First there's a tag, a unique ID used to identify that node in searches. We could have just used id here instead of tag, but in Ruby 1.8.x the id method returns an object's object_id. Second there's a reference to a node's parent (or nil, in the case of the root node). These will be very important in one of the core methods used to work with the tree structure: the find method. The beginning of the Node class might look something like this.
class Node
Nothing complicated here but we can even define our entire game world with just this. Thanks to the yield self trick, it's very easy to preserve the hierarchical structure of the data in the definition itself. The following example is far from complete, but the tree structure is represented and all rooms and items are accounted for.
Node.new(nil, :root) do|root|Node.new(root, :living_room) do|lr|Node.new(lr, :cat) do|cat|Node.new(cat, :dead_mouse)endNode.new(lr, :remote_control) do|rem|Node.new(rem, :dead_batteries)endNode.new(lr, :player) do|pl|Node.new(pl, :ham_sandwich)endendNode.new(root, :kitchen) do|kit|Node.new(kit, :drawer) do|dr|Node.new(dr, :new_batteries)endendNode.new(root, :hall)end
Even from our woefully primitive domain specific language we can clearly see the hierarchical structure of the document. To keep with the "everything is a node" philosophy, you'll notice that there's a :player node. Since the player is just a node, if we wanted to move the player from the living room to the kitchen (to get a glass of milk to go along with that ham sandwich) we'd just delete the player from the living room's children and add it to the kitchen's children. If the cat throws up the mouse you'd just delete the mouse from the cat's children and add it to the cat's parent's children (and if you happen to be holding the cat, you're now holding a dead mouse). Things are already shaping up nicely.
Different Types of Nodes
Though everything is indeed a node, the different types of nodes will have different key/value pairs and default behavior. For instance, when you construct an item you need to give it a name and some adjectives the player can use to describe it. To accommodate this need, we'll implement a few constructor methods that will also clean up the world definition quite a bit.
If you look at the previous code example, you'll notice that it's a bit heavy. To make a new room, you say Node.new(hallway, :awesome_room). That doesn't really tell the reader you're making a new room, they can only infer it from the tag name and how it's used. The constructor methods make this clear as day, as well as slim down the world definition. Terseness is important, or the reader of the world definition will feel like they're staring at a wall of text.
You'll also notice a few extra parts in the code. There's a DEFAULTS hash that defines some default behavior for different types of nodes (right now, they only define if they're "open" to receiving new items) and instead of simply using yield self, we now use instance_eval(&block). This eliminates the need to think of unique block parameter names, the node being created can now be accessed using the self keyword. You'll also see that rooms now have keys like exit_north and exit_south, which defines how the player can get from room to room.
class Node{ :open => true },:room => { :open => true },:item => { :open => false },:player => { :open => true }}def initialize(parent, tag, defaults={}, &block)super()defaults.each {|k,v| send("#{k}=", v) }self.parent = parentself.parent.children
And let's re-define our little world again with our now more slightly descriptive vocabulary. This version is getting much more complete. The items have words and adjectives and the rooms have exits to the other rooms. Notice that the exits are not references to the rooms themselves, but simply tags to the rooms. This is done simply because when defining the living room node, the hall and kitchen nodes don't exit yet.
Node.root doroom(:living_room) doself.exit_north = :kitchenself.exit_east = :hallitem(:cat, 'cat', 'sleeping', 'fuzzy') doitem(:dead_mouse, 'mouse', 'dead', 'eaten')enditem(:remote_control, 'remote', 'control') doitem(:dead_batteries, 'batteries', 'dead', 'AA')endendroom(:kitchen) doself.exit_south = :living_roomplayer doitem(:ham_sandwich, 'sandwich', 'ham')enditem(:drawer, 'drawer', 'kitchen') doitem(:new_batteries, 'batteries', 'new', 'AA')endendroom(:hall) doself.exit_west = :living_roomendend
What Next?
In the next section, we'll look at some ways of visualizing the tree. It's clear what goes where from the definition (though it would be nice to verify it), but once we start moving nodes around we'll need a way to reliably visualize the tree both in the console, and with generated images.
Source...