Interactive Fiction (part 2)
I decided to clean up and refactor the code some from part one, although it still works the same way: we still have behaviors that various objects can become. I’m not going to go through every part of the code, but I will talk about how behaviors are designed, explain the parser, how commands are handled, and how a prop (the lamp) is implemented.
Behaviors
First, here’s how you create a behavior:
Something = behavior() function Something.methods.whatever(self) ... end
Behaviors are now tables that contain a table of methods (called methods
), a table of every object tied to that behavior (called all
), and functions called to initialize or tear down objects that are gaining or resigning that behavior (on_become
and on_resign
). To create a new object:
blah = new({ a=5 }, Something)
This creates an object called blah
that has a field “a” equal to 5, and two behaviors: Something and Object. That’s right, Object is a behavior, containing methods like become
and resign
, and every object we create behaves like an Object (unless they resign the Object behavior). We can resign things like so:
blah:resign(Something)
Now blah behaves like an Object but not like a Something, so we can still call become
(because that method is how an Object behaves) but not whatever
.
Rulebooks
In order to respond to commands, we will have this idea of “rules”. A rule is simply a function that takes a command and returns either a response (a string to give the user) or nil. Rooms have rules, props have rules, the game has global rules, but they all behave the same way, so, let’s make a behavior for them:
Rulebook = behavior() function Rulebook.on_become(obj) obj.rules = obj.rules or {} end
Every object that behaves like a Rulebook must contain a list of rules, so we’ll add an empty one to it when it becomes a Rulebook. Now, a Rulebook has to be able to handle a command, by letting the rules that apply to it give their response:
function Rulebook.methods.handle(rulebook, command) local message = nil for _, rule in ipairs(rulebook.rules) do message = rule(rulebook, command) or message end return message end
Note that we keep going even after a rule has responded; a well-designed Rulebook will only have one rule that applies to each command but more that one might need to be informed (think about a rule that increments a timer every turn but never gives a response, say). Later on we may need to let commands return more than just a response (like a flag saying whether any other rules should run) but this is fine for now.
Rooms
Rooms are about the same as before. However, we no longer keep the big global list of rooms, because the Room behavior does that itself, we just need to write a function to look up rooms by name:
function Room.find_by_name(_, name) return Room.all:select(function(room) return room.name == name end)[1] end
And tell the Room behavior to use that for its __index, so we can say something like Room.Kitchen
:
setmetatable(Room, {__index = Room.find_by_name})
Since all Rooms are also Rulebooks, we’ll make a quick function to make it easier to create them:
function Room.new(attrs) return new(attrs, Room, Rulebook) end
Props
Now we’re ready to do something that’s not just reorganization of the old code: props. “Prop” will be a behavior, obviously, and I’ve already said that all Props are Rulebooks. All Props also have a name, an article (which defaults to “a”, and is used in constructing messages, like “a boat and some water”), and a container. The container is the thing (either a Room or a Prop) that this Prop is inside, and it will be used later on to find all the props we can see. So, creating the Prop behavior:
Prop = behavior() Prop.methods.article = "a" function Prop.new(name, container) return new({ name = name, container = container }, Rulebook, Prop) end
Note that we’re a Rulebook first and a Prop second. Since behaviors are applied in order, and the most-recently-applied takes precedence, this enables us to have Prop change how props handle commands. Also, notice that the default article “a” is put in methods
. methods
holds not just functions but anything that you want each object to be able to override.
Parser
Now, the parser. This is actually pretty simple. We want to take a string, like “give gold coin to merchant”, and turn it into a command. So, we have to decide what kind of grammar we’ll accept. The tricky part is that some things can be multiple words, like “gold coin”, and there are different forms of sentences that are valid, like we could also understand “walk north” or “drop brick”.
There are four parts of speech we’ll try to capture:
- Verb: always in the first position, always one word, not optional.
- Preposition: can only be one of about 70 words, which we have a list of (like “under” and “to”). Marks the end of the second term, if there is one.
- Subject: everything between the verb and the preposition. This is what the verb will be done to.
- Object: everything after the preposition. This is more information that the verb/subject needs to run.
There are, of course, as many weird edge cases as you can think of, but what we’ll do is cheat: have rules in the game to rewrite these perverse commands into something that makes sense, like, if the verb is “turn” or “switch”, then the preposition (which is “on” or “off”) gets stored as the object. Anyway, here’s the little bit of code to do the parsing, courtesy of Lua’s string.gmatch
:
function Game.methods.parse(game, str) str = str:lower() local words, term = table.new(), nil for word in str:gmatch("%w+") do words:insert(word) end local command = {game = game, verb = words:remove(1)} for i, word in ipairs(words) do if Game.prepositions[word] then command.preposition = word command.subject = term term = nil elseif term then term = term .. " " .. word else term = word end end if command.preposition then command.object = term else command.subject = term end return command end
Game command handling
After much thought, I decided that we needed multiple rulebooks for handling each command. We’ll try each rulebook in a certain order, and that should be sufficient to handle most of the cases of rules conflicting. So, there are five layers:
Game.system_rules
: This holds things like “exit” and “save”, that you really don’t want to ever override. It goes first.Game.current_room
: The current room the player is in might have special conditions that should override everything else, like if it’s dark.Game.global_rules
: The game has global rules for handling things like movement, inventory, etc.subject
: The subject of the command gets to determine how it handles commands, if nothing else overrides it.Game.last_chance
: The game also has a set of last chance rules; if the command gets to here then it’s probably an error, so we’ll detect the common cases here and try to give a helpful error message.
It seems a little over-complicated but in practice, most rules will go in the props or the room. The author should never have to touch system or last chance rules, and very rarely global rules.
Props will do their command-handling a little differently: first they’ll try a list of rules, just like normal, but if that doesn’t yield a response they’ll send the command to a method named the same as the verb. This means that most of our code can be written with just Prop.whatever.verbname
:
function Prop.methods.handle(prop, command) local resp = Rulebook.methods.handle(prop, command) if resp then return resp elseif prop[command.verb] then return prop[command.verb](prop, command) else return nil end end
A prop
So now that that’s out of the way, let’s implement a simple prop, the lamp. It will support two verbs, “examine” and “turn”, so you can “turn on lamp” or you can “turn off lamp”, and you can see if it’s on or off with “examine lamp”. First make a prop:
Prop.new("lamp", Room.Bedroom) -- It starts out in the bedroom Prop.lamp.turned_on = false -- Record whether it's on or off
Now handle turning it on and off:
function Prop.lamp.turn(lamp, command) if command.preposition == "on" then lamp.turned_on = true return "You turn the lamp on." elseif command.preposition == "off" then lamp.turned_on = false return "You turn the lamp off." end end
And then a function to examine it:
function Prop.lamp.examine(lamp, command) if lamp.turned_on then return "The lamp is glowing brightly." else return "The lamp is dark." end end
That actually looks really straightforward, right? All the code to handle this is in one place, and it doesn’t need to worry about knowing about any other objects. If we really wanted to, we could make Lamp into a behavior even, just by making it define these two methods. Let’s try it out:
> return game:input("examine lamp") "The lamp is dark." > return game:input("turn lamp on") "You turn the lamp on." > return game:input("examine lamp") "The lamp is glowing brightly."
Very simple.
What’s next?
The game engine works but it only really responds to those two commands. All the basic commands like walk, get, say, etc. need to be implemented, as well as concepts like darkness, open / locked doors, and so on. So there’s still a lot of work making an interactive fiction standard library, but the basic scaffolding of parsing and handling commands is there, as well as describing items and locations.
If you want to write up any of that standard library, here’s the code so far. Have fun!