When I first heard that Elixir was “immutable,” I understood what this meant in theory. However, it took writing a bit of Elixir code to really understand what this meant. This section is focused on really getting at the heart of how to build with immutability at its core.
How do you write a game where the player’s money never changes? Where the inventory is frozen in time?
Today, we’re building a Player module that tracks money, inventory, and upgrades. We’ll write every test before we write the code it tests — so you’ll see the design emerge from what we want the Player to do, not from implementation details.
Our approach for the next few chapters: build each component in isolation first — Player, Herbs, GameState — each with its own tests. Once the pieces work independently, we’ll wire them together through a UI layer. This way, the game logic is solid and tested before we worry about how the player interacts with it.
Maps: The Flexible Friend
Fire up IEx (iex -S mix) and follow along. Maps are Elixir’s key-value data structure. If you’ve used dictionaries in Python or objects in JavaScript, you already understand the concept:
player_data = %{name: "Alice", money: 1000, level: 5}
Accessing values is straightforward:
player_data.name # "Alice"
player_data[:money] # 1000
Both syntaxes work, but .name is more common when you know the key exists.
Now here’s where immutability kicks in. When you “update” a map:
new_data = %{player_data | money: 1500}
This is the map update syntax. Let’s break it apart:
%{ ... }- the outer braces mean “this is a map”player_data |- “start with everything fromplayer_data, but…”money: 1500- “…replace the value ofmoneywith 1500”
The | here is not the pipe operator (|>) from earlier. It’s the update operator and only works inside %{}. Think of it as “take this map, but change these fields.” You can update multiple fields at once too: %{player_data | money: 1500, level: 6}.
Crucially, you’re not changing player_data. You’re creating a brand new map where money is 1500, while everything else comes from the original. player_data.money is still 1000. This is immutability in action — the original data is never modified. You’ll see this pattern constantly in Elixir code.
Structs: Maps with Guardrails
Maps are flexible. Maybe too flexible. You can add any key at any time, and typos create new keys instead of errors. For important data structures like game entities, we want something more structured.
Enter structs:
defmodule Player do
defstruct name: "Unknown", money: 0, level: 1
end
Now you can create typed instances:
player = %Player{name: "Bob", money: 1000}
player.name # "Bob"
player.money # 1000
player.level # 1 (default value)
Try to add a random field and Elixir says no:
%Player{name: "Bob", superpower: "flight"}
# ** (KeyError) key :superpower not found
This is exactly what we want for game state. Predictable, validated structures.
Writing Tests First
Before we write any Player code, let’s describe what we want the Player to do. Elixir projects come with a built-in testing framework called ExUnit. When you ran mix new, it created a test/ directory — that’s where all test files live.
Create test/herb_wars/player_test.exs:
defmodule HerbWars.PlayerTest do
use ExUnit.Case
alias HerbWars.Player
describe "player creation" do
test "new player has default values" do
player = Player.new()
assert player.money == 1000
assert player.inventory == %{}
assert player.max_inventory == 10
end
end
end
A few things to unpack:
use ExUnit.Case— pulls in ExUnit’s testing macros (describe,test,assert, etc.). You’ll see this at the top of almost every test file.alias HerbWars.Player— lets you writePlayer.new()instead ofHerbWars.Player.new()throughout the file. Just a shortcut, not test-specific.describe "player creation" do— groups related tests together. This is optional but makes your test output easier to read. When a test fails, you’ll see"player creation - new player has default values"instead of just the test name.test "description" do ... end— defines a single test case. The string is a human-readable description that shows up in your test output.assert— the core assertion macro. It checks that the expression is truthy. If it’s not, the test fails with a helpful diff showing what you expected vs. what you got.
Notice the pattern: assign, act, assert. Create a player, then check the result. Every test we write will follow this shape.
Run it:
mix test
It fails — HerbWars.Player doesn’t exist yet. That’s the point. The test tells us exactly what to build.
Making It Pass: The Player Module
Now create lib/herb_wars/player.ex — just enough to make the test pass:
defmodule HerbWars.Player do
@moduledoc """
Represents a player in the HerbWars game.
"""
defstruct money: 1000,
inventory: %{},
max_inventory: 10,
has_trench_coat: false,
has_suitcase: false
@type t :: %__MODULE__{
money: integer(),
inventory: %{String.t() => integer()},
max_inventory: integer(),
has_trench_coat: boolean(),
has_suitcase: boolean()
}
@doc """
Creates a new player with default values.
## Examples
iex> player = HerbWars.Player.new()
iex> player.money
1000
iex> player.inventory
%{}
"""
def new do
%__MODULE__{}
end
end
A few things to unpack:
__MODULE__ is a special constant that expands to the current module name. Inside HerbWars.Player, %__MODULE__{} becomes %HerbWars.Player{}. You could write %HerbWars.Player{} directly, but then if you ever rename the module you’d have to find and update every reference. __MODULE__ always refers to whatever module it’s inside.
@type t defines a type specification. It’s documentation for humans and tools like Dialyzer (Elixir’s type checker). The convention is to name your struct’s type t.
inventory: %{String.t() => integer()} in the type spec says the inventory is a map where the keys are strings and the values are integers. In practice, that means herb names mapped to quantities: %{"Mint" => 5, "Dandelion" => 3}. The default value %{} starts it as an empty map.
The @doc block contains iex> examples — these are doctests. ExUnit finds every iex> block in your module’s @doc strings and runs them as tests. Your documentation always stays in sync with your code. Let’s enable them by adding doctest HerbWars.Player to the test file:
defmodule HerbWars.PlayerTest do
use ExUnit.Case
doctest HerbWars.Player
alias HerbWars.Player
describe "player creation" do
test "new player has default values" do
player = Player.new()
assert player.money == 1000
assert player.inventory == %{}
assert player.max_inventory == 10
end
end
end
Run mix test:
...
Finished in 0.0x seconds
1 doctest, 2 tests, 0 failures
Green. Three passes — the “module exists” test from herb_wars_test.exs, the “new player has default values” test from player_test.exs, and one doctest from the @doc on new/0. Each dot represents a passing test. Now let’s add more behavior.
Counting Items: Test First
We need a function to count total inventory items. Let’s describe what we want before we build it:
describe "inventory counting" do
test "counts total items across all herbs" do
player = %Player{inventory: %{"Mint" => 3, "Dandelion" => 5}}
assert Player.total_inventory_count(player) == 8
end
end
Run mix test — it fails: undefined function total_inventory_count/1. Good. Now we know what to build.
Add to lib/herb_wars/player.ex:
@doc """
Counts the total number of items in the player's inventory.
## Examples
player = %HerbWars.Player{inventory: %{"Mint" => 3, "Dandelion" => 5}}
HerbWars.Player.total_inventory_count(player)
# => 8
"""
def total_inventory_count(player) do
player.inventory
|> Map.values()
|> Enum.sum()
end
Here’s what’s happening step by step:
player.inventory- Get the map%{"Mint" => 5, "Dandelion" => 3}Map.values()- Extract just the values[5, 3]Enum.sum()- Add them up8
The pipe operator chains these transformations beautifully. Each line does one thing, and you can read the logic top-to-bottom.
Run mix test — 1 doctest, 3 tests, 0 failures.
The Error Tuple Pattern
What happens when a player tries to add herbs but their inventory is full? We need to handle success and failure cases. Elixir has a convention for this:
{:ok, result} # Success, here's what you asked for
{:error, reason} # Failure, here's why
Let’s start with the simplest case — adding herbs to an empty inventory:
describe "adding herbs" do
test "adding herbs to empty inventory" do
player = Player.new()
{:ok, player} = Player.add_herb(player, "Mint", 5)
assert player.inventory == %{"Mint" => 5}
end
end
Look at this line carefully:
{:ok, player} = Player.add_herb(player, "Mint", 5)
This is doing two things at once. First, Player.add_herb returns a tuple like {:ok, %Player{...}}. Then, = isn’t assignment — it’s pattern matching. The left side {:ok, player} is a pattern that says: “I expect a two-element tuple where the first element is the atom :ok. Pull out the second element and bind it to player.”
If add_herb returned {:error, "some reason"} instead, this line would crash with a MatchError — the pattern {:ok, player} doesn’t match {:error, ...}. In a test, that’s exactly what we want: if the function fails unexpectedly, the test blows up immediately at the line that failed, not at some later assertion.
This also rebinds player. The original player (with an empty inventory) is gone. From this line forward, player refers to the new struct returned by add_herb.
Now let’s add two more tests to cover the remaining behaviors:
test "adding herbs stacks with existing quantity" do
player = Player.new()
{:ok, player} = Player.add_herb(player, "Mint", 3)
{:ok, player} = Player.add_herb(player, "Mint", 2)
assert player.inventory == %{"Mint" => 5}
end
test "cannot add more than max inventory" do
player = Player.new()
result = Player.add_herb(player, "Mint", 11)
assert result == {:error, "Not enough inventory space"}
end
The “stacking” test calls add_herb twice, rebinding player each time — the same pattern matching we just discussed, applied twice in a row.
The third test is different. It doesn’t pattern match with {:ok, ...} because we expect an error. Instead, we capture the full result with a plain variable and assert it matches the error tuple.
Run mix test — three failures. All three tell us the same thing: add_herb/3 doesn’t exist yet. Let’s fix that.
def add_herb(player, herb_name, quantity) do
if remaining_inventory_space(player) >= quantity do
new_inventory = Map.update(player.inventory, herb_name, quantity, fn current ->
current + quantity
end)
updated_player = %{player | inventory: new_inventory}
{:ok, updated_player}
else
{:error, "Not enough inventory space"}
end
end
def remaining_inventory_space(player) do
player.max_inventory - total_inventory_count(player)
end
Run mix test — 1 doctest, 6 tests, 0 failures. All green.
This works, but if/else on a comparison that picks between two distinct return values is a good candidate for case. The case version makes the branching explicit — each outcome gets its own clause:
@doc """
Adds a quantity of herbs to the player's inventory if there is enough space.
Returns `{:ok, player}` or `{:error, reason}`.
"""
def add_herb(player, herb_name, quantity) do
case remaining_inventory_space(player) >= quantity do
true ->
new_inventory = Map.update(player.inventory, herb_name, quantity, fn current ->
current + quantity
end)
updated_player = %{player | inventory: new_inventory}
{:ok, updated_player}
false ->
{:error, "Not enough inventory space"}
end
end
@doc """
Returns how many more items the player can carry.
"""
def remaining_inventory_space(player) do
player.max_inventory - total_inventory_count(player)
end
Run mix test again — still green. Same behavior, but now the two branches read as pattern matches on true and false rather than an if/else block.
Map.update/4 takes four arguments:
player.inventory- the map to updateherb_name- the key to look upquantity- the default value if the key doesn’t exist yetfn current -> current + quantity end- a function to apply to the existing value if the key already exists
So if you add 5 Mint when you have none, the key isn’t found and it uses the default: 5. If you add 3 more Mint when you already have 5, it runs the function: 5 + 3 = 8.
Removing Herbs: Test First
Removal is trickier — if the quantity drops to zero, we should delete the key entirely. Let’s describe that behavior:
describe "removing herbs" do
test "removing all herbs deletes the key" do
player = %Player{inventory: %{"Mint" => 5}}
{:ok, player} = Player.remove_herb(player, "Mint", 5)
assert player.inventory == %{}
end
test "removing some herbs reduces the quantity" do
player = %Player{inventory: %{"Mint" => 5}}
{:ok, player} = Player.remove_herb(player, "Mint", 3)
assert player.inventory == %{"Mint" => 2}
end
test "cannot remove more herbs than you have" do
player = %Player{inventory: %{"Mint" => 2}}
result = Player.remove_herb(player, "Mint", 5)
assert result == {:error, "Not enough Mint in inventory"}
end
end
Notice we’re building specific state directly — %Player{inventory: %{"Mint" => 5}} skips Player.new() and jumps straight to the state we need. This is handy when you don’t want to set up a bunch of preconditions.
Run mix test — three new failures. Now implement:
@doc """
Removes a quantity of herbs from the player's inventory.
Deletes the key entirely if the quantity reaches zero.
Returns `{:ok, player}` or `{:error, reason}`.
"""
def remove_herb(player, herb_name, quantity) do
current_quantity = Map.get(player.inventory, herb_name, 0)
new_quantity = current_quantity - quantity
case new_quantity do
n when n < 0 ->
{:error, "Not enough #{herb_name} in inventory"}
0 ->
{:ok, %{player | inventory: Map.delete(player.inventory, herb_name)}}
n ->
{:ok, %{player | inventory: Map.put(player.inventory, herb_name, n)}}
end
end
Instead of nesting two case expressions, we compute new_quantity up front and pattern match on it directly. The when n < 0 guard handles the error, 0 deletes the key, and any positive value updates it — three branches, no nesting.
This function introduces three more Map functions:
Map.get(player.inventory, herb_name, 0)- looks upherb_namein the map, returning0if the key doesn’t exist. Safer thanplayer.inventory[herb_name], which would returnnil.Map.delete(player.inventory, herb_name)- returns a new map with that key removed entirely.Map.put(player.inventory, herb_name, new_quantity)- returns a new map with the key set to the new value, whether or not it existed before.
Run mix test — 1 doctest, 9 tests, 0 failures.
Money Management: Test First
Money is simpler since we just need to check if the player can afford something. Tests first:
describe "money management" do
test "adding money increases balance" do
player = Player.new()
player = Player.add_money(player, 500)
assert player.money == 1500
end
test "spending money when you can afford it" do
player = %Player{money: 1000}
{:ok, player} = Player.spend_money(player, 300)
assert player.money == 700
end
test "cannot spend more money than you have" do
player = %Player{money: 100}
result = Player.spend_money(player, 500)
assert result == {:error, "Not enough money"}
end
end
Run mix test — three failures. Implement:
@doc """
Adds money to the player's balance.
## Examples
player = HerbWars.Player.new()
player = HerbWars.Player.add_money(player, 500)
player.money
# => 1500
"""
def add_money(player, amount) do
%{player | money: player.money + amount}
end
@doc """
Spends money from the player's balance if they can afford it.
Returns `{:ok, player}` or `{:error, reason}`.
"""
def spend_money(player, amount) do
remaining_amount = player.money - amount
case remaining_amount do
n when n < 0 ->
{:error, "Not enough money"}
n ->
{:ok, %{player | money: n}}
end
end
Notice add_money doesn’t return a tuple. There’s no way to fail when adding money. But spend_money can fail, so it returns the tuple pattern.
Run mix test — all 12 tests passing.
We only used a doctest on new/0 — the one function where the doctest adds something the explicit test doesn’t (a quick-glance usage example in the docs). For everything else, the explicit tests already cover the behavior, so we kept the @doc examples as plain code blocks instead of iex> lines. Duplicating coverage between doctests and explicit tests just inflates your test count without catching more bugs.
The Immutability Mindset
Look at this sequence:
player = Player.new()
{:ok, player} = Player.add_herb(player, "Mint", 5)
{:ok, player} = Player.add_herb(player, "Dandelion", 3)
player = Player.add_money(player, 500)
Each line creates a new player struct and rebinds the variable. The right side reads from the old player, the left side binds a new one. Nothing is modified in place.
Try this yourself in iex -S mix — paste those lines one at a time and inspect player after each step. You’ll see the struct change with every rebind.
In a mutable language, you might write something like:
player = Player.new
player.add_herb("Mint", 5)
player.add_herb("Dandelion", 3)
player.add_money(500)
These look similar, but the difference matters. In Ruby, player is an object and methods can mutate it in place. That leads to surprises:
def apply_tax(player)
player.money = (player.money * 0.9).to_i
player
end
puts player.money # => 1000
receipt = apply_tax(player)
puts player.money # => 900 — wait, what?
You called apply_tax expecting it to return a taxed copy, but it mutated the original player too — they’re the same object. These hidden mutations are hard to track down because the change happens inside a method you might not even be looking at.
In Elixir, this can’t happen:
def apply_tax(player) do
%{player | money: trunc(player.money * 0.9)}
end
player.money # => 1000
receipt = apply_tax(player)
player.money # => still 1000
The original player is untouched. If you want the taxed value, you rebind explicitly:
player = apply_tax(player)
player.money # => 900
The result is the same — player now holds the taxed value. The difference is that the caller made that choice. In Ruby, apply_tax(player) might mutate your object whether you wanted it to or not — you’d have to read the method’s source to know. In Elixir, if you don’t rebind, your data didn’t change. That’s the guarantee.
Running the Full Suite
At this point, mix test should give you:
.............
Finished in 0.0x seconds
1 doctest, 12 tests, 0 failures
13 tests — 12 explicit (11 from player_test.exs plus the “module exists” test from herb_wars_test.exs) and 1 doctest on new/0. We kept doctests only where they add value beyond the explicit tests — as a quick usage example in the docs without duplicating coverage. Writing the tests first meant we always knew what “done” looked like before we started coding.
References
- Map module - functions for working with key-value maps
- Structs - typed maps with compile-time guarantees
- Typespecs - the
@typeand@specannotation system - Pattern matching - destructuring and matching values
- Kernel.SpecialForms -
%{}syntax,=match operator, and other built-in forms
What’s Next?
In the next post, we’ll explore the Enum module in depth. Mapping, filtering, and reducing collections. We’ll build a herb pricing system with dynamic markets, and you’ll see just how expressive Elixir can be when processing data.