Mini text adventure game

This article shows how to write a simple text adventure game in Lisp. I wrote it as a program that beginners could modify to create their own games, and in the process it might inspire them to learn Lisp. It's also a good demonstration of the benefits of using object-oriented programming.

10th February 2023: Updated with clearer instructions of how to install and run the game.

The objective

The objective in this mini adventure game is to escape from a castle to your freedom. It allows you to:

  • Move in a specified direction with a command such as go north or go west.
  • Go to the previous location with go back.
  • Pick up objects with a command such as take key, or drop objects with drop key.
  • Get a list of the objects you are carrying with inventory.
  • Get a description of your current location with look.
  • Solve puzzles with words appropriate to each challenge, such as unlock door.
  • Exit from the game with quit.

Running the adventure game

The adventure game will run in uLisp on a platform with a reasonable amount of memory; I've tested it on an Adafruit ATSAMD21 board, an Adafruit ATSAMD51 board, and an Adafruit nRF52840 board.

  • Download the latest ARM version of uLisp from Download uLisp - ARM version.
  • Use the Arduino IDE to upload it to board; see Download uLisp - Installing uLisp.
  • Open the Serial Monitor in the Arduino IDE to display the uLisp prompt.
  • Display the source of the adventure game here: Text adventure program - uLisp.
  • Select the text of the adventure game, copy it, paste it into the Arduino IDE Serial Monitor input field, and press Return.
  • Optionally save an image of the game onto the board's non-volatile memory by typing:
(save-image)
  • Run the game by typing the following command into the Arduino IDE Serial Monitor input field, followed by Return:
(adventure)
  • Give your commands after the ':' prompt by typing them into the Arduino IDE Serial Monitor input field, followed by Return.

Here's a sample showing the beginning of the game:

> (adventure)
Escape from Castle Gloom.
You slowly come to your senses, and then remember what happened.
You were kidnapped and brought to this castle by an evil ogre, who is asleep on a chair
nearby, snoring loudly.
You are in a large banqueting hall.
To the north is an oak door.
To the east is a small arch.
To the west is a staircase.
To the south is a large metal panel.
: go west
You go up the staircase.
You are in a small study.
To the west is a staircase.
There is a candle here.
There is a box of matches here.
: take candle
You pick up the candle.
: take matches
You pick up the matches.
: light candle
You light the candle with a match, and it burns brightly.
: quit
nil

If you quit and then run the game again it will continue from your previous position. To reset the game to its initial state, first reload it by typing:

(load-image)

Background

Text adventures, also called interactive fiction, were popular in the early days of personal computers, before they became powerful enough to do animated graphics or video. The earliest games, such as Colossal Cave Adventure [1] and the Zork series [2], presented a fictional world in which you could explore and solve puzzles by giving commands such as "go north" or "rub lamp". At each stage your position in the game was described with a series of text descriptions.

Although there's no longer a commercial market for text adventures, they are still great fun to play, and perhaps even more fun to design, and they make a great application to introduce beginners to programming.

The aim of this example was not to write a first-class text adventure, but to demonstrate how to write one, and show how you would implement some of the types of puzzle that featured in the classic text adventures. My challenge was to keep the code comprehensive enough to be used as the starting point for a full-scale game, but simple enough so it wouldn't seem too daunting to a beginner. I hope I've achieved a good balance.

The program

Writing a text adventure is an excellent example of an application that's made far easier using object-oriented programming. The following example uses a simple object system, called ULOS, that I designed for use with uLisp; for more information about ULOS itself see Simple object system.

Every class and object in the game is defined as a global variable, using a defparameter or defvar statement. This makes the game easy to write, but you should be careful not to use the name of an object, such as exit, as a parameter or local variable in a function, as it will mask the global variable. It would be fairly easy to remove this restriction, but it would involve a bit of extra programming; see Further suggestions below.

All the information associated with each object in this game is stored in slots in the object's definition. This makes it easy to understand the source code, and extend the game by adding objects as the game is developed.

Testing the adventure game

Because the state of the adventure game is stored in a series of global variables you can test it out while you're developing it by typing commands into the command line, or Arduino IDE Serial Monitor. If a command takes an argument you need to quote it; for example:

> (take 'key)
You pick up the key.

Note that, for reasons explained later, for the go command you need to call the function walk:

> (walk 'east)
You go through the door.
You are in a small walled garden full of beautiful flowers.
To the west is a small arch.

You can save the game together with its current state with (save-image), and restore it with (load-image).

For playing the game a function adventure is provided that allows you to give commands without having to type brackets or quotes, and provides error handling. It also provides an introduction to the game:

Escape from Castle Gloom.
You slowly come to your senses, and then remember what happened.
You were kidnapped and brought to this castle by an evil ogre,
who is asleep on a chair nearby, snoring loudly. You are in a large banqueting hall. To the north is an oak door. To the east is a small arch. To the west is a staircase. To the south is a large metal panel. :

To reset the game back to the beginning reload the source.

The World

Here's a diagram of the game's class system:

AdventureWorld.gif

First we define a class called world, from which everything else is descended:

(defvar world 
  (object nil
          '(playing nil take #'take-anything drop #'drop-anything
            light #'light-anything unlock #'unlock-anything)))

This defines the methods to be called if there's no more specific method for an object:

(defun take-anything (obj) (format t "You can't take that.~%"))
(defun drop-anything (obj) (format t "You can't drop that.~%"))
(defun unlock-anything (obj) (format t "You can't unlock that.~%"))
(defun light-anything (obj) (format t "That's not a good idea.~%"))

For example:

> (light 'door)
That's not a good idea.

The player

The only object descended directly from world is the player:

(defvar player (object 'world '(location hall last-location hall items nil)))

This specifies the player's starting location, and the list of items they are carrying, initially empty. The slot last-location is used to implement the go back command.

Items

The item class is used to define items in a game, such as "dagger", "book", "bird" etc:

(defvar item (object 'world '(take #'take-item drop #'drop-item)))

There are two methods that respond to the commands take and drop:

(defun take-item (obj)
  (let ((where (value player 'location)))
    (cond
     ((member obj (value where 'items))
      (format t "You pick up the ~a.~%" (name-of obj))
      (update where 'items (remove obj (value where 'items)))
      (update player 'items (cons obj (value player 'items))))
     (t
      (format t "There's no ~a here.~%" (name-of obj))))))

(defun drop-item (obj)
  (let ((where (value player 'location)))
    (cond
     ((member obj (value player 'items))
      (format t "You drop the ~a.~%" (name-of obj))
      (update where 'items (cons obj (value where 'items)))
      (update player 'items (remove obj (value player 'items))))
     (t
      (format t "You're not holding any ~a.~%" (name-of obj))))))

Specific items

There are three items in this game: key, matches, and candle. Each item has a description slot, which is the text that is printed to describe the item:

(defvar key (object 'item '(description "a rusty key")))

(defvar matches (object 'item '(description "a box of matches")))

In addition, the candle has a light-candle method that gets called with the light command, and a lit flag that stores the state of the candle: 

(defvar candle 
  (object 'item '(description "a candle" light #'light-candle drop #'drop-candle lit nil)))

Here's the light-candle method:

(defun light-candle (obj)
  (let* ((holding (value player 'items))
         (got-matches (member 'matches holding))
         (got-candle (member obj holding)))
    (cond
     ((and got-candle (value obj 'lit))
      (format t "It's already alight.~%"))
     ((and got-matches got-candle)
      (format t "You light the candle with a match, and it burns brightly.~%")
      (update obj 'lit t))
     (got-candle
      (format t "What with?~%"))
     (t
      (format t "You're not holding a candle.~%")))))

It checks to make sure that you're holding the candle and matches, and then sets the candle's lit slot to t.

There's also a drop-candle method to stop you dropping a burning candle!

(defun drop-candle (obj)
  (cond
   ((value obj 'lit)
    (format t "It's not a good idea to drop a burning candle.~%"))
   (t
    (drop-item obj))))

Places and exits

The locations in the game are defined by the place class. Places can be rooms, such as "bedroom", or more general locations such as "field", "crossroads", etc.

The locations are interconnected by exits, defined using the exit class.

(defparameter place (object 'world))

(defparameter exit (object 'place '(locked nil move-text "go through the door")))

By default all exits are not locked, and when you go through an exit the default text is you "go through the door". This will be specialised for exits where this is not appropriate.

Places

At this point it's a good idea to draw a map of your game, showing how the locations and exits interconnect. Here's the map for this game:

AdventureMap.gif

First we have a hall, which has four exits:

(defvar hall
  (object 'place '(description "in a large banqueting hall" 
                   exits (door hall-east hall-west hall-south) items nil)))

The description text is designed to follow the introduction "You are …". Each place must also have an items slot to hold any items the player drops there.

Exits

The exits slot lists the exits. First door, which has a specific name so it can be referred to in the command unlock door:

(defvar door
  (object 'exit '(direction north description "an oak door" leads-to maze1 locked t
                  unlock #'unlock-door move-text "go through the oak door")))

For each exit the slot leads-to specifies the place that the exit leads to.

Here's the definition of unlock-door. It checks that you're holding the key:

(defun unlock-door (what)
  (let ((locked (value what 'locked))
        (got-key (member 'key (value player 'items))))
    (cond
     ((and locked got-key)
      (format t "You unlock the door with the rusty key.~%")
      (update what 'locked nil))
     (locked
      (format t "You haven't got anything to unlock it with.~%"))
     (t (format t "It's not locked.~%")))))

The other three exits from the hall have arbitrary names as they don't need to be referred to in commands:

(defvar hall-east
  (object 'exit '(direction east description "a small arch" leads-to garden)))

(defvar hall-west
  (object 'exit '(direction west description "a staircase"
                  leads-to study move-text "go up the staircase")))

(defvar hall-south
  (object 'exit '(direction south description "a large metal panel" leads-to dungeon  
                  move-text "you climb through the metal panel which swings shut behind you")))

Study, Garden, Dungeon, and Beach

Here are the definitions of four further places, and their exits:

(defvar study
  (object 'place
          '(description "in a small study" exits (study-west) items (candle matches))))

(defvar study-west
  (object 'exit '(direction west description "a staircase"
                  leads-to hall move-text "go down the staircase")))
(defvar garden
  (object 'place '(description "in a small walled garden full of beautiful flowers"
                   items (key) exits (garden-west))))

(defvar garden-west
  (object 'exit '(direction west description "a small arched door" leads-to hall)))
(defvar dungeon
  (object 'place '(description "in a dark dungeon with no windows.
                   The name 'Wes' is scratched on the wall; you wonder what his fate was" 
                   exits (dungeon-north) dark t items nil)))

(defvar dungeon-north
  (object 'exit '(direction north description "a metal panel" leads-to hall)))
(defvar beach
  (object 'place '(description "on a beautiful sandy beach next to an azure-blue sea.
                   A boat is moored nearby. Well done! You've escaped from Castle Gloom"
                   items nil)))

(defvar beach-north
  (object 'exit '(direction north description "a cave entrance" leads-to maze3)))

The maze

No traditional text adventure would be complete without some sort of maze. You could often solve the maze by dropping items to help you map it out, or there might be a clue to its solution elsewhere in the adventure.

In this adventure the maze consists of three interconnected places, and the descriptions of the places and exits are all the same. To avoid having to repeat these for each place and exit they are all based on the same maze and maze-exit classes:

(defvar maze
  (object 'place '(description "in a maze of twisty little passages, all alike")))

(defvar maze-exit
  (object 'exit '(description "a passage" move-text "go along the passage")))

Here are the actual definitions of the three maze places, maze1, maze2, and maze3,  and the exits between them:

(defvar maze1
  (object 'maze '(exits (maze1-north maze1-south maze1-east maze1-west) items nil)))

(defvar maze1-north (object 'maze-exit '(direction north leads-to maze1)))
(defvar maze1-south (object 'maze-exit '(direction south leads-to hall)))
(defvar maze1-east (object 'maze-exit '(direction east leads-to maze1)))
(defvar maze1-west (object 'maze-exit '(direction west leads-to maze2)))

(defvar maze2
  (object 'maze '(exits (maze2-north maze2-south maze2-east maze2-west) items nil)))

(defvar maze2-north (object 'maze-exit '(direction north leads-to maze1)))
(defvar maze2-south (object 'maze-exit '(direction south leads-to maze2)))
(defvar maze2-east (object 'maze-exit '(direction east leads-to maze3)))
(defvar maze2-west (object 'maze-exit '(direction west leads-to maze2)))

(defvar maze3
  (object 'maze '(exits (maze3-north maze3-south maze3-east maze3-west) items nil)))

(defvar maze3-north (object 'maze-exit '(direction north leads-to maze3)))
(defvar maze3-south
  (object 'maze-exit '(direction south leads-to beach
                       move-text "go along the passage and through a cave")))
(defvar maze3-east (object 'maze-exit '(direction east leads-to maze2)))
(defvar maze3-west (object 'maze-exit '(direction west leads-to maze3)))

Generic methods

Next we define some generic methods, or functions, that aren't linked to specific objects.

Here are the generic functions that implement the take, drop, light, and unlock commands:

(defun take (obj) (funcall (eval (value obj 'take)) obj))
(defun drop (obj) (funcall (eval (value obj 'drop)) obj))
(defun light (obj) (funcall (eval (value obj 'light)) obj))
(defun unlock (obj) (funcall (eval (value obj 'unlock)) obj))

These call the method provided by a specific object; for example:

> (unlock 'door)
You unlock the door with the rusty key.

If the specified object doesn't have an appropriate method, the default method provided by world is called:

 > (unlock 'key)
You can't unlock that.

Go

The go command is implemented by the function walk. The function go isn't available as it's a reserved word in Common Lisp, so the main adventure program translates the command go to a call to walk.

Here's the definition of walk:

(defun walk (obj)
  (let* ((where (value player 'location))
         (way (dolist (x (value where 'exits)) 
                (when (eq obj (value x 'direction)) (return x)))))
    (cond
     ((value way 'locked)
      (format t "You can't - the door seems to be locked.~%"))
     (way
      (let ((to (value way 'leads-to)))
        (format t "You ~a.~%" (value way 'move-text))
        (update player 'location to)
        (look)
        (update player 'last-location where)))
     ((eq obj 'back)
      (let ((to (value player 'last-location)))
        (format t "You go back.~%")
        (update player 'location to)
        (update player 'last-location where)
        (look)))
     (t (format t "There is no exit ~a.~%" (name-of obj))))))

It checks that there's an exit in the specified direction, and that it's unlocked. It then moves the player to the location specified by the exit's leads-to slot, and updates last-location to the previous location.

Look

The look command checks that you're not in the dark, and then describes the current place, exits, and items:

(defun look ()
  (let ((where (value player 'location)))
    (cond
     ((and (value where 'dark) 
           (not (and (member 'candle (value player 'items)) (value 'candle 'lit))))
      (format t "It's totally dark and you can't see a thing.~%"))
     (t
      (format t "You are ~a.~%" (value where 'description))
      (mapc #'(lambda (exit)
                (format t "To the ~a is ~a.~%" 
                        (name-of (value exit 'direction)) (value exit 'description))) 
            (value where 'exits))
      (mapc #'(lambda (item) (format t "There is ~a here.~%" (value item 'description))) 
            (value where 'items))))))

Inventory

The inventory command lists the items you are holding:

(defun inventory ()
  (let ((items (value player 'items)))
    (cond
     (items
      (format t "You are holding: ~{~a~^, ~}.~%"
              (mapcar #'(lambda (item) (value item 'description)) items)))
     (t (format t "You are not holding anything.~%")))))

Adventure function

Finally an adventure function provides a friendlier interface to the adventure game. To run the game evaluate:

(adventure)

It prints an introduction to the game, and then loops, reading the player's input, and printing the response, like Lisp's REPL. It avoids the need to quote parameters, and also provides some error checking:

(defun adventure ()
  (unless (value world 'playing)
    (format t "Escape from Castle Gloom.~%")
    (format t "You slowly come to your senses, and then remember what happened.~%")
    (format t "You were kidnapped and brought to this castle by an evil ogre, ")
    (format t "who is asleep on a chair nearby, snoring loudly.~%")
    (update world 'playing t))
  (look)
  (loop
   (format t ": ")
   (let* ((line (read-line))
          (verb (read-from-string line))
          (sp (dotimes (c (length line)) (when (eq (char line c) #\space) (return c)))))
     (terpri)
     (case verb
       ; Single-word commands
       ((look inventory) (funcall (eval verb)))
       ((~ quit) (return))
       ; Two-word commands
       ((go take drop light unlock)
        (when sp
          (let ((noun (read-from-string (subseq line (1+ sp)))))
          (cond
           ((eq verb 'go) (funcall #'walk noun))
           ((not (boundp noun))
            (format t "I don't understand.~%"))
           (t (funcall (eval verb) noun))))))
       (t (format t "~%I don't know how to do that.~%"))))))

The slot playing in world is set to t if you have started playing the adventure. This suppresses the introductory text if you quit, and then run adventure again to carry on where you left off.

I hope you enjoy playing this mini adventure, and that it inspires you to write something more substantial.

Common Lisp version

The adventure game can also be run it under a full version of Common Lisp with a few minor changes; I've tested it on LispWorks. Here's the Common Lisp version: Text adventure program - Common Lisp.

Further suggestions

Here are some enhancements you could add to make this game even better!

Synonyms

You could add synonyms for commands to save the player typing; for example "go n" for "go north", "inv" for "inventory", etc.

Brief descriptions

The game will seem much more intelligent if, when the player returns to a location, the description gives a brief description, such as:

You are in the hall.

rather than the full description, such as:

You are in a large banqueting hall.

This is simple to implement by giving each place object a visited slot that records whether the player has already visited that place. This would be updated in the walk function.

Arbitrary global variables

In this simple adventure game objects are defined by global variables whose name, such as door, is the actual name of the object in the game. This isn't ideal for two reasons: you have to be careful not to use a parameter or local variable of the same name in a function, as it will mask the global variable. Also, it prevents you from having two objects with the same name, such as a door in two different rooms.

The solution is to make the global variable names arbitrary, and distinctive, such as *hall-door*, and add a name slot to each object which defines the object's actual name in the game. When looking for an object you would need to search through the objects to find the one with the matching name, in the same way that walk finds an exit with a particular direction.


  1. ^ Colossal Cave Adventure on Wikipedia.
  2. ^ Zork on Wikipedia.