Coming from other languages, you might be used to writing loops. Lots of loops. for loops, while loops, forEach. They’re everywhere.
Elixir has a different philosophy. Instead of telling the computer how to iterate, you describe what you want to happen to each element. The Enum module is where this magic lives, and mastering it will fundamentally change how you think about data processing.
Today we’re building the core of our game: herb pricing, game state, and the actions that make HerbWars playable. By the end, you’ll be buying and selling herbs from IEx.
Thinking in Transformations
Let’s try this in IEx. Say you have a list of numbers and want each one doubled. Elixir has a for comprehension that looks like a traditional loop:
iex> for n <- [1, 2, 3], do: n * 2
[2, 4, 6]
That works, but Elixir developers rarely reach for for. The idiomatic way is Enum.map:
iex> Enum.map([1, 2, 3], fn number -> number * 2 end)
[2, 4, 6]
Same result, but Enum.map composes better with pipelines (which you’ll see shortly) and makes the intent clearer: “transform each element with this function.” The for comprehension shines for more complex scenarios like filtering and nested iteration — we’ll revisit it later. For now, Enum.map is your go-to.
Anonymous Functions: Three Ways
You’ll write a lot of anonymous functions with Enum. Try all three syntaxes in IEx. First, set up some data:
iex> prices = [10, 20, 30]
[10, 20, 30]
Full syntax — the most explicit:
iex> Enum.map(prices, fn price -> price * 2 end)
[20, 40, 60]
Capture syntax — concise when the function is a single expression:
iex> Enum.map(prices, &(&1 * 2))
[20, 40, 60]
The &(&1 * 2) looks weird at first. &1 is the first argument, &2 would be the second, and so on. You’ll get used to it quickly.
Named function reference — when you already have a function defined:
iex> double = fn n -> n * 2 end
iex> Enum.map(prices, double)
[20, 40, 60]
All three produce the same result. Use full syntax when the logic is complex, capture syntax for one-liners, and named references when the function already exists.
Beyond Map: Filter and Reduce
Enum.map transforms every element, but sometimes you only want some elements or need to combine them into a single value.
Filter keeps elements that pass a test:
iex> Enum.filter([1, 2, 3, 4, 5, 6], fn n -> rem(n, 2) == 0 end)
[2, 4, 6]
Reduce accumulates elements into a single result:
iex> Enum.reduce([1, 2, 3, 4], 0, fn number, total -> total + number end)
10
The three arguments: the collection, the starting accumulator (0), and a function that takes (current element, accumulator) and returns the new accumulator. Walking through: 0 + 1 = 1, then 1 + 2 = 3, then 3 + 3 = 6, then 6 + 4 = 10.
Find returns the first match:
iex> Enum.find([3, 7, 2, 9], fn n -> n > 5 end)
7
You’ll see all three patterns in the modules we’re about to build.
Common Enum Patterns
Here’s a reference you’ll use constantly:
| I want to… | Use… |
|---|---|
| Transform each element | Enum.map/2 |
| Keep elements matching condition | Enum.filter/2 |
| Remove elements matching condition | Enum.reject/2 |
| Find one element | Enum.find/2 |
| Check if any match | Enum.any?/2 |
| Check if all match | Enum.all?/2 |
| Accumulate a result | Enum.reduce/3 |
| Get first N | Enum.take/2 |
| Sort by something | Enum.sort_by/2 |
| Count matches | Enum.count/2 |
| Sum values | Enum.sum/1 |
Building the Herb Module: Test First
Now let’s put Enum to work. We need a module that manages herb data and market pricing. Before writing any implementation, describe the behavior we expect. Create test/herb_wars/herb_test.exs:
defmodule HerbWars.HerbTest do
use ExUnit.Case
alias HerbWars.Herb
test "all_herbs returns herb names as strings" do
herbs = Herb.all_herbs()
assert is_list(herbs)
assert "Mint" in herbs
assert "Stinging Nettles" in herbs
end
test "get_price returns price for a herb" do
prices = %{"Mint" => 20, "Dandelion" => 35}
assert Herb.get_price(prices, "Mint") == 20
assert Herb.get_price(prices, "Unknown") == nil
end
test "market_summary returns cheapest, most expensive, and average" do
prices = %{"Mint" => 20, "Dandelion" => 40}
summary = Herb.market_summary(prices)
assert summary.cheapest == {"Mint", 20}
assert summary.expensive == {"Dandelion", 40}
assert summary.average == 30
end
test "generate_city_prices uses herb volatility for pricing" do
prices = Herb.generate_city_prices("Akron", fn -> 0.5 end)
assert prices["Mint"] == 17
assert prices["Stinging Nettles"] == 75
end
end
That last test looks odd — we’re passing fn -> 0.5 end as a second argument to generate_city_prices. What’s going on?
The problem: generate_city_prices calls :rand.uniform(), which returns a different number every time. That makes it impossible to test — we can’t assert exact prices when the output is random. We could skip testing it entirely, but that leaves a gap.
Instead, we’ll use dependency injection: instead of hardcoding the random function inside generate_city_prices, we’ll accept it as a parameter. In production, callers won’t pass it and the function defaults to :rand.uniform/0 — real randomness. In tests, we inject fn -> 0.5 end — a function that always returns 0.5 — so the output is predictable.
With fn -> 0.5 end, we can trace the math by hand. The random function gets called twice per herb: once for variance, once for the multiplier direction. For Mint (base price 20, volatility 0.3):
- First call:
0.5 * 0.3 = 0.15(variance) - Second call:
0.5 > 0.5isfalse, so the multiplier is1 - 0.15 = 0.85 - Final price:
round(20 * 0.85) = 17
That’s 17 — exactly what our test asserts. By injecting a fixed random function, we turned an untestable function into one where we can verify the exact output.
Run mix test — four failures. The HerbWars.Herb module doesn’t exist yet. Now create lib/herb_wars/herb.ex:
defmodule HerbWars.Herb do
@moduledoc """
Manages herb data and market pricing.
"""
@herbs [
%{name: "Mint", base_price: 20, volatility: 0.3},
%{name: "Crushed Dandelion", base_price: 35, volatility: 0.5},
%{name: "Wild Mushrooms", base_price: 80, volatility: 0.8},
%{name: "Stinging Nettles", base_price: 150, volatility: 1.0}
]
def all_herbs, do: Enum.map(@herbs, & &1.name)
def generate_city_prices(_city, rand_fn \\ &:rand.uniform/0) do
@herbs
|> Enum.map(fn herb ->
variance = rand_fn.() * herb.volatility
multiplier = if rand_fn.() > 0.5, do: 1 + variance, else: 1 - variance
{herb.name, max(1, round(herb.base_price * multiplier))}
end)
|> Enum.into(%{})
end
def get_price(prices, herb_name), do: Map.get(prices, herb_name)
def market_summary(prices) do
{min_herb, min_price} = Enum.min_by(prices, fn {_, p} -> p end)
{max_herb, max_price} = Enum.max_by(prices, fn {_, p} -> p end)
avg = round(Enum.sum(Map.values(prices)) / map_size(prices))
%{
cheapest: {min_herb, min_price},
expensive: {max_herb, max_price},
average: avg
}
end
end
Let’s walk through each function:
@herbs is a module attribute — a compile-time constant that holds our herb definitions. Each herb has a name, base price, and volatility (how much the price swings).
all_herbs/0 uses Enum.map to extract just the names from the herb maps. The & &1.name is capture syntax for fn herb -> herb.name end.
generate_city_prices/2 creates randomized market prices. The second argument, rand_fn, defaults to &:rand.uniform/0 — Erlang’s built-in random number generator. In production, callers don’t pass it and get real randomness. In tests, we inject fn -> 0.5 end to get predictable output. This pattern is called dependency injection: instead of hardcoding a dependency (the random function), we accept it as a parameter. The function stays pure when we choose to make it pure, and stays convenient when we don’t.
It maps each herb to a {name, price} tuple with a random multiplier based on volatility, then Enum.into(%{}) converts the list of tuples into a map like %{"Mint" => 18, "Crushed Dandelion" => 42}.
get_price/2 is a thin wrapper around Map.get — it returns the price for a herb or nil if the herb isn’t in the price list.
market_summary/1 demonstrates several Enum functions working together: Enum.min_by and Enum.max_by find the cheapest and most expensive herbs, and Enum.sum with Map.values calculates the average.
Run mix test — 1 doctest, 16 tests, 0 failures.
The GameState Struct: Test First
Our game needs to track more than just the player — we need the current city, market prices, and time remaining. Start with the tests. Create test/herb_wars/game_state_test.exs:
defmodule HerbWars.GameStateTest do
use ExUnit.Case
alias HerbWars.{GameState, Player}
test "has sensible defaults" do
game = %GameState{}
assert game.current_city == "Akron"
assert game.days_remaining == 30
assert game.market_prices == %{}
end
test "composes with Player struct" do
game = %GameState{player: Player.new()}
assert game.player.money == 1000
assert game.player.inventory == %{}
end
end
Run mix test — two failures. Now create lib/herb_wars/game_state.ex:
defmodule HerbWars.GameState do
@moduledoc """
Represents the complete state of a game.
"""
alias HerbWars.Player
@cities ["Akron", "Buffalo", "Cleveland", "Detroit", "Erie", "Flint"]
defstruct player: nil,
current_city: "Akron",
market_prices: %{},
days_remaining: 30
@type t :: %__MODULE__{
player: Player.t(),
current_city: String.t(),
market_prices: %{String.t() => integer()},
days_remaining: integer()
}
def all_cities, do: @cities
end
This struct holds everything you’d need to save, load, or reason about a game. The player field holds a Player struct from ch2, demonstrating how structs compose. @cities is a module attribute listing the game’s locations.
Run mix test — 1 doctest, 18 tests, 0 failures.
Pure Game Actions: Test First
Now the fun part. Let’s build functions that actually play the game — buy herbs, sell herbs, travel between cities. The key insight: these functions are pure. They take a game state, return a new game state (or an error). No IO, no side effects, no user prompts. Just data in, data out.
We’ll follow the same rhythm as ch2: write a test, watch it fail, make it pass.
Buying Herbs
Open test/herb_wars_test.exs and replace its contents with our first game action tests:
defmodule HerbWarsTest do
use ExUnit.Case
alias HerbWars.{GameState, Player}
defp new_test_game do
%GameState{
player: Player.new(),
current_city: "Akron",
market_prices: %{"Mint" => 20, "Crushed Dandelion" => 35},
days_remaining: 30
}
end
describe "buy/3" do
test "buys herbs and deducts money" do
game = new_test_game()
{:ok, game} = HerbWars.buy(game, "Mint", 3)
assert game.player.money == 940
assert game.player.inventory == %{"Mint" => 3}
end
test "fails for unknown herb" do
game = new_test_game()
assert {:error, "Unknown herb"} = HerbWars.buy(game, "Fake Herb", 1)
end
test "fails when player can't afford it" do
game = new_test_game()
assert {:error, "Not enough money"} = HerbWars.buy(game, "Mint", 100)
end
end
end
new_test_game/0 is a private helper that builds a GameState with fixed prices instead of random ones — this gives us deterministic tests. We’ll reuse it for every game action test.
Run mix test — three failures. Now open lib/herb_wars.ex. Right now this module just has a @moduledoc from ch1 — let’s fill it with the first game functions:
defmodule HerbWars do
@moduledoc """
Core game logic for HerbWars.
"""
alias HerbWars.{GameState, Player, Herb}
def new_game do
%GameState{
player: Player.new(),
current_city: "Akron",
market_prices: Herb.generate_city_prices("Akron"),
days_remaining: 30
}
end
def buy(game, herb_name, quantity) do
with {:ok, price} <- get_herb_price(game, herb_name),
{:ok, player} <- Player.spend_money(game.player, price * quantity),
{:ok, player} <- Player.add_herb(player, herb_name, quantity) do
{:ok, %{game | player: player}}
end
end
defp get_herb_price(game, herb_name) do
case Herb.get_price(game.market_prices, herb_name) do
nil -> {:error, "Unknown herb"}
price -> {:ok, price}
end
end
end
new_game/0 creates a fresh game: new player, starting city, randomized market prices, and 30 days on the clock.
get_herb_price/2 is a private helper (defp) that wraps Herb.get_price in the {:ok, price} / {:error, reason} tuple pattern. It’s private because only the functions in this module need it.
buy/3 introduces with, one of Elixir’s most elegant constructs. with chains operations that might fail. Each line uses <- to pattern-match the result:
- Look up the herb’s price — fails if the herb doesn’t exist
- Spend the money — fails if the player can’t afford it
- Add the herb to inventory — fails if inventory is full
If any step returns something other than {:ok, value}, the with immediately returns that non-matching value. Since all our error functions already return {:error, reason} tuples, those errors pass straight through without any explicit error handling. No nested case statements needed.
Run mix test — 1 doctest, 20 tests, 0 failures.
Selling Herbs
Add the sell tests to test/herb_wars_test.exs, inside the module after the buy describe block:
describe "sell/3" do
test "sells herbs and adds money" do
game = new_test_game()
{:ok, game} = HerbWars.buy(game, "Mint", 5)
{:ok, game} = HerbWars.sell(game, "Mint", 3)
assert game.player.money == 960
assert game.player.inventory == %{"Mint" => 2}
end
test "fails when not enough herbs" do
game = new_test_game()
assert {:error, _} = HerbWars.sell(game, "Mint", 1)
end
end
The sell test calls buy first to set up inventory — reusing our existing game logic to build the state we need.
Run mix test — two failures. Add sell/3 to lib/herb_wars.ex, above the private get_herb_price function:
def sell(game, herb_name, quantity) do
with {:ok, price} <- get_herb_price(game, herb_name),
{:ok, player} <- Player.remove_herb(game.player, herb_name, quantity) do
player = Player.add_money(player, price * quantity)
{:ok, %{game | player: player}}
end
end
Selling mirrors buying: look up the price, remove from inventory, add the money. Notice Player.remove_herb/3 from ch2 is finally being used in game logic — this is where it fits in the flow.
Run mix test — 1 doctest, 22 tests, 0 failures.
Traveling Between Cities
Add the travel tests to test/herb_wars_test.exs:
describe "travel/2" do
test "travels to a valid city" do
game = new_test_game()
{:ok, game} = HerbWars.travel(game, "Detroit")
assert game.current_city == "Detroit"
assert game.days_remaining == 29
assert game.market_prices != %{}
end
test "fails for unknown city" do
game = new_test_game()
assert {:error, "Unknown city"} = HerbWars.travel(game, "Atlantis")
end
test "fails for current city" do
game = new_test_game()
assert {:error, "Already in Akron"} = HerbWars.travel(game, "Akron")
end
end
Run mix test — three failures. Add travel/2 to lib/herb_wars.ex:
def travel(game, destination) do
cond do
destination not in GameState.all_cities() ->
{:error, "Unknown city"}
destination == game.current_city ->
{:error, "Already in #{destination}"}
true ->
{:ok, %{game |
current_city: destination,
market_prices: Herb.generate_city_prices(destination),
days_remaining: game.days_remaining - 1
}}
end
end
Travel validates the destination with cond, which checks conditions in order. If the destination is valid and different from the current city, we generate new market prices and decrement the days remaining.
Run mix test — 1 doctest, 25 tests, 0 failures.
..........................
Finished in 0.0x seconds
1 doctest, 25 tests, 0 failures
26 tests total — 1 doctest from the Player module, 11 explicit Player tests from ch2, plus 14 new tests: 4 for the Herb module (including generate_city_prices with dependency injection), 2 for GameState, and 8 for game actions. Every function is tested.
Playing from IEx
The game actions are pure functions, which means you can play directly from IEx:
iex -S mix
iex> game = HerbWars.new_game()
iex> game.current_city
"Akron"
iex> game.market_prices
%{"Mint" => 18, "Crushed Dandelion" => 42, ...}
iex> {:ok, game} = HerbWars.buy(game, "Mint", 5)
iex> game.player.money
910
iex> game.player.inventory
%{"Mint" => 5}
iex> {:ok, game} = HerbWars.travel(game, "Detroit")
iex> game.market_prices # New prices!
%{"Mint" => 25, ...}
iex> {:ok, game} = HerbWars.sell(game, "Mint", 5)
iex> game.player.money # Profit!
1035
iex> HerbWars.sell(game, "Mint", 1)
{:error, "Not enough Mint in inventory"}
No UI, no game loop — just data transformations. You can inspect the game state at any point, rewind by keeping old bindings, or script entire sessions. This is the power of pure functions.
References
- Enum module - the complete list of enumerable functions
- Stream module - lazy equivalents of Enum for large or infinite collections
- Anonymous functions -
fn, capture syntax, and closures - with expression - chaining pattern matches with early exit
- :rand module - Erlang’s random number generation
What’s Next?
The game works from IEx, but players deserve a real interface. In the next chapter, we’ll build a UI module that displays game status, reads player input, and connects user commands to the pure game functions we just wrote. The core logic won’t change — we’re just layering presentation on top.