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!