Writing a Text Adventure Game in Elixir: Part 1
I’m not much of a gamer. My parents were more of the old-school book-reading types, so we didn’t even own a television for many years, let alone a gaming console. But we did have a computer that ran Microsoft DOS 2.1, and that was enough to fire up a couple interactive text adventure games including Zork and Adventure, the Colossal Cave. I loved those games. Less is more: my imagination was more vivid than any GPU graphics.
I thought it might be fun to create one of these games using Elixir, partly out of nostalgia, but also because it offers a useful premise for discussing how to handle state in an application.
Ingredients for a Text Adventure
- A way to handle game state (so a player knows their location, inventory, score, etc.).
- A way to easily create and manage the descriptions of the locations in the game.
- A way to handle text input/output.
- a weird, non-descriptive name that attempts to lure players in with its nerdlike mystique. Xventure? Perfect.
Ultimately, we’re going for something that resembles this:
I reached for YAML as an easy way to store descriptions of the various locations in the game and yaml_elixir as the Elixir package to decode them. I ate my own dog food and used packages I authored to help make this work: cowrie to deal with CLI input and output and figlet to generate ASCII text. But I don’t want to waste everyone’s time with the trivial bike-shedding details. What I really wanted to explore was how to manage state.
Maybe you’ve heard somewhere that processes are the only way to maintain state in Elixir. This might technically be true, but it is inaccurate, or at least, overly simplified. In the back of my mind I thought I’d be reaching for GenServer to manage the game’s state. But as I put together this proof-of-concept, I realized that I had no need for GenServers or separate processes at all. I could manage simple state by simply passing it through my game loop as an argument. It literally boiled down to something as simple as this:
defp game_loop(command, state) do
# handle the command, maybe modify the state, then...
command_prompt()
|> game_loop(state)
end
Handle the command (e.g. pick up an item, go to a new location, etc.), prompt the player for their next command, repeat. The state can be handled and passed to the next iteration of the loop, over and over again. It looked a lot like an accumulator in an Enum.reduce/3
function.
This is the point of interest that we’ll be coming back to in the next article in this series. This simple text adventure turned out to provide me with a glimpse of the dangers/benefits of the “Single Global Process” pattern, an advanced topic that barely seems relevant to such a simple game, but even in the smallest things there can be large lessons.
For any of you who want more explanation on how the game works, following is a brief rundown.
How the Game Works
The game “world” is defined by .yaml
files that are stashed inside the priv/locs
directory. At this point, each location only needs a description and a way to define its exits. I came up with something like the following:
%YAML 1.2
---
content: >
You are standing in a grassy clearing on the crest of a gentle
hill. There is a gravel path leading to a red barn to the south.exits:
south: barn
For the uninitiated, going south in this example would take you to the barn (i.e. to another location). So the expectation is that there’d be a barn.yaml
file inside the priv/locs/
directory. As long as you don’t misspell the locations and don’t accidentally orphan a location by not connecting it, this setup should make it easy to add as many locations as you desire and easily link between them.
It’s easy enough to load and decode these files with a model-like helper module:
defmodule Xventure.Location do
def load!(name) do
YamlElixir.read_from_file!("priv/locs/#{name}.yaml")
end
end
One caveat here to keep in mind is that when you decode YAML into maps, the keys will be strings by default, so you’ll have to use the appropriate syntax to get at the data.
Other than that, perhaps the only other thing worth mentioning is that when you read text input from the user using Cowrie
(or some other tool that gets its data from Mix.Shell.IO.prompt/1
) it will always include the newline character (\n
), so we’ll want to trim that off, and it will be easier for us if we normalize all commands by making them lowercase via String.downcase/2
.
Put this all together and you can move around your newly created world relying on a function clause like the following:
defp game_loop("go " <> dest, %State{loc: loc} = state) do
case loc_data(loc) do
%{"exits" => %{^dest => new_loc}} ->
game_loop("look", Map.put(state, :loc, new_loc)) _ ->
Cowrie.error("You can't go #{dest} from here.")
game_loop("look", state)
end
end
Here we see that we can update the state
using the humble Map.put/3
; going to a new destination has the effect of updating the user’s location and executing the look
command.
Thinking Ahead
With game-play structured this way, a single instance of the application can support multiple games. Each game is a single-player game with no data shared between them, so starting a game is essentially no different than calling a “Hello World” function.
Although there are plenty of areas requiring more polish and it is fun to think about item inventory, monsters, or conditional behavior in rooms, at the end of the day, the fundamental architecture of the app and how it manages its state wouldn’t need to change with any of these additions. For now, I’ll leave those implementations to interested parties and hope that my simple proof-of-concept offers a useful jumping-off place.
The thought experiment that will drive a serious refactor revolves around how to support multiple players within a single game. Stay tuned…
The code for this article is available here.