More complex puzzle objects in interactive fiction
Amidst all the technical discussions I’ve posted, covering various implementation details of the internals for my text adventure project, over Christmas, I realized that I’ve never really talked about actual puzzles. Now, I do not claim to be an expert on any text adventure puzzle or puzzles in general, but I think, it might be helpful if I spent some time talking about how to implement an actual complex object in my text adventure engine. Such complex objects are essentially the groundwork for good puzzles in text adventures, so understanding and handling them properly is definitely important.
I decided to illustrate the process using a lantern that I have in the game and its specific purpose to unlock a hidden door. Naturally, you may have other objects and situations in your game, but it doesn’t really matter. I want to show you how to add and manage the increased overall complexity that comes with complex objects.
For clarity, I have left out or snipped away parts of the code, because I think it is more important to show the logic for a change, instead of the full implementation down to the last instruction.
In my case, the lantern is used in a puzzle to unlock a secret door, so let me explain, real quick, the steps the player will have to go through in order to make it work.
When the player first finds the lantern it is empty. The player needs to find lamp oil first to refill the lantern before it can be lit.
The player will also need to find matches before he can actually light the lantern.
Once the lantern is lit, it actually illuminates a dark part of a certain room that has previously been indiscernible. That newly revealed area hides the secret door. The inscription on the door gives the player a hint how it might be opened.
To unlock the secret door, which is the key part of the puzzle, the player needs to find a special amulet first. Once he has obtained it, the player will have to dangle it before the lantern so that it creates a specific color spectrum, which in turn unlocks the door.
If you list the steps and lantern logic like this, it seems very simple and straight-forward, but you will soon realize that there are a great many steps and checks necessary to make this work properly. Why? One of the hallmarks of a good text adventure, for me, is its responsiveness to various commands and situations. Unless you want to reply to any ineffective command with a standard “That doesn’t work” response, adding responsiveness that adapts to the game situation takes a lot of forethought and planning, and typically a good amount of code.
So, let’s get to it.
After writing a lot of stringy code that became unwieldy, I soon decided that it would be best to put different functionality in different functions—now I am glad because it also helps me better explain things in proper, logical chunks.
Let’s start with the filling of the lamp, the first step to solving the puzzle. As we established previously, the lamp needs to be filled with oil before it can be lit. Therefore, we need a function to handle that action as well.
def FillIt ( self ): if globals.theInventory.Has ( Tokens.Oil ): self.Full = True globals.theInventory.Remove ( Tokens.Oil ) print ( "You fill the oil in the lantern." ) else: print ( "You have nothing to fill the lantern with." ) return True
It is actually very straightforward, especially because there is only one bottle of oil in the game, which means, the action can be performed only once. If that weren’t the case, the function would also need to include additional checks to see if it has been filled already and create a response to that.
Next, we take a look at lighting the lamp. A variety of things can happen here. The lantern can still be empty, it can be full, you may not have matches to light it with or it may be lit already… all things the game needs to consider and respond accordingly.
Below is the code to handle all these different states and responses.
def LightIt ( self ): if False == self.Burning: if globals.theInventory.Has ( Tokens.Matches ) or globals.theInventory.Has ( Tokens.Match ): if True == self.Full: print ( "You touch the match to the wick and within seconds a soft glow emanates from the lantern." ) self.Burning = True else: print ( "You try to light the lantern but the flame doesn't catch. It appears to be out of oil." ) elif globals.theInventory.Has ( Tokens.Matches ): if True == self.Full: print ( "You light the match and within seconds a soft glow emanates from the lantern." ) self.Burning = True else: print ( "You try to light the lantern but the flame from your match doesn't catch. It appears to be out of oil." ) else: print ( "You have nothing to light the lantern with." ) else: print ( "Seriously? The lantern is alight already." ) return True
As you can see from the code, I made sure to keep things automatic so that a player command like “Light the lamp” will automatically check for the required match even though it is not explicitly mentioned in the command. Otherwise, the player would be forced to type something like “Light the lamp with the match,” which seems very unwieldy to me.
Having said that, however, you may have noticed that the code checks to see if there is a secondary noun in the command and if it is actually in the player’s possession also. The reason I am doing this is to make sure commands like “Light the lamb with the key” are rejected, even if the player has the matches in his inventory. The player’s command will ALWAYS supersede inherent assumptions and it is important to program and catch these kinds of specifics correctly. It is one of the reasons why it is very easy to lose your thread while programming text adventures. You constantly have to predict all the different kinds of entries the player might try to solve your in-game problems.
What else?
Naturally, we want to be able to extinguish the lantern as well. Whenever I have a function to do one particular thing, I try to think of the opposite action and implement it as well. Open/Close, Sit/Stand and so on. I do this regardless of whether it is actually used or needed in the game, just to give the game more depth and create a more realistic illusion of reality.
def ExtinguishIt ( self ): if True == self.Burning: if not globals.theRoom.HasLightSource(): print ( "You extinguish the lantern and instantly, darkness reclaims its domain." ) globals.theRoom.Light = False else: print ( "You extinguish the lantern." ) self.Burning = False else: print ( "The lantern is already out. ") return True
One of the things I am doing in this snippet is to check if a room has an alternate light source by calling HasLightSource()
, a special method that I have set up in my Room
class. If the player is in a sunlit room, turning off the lantern won’t make much difference, but if the player is in an otherwise dark basement room, everything around him will go dark, as soon as the lantern is extinguished.
Now, let’s go back to filling the lantern with oil for a moment, which was part of the overall puzzle. Earlier, I described a lantern function to fill it. This function will be called when the player enters a command such as “Fill the lantern.”
We cannot always predict how the player will make his wishes known and instead of “Fill the lantern,” the player may decide to type “Use the oil on the lantern” instead. Oooh… yeah, problem, right there, because now the Oil is the subject of the sentence and its Evaluate()
function will be triggered instead of that of the lantern.
Not a big deal, we simply catch it write the appropriate command in the Oil object and then sort of forward the action to the lantern object.
if Tokens.Use == globals.theVerb: if Tokens.Lantern == globals.theNoun2: _lantern = globals.theInventory.Has( Tokens.Lantern ) if _lantern: return _lantern.FillIt ( ) else: print ( "You don't have a lantern." ) else: print ( "You don't really know, what to do with the oil." ) return True
You can see here that a command such as “Use the oil on the window” will not do much. However, if you want to add some silly stuff here, you could extoll countless different uses for oil in the else
branch, like this…
else: if Tokens.Window == globals.theNoun2: print ( "You smear the oil all over the window pane, until everything seen through it looks entirely distorted." ) elif Tokens.Key== globals.theNoun2: print ( "I am sure the key will work much better now." ) else: print ( "You don't really know, what to do with the oil." )
The possibilities are endless and limited only by your imagination, of course, so go wild!
The last piece of the puzzle is the amulet. To give the player a hint of its use, the item description of the amulet will be slightly modified if the player is in the special basement room with the secret door.
if Tokens.Look == globals.theVerb and not globals.thePrep: if globals.RoomIDs.Basement == globals.theRoomID: self.Describe() print ( "In the light of the lantern the amulet seems to almost glow, as if there was some kind of luminescence trying to break forth from the green jewel." ) return True
Now, the big step is, of course, when the amulet is held over the lantern.
if Tokens.Hold == globals.theVerb and Tokens.Before == globals.thePrep: if Tokens.Lantern == globals.theNoun2 or Tokens.Lamp == globals.theNoun2 or Tokens.Light == globals.theNoun2: _lantern = globals.theInventory.Has ( Tokens.Lantern ) if not _lantern: _lantern = globals.theRoom.LocateItem ( Tokens.Lantern ) if _lantern and _lantern.Burning: print ( "The secret door is opening…" ) globals.theRoom.Exits.append ( Exit ( Tokens.East, RoomIDs.Bedroom ) ) # Add a new exit globals.portalOpen = True globals.portalOpenCount = 0 else: print ( "Your expectations are sorely disappointed when absolutely nothing happens." ) return True
As you can see, the logic in the command structure is a bit more complex than anything we’ve discussed before. Just go through it one test at a time and you’ll soon figure out how it works. It simply checks for the existence of the command “Hold amulet before lantern” and a number of its permutations. Remember, that Tokens.Before catches both, the syntax, “before” as well as “in front of” because my parser logic has handled that before the execution ever reached the amulet’s Evaluate()
function.
From there, it’s really very straightforward—text output to describe what happens, then adding a new exit to the room and setting the respective flags that indicate the opening of the portal, including a counter to indicate how long it has been open, because in my game, the portal will close after a certain number of moves.
So, I think this is enough of the hard stuff for now. Plenty for you to go through and think through, and perhaps to try and build your own puzzle object that has a certain amount of complexity. But go slow, because these things tend to get very convoluted very quickly and it is sometimes hard to keep track of all the potential possibilities.