Our game has events, effects, and real gameplay — but every piece of content is hardcoded in Elixir modules. Want to add a new event? Edit code, recompile, hope you didn’t break something.
There’s a better way. By moving game content to external files, you separate what the game contains from how the game works. This chapter is a refactoring exercise: the game won’t gain new features, but it’ll become much easier to extend.
Why YAML?
YAML is a human-readable configuration format. Compare our hardcoded event:
%{
"id" => "find_money",
"description" => "You found a wallet on the ground!",
"probability" => 5,
"conditions" => [%{"type" => "always"}],
"effects" => [%{"type" => "money", "amount" => 100}]
}
With the same data in YAML:
- id: find_money
description: "You found a wallet on the ground!"
probability: 5
conditions:
- type: always
effects:
- type: money
amount: 100
Anyone can read and modify the YAML version, even without programming experience. This is why we used string keys like "type" instead of atoms in ch7 — the code already speaks YAML’s language.
Adding the Dependency
Elixir doesn’t parse YAML by default. Update mix.exs:
defp deps do
[
{:owl, "~> 0.13"},
{:yaml_elixir, "~> 2.9"}
]
end
Then fetch it:
mix deps.get
The priv Directory
Elixir projects have a special priv/ directory for application data. It’s included when you build releases, and there’s a built-in way to find it at runtime. Create the structure:
mkdir -p priv/game_data
Moving Events to YAML
Take the hardcoded @events from lib/herb_wars/event_system.ex and create priv/game_data/events.yml:
events:
- id: find_money
description: "You found a wallet on the ground!"
probability: 5
conditions:
- type: always
effects:
- type: money
amount: 100
- id: police_raid
description: "Police raid! They confiscated some herbs!"
probability: 3
conditions:
- type: has_herbs
effects:
- type: lose_herb_percentage
percentage: 30
- id: get_mugged
description: "You got mugged! They took $200!"
probability: 4
conditions:
- type: has_money
min_amount: 200
effects:
- type: money
amount: -200
- id: find_herbs
description: "You found a stash of herbs!"
probability: 6
conditions:
- type: can_carry
min_space: 3
effects:
- type: add_herb
herb: random
quantity: 3
Reading YAML in Elixir
Let’s see what happens when we parse this. In IEx:
iex> {:ok, data} = YamlElixir.read_from_file("priv/game_data/events.yml")
{:ok,
%{
"events" => [
%{
"id" => "find_money",
"description" => "You found a wallet on the ground!",
"probability" => 5,
"conditions" => [%{"type" => "always"}],
"effects" => [%{"type" => "money", "amount" => 100}]
},
...
]
}}
YAML becomes Elixir maps with string keys — exactly the same shape as our hardcoded data. That’s why the refactor is painless.
Updating the EventSystem
Replace the hardcoded @events and all_events/0 in lib/herb_wars/event_system.ex with a function that loads from the file:
@events_file "priv/game_data/events.yml"
def load_events do
case YamlElixir.read_from_file(@events_file) do
{:ok, %{"events" => events}} ->
{:ok, events}
{:error, reason} ->
{:error, "Failed to load events: #{inspect(reason)}"}
end
end
The pattern match %{"events" => events} extracts just the events list from the YAML structure.
Now update maybe_trigger_event/1 to use load_events/0 instead of @events:
def maybe_trigger_event(%GameState{} = game) do
with {:ok, events} <- load_events(),
event when not is_nil(event) <- select_random_event(events, game) do
apply_event(event, game)
else
_ -> {:no_event, game}
end
end
Remove the old @events module attribute and all_events/0 function. The rest of the EventSystem — check_conditions, apply_effect, etc. — stays exactly the same because the data shape hasn’t changed.
Run mix test — all passing. The existing tests still work because they build event maps directly, not through load_events/0.
Adding Arrival Messages
Let’s add another YAML file for city flavor text. Create priv/game_data/arrivals.yml:
arrivals:
- message: "Welcome to {city}! The market is bustling today."
conditions:
- type: always
- message: "You arrive in {city}. The streets are quiet."
conditions:
- type: always
- message: "{city} looks prosperous. Many traders here."
conditions:
- type: always
The {city} placeholder gets replaced at runtime. Add these functions to EventSystem:
@arrivals_file "priv/game_data/arrivals.yml"
def get_arrival_message(game) do
case load_arrivals() do
{:ok, arrivals} ->
arrivals
|> Enum.filter(&check_conditions(&1["conditions"], game))
|> Enum.random()
|> Map.get("message")
|> format_message(game)
{:error, _} ->
"You arrive in #{game.current_city}."
end
end
defp load_arrivals do
case YamlElixir.read_from_file(@arrivals_file) do
{:ok, %{"arrivals" => arrivals}} -> {:ok, arrivals}
{:error, reason} -> {:error, reason}
end
end
def format_message(message, game) do
message
|> String.replace("{city}", game.current_city)
|> String.replace("{days}", to_string(game.days_remaining))
end
Test the formatting:
describe "format_message/2" do
test "replaces placeholders with game values" do
game = %GameState{current_city: "Buffalo", days_remaining: 15}
assert EventSystem.format_message("Welcome to {city}!", game) == "Welcome to Buffalo!"
assert EventSystem.format_message("{days} days left", game) == "15 days left"
end
end
Run mix test — all passing.
The Data-Driven Advantage
Consider adding a new event. Before this chapter:
- Edit Elixir code
- Understand the event system internals
- Recompile
- Test
- Deploy
After this chapter:
- Edit the YAML file
- Restart the app
Non-programmers can create content. A/B testing becomes trivial. You can ship different event files for different difficulty levels.
Common YAML Gotchas
String keys, not atoms:
# This won't match YAML data:
%{type: "money"}
# This will:
%{"type" => "money"}
Indentation matters:
# Wrong - inconsistent indentation
events:
- id: first
name: "Oops"
# Right - consistent indentation
events:
- id: first
name: "Correct"
Handle missing files gracefully:
case YamlElixir.read_from_file(path) do
{:ok, data} -> process(data)
{:error, _} -> fallback_behavior()
end
References
- YamlElixir - YAML parsing library for Elixir
- Application.app_dir/2 - locating priv and other application directories
- Mix project structure - where
priv/fits in the build - YAML specification - the full YAML language reference
Reflecting on Your Journey
Eight tutorials ago, you wrote your first Elixir module. Now you’ve built:
- A modular Elixir application with pure game logic
- Interactive terminal UI with colored output
- Random event system with conditional triggers
- Herb consumption with effects, overdoses, and decay
- Data-driven content loaded from external files
You understand:
- How Elixir thinks about data (immutable structs and maps)
- Why immutability makes code reliable (no hidden mutations)
- How to structure growing codebases (one module, one job)
- When to use recursion vs Enum
- How to separate logic from presentation
Where to Go Next
You’re ready for the deeper waters:
- GenServer - stateful processes that run concurrently
- Supervision trees - fault tolerance and self-healing systems
- Phoenix - web applications built on the same patterns
- Ecto - database interactions with changesets and schemas
- LiveView - real-time web UIs without JavaScript
The same patterns you learned here — transforming data through pure functions, pattern matching, explicit state — scale to massive distributed systems.
Now go build something amazing.