One thing that I've found with Elm is that there are fantastic resources for learning how to start with the language, and there are equally fantastic resources talking you through a more complicated application architecture, but I think it might be useful to give people a more high-level view of what you're likely to find in an Elm application. Or at least, this is something that I think would have helped me, anyway - hopefully it will help someone else!
The Elm Architecture
Before we look at the code for this application, let's take a little detour into how the Elm runtime works.
The Elm Architecture is how application state managed in an Elm application. Your application has an overall model, an update function, and a view function. Your view function is capable of sending messages, which are fed into your update function along with your model, and this returns a new (updated) model, which is then used to render your new view. Diagrams for this make a lot more sense, so:
Anyone who is familiar with Redux ought to recognise this immediately (Redux was heavily influenced by Elm's state management), with some terminology changes. In short:
The key difference is that this is what the Elm runtime is built on, not something you opt-in to.
The app we're looking at
Project Arklay is a super basic text-adventure game that I've written a few times now in order to test out different tools or ideas or languages. Put simply, you (the player) navigate through various rooms, picking up items and unlocking doors, until you're ultimately met with an abrupt and disappointing ending.
Now that I've saved you the time of actually playing it, let's talk about it.
The application can be in one of three states (at the time of writing).
Either, you haven't started playing:
Or you have started playing, and the current available directions are being displayed:
Or, you have started playing, and your current inventory is being displayed:
Although these are three separate finite states, it's clear that the second and third are siblings in an overall parent view - so we have two views here. We'll refer to these as the Intro view and the Game view.
The player can: In the Intro view: + Start the game In the Game view: When the state is Displaying Directions: + See which directions are available to move in + Select a direction to move in + Examine the room (this will pick up any items available in the current room, and add them to the inventory) + Open the inventory - so long as there are items in the inventory When the state is Displaying Inventory: + See which items are available + Use an item + Close the inventory
How this is looks in the code
Going through this entire application line by line probably isn't going to be super helpful, but looking at the parts that have already been described should give you a decent taste for what working with Elm is like. It is worth knowing at this point though that all Elm applications will have a Main module, which will have a main function, and will be in a file called Main.elm. It's probably also worth knowing that every .elm file is its own module.
You don't need to worry about the "key" here - (it exists in order to allow us to navigate between different pages of the application whilst controlling what's displayed in the URL, and is generated by Elm's internals), but the state field is defined here as holding a State, which is a custom type. In this application's case, this looks like:
So the overall application state can be either ViewIntro (in which case it needs something that looks like whatever is defined as Model in the Intro module), or ViewGame (in which case it needs something that looks like whatever is defined as Model in the Game module). That takes care of two of our possible application states, and we said that the there were two possible states of our ViewGame state, so let's have a look at what Game.Model looks like:
Great, no surprises there then - Game.Model also has a state key, which also holds a custom type of State. It's worth noting that this is actually a Game.State, where the "State" that we looked at last was a Main.State.
Custom types can look a bit odd when you see them like this for the first time, so for a very quick clarification - a custom type in Elm is a type that can be enumerated. You could consider the way that Bool works as an example: it is a type that can either be True or False, and if you were to enumerate over it, you'd know in advance that you were going to be dealing with (at most) those two values. Our State here is no different. It could easily be a flag of "displayingDirections" with a boolean value, but I think there is a nice benefit of being able to look at the state here and quickly reason about what the possibilities are, which would be lost if this was a "displayingDirections" flag (since you wouldn't know what that being false would indicate).
How these states are rendered
Earlier I said that the view was rendered based on the Model, and we know that in this application the state is held in the model too, and we also know that State is a custom type, so it shouldn't be a big surprise to see how this all comes together in the code:
If that looks a little confusing, it might be because functions in Elm don't have parentheses around their arguments, every function in Elm has a return value, and every function in Elm is evaluated as a single expression. If the above were in JS, it would look something like this instead:
Although you'd be relying on state in the JS example being either "ViewIntro" or "ViewGame". In our Elm implementation, we know it is one of those two things, as those are the only things it can possibly be - so no need for a default in Elm here, which we would need in the JS version.
In the case of our Game states, only part of the view changes depending on Game.State, but it works in exactly the same way - in the part of the view that changes, there's another case expression:
How updates happen
I have a custom type of Msg which holds the possible message type in any module that has messages (this is something you'll see in basically every Elm app ever - very much the "standard" way of handling messages, since you're always going to be wanting to run a case expression on them).
There is an update function in our Main module, which is the one run by Elm's runtime. My Intro and Game modules also have their own update functions, and whenever an Intro.Msg or Game.Msg is received by the runtime, it gets piped into Intro.update or Game.update, allowing the message to be handled and the model to be updated correctly. This wiring isn't automatic, but it isn't especially complicated either - Main's Msg type looks like this:
The ChangedUrl and ActivatedLink messages are there because I need to supply the runtime with a message to call in the event of either of those things happening, but the things we're interested in here are Intro.Msg and Game.Msg. By this point you probably already know that we're about to look at Main.update to see what's happening:
So in the event of a GameMsg, we unpack the Game.Msg into msgReceived, and then we call Game.update passing in the message along with the current gameModel to get an updatedGameModel, which we then use to update our state to be ViewGame updatedGameModel.
Now if we look at that Game.Msg type, we can see what messages Game.update handles:
You won't be surprised to learn that this is all as you'd expect:
- Item and Room are both custom types
- ToggleInventory toggles whether the inventory is being displayed or not (by checking the current Game.model.state and switching to the other possibility)
- UseItem stores an Item and a Room, and checks to see if the item in question can be used in the room in question (and uses it if it's possible)
- ChangeRoom stores a Room and updates the Game.model to have that room stored in its Game.model.room
- ExamineRoom may have an Item, and if it does, it adds it to the player's inventory. Otherwise, the inventory remains the same.
Okay, that last one is technically not quite true. It should have been:
- ExamineRoom always has a Maybe, and that Maybe either has an Item or it has Nothing.
That might sound like a subtle difference, but this is how we avoid the potential headache of null or undefined - a Maybe can be enumerated with a case expression as well, and just like everything that can be enumerated in Elm, you have to handle all possibilities.
Let's have a look at the ExamineRoom function in Game.update:
If you're not familiar with Elm, this might look confusing to see all at once - remember that you felt the same way about whatever every other language you've ever used at one point too though!
What's happening here is:
I'm first checking to see if there's an item stored in the (Maybe Item) that is passed along with the ExamineRoom message
- if there is, I'm checking to see if the player is either currently holding that item, or if they've already used it.
- In either case, I want their inventory to remain the same, so I'm returning an identical copy* of the list that is stored in Game.model.inventory.
- If the player is neither currently holding the item, and they haven't already used it, then we're creating a new list of the current inventory, plus the new item. And then we're reversing it so that the new item appears at the end rather than the start - items are always added to the start of a list in Elm.
If there was no item to begin with, then I'm returning an identical copy* of the list that is stored in Game.model.inventory.
(*don't worry, the compiled and deployed code is using the value stored at the original memory location - but as every function in Elm returns a value, and values in Elm can't be mutated, we're conceptually returning an identical copy here)
And we can look at the functionality that was described at the start of this post, and see how it relates to the messages in Game.Msg:
|ToggleInventory||Player can open inventory|
|UseItem||Player can use an item|
|ChangeRoom||Player can select a direction to move in|
|ExamineRoom||Player can examine the current room|
The requirements not covered by these messages are both to do with the player being able to see something (i.e. the available directions or their current items), and that's not a coincidence - things that a user can see are controlled Game.view and not Game.update.
(There is also a sole Intro.Msg of StartGame, which matches up to our other requirement, but it isn't worth talking about that here!)
One thing that I find especially useful with an Elm application is to look at requirements and work out whether something is the responsibility of update, and then add a Msg for it if it is.
Anyway, I hope that someone found this interesting - I know this hasn't been an especially deep dive into the language itself, but I feel like it should give a little flavour of how to look around an application to figure out what's happening with it. Personally, I'm always happy when I end up with something that is this easy to understand:
If you are interested, you can look at the full repo: https://github.com/dnimmo/project-arklay-v3 (things will change after this post is published, so the examples here may well be different in the repo than they are in this post - happy to answer questions on any of it mind, feel free to get in touch)
Or you can play the game itself in its still-could-use-some-styling-tweaks state: http://arklay.surge.sh