THE
ADVENTURER'S
TALE
by Alex Leavins
The author of the 8-bit Atari text adventure, Wombats, reveals how to create text adventures in C. Includes an analysis of mapping, parsers and object manipulation-plus a "mini," five-room text adventure which you will discover within the ADVENTUR.STQ folder on your START disk.
Most readers will recognize this as part of a text adventure, an interactive book, in which the computer acts as guide, referee, scorekeeper and adversary in an alternate-and often quite complex-world, a world that exists only inside the computer itself. The rules are not always clear, and combat (if there is combat) can prove fatal. But doff t worry- there's always the Reset button.
A typical adventure-game interplay between computer and player might read like this:
You're standing in a small room; a sputtering torch is on the wall.
- -> TAKE TORCH
OK, you now have a torch.
- ->EXAMINE TORCH
It's just your basic torch.
- ->NORTH
You're standing in a long corridor, with exits to the south and west.
- ->EAST
You can't go that way
- ->WEST
This is the end of the corridor. You can go east from here. There are
some jewels here.
- ->TAKE JEWELS
Taken.
- ->EXAMINE JEWELS
They're beautiful gemstones, agates and diamonds.
The - -> is a prompt telling the player to enter a command. The uppercase words represent the player's input, and the computer's responses are shown in lower case. In our example, the computer prints a location description, "This is the end of the corridor...," followed by the - -> and waits. The player then types TAKE JEWELS. The computer tries to recognize the input and take the appropriate action, in this case, taking the jewels.
OK, we've got a basic understanding of what an adventure game is. Now, how do they work?
BUILDING A UNIVERSE CAN BE TOUGH
Were going to create a simple text ad venture in C, which you will find on your START disk within the folder, ADVENTUR.STQ. Refer to the Disk Instruction page for further information. We have no bells and whistles in this program, no GEM, no graphics-just the essentials: five rooms, some objects, and a two-word parser Once you understand this structure, you can expand upon it, and perhaps create your own Infocom-style game.
Let's ignore for a minute just how our program will interact with the player. This is not a trivial matter, but before we can address it we must first determine what the game itself is going to do. Every adventure game includes a number of discrete locations, known as rooms. Room is a generic term, referring to any distinct location in the game. It needn't be inside a building, indeed it needn't even be enclosed. If it's a unique, specific location that a player can travel to, then it's a room. All of the rooms in our game, taken all together, make up the game's universe. Here's the universe for the simple adventure game that we'll be building (see Figure 1).
Here we have five rooms, each of which leads to at least one other room.
(Rooms aren't very useful if you can't get to them.) Going east from the
Open Forest room (room #1) will take us to
the Forest Edge room (room #2). Similarly, going south from the Large
Rock room (room #4) will take us to the Dense Forest room (room #5). We
can summarize all of this as in Figure 2.
So, north from room #3 leads to room #2. South leads nowhere, as does east, and finally, west will take us to room #4. The rows give us the direction we wish to move in, the column headings give us the room we're currently in. If we're in room #4 and want to move north, we look in the NORTH row, under room #4," and discover the number 1, which is the Open Forest room. Ah, but what if we doff t find a room number, but a zero? Easy Zero means for that room, that direction leads nowhere. By the way, you'll note that going north from room #1 leads to-room #1! This often happens in adventure games; rooms will double back on themselves, lead to rooms way on the other side of the universe, or other, nastier surprises.
If you'll look at the code of ADVENTUR.C on your START disk, just before main(), you'll notice that I've translated the above table to a series of integer arrays, containing room values. We have a north, south, east, and west array, corresponding to the four directions. Movement for our program is just as simple as the table; north[1] is the room we'll reach if we walk north from room #1.
This is implemented in the routine move_us(). This routine takes a direction (n, s, e, w) and a current position (the room the player is in), and determines what room (if any) the player ends up in. It does this by a simple switch on the desired direction, which then routes the current room into the appropriate directional array. The capitalized versions of NORTH, SOUTH, EAST and WEST are defines contained in the file GAME.H, which represent 1, 2, 3, and 4, respectively After the switch, the value of i is either the room that the player is now in, or zero. If it is zero, we know the player can't go in that direction, and a simple test followed by a printf statement lets the player know this. If the move is valid, we show the player the new room and any objects that might be there.
Ah, objects. Haven't mentioned those yet, have I?
THE CARE AND FEEDING OF OBJECTS
The universe of rooms is one building block of adventure games. Objects are another Briefly, an object is any item that can be seen, taken, used, or interacted with in any other way. Objects in sophisticated adventure games can have many properties. For example, not all objects seen in a room may be takeable," while some takeable objects may not he revealed in the original room description. Complex, eh? Don't worry, in our adventure game, an object is easily spotted by the phrase:
"There is an XXXXX here" where XXXXX is the object in question. In our game there will be four objects: a book, an axe, a coin and a stick. Each of these will be in a different room, and can be acted upon in several simple ways. We will be able to take an object, drop it, examine it, read it, even throw it. Each of these commands is implemented by a separate routine. Obviously, take_object(), takes objects. Each of the other routines is similarly named, except exam_object, which is shortened to prevent a seven-character label-name conflict.
The basic procedure for each of these routines is the same. First, determine the command requested by using the switch(cmd_index) statement in main(). Next, find out if the word following the command is a valid one, with split_word(), which separates the succeeding word from the input string and puts it into a test array, and scan__objects(), which tests the word in the test array against the objects that the game recognizes. If the object is one that the game knows, the program attempts to process it, using the appropriate xxxx__object routine. If the object is not recognized, then the game prints a short message and waits for the next input.
So far, I've avoided the issue of how the computer recognizes words. This is the third major aspect of adventure games, and is known as the parser.
PARSERS, OR WHAT'S THAT WORD?
We now have some understanding of the low-level routines that process game procedures, such as the taking of objects. But how do we get from the player typing "TAKE BOOK" to actually taking that book?
Obviously, a computer doesn't "know" words, it can only process numbers. We need a way of translating the user's input into simple numerical values - or tokens - that the game can use to determine what happens. This translation process is performed by the game's parser, which takes words and sentences, breaks them down, analyzes them, and tokenizes them by assigning a unique number to each command. The array commands[] and the array objects[] (just before main()) contain all the words that our game will recognize:
COMMAND TOKEN OBJECT TOKEN NORTH 1 BOOK 1 SOUTH 2 AXE 2 EAST 3 COIN 3 WEST 4 STICK 4 TAKE 5 DROP 6 THROW 7 READ 8 EXAMINE 9 INVENTORY 10 LOOK 11 QUIT 12
Notice that these are the actual words that the game recognizes; they are literal strings. The computer recognizes these words by their token numbers, which are actually the positions of the words within the command[] or object[] arrays. Notice also that the file GAME.H contains each of these words in a define statement:
#define NORTH 1
#define SOUTH 2
#define etc.
Here the words are actually labels that are replaced with the corresponding numerical values at compile time. So there are two versions of the same word, both related to the same number, but seen differently by the program. Why do we do this? There's a very good reason-it's just a little confusing at first.
Let's go back to our parser We want it to accept a word or two of input, such as NORTH, then translate that into a numerical value, such as 1, so that the program can do something like:
if(command_value = = 1)
{
.../*move the player north */
}
Take a look at that if statement. Without the added comment, the code isn't particularly obvious, is it? What does the 1 mean, anyway? But now suppose we do this:
#define NORTH 1
*
*
*
if(command_value = = NORTH)
{
.../*move player north */
}
This makes things much clearer to someone reading the code, and makes the code a whole lot easier to write. We can now reference, in the code, the actual command tokens that were created by the parsing language. We can build the entire adventure game, and use the command and object names that the game itself recognizes, without having to reference the token values of a command or object.
OK, enough of tokens, objects, and rooms. Let's get to the heart of the matter: the parser.
PARSING AGAIN, OR ENGLISH MADE SIMPLE
The player has just typed "TAKE BOOK" and is eagerly awaiting a response. OK, what's the first word? What do you mean, what's the first word? It's obvious. TAKE is the first word. Ah, but it's not obvious to the computer, which sees language as just a string of bits. But it's easy to define a "word" for the computer. A word, in this case, is any text string, followed by a blank space. So, in the following sentence:
TAKE THE GHSTS AND THE BOOK
TAKE, THE, GHSTS, AND, THE, and BOOK are all words to the computer- even though GHSTS is no word that I know. With our simple definition of "word," scanning the input string and separating it into discrete words becomes simple. It is implemented in the routine split_word(), by scanning the input array, parser[], and looking for either a blank space, or the end of the array (since the last word in a sentence won't have a space after it). Once the blank space is found, everything is copied from the start of the array up to that space, into the array test_word. Now, shift the remainder of the array after the space, to the start of the array so that the next time we examine the array the next word will be at the beginning.
We now have the first word of the player's command, TAKE, in the array test__word[]. We next pass it to scan_ commands(), which will test it against the list of known words. You'll note that the array commands[] contains all our command words, with each one separated by an asterisk. The asterisk is used as a delimiter so that scan__ commands() can tell where one word ends and the next one begins. We use the pound sign (#) to tell the routine that there are no more words.
The scan_commands() routine itself is very simple. Read a command word into the test array showword[], then do a string comparison between it and the word in array test__word[]. If the result is zero, the strings are identical and the routine returns the index of the word, which is the position the word occupies in the array commands[]. If the result is not zero, the routine tests to see if we've exhausted our word list. If not, then the word index is incremented, and the next word is read into the array showword[]. If we have exhausted the list of words in command[], we exit the routine with a returned value of zero, which tells the main routine that we didn't recognize the word.
PUTTING IT ALL TOGETHER
We now have all the pieces we need to put together an adventure game. Let's walk through the entire procedure for a single command, to get a feel for how it all works. We'll examine how the adventure game processes the command:
TAKE BOOK
Starting at the top of the WHILE (1) loop in main(), we first execute get_input(), which simply waits for the player to type a string into the array parser[]. Note that if the player simply presses IReturn] without typing anything, the game displays a brief "Say what?" message, and recycles through the keyboard-input routine.
The player types TAKE BOOK, and get_input() returns with the string TAKE BOOK in the array parser[]. Now, we call split_word(), which breaks the first word, TAKE, out of the array parser[], and puts it into the array test__word[]. It then shifts the string in parser[] over, so that parser[] now contains the string BOOK. Now test__word[] contains the string TAKE. Next, we call scan_command() which compares the words NORTH, SOUTH, EAST, WEST, and TAKE to the word in test_word[]. On the fifth pass through the loop it finds a match in the word TAKE, and returns the number 5, which is put into the variable cmd_index.
Now the program tests the value of cmd_index. Zero would mean the player had typed a command that the program didn't recognize, and the message "I don't understand that cornmand," would be displayed before it returned to the top of the WHILE (1) loop. However, in our case cmd_index equals 5, so the program passes it to switch(cmd_index), where case TAKE will be executed. (Remember, we've defined the word TAKE to be 5, in GAME.H.)
The first thing the program does in the case statement is call split_word() again. Now the word BOOK is shifted into the array test_word[], leaving the array parser[] empty. Then the program calls scan_objects(), which does for objects what scan__commands() does for commands. It scans through the list of objects in the game to match them against the test word. In this case, scan_objects() tests the word BOOK, finds a match, and returns a value of 1, which is put into the variable obj_index. The program then tests the value of obj_index to see if it is a recognized object. If not, the program will print "I don't know what that is," and exit to the top of the WHILE (1) loop. In our case, it did recognize the word, and so passes its index to take_object(), which performs the actual taking of the object.
Notice that up until this point the only thing our parser has done is to break down the input of the player, and process it to find out what to do with it. No "game" actions have taken place. It's only now that the program can invoke any of the game mechanics themselves.
TAKING THE OBJECT
The program has passed to take_ object() the index of the item the player wants to take. Note in GAME.H that we've defined:
#define BOOK 1
#define AXE 2
#define COIN 3
#define STICK 4
The first statement in take__ object() is an IF statement, which says:
IF (invenrory[obj] EQ ON) THEN printf("You've already got it!\n")
But what's inventory? Since the program needs some way to determine, at any time, what the player is carrying, we implement this in a simple fashion with the array inventory[]. The value of nth element of inventory[] tells the program if the player is carrying the nth object. If the value is zero, or OFF, the player isn't carrying the object, but if the value is 1, or ON, the player is. Thus, to see if the player is carrying the BOOK (which has an index value of 1), the program looks at inventory[1].
The IF test now becomes clear: If the player is already carrying an object, it can not be taken again. Assuming the player is not carrying the BOOK, the next statement,
ELSEIF (where[obj] NE position) is executed. The array where[] works exactly the same way as the inventory[] array except that the where[] array defines what room an object is currently in. So if where[3] equals four, the third object (in our game, the COIN) is currently in room #4 (in our game, the Large Rock room). If some value of where[] is zero, the related object is in the player's possesion. Note that this overlaps the inventory function. We could, if we wanted, use the where[] array to define both the placement of objects in the universe, and objects currently in the player's possesion. But it is much simpler, conceptually, to have two separate arrays.
The variable position defines the current position of the player. If the object the player is trying to take is not in the same room, the player can hardly get it. But if we assume the book is in the room, the program sets where[obj] to OFF, indicating that the object is no longer in a room, then sets inventory[obj] to ON, indicating that it's in the player's possession. The program then prints a message that the player has taken the object and exits the routine. Back in main(), we've reached the last statement in case TAKE: and we're done with our example. Ta-da!
ENHANCEMENTS
Adventure games are great fun to build but can be frustrating because there's always still one extra feature that's just too good to leave out. Here are a few ideas for improving our little adventure game (aside from enlarging its universe). I'm sure you can come up with many others.
* Increased vocabulary- Obviously an adventure game is only as good as the words that it can handle. Some adventure games handle a few hundred; others, such as Infocom's, handle more than a thousand. A bigger vocabulary also creates a much better illusion of reality.
* Better semantic and syntactic handling of English - Our adventure game uses a simple verb/noun parser limited to either one- or two-word commands. Wouldn't it be nice to have it recognize such things as:
DRAGON, GIVE THE RARE BOOK TO THE ANGRY TROLL
All of this is possible, but would require at least a thorough understanding of the elements of English sentences. If you're serious about building a better parser, I recommend that you first acquire further information on the syntactic relationships of words (see Reference, below). As an example of the kinds of things you'll have to consider when designing your parser, here are two sentences that are semantically identical (they mean exactly the same thing) but syntactically quite different:
GIVE THE BOOK TO THE ALIEN
GIVE THE ALIEN THE BOOK
Both sentences have the same result: the alien is given the book. But the direct and indirect objects are exactly reversed, and the word TO is missing in the second sentence.
* More complex objects- Wouldn't it be nice to give each object a variety of characteristics, such as weight, size, visibility (objects might appear only after a player has done some other task first), "takeability" (which would define under what circumstances you could take an object), and many other things. Something like this could perhaps be implemented using a structure. For example:
struct object ={ int weight; int size; int take; int visible; };might define the weight, size, takeability and visibility of an object. Then we could define objects simply, in terms of the generalized structure. Every object would have the same set of possible properties and we could build a more complex game without a great deal more work.
* Frames- A concept from the artificial intelligence field, a "frame" is a set of default conditions. Suppose we made the default condition for all objects as takeable. We would then only have to define-or flag-those objects in the game which were not takeable. By establishing a default condition covering the majority of situations, we eliminate a lot of code we would have had to apply to each individual object. Applied throughout the game, this concept can save a great deal of coding time. But keep in mind that, once programmed, default behavior cannot be changed.
* More text- Wouldn't it be nice to describe every room with a whole screenful of rich, imaginative text? Instead of printing a simple one line string for each room, we could open up a text file corresponding to the room number and read in as much text as we wanted. We could apply this concept to, everything, not just rooms, so that a great many actions would yield interesting, non-trivial results. Or, how about placing pictures instead of text in our files?
* Independent characters- How about characters that walk around the universe independent of you and help or hinder your progress, depending upon your interactions with them?
How about..., but it's pointless, I could go on for days. Suffice it to say the ultimate adventure game has not yet been written. Up to now, we have seen only the fledgling first steps of a whole new artform. With patience, persistence, and a little bit of luck, you might enlarge the five rooms in our sample program and help shape the future of adventure gaming.
REFERENCE:
-
( Zork and the Future of Computurized Fantasy Simulations by David P Lebling,
BYTE magazine, December 1980, vol. 5, number 12.
-
( Syntactic Structures by Noam Chomsky, Mouton, The Hague, Netherlands.
- ( Foundations of Syntactic Theory by Robert P Stockwell, Prentice-Hall, New Jersey
SEARCHING
FOR
ADVENTURE
Looking for ST adventure? There's plenty out there. Because of the huge amount of available memory the ST is a natural for labyrinthine adventure games. Most of the following products are adventures with graphics, although some (such as Infocom and the Synapse series) ace strictly text.
Any Infocom Game
(you can't go wrong)
Infocom Software
125 Cambridge Park Drive
Cambridge, MA 02140
(800) 262-6868
$39.95-$44.95
CIRCLE 220 ON READER SERVICE CARD
Essex
Brimstone
Mindwheel
Synapse/Broderbund
17 Paul Drive
San Rafael, CA 94903
(415) 479-1170
544.95
CIRCLE 221 ON READER SERVICE CARD
Borrowed Time
Mindshadow
Activision
P.O. Box 7286
Mountain View, CA 94039
(415) 960-0410
$44.95
$49.95
CIRCLE 222 ON READER SERVICE CARD
Crimson Crown
Transylvania
Polarware Penguin
830 4th Avenue
Geneva, IL 60134
(312) 232-1984
$19.95
CIRCLE 223 ON READER SERVICE CARD
The Black Cauldron
King's Quest II
Winnie the Pooh*
Donald's Playground*
Sierra On-Line
P.O. Box 485
Coarsegold, CA 93614
(209) 683-6858
$49.95
$39.95
$24.95 (* For younger adventurers)
CIRCLE 224 ON READER SERVICE CARD
The Pawn
Firebird Licensees
P.O. Box 49
Ramsey NJ 07446
(201) 934-7373
$44.95
CIRCLE 225 ON READER SERVICE CARD
Gateway
Action Software
69 Clementina St.
San Francisco, CA 94105
(415) 974-6638
$39.95
CIRCLE 226 ON READER SERVICE CARD