In part 1 of this series, we achieved a simple proof-of-concept for a single-player text adventure game. In part 2, we added support for multiple players, but things got considerably more difficult and foreshadowed headaches with code organization. In this article, we are going to examine more closely the nascent problems from our previous attempts, and I will share with you the code and lessons learned from crafting a terminal UI that can support real-time interactions such as chatting with other players in the game. Onward!

“Where buy chicken?” “Avocoda”

Brief Moments of Time

Before we go any further, let’s take a moment to talk about time in a text adventure game. Most games don’t play out in real time, they unfold in game time. In a game, what exactly marks the passage of time? In a single-player text adventure game, moves are the ticks that drive the clock: e.g. your wound will heal in 10 moves, or 3 moves until the bomb explodes.

One of the things I realized while writing the second part of this series is that the notion of “time = moves” falls apart as soon as you make allowances for multiple players. The game favors the fastest typists. A fast typer could drop a stink bomb in your lap then move move move! BOOM, it would explode before you could hunt-and-peck your way to a gas mask. Short of godlike enforcing of turns, however, a game cannot normalize the passage of time for all players.

This might seem like an intellectual tangent, but I soon realized that this discrepancy in “time-telling” had repercussions even more fundamental. (Should I have been pondering this from a Swiss patent office? Will anyone understand this parenthetical reference?)

When your UI is a ticking time bomb

Up until now, my game input had relied on Mix.Shell.IO.prompt/1 , the documentation for which consists of only two sentences, one of which was “Input will be consumed until Enter is pressed.” That humble sentence didn’t seem to foretell the complete rewriting of my codebase, but it did: I had to rethink my entire approach.

If you recall from part 2, when another player was at the same location, you could “see” them when you arrived in a location (or when you ran the look command):

iex(world1@localhost)1> Xventure.new(:louise)
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.
Other people at this location: thelma
Exits: south
>

The problem here is that another player could arrive at or depart from your location and you would never know it. The text on your screen would not update to announce the change until you entered a command. Why? Because “Input will be consumed until Enter is pressed.” If time = moves, then no time has passed for you because you haven’t moved. You see? Time is relative. You weren’t expecting a text adventure game to illuminate Einstein’s theory of Special Relativity, were you? Booyah! Mix.Shell.IO.prompt/1 strikes again!

Ultimately, I was Ok with the fact that some players are going to type faster than others, but what I couldn’t tolerate was a UI that would never reveal the presence of another player unless you happened to look. Oiy — are we delving into Heisenberg’s “observability principle” now? My familiarity with scientific breakthroughs of the twentieth century is too flimsy to know, but what became clear was that I needed a UI that could update on specific events and not just when the enter key was pressed.

It turns out that a “real-time” command-line UI is exactly what is needed to support this and a handful of other features that I had pondered. So I set out to build another proof-of-concept. I chose to implement a chat feature because it is so familiar and has less baggage than some of the other features I was considering.

Raw Input Mode

What makes this article worth reading is the knowledge of how to transition from the predictable world of Mix.Shell.IO.prompt/1 and into the quantum singularity of the terminal’s “raw input mode”. Honestly, this was a bit of a struggle and it took some time for me to figure it out.

Player “bob” on the left, receives a message sent to him from the terminal window on the right

I have to give a big shouts out to Nick Reynolds (ndreynolds on Github) for his ex_termbox package, which provides bindings to interact with the terminal using raw input mode. Examples are required for any good documentation, and Nick’s ex_termbox package has lots of them. The amount of time this saves is absolutely huge. Nick’s package is thoughtful, thorough, well-maintained, and is full of useful examples so people like me can use it to build something. Stellar.

I inspected the hello_world.exs example to get a feel for how raw input mode worked, and then I discovered one of Nick’s other packages, ratatouille. It builds on ex_termbox, so I could understand more about how to use it. Like ex_termbox, ratatouille featured lots of examples, and one in particular seemed to be a good starting point: the editor.exs.

My primary task was to refactor the examples into a GenServer that I could start and stop from iex . Although the documentation was great, I still had to stare at the source code for a while to see how it worked.

The Takeaways

Working with raw input mode is hard. As soon as I executed ExTermbox.Bindings.init/1 things in the terminal would get weird. If I had errors or I wanted to ctrl-c out, often I couldn’t, and I had to close the entire terminal window. I got really familiar with moving back into my project’s directory, over and over again.

Screen output can conflict with iex itself. For a while, when I started up a player process, my terminal “canvas” showed an :ok tuple along with the pid I had just started. Although that was good confirmation that my GenServer instance was doing its thing, it polluted the screen and distracted from what was supposed to be real-estate reserved for “game stuff”. Why wasn’t ExTermbox.Bindings.clear() working? It turns out that it was in fact working, but iex got the final word and scrawled its output on my canvas like graffiti.

The solution to this “screen pollution” was to monitor the PID I had just started and cleanly handle its exit:

def start(player_name) do
{:ok, pid} = Txtr.Player.start_link(%{name: player_name})
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, _, _, _} -> :ok
end
end

This effectively delayed the iex output until a player was exiting.

Don’t use supervisors unless you need to. This one sounds obvious in retrospect, but at first I had set up a DynamicSupervisor to preside over all the players I instantiated. I kept noticing that I had to quit three times before I could exit back to iex and it took me a while to realize that (duh!) the supervisor was restarting the process. It would only abandon process resuscitation if I exited 3 times in the space of 5 seconds. I had fallen into the trap of using processes for code organization. I can add a clarification to that rule that might sound obvious to many of you, but here it is anyway: don’t use a supervisor unless you need to have your processes restarted automatically!

handle_continue/2 is a useful GenServer callback. For whatever reason, I had never encountered it but it fit perfectly into what each player process needed to do: handle a message and then redraw the terminal canvas before dealing with other messages. In this case, a player’s process can provide a snappy response to an outside interaction, but internally the process could take the time it needs to update the player’s terminal interface with the appropriate blocking of incoming messages that might otherwise produce a race condition. I feel like I stumbled into this solution through dumb luck, so please feel free to read an article devoted to the problems that handle_continue/2 solves.

What we Have and What’s Next

I have spared you most of the coding details in this article in an effort to keep it shorter— you can review the source code for details. What we have now is the seed of a dynamic real-time terminal UI that will support multiple users and features like chats between players. This required adding a new kind of state: a “view state” dedicated to supporting a player’s terminal canvas and buffering keys, but it’s not yet clear how that will relate to the rest of the state in the game or what patterns might emerge from dealing with it.

With the hurdle of a raw-input-mode terminal UI out of the way, we can focus more on the application architecture — specifically how and where to store game state and how to access it. We’ll tackle that in a future article.

Until then, consider becoming a Medium member to support more stories like this. Happy coding!

--

--

Responses (1)