"You open your eyes and find yourself in dimly lit surroundings. The air is damp and cool. The smell of mildew reminds you of an old basement. In your head, you label this location 'room 👀👃'.
Nearby, you see a small potion.
You can head east."
- Dungeon Memalign
I hinted at a new dungeon game in my post about Pac-Man
. Well this game is now complete!
This game, "Dungeon Memalign," puts the player in a maze and a daze. The player solves puzzles and battles their way through ever-stronger monsters. Are you clever and strong enough to win? Play here!
The game is beatable in ~15 minutes and should work well on phones, tablets, and computers. It'll save your progress if you get interrupted.
In the rest of this post, I'm going to talk about how I wrote this game, the structure of the source code, and ideas for other games that can be built on the same foundations.
How I approached writing this game:
- I talked about my long-time desire to built my own text-based game in a previous post
- I brainstormed about potential stories and even came up with a fairly rich idea
- I created a local git repository
and I used a notes.txt
engineering journal to capture my thoughts and pending task list
- I worked on the foundations: the game engine, the map, and how to structure game-specific logic. More details on this below.
- As I started scoping out the story-specific work, I realized that writing such a sophisticated story and supporting the game mechanics would take more time than I have for a side project. I didn't want this project to drag on for more than a handful of months. If it took longer, I'd have a bigger and more interesting game but I might get bored of it and never finish. Or it would prevent me from developing other ideas I haven't thought of yet.
- I found a way to scope the story down so I could keep it interesting, fun, and achieve my goal of writing a text-based game in a timeframe I was willing to devote to it.
- With the scoped-down story, I worked on designing the game map next
- I used a pixel art app to draw a grid and then I filled in a maze-like set of inter-connected rooms, light puzzles, and enemies of increasing difficulty. At this phase of design, I knew I wanted to have battles but I wasn't sure how they would work or what rewards to give to celebrate victory. Here's the map I drew
(don't look until you've played the game!).
- Next, I worked on implementing the map itself. I wrote the code to name each room, add items to the rooms, describe each room's contents, and hook the rooms up to each other. Some of this work was tedious/mechanical which turned out to be an easy way to unwind in the evening and build momentum. I could even do some of this work from a mobile device while using an exercise bike (e.g. naming all of the rooms).
- Next, I implemented the game mechanics one-by-one:
- Locked doors and keys to unlock them
- Simple battle mechanics where the user one-hit killed every enemy
- Gear: armor to reduce damage during battle, weapons
- Complex battle mechanics with attacks, variable damage, and enemies fighting back
- Rewards for battles
- Healing items and a healing room
- At every step of the way, the game was playable and it got iteratively richer. I like to work my way up from a simpler system to a more complex system iteratively.
- I'm reminded of Gall's Law
: "A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works and cannot be patched up to make it work. You have to start over with a working simple system."
- The whole time, I kept a detailed task list in my notes.txt file: enemies I needed to flesh out, details I skipped over, mechanics I still needed to implement, and test coverage I needed to write
- I played through the game a few times, tweaking rewards. I added a weapon upgrade and added more explanation of game items and attacks as a reward.
- I added saving/loading/and starting over. I had a strategy in mind for how I would save game progress from the beginning of the project (because the tools available could change how I maintained game state). Knowing this would be played in a web browser meant I only had some tools available.
- Then I worked through the whole test backlog. Though I often wrote tests as I added new logic, I also accumulated a big list of missing coverage for edge cases and some aspects of gameplay. I love projects where most testing can be automated as unit tests and multi-layer "unit tests" (which are actually integration tests written using the unit test harness). More on testing below.
- I played it a bunch and my wife played it too! I fixed any bugs we found.
- And then the game was ready to share :)
How the code
- Most of the code in this file is game-specific. When writing a new game, most of this code would be deleted and replaced.
- Defines the enemies (MAEnemy instances)
- Defines the rooms (MALocation instances)
- Defines the graph of room-to-room connections
- Enemies and rooms have properties and methods that the game engine calls to get the appearance, attacks, etc
- Has logic to generate an emoji representation of the map
- Most of the code in this file is foundational. This should require only small tweaks when it's used in a new game.
- Many foundational classes are in this file. They could be factored out into their own files.
- Notable classes:
- MAGameState: an instance of this class stores all of the current game's state. This instance is passed into game engine methods and modified as the game progresses. This object has a reference to the Map instance, the current location, the user's inventory, and storage used by MAGameSegment subclasses.
- MAGameEngine: this class is the entry point to all of the game logic. It contains some common methods that most games would need (such as a method to calculate the possible actions the user can take given the current game state). There are only two places in this class that contain game-specific logic: constructor and setupNewGame. The constructor instantiates a hard-coded list of MAGameSegment subclasses (more on these below). The setupNewGame method simply has the name of the game hard-coded.
- MANoun, MAScenery: classes used to represent items in the game map. These include some methods to guess the right indefinite article and in-sentence representation of the objects.
- MADirection, MALocation: used to represent map locations and the connections between them
- Methods used in many different parts of the codebase
- One example: naturalLanguageStringForArray, accepts an array of MANoun objects and constructs a string like "a dog and a cat" or "a dog, a mouse, and some ants"
- Another useful method: fakeRandomInt. All of the "randomness" in the game is actually deterministic so I can replay every action taken since the beginning of the game and always get the same result. I use this trick for easy saving/loading of game progress. It also impacts unit testing.
- MAGameSegment is an abstract class that is subclassed to create game-specific GameSegments
- A GameSegment is a way to organize game logic, such as locked doors & keys to unlock them, using potions to heal, etc
- The GameEngine asks every GameSegment for actions the user can perform (e.g. "Use potion"). The GameSegment is then asked to perform the action that's chosen.
- GameSegments also get callbacks when other parts of the game will perform or did perform an action in case they have reason to block it or react to it. For example, a GameSegment may want to block "Go north" if the door is locked.
- Game mechanics and room-specific behaviors are implemented as GameSegments.
- The battle mechanics of the game are complex enough that I put this game segment in its own file
- This is a simple unit test harness and collection of tests of both foundational and game-specific pieces
- Run the unit tests by visiting unitTests.html
- Some of the tests are real "unit" tests
- A lot of the test coverage comes from multi-layer tests which are more accurately called "integration" tests. I chose to get a lot of test coverage this way because it was less effort for me to write these instead of granular unit tests for everything.
- These integration tests essentially play the game and assert that the game ended up in the right state along the way. See "test_MAGameEngine_beatGame" for a full play-through (and spoilers!).
I'll close this post with some ideas to extend or reuse these pieces:
- Create a game with a linear dungeon where enemies get stronger as you move deeper into it. If you die, you start over at the beginning but you get to keep your experience/attacks/equipment.
- Create an "Emojimon" text-based RPG to collect and battle emoji (similar to Pokémon)
- And one idea I executed on as soon as I had it: Pac-Man Dungeon