An Adventure Game – Part 5
We’re rapidly approaching the end. This time we’ll implement a meta-language that makes it easier to create new games with the existing engine. Conceptually speaking we’re not adding anything new to the table, but the example game from the previous post was created in an ad-hoc manner that demanded knowledge of both Prolog/Logtalk and the engine in question. What we want is a declarative language in which it’s possible to define rooms, entities and how they should be connected. Exactly how this language is interpreted should not be the game designer’s concern. This is a scenario in which it’s best to start with the language itself, since it’s pretty much impossible to write an interpreter otherwise.
We want the language to be capable of:
- Creating entities.
- Adding properties to existing entities.
- Setting new values for entities. For instance, if we first create a door with the default lock we’ll probably want to change this later on.
- Adding entities to rooms.
- Connect two rooms with an entrance.
All these steps should be expressible within a single file. The first part might look something like (as always, I’m just making stuff up on the fly, it’s quite possible that there are simpler/better ways to accomplish this!):
begin entities. build(room, room1, "A rather unremarkable room.\n"). build(room, room2, "..."). build(door, door, "..."). build(lock, lock). build(key, key, "A slightly bent key.\n"). build(player, player). end entities.
Where should be read as: build an entity of type and name it . allows us to create printable entities, where the string is the description. This suggests that we’ll need a builder-object that’s capable of constructing some default entities. Since entities consist of properties, it would be possible to only build generic entities and manually add all the interesting properties in question, but we make the job easier for the game designer if he/she can assume that some primitive objects are already available. Of course, it wouldn’t be practical to demand that all entities are created in this manner. If two entities are identical except for a few differing properties then it would be simpler to create a generic entity and add the properties manually instead of defining a new build-predicate. For example, say that our game will consist of two different kinds of fruits: magical and non-magical. If we eat the former we finish the game, if we eat the latter we increase the health of the player. This is naturally implemented by creating two different properties: and . Hence, to create two fruits – one magical and one non-magical – we first create two generic instances and then add the defining properties.
begin entities. build(generic_entity, apple, "An apple! \n"). build(generic_entity, banana, "A yellow banana. Hot diggity dog!\n"). end entities. begin properties. add_property(fruit_property, apple). add_property(carriable_property, apple). add_property(magic_fruit_property, banana). add_property(carriable_property, banana). end properties.
The Argus-eyed reader will probably realize that it would be ever better to factorize the common properties () of the two fruits into a prototypical base object, and then clone this object and later add the unique properties ( and ).
Since the entities that have been created thus far only has the default values we now turn to the problem of sending messages, so that we’re able to change these at whim. Say that we want to tell the lock that it should use the key that we just created, and the door that it should use the lock. A first attempt might look like this:
begin relations. action(set_key, lock, key). action(set_lock, door, lock). end relations.
All identifiers here refer to the entities that have already been created. This won’t work however, due to a subtle semantic difference between how locks and doors work. A door has a lock, it consists of a lock. Therefore it’s correct to send the lock entity as an argument to . A lock on the other hand doesn’t consist of a key. It only needs to know what key will unlock it, hence it’s not correct to send the whole key entity as argument. We only need one part of the key entity, its identity, in this case. To be able to differentiate between these cases we’ll introduce the notation that a term preceded by a dollar sign ($) will be sent as-is, instead of sending the entity corresponding to the identity. The previous attempt should hence be rewritten as:
begin relations. action(set_key, lock, $ key). action(set_lock, door, lock). end relations.
The file will be interpreted from top to bottom, so if we added a property in the preceding block we’re able to change it here. The next step is to connect rooms. Strictly speaking this relation is not necessary since we’re already able to send messages to entities, but including it as a primitive in the language will make it much easier to use. The syntax is:
begin relations. . . . connect(room1, room2, door). end relations.
The full example game in all its glory would be written as:
begin entities. build(room, room1, "A rather unremarkable room.\n"). build(room, room2, "A room almost identical to the previous one. What on earth is going on!?\n"). build(door, door, "A wooden door with a small and rusty lock.\n"). build(lock, lock). build(key, key, "A slightly bent key.\n"). build(generic_entity, apple, "An apple! \n"). build(generic_entity, banana, "A yellow banana. Hot diggity dog!\n"). build(player, player). end entities. begin properties. add_property(fruit_property, apple). add_property(carriable_property, apple). add_property(magic_fruit_property, banana). add_property(carriable_property, banana). end properties. begin relations. action(set_key, lock, $ key). action(set_lock, door, lock). action(set_state, door, $ closed). action(add_item, room1, apple). action(add_item, room1, key). action(add_item, room2, banana). action(set_location, player, $ room1). connect(room1, room2, door). end relations.
A substantial improvement in readability compared to the previous efforts!
Another boring, dry entry on parsing? Fear not, because I have a trick up my sleeve -there was a reason why the syntax of the meta-language was a spitting image of Prolog’s all along! One way to interpret the file is to say that begin/end and the dollar sign are all operators with a single argument. Then the file is nothing but a collection of facts that can be accessed as normal and we won’t have to worry about parsing at all. A slightly more contrived but more general approach is to use what in Prolog-nomenclature is known as term expansion. This is usually the preferred approach to handle embedded languages and is somewhat similar to macros in Lisp. The basic idea is simple: instead of taking a term at face-value we expand it according to a user-defined rule. What’s the point? Basically we don’t have to type as much. For example, let’s say that we have a database consisting of facts, where the first argument is an atom, the second a list and the third an integer denoting the length of the list.
rule(a, , 0). rule(b, [a], 1).
Furthermore assume that we don’t want to calculate the length of the second argument at run-time. There’s nothing inherently wrong with this approach, but manually entering the length of the list is a drag and quite error-prone. It would be better if we could simply write:
rule(a, ). rule(b, [a]).
And tell the Prolog system that these facts should be construed as facts with an additional third argument which contains the length of the list. Fortunately this can easily be realized with the built-in predicate . The first argument of is the term that shall be expanded. The second argument is the expanded term. A suitable definition for the previous example is:
term_expansion((rule(Head, Body)), rule(Head, Body, Length)) :- length(Body, Length).
Great success! We don’t have to concern ourselves with how or when this predicate is called, just that it will eventually be called when the file is loaded. Like all powerful language constructs it’s easy to abuse the term expansion mechanism and create unreadable code. We could for instance expand to if we were in a facetious mood (don’t do it, OK?). Fortunately the Logtalk support is a bit more sane than in most Prolog systems. Instead of defining rules in the same file as the rules that shall be expanded, they’re encapsulated in a special expansion object. This object is later used as a hook in to make sure that the effect is localized to a given file. In summary, there’s two steps involved:
- Define the rules in an expansion object (which must implement the expanding protocol).
- Load the file (the script file in our case) with the expansion object.
I shan’t spell out the full details of the expansion object, but what it does is to remove the begin/end directives and create a set of facts with the initial entities. Also, to make things easier in the script interpreter, it removes , , and replaces them with unary facts instead.
The builder is in charge of building game entities. As mentioned earlier it’s not strictly needed since it’s always possible to add properties manually, but it does simplify things. Here’s how a map and a room could be created:
build(world, Id, Rooms, Player, World) :- Ps = [map_property-State], build(final_state, F), map_property::new([[F|Rooms], Player], State), entity::new(Ps, Id, World). build(room, Id, Description, Room) :- Ps = [container_property - State1, printable_property - State2], container_property::new(, State1), printable_property::new([Description], State2), entity::new(Ps, Id, Room).
We have a description of the game and want to transform it into an entity that has the . Strictly speaking this is not an interpreter, but rather a compiler from the script language to the entity language. The most important predicate is that takes an expanded script file as argument and produces a game world. It works by extracting the entity directives, the property directives, the relations directives and then separately interprets each one of these. Finally it extracts the rooms and the player and asks the builder to build a game world.
interpret_game(DB, World) :- findall(E, DB::entity(E), Es0), findall(P, DB::add_property(P), Ps), findall(A, DB::action(A), As), findall(C, DB::connect(C), Cs), interpret_properties(Ps, Es0, Es1), interpret_actions(As, Es1, Es2), interpret_connectors(Cs, Es2, Es), get_rooms(Es, Rooms), get_player(Es, Player), builder::build(world, world1, Rooms, Player, World).
The predicates are all rather similar. They iterate through the list of commands and changes the set of entities accordingly. For brevity, let’s concentrate on .
interpret_actions(, Es, Es). interpret_actions([t(M, Id1, Id2)|As], Es0, Es) :- select_entity(Id1, Es0, E0, Es1), lookup_argument(Id2, Es0, Arg), entity::action(M, [Arg], E0, E), interpret_actions(As, [E|Es1], Es). lookup_argument(Id, Es, Arg) :- ( Id = $(Symbol) -> Arg = Symbol ; lookup_entity(Id, Es, Arg) ).
The body of should be read as: execute action on the entity corresponding to with the argument (remember that arguments preceded by a dollar-mark are left unchanged). Since we might need to update an entity several times, it’s re-added to the list of entities whenever it’s updated.
Putting everything together
We need to make a small change to in the object. Instead of building a world manually it’ll take a script file as argument and ask the interpreter to interpret (compile!) it.
init(Game) :- write('Welcome to Bacchus-Bosch!'), nl, current_input(S), script_interpreter::interpret_game(Game, World), repl(S, , World).
That’s pretty much it – the end of Bacchus-Bosch. As I suspected when I started the project the final game isn’t very fun. Wait, scratch that, it might be the worst game I’ve ever played, and that includes the infamous yellow car game. But it does have a magic banana, that ought to count for something. In any case it wouldn’t be hard to create more engrossing games since all the building blocks are in place. It should also be noted that the engine is hardly limited to text-based or turn-based games. In a real-time game we could for instance run the update method a fixed amount of times per second instead of waiting for player input. We could also add e.g. role playing elements by defining new properties.
I hope that this pentalogy has been at least somewhat comprehensible and coherent. What the future holds for the blog I dare not promise. Hopefully we’ll someday see the return of the magic banana!
The source code is available at https://gist.github.com/924052.