Building an adventure game: part 4

Spread the love

Welcome to the fourth and final installment of my adventure game saga. We made it up to Day 15 last time. Let’s finish this up now.

Day 16

I don’t like that the method to add an item to a room is called Drop. So let’s fix that. This will allow the later implementation of an actual Drop command, to give the user the ability to leave behind items currently in the inventory. Now Drop can be implemented, which makes use of a new InventoryDropMessage. Again, we should give the item a chance to weigh in whether Drop is allowed, so we create a DropCore to be overridden for custom behavior. Now we just need to support “drop” in the sample game and make sure “drop coin” resets the item’s status. One more finishing touch to print a useful message for “drop” with no noun (with corresponding coverage in the sample game walkthrough) and we are done for the day.

Day 17

Today I notice there is a bug in how “look” is handled. If you pick up an item and it goes into your inventory, you can no longer look at it! Again I’ve made the mistake of using too much imperative code in the Room.Look handler. I should instead use the ever-faithful MessageBus to send a LookItemMessage when the “look” verb is detected. Now I can respond to this message in Inventory. With this and a few more small code updates, “look coin” now works in the sample game.

We have amassed quite a collection of game messages, and it’s time to evaluate them for consistency. I like the pattern of LookItemMessage, so I go ahead and change InventoryDropMessage to DropItemMessage, InventoryAddedMessage to TakeItemMessage, and InventoryRequestedMessage to ShowInventoryMessage. While we’re at it, let’s also create a new namespace for all the messages so that they can be grouped together. I hold off on moving OutputMessage until I add an Item.Output convenience method — I hate it when you have to import a whole namespace just for one class, so this “façade” will be a nice (albeit, small) improvement for the user experience.

Next on the agenda is some overdue cleanup of Item. Up to this point, I got away with having a simple constructor for Item because the MessageBus instance was always passed to each method. But that’s the problem — passing the same instance to every method means we should just bite the bullet and make an instance field and a new constructor for Item that requires MessageBus. Now we can stop accepting the MessageBus parameter in Take, Drop, and Do. Last (and probably least), I make one small change to add an Output extension method to MessageBus; I rather like this micro-improvement/simplification to the interface.

Day 18

Several days ago I added the concept of the RoomMap and connections between Room objects but did not implement any ability to actually go anywhere. This changes today! First, a simple warmup change to ensure map points can connect back to themselves (essentially allowing cycles). There’s no reason why this shouldn’t work, and many adventure games explicitly use this mechanic (notably the King’s Quest series with their magical law of containment). Now let’s implement the Go method in Room. I already implemented the GoMessage well in advance, so now I can just use it when processing Go for a specific direction of travel.

With the groundwork laid for movement, I can implement a secondary room in the sample game, which goes by the autonym AuxiliaryRoom. To avoid inevitable duplication, I extract the core logic from MainRoom and push it up to RoomBase. Now all current and future rooms can behave the same way for the standard set of commands (“go”, “look”, etc.). To end the day, I handle a few more error cases for “go.”

Day 19

The framework for the game is nearly complete, so today I spend time smoothing over a few rough patches. You can’t tell from the commits (which by their nature demonstrate only working functionality) but I kept running into problems when adding new verbs and nouns to the sample game. Invariably I would forget to register the word in the WordTable. To solve this problem once and for all, I change Noun to an instance class, do the same for Verb and then implement a reflection-based registration method for Noun followed by Verb as well. Now it is impossible to miss the registration step of a new noun or verb. I make one simple change before ending the day — adding a hint to the “look table” command. It probably isn’t obvious to the user that moving the table is possible, so I want to help them out a bit here.

Day 20

Can you believe that we’re this far into the development of this project and there is still no way for the player to die? We will have to fix that. It seems logical to leverage the existing QuitHandler to allow for ending the game, but we can’t use it as is. Thus I start with a new EndOfGame class. Unlike the (probably flawed) design of QuitHandler which attempts to parse the “quit” verb directly, EndOfGame uses a message-based protocol (EndOfGameMessage) to ultimately signal cancellation of the inner game loop. It also accepts a string of text to display when ending, which could be useful for giving the user a helpful description of how they met their doom. To prepare for the replacement of QuitHandler, we need to add an End method to Room. Now we can delete QuitHandler and use EndOfGame in the sample game instead. To show off this new feature, we add a death scene to the game; it simply involves walking eastward off a cliff in AuxiliaryRoom. Since this is a terminal condition and a different way to end the game than merely quitting, we need a new WalkthroughDieTest to pair with our (now renamed) WalkthroughQuitTest.

Looking at some of the other code, I don’t like how the Coin item has to keep track by itself of whether it has been taken or not. So let’s implement a Taken flag on the Item directly. Several TDD cycles later, we can now remove the custom code from Coin and rely on the framework. I’m also a bit annoyed by the need to pass the parent Room to the Table item, so I get to work on a RoomActionMessage protocol to handle sending commands to whatever room the player is in. With the addition of a SendRoom message on Item, I can now remove the Room parameter from the Table constructor. Loose coupling FTW!

I now identify one last feature before declaring “code complete”: the ability to remove an item. Most adventure games have actions that result in using up an item (e.g. making potions in King’s Quest III). So what we want is a way to delete an item from the Inventory or a Room. Let’s start with Room. There is already a Take method on Items that does what we want so I just rename it to Remove to better pair with Add. To expose this functionality I now have to add a public Remove method to Room, which just calls the corresponding method on Items. I plan to use this feature in the sample game, so let me set this up now by adding a hole in the wall to the AuxiliaryRoom. Now if the player looks at the wall, the hint “INSERT COIN” is given.

For completeness I follow up with Inventory.Remove; again, it is basically a pass-through to the underlying Items method. As I did before with Room I also add an InventoryActionMessage to send generic commands to the Inventory instance. At this point, I note that the -ActionMessage classes are functionally identical but for their parameter type, so I use generics to resolve the duplication, creating an ActionMessage<T>.

As a final step, I add handling of the “insert coin” action to the sample game. As you might expect, the user can insert the coin into the hole if and only if the Inventory contains the item. Once the action is processed, the Coin object is removed, never to be seen again. (In a “real” game, this action might move the plot along, e.g. by causing a special object to appear in a distant room which is necessary to solve the quest.)


Epilogue

Well, that is it! Twenty spare-time days of development has led to a usable, if barebones, text adventure game engine. It employs what we might consider the original object-oriented principles formulated by Alan Kay which place most of the emphasis on messaging. Indeed, almost every time I went down the imperative/procedural route, some time later I was forced to backtrack and create a message/subscriber protocol instead.

Probably the biggest takeaway for me was how message passing helped immensely in avoiding the typical god/context object approach you see in many other frameworks. Since you can never be sure how a user-defined item will interact with its environment, you might be compelled to pass around anything you might ever need to every call site. I largely avoided this by making one trade-off — the use of the single MessageBus instance in most of the core classes (Item, Room, etc.). It is not a purely context-independent approach by any means (is such a thing truly possible in any nontrivial system?), but it minimizes the explicit knowledge needed between collaborators and converges most interactions toward one core reusable abstraction.

Thanks for joining me on this journey. See you next year!

Leave a Reply

Your email address will not be published. Required fields are marked *