Writing a Text Adventure Game in Elixir: Part 2
In the previous article, we sketched out the foundations for making a text adventure in Elixir. We discovered that for simple cases where games support only a single player, we don’t require anything special to maintain state: we just pass the state along through the loop as an argument in the way we would pass an accumulator through a map-reduce operation.
This might have seemed like a cop-out, but our solutions need not be complicated. In this article, we are going to explore how to change our strategy to support multiple players in the game, and we will see that changes spawn more and more decisions we will have to make.
Pay attention to the boundaries that are visible in our humble diagram: where must the state be accessed? Like an homage to Conway’s Law, the shape of our code will take on some similarities with this diagram. Let’s begin our upgrade!
Changing a Library into an Application
The first version of the game was just a simple library: you would run it by calling its functions manually. It was not an application in the sense that it did not start or run itself — it was just a handful of modules and functions sitting up on a shelf. Some languages make a more obvious distinction between a library and an executable.
In Go, for comparison, there is a distinction between making a package vs. a standalone executable: a Go standalone executable relies on package main
and it must declare a func main()
. In Python, you might have seen something similar achieved where execution is routed based on the built-in__name__
variable:
# a trick used in some python scripts
if __name__ == '__main__':
main() # <-- or whatever your function is named
Here’s my stab at at explaining how Elixir deals with the distinction between a “library” and an “executable”. When you must bring your modules and functions to life and start them as an application (think: “executable”), then you need to tweak your mix.exs
. Specifically, the magic happens by defining an application/0
function in your mix.exs
and specifying a :mod
option that (per the docs) “specifies a module to invoke when the application is started.” Typically, this will reference an Application
module in your package’s namespace, e.g.
# mix.exs
def application do
[
mod: {Xventure.Application, []}
]
end
There is nothing magical about having an application.ex
file in your package — that’s just a sensible convention because the module you reference must implement the callbacks defined by the Application module (specifically c:start/2
). What makes it a “real boy” is that line in your mix.exs
The contents of your module will look something like the following — see how it implements the Application
behaviours by starting a supervision tree:
defmodule Xventure.Application do
use Application@impl true
def start(_type, _args) do
IO.puts("I am starting!") # <-- see what happens!
children = []
opts = [strategy: :one_for_one, name: Xventure.Supervisor]
Supervisor.start_link(children, opts)
end
end
I’ve included IO.puts
to help you see when exactly that code executes: when you start up iex -S mix
or simply run mix run
your app will run this code. Neat.
Adding this module and referencing it in your mix.exs
is exactly what mix new foo --sup
will do for you if you know from the outset that your code will need to act as an “executable”. Hooray for conveniences!
Getting our State Up and Running
Why did we go through all the trouble of adding an application.ex
and modifying our mix.exs
? We did it so we can bootstrap our game’s state. We need to know the “state of the world” in order to support multiple players in it. In this article, we are going to rely on the humble GenServer to provide us with a way to maintain the game’s state inside a dedicated process. In another article, we’ll compare this with ETS (which allows us to store values inside a memory table).
What exactly do we need to store? Again, I’m going to leave aside the interesting distractions like inventory or monsters and focus on simply storing the location of players. Our game state needs to answer two questions:
- Where am I?
- Who is in the room with me?
Each of these questions suggests a different way to store the data. In the interest of satisfying both questions, I am going to shoot for a “double-entry” structure that can easily answer both of the above questions. We end up with a data structure that looks something like this:
%{
players: %{
p1: :barn,
p2: :clearing
},
locs: %{
"barn" => [:p1],
"clearing" => [:p2]
}
}
When a player moves to a new location, we will have to update the state in two places. You can check the repo to see the specifics of how this was done (see the v2 sub-folder), but for those who may only wish to understand a bit more about the ways we can manage state and who may not wish to scroll through source code, we list the important take-aways.
Takeaways for Refactoring
Instead of passing the full game state through a the game loop, we now pass only the player’s identifier. You don’t take your coat into the concert hall, you leave it at the coat check and keep the ticket. This is a lot like using sessions in a web application where a small cookie is passed around containing the session identifier and the actual session data lives on the remote server somewhere.
Most of the code that manipulates the state now moves into our Xventure.World
module (the one that uses GenServer
). You can start to feel how the application is becoming organized around roles, e.g. handling the state feels like a model in an MVC app. Our handling of commands is a bit like handling routes. Perhaps we should have a distinct presentation layer that would be analogous with a view. This is only a proof-of-concept, but already you can start to see where we might need some refactoring to improve our organization.
I’ll make only passing mention of the under-appreciated MapSet module. Use it when you really are dealing with a set of unique members (such as the collection of players in a given location). Don’t try to make a list deal with this.
How to See Concurrency in Action without a Web Server
One of the things that Elixir is really good at is handling concurrent processes, so how exactly do we get multiple players to connect to the same instance of the game? Virtually any time I’ve seen this topic discussed, it has almost always been presented in the context of a Phoenix web application, and although that’s certainly a common use case, I never was satisfied with that as a basis for explanation because any web server will provide some means to handle multiple connections. Any decent web server layer will provide a cloak of “concurrency” when it sits in front of code, even for code written in languages that do not really excel at concurrency (I’m remembering those days of dealing with mod_php
on Apache servers). But Elixir does offer good support for concurrency natively, so how can we see it in action without the smoke and mirrors of a web server?
To re-state the problem, if you open two terminal windows and launch iex -S mix
from our v2/
directory, each terminal window will start up a separate instance of the game: its players will never encounter each other because they are in different worlds.
In order to have multiple players join the same world, we need to have them connect to the same iex
session. I have discovered three ways to connect multiple terminal sessions to a single running Elixir application.
1. Connecting via remsh
For this to work, we need to name the first iex
session in our first terminal window. We do this by passing the --sname
option:
cd path/to/app/root/
iex --sname world1@localhost -S mix
You must start this from the root of your application (because -S mix
is called, and that’s ultimately what launches the app).
From subsequent terminal windows, you can navigate to root of the application and then attach to the named session using the remsh
flag to reference the original session from the first terminal window:
cd path/to/app/root/
iex --sname player2 --remsh world1@localhost
The first connection is only one that starts the app by running mix
. Everybody else just attaches to that running instance (that’s why it is named world1
instead of player1
).
Inside the first terminal window, you can join the world using Xventure.new/1
:
iex(world1@localhost)1> Xventure.new(:thelma)
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:
Exits: south
>
And in the second (or third) terminal window, you can join in the same way:
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
>
See how :louise
sees :thelma
? If you enter the look
command back in the first terminal, :thelma
will see :louise
— this is because both sessions share the same state!
If you ctrl-c out of the first window (the one running the app), all players attached to this instance will get booted out.
2. Connecting via Node.spawn_link
This variant is pretty similar to the first, but the distinction is that you don’t need to cd
into the application root in order to join the game. Aniex
session simply needs to be able to connect with the iex
session that is running the application. These sessions can be started from anywhere on your computer (or anywhere on your network), provided they can communicate with the node running the app. We can establish awareness by creating a cookie — this acts something like a shared secret: if you don’t know it, you won’t be able to join the game instance.
In the first terminal window, start the game:
cd path/to/app/root/
iex --sname world1@localhost --cookie 's3cr3p@$$word' -S mix
In subsequent terminal windows, start the iex
session referencing the same cookie (i.e. the “shared secret”). This can be done from anywhere on your computer: you don’t need to cd
into the app’s directory.
iex --sname p2@localhost --cookie 's3cr3p@$$word'iex> Node.spawn_link(:world1@localhost, fn -> Xventure.new(:bill) end)
Once you’re in iex
, you will use Node.spawn_link/1
to send a command to the node identified by its session name, :world1@localhost
. You can add players and see them when they are in the same room just as before.
3. Connecting to a running release
We haven’t discussed building a release, but since it does offer a viable way to have multiple people connect to a running instance, I will mention it here. All you need to do is run the app’s executable with the remote
option.
./build/myapp remote
This will connect you to the running iex
instance. See the docs for more explanation of this since this article has already gone on longer than I expected.
Next Steps
We have a working proof-of-concept and we have discussed a few ways to handle multiple connections to our multi-player app, but we don’t yet know if it is performant. While testing it out, I noticed my second terminal window seemed to be really slow with its auto-complete. Was something horribly broken because the first window was looping its brains out? How many players could this actually support?
Other considerations are around the code organization… it felt pretty smelly doing “double-bookeeping” on the state of the world. Should I track players and locations in two separate processes? Should each player exist in its own process so I can better isolate crashes? There are lots of choices in this adventure, but we’ll have to tackle them in a forthcoming article.
The code for this article is in the same repo as before, specifically in thev2
folder.
If you like reading content like this, consider becoming a Medium member!