From Hardcoded to Data-Driven: Loading YAML in Elixir

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:

  1. Edit Elixir code
  2. Understand the event system internals
  3. Recompile
  4. Test
  5. Deploy

After this chapter:

  1. Edit the YAML file
  2. 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

Reflecting on Your Journey

Eight tutorials ago, you wrote your first Elixir module. Now you’ve built:

You understand:

Where to Go Next

You’re ready for the deeper waters:

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.