Skip to content

8. Mini Zork: A functional adventure

Claude Roux edited this page Feb 3, 2021 · 36 revisions

ZORK

One of the first video games I played was called "Scott Adams Adventure Games". At least according to what I could glean on the Internet. It was delivered with the computer: a Video Genius System that my father had bought in the very first days of personal computing, a time as old as World War II was for me at the time. This game was a variation on Zork, a minimalist game invented at MIT. Truly minimalist... There was no image, just text, and you walked around the game writing commands in English, the majority of which sent back cryptic messages like: "I don't understand". Worse, my knowledge of English at the time was rudimentary and I spent more time in the dictionary looking up vocabulary than playing. At least in the beginning...

However, "Scott Adams Adventure Games", despite its archaic form, may have unconsciously influenced my interests: I spent a good part of my career writing automatic language processing software.

The sadism of video game designers can have terrifying effects afterwards. We can never say it enough.

If further proof were needed, one need only look at the strange status of this game in geek culture: Sheldon, for example, plays Zork in one of the episodes of season 4 of Big Bang Theory. He quickly loses his temper, very quickly...

However, the mechanics of this game offers a unique opportunity to introduce some of the most interesting aspects of functional programming...

And of course, the examples will be illustrated with LispE code...

The game code is in the directory: examples/patterns/minizork_en.lisp

Play

To examine the code and play, you can launch:

lispe -e minizork_en.lisp

You can even debug the game directly by placing "breakpoints" with ctrl-b. Then just execute the code with: Ctrl-X d to enter the debugger.

Game board

The game consists of a 3x3 grid in which you can move around. Each square is associated with a description in the dictionary: castlemap.

(setq castlemap {
      "1:1":"You stand in front of a gate."
      "1:2":"You stand in front of a large window"
      "1:0":"You stand in front of a Ogre"
      "2:1":"You wake up an angry venomous snake. It bites you. The pain is terrible..."
      "0:0":"Your are in a large dark room"
      "0:2":"You are on in small room"
      "2:0":"You are in the middle of a forest"
      "2:2":"You are in a large plain. A river is flowing east"
   }
)

Each time you move in the grid, the game displays the corresponding description. There are a multitude of dictionaries and lists to record the objects present in the boxes, the dangers that await the traveler or the valid directions to go from one point to another.

; The objects present in the different boxes

(setq objects {
      "1:1": 'stone
      "0:2": 'key
      "0:0": 'sword
   }
)
  • The display_position function allows you to display the contents of a box on the screen as well as your possessions.
  • The function update_direction allows to define in advance where the traveler can go. This function allows you to update the authorized places, when a puzzle has been solved.

The game board is therefore limited to a few text descriptions that will be used for each move in the grid.

Finally, let's note an amusing aspect about declaring a dictionary. This is an interesting case of lazy evaluation. Indeed, LispE does not create a dictionary when it encounters a static definition but the sequence of commands to build it .

(defun test(s) (setq s {1:x "a": "b"}))

; is compiled in the following form:

(defun test(s) (setq s (key (key) 1 x) "a" "b"))
  • key used without argument returns a dictionary
  • key d k returns nil or the value stored for key k in dictionary d.
  • key d k v initializes the dictionary d with the key k and the value v and returns d.

In this way, the dictionary is only created on call, when the need arises. In fact, the "{}" are only syntactic sugar to make dictionaries easier to handle, as nested calls of key have the unfortunate tendency to quickly become unreadable .

Commands

It is clear that the above description is not very original. On a Yawn Scale (YS) of 1 to 10, we approach 7 without any problem. Indeed, the difficulty is to analyze a command such as: "go west" and to deduce that it is a direction. In the same way, we also want to be able to say: "take the stone" and deduce that it is a matter of picking up an object on the ground.

This is where Functional Programming shows the tip of its nose...

We are going to base all actions on the joint use of Data Types and Pattern Matching Functions, with a hint of high-level functions.

Input

Input is done via the input function which returns the string typed on the keyboard. The result is stored in the variable dialog ( YS=6).

The following code is a more subtle one... (YS=3)

   (setq dialog (lower dialog))
   (setq dialog (+ (upper (at dialog 0)) (extract dialog 1 (size dialog))))
   (setq commands (map (\(x) (select (key synonyms x) x)) (split dialog " ")))
   ; we transform each of our strings into atoms, for match sake
   ; we remove useless words: the, a etc...
   (setq commands (filter (\(x) (not (key stopwords x))) (map 'atom commands))))

The first line takes our order: get the stone to turn it into: Get the stone. In other words, we put the chain in lower case and we put the first letter in uppercase.

The dialogue is then cut along spaces. We then check for each word if there is a synonym. The word Get has a synonym (see the synonyms dictionary in the code) which turns out to be the word Take.

Our sentence becomes a list of strings: ("Take" "the" "stone")

The last line transforms this string list into a list of atoms, from which useless words (stopwords) are removed.

Note the combination of filter and map which allows in two lines of code to replace words by their synonyms, to transform each of them into an atom and to remove those that are considered redundant. Finally, note the use of (\(x)...) which is a more compact way of writing (lambda(x)...).

Our sentence is now a list of atoms: (Take Stone).

And there begins the elegance of pattern programming...

maybe

maybe is an instruction that could make you think of a try/catch (YS=5). The instructions are executed in sequence, if one of them triggers an error, then the last line is executed:

   (maybe
      (println (action commands))
      (println (random_choice 1 msgs 10))
   )

Remember that commands is a list of atoms which in this context will be evaluated as a data type. If this evaluation fails, we will display a random message from the list of error messages.

Data Types

The following description is at the beginning of the code (YS=2):

(data
   [Move atom_]
   [Break atom_ atom_]
   [Open atom_ atom_]
   [Kill atom_ atom_]
   [Take atom_]
   [Drop atom_]
)

These are the valid actions in the game... Note that the previous work consists in matching these definitions with the text that the player has written. The synonyms and empty word filters have no other objective than to establish this correspondence.

Moreover, we can then benefit from an intrinsic verification of the user declarations... If the sentence contains one word too many or too few, the system can immediately return an error message based on these declarations.

Thus, maybe takes all its meaning. If the sentence does not correspond to one of the statements allowed here, an error message is immediately returned.

Pattern Functions

But for the mechanics to be complete, one element is missing. We have shown how an utterance can be transformed into a data structure.

We will now show how this structure can be exploited to our advantage (YS=1).

For this, we will use defpat, the pattern function operator.

Each data type is then associated with a function whose name is action.

(defpat action ([Take x] ) ...)
(defpat action( [Break 'window x] ) ...)

; We can obviously define in advance the atoms we are looking for.
(defpat action ([Open 'door' key] ) ...)

; direction = north, south, west, east
(defpat action ([Move direction] ) ...)

Thus, the (action commands) call consists in choosing the function that corresponds to the data structure obtained after processing the player's command.

If commands is not a valid data type, a first error message will be returned and caught by maybe.

Otherwise LispE will execute the function corresponding to this data type.

A well-oiled machine

As can be seen, this code benefits from a joint use of data structures and pattern functions. Thus, syntax checking is covered by the simple declaration of data structures. As for execution, it consists in providing an action function that takes these data structures as arguments.

You can then enrich the story as you wish...

Clone this wiki locally