Game Mechanics: Random Events and Herb Effects

Our game has buying, selling, and traveling — but every playthrough feels the same. Time to add some chaos. In this chapter we’ll build two systems: random events that trigger when you travel, and herb consumption with effects that wear off over time.

Both follow the same pattern we’ve used throughout: build it with tests, keep the logic pure, wire up the UI last.

Updating the Player Struct

Before building new systems, we need new fields on the Player struct. Open lib/herb_wars/player.ex and update the struct:

defstruct money: 1000,
          inventory: %{},
          max_inventory: 10,
          has_trench_coat: false,
          has_suitcase: false,
          active_effects: [],
          consumption_tracker: nil

We also need a helper to check how much of a specific herb the player has:

def get_herb_quantity(player, herb_name) do
  Map.get(player.inventory, herb_name, 0)
end

Test it. Add to test/herb_wars/player_test.exs:

  describe "get_herb_quantity/2" do
    test "returns quantity for a herb the player has" do
      player = %Player{inventory: %{"Mint" => 5}}

      assert Player.get_herb_quantity(player, "Mint") == 5
    end

    test "returns 0 for a herb the player doesn't have" do
      player = %Player{inventory: %{}}

      assert Player.get_herb_quantity(player, "Mint") == 0
    end
  end

Run mix test — all passing.

The Effect Structs

An effect needs a type, intensity, duration, and the herb that caused it. Create lib/herb_wars/effects.ex:

defmodule HerbWars.Effects do
  @moduledoc """
  Manages herb consumption effects and player status.
  """

  alias HerbWars.{GameState, Player}

  defmodule Effect do
    @moduledoc "An active effect on the player."
    defstruct [:type, :intensity, :duration, :herb_source]
  end

  defmodule ConsumptionTracker do
    @moduledoc "Tracks recent herb consumption for overdose calculation."
    defstruct recent_consumption: %{}, total_consumed: 0
  end
end

Two structs: Effect for active effects on the player, and ConsumptionTracker for usage history. The tracker lets us detect when someone consumes too much of the same herb.

Herb Effect Profiles

Each herb has different effects. Add herb_effects/1 to the Effects module:

  def herb_effects("Mint") do
    {
      [{:calm, "A sense of calm washes over you.", 4},
       {:negotiation, "You feel more persuasive.", 3}],
      [{:drowsy, "You feel a bit drowsy.", 2}],
      2
    }
  end

  def herb_effects("Crushed Dandelion") do
    {
      [{:energy, "A surge of natural energy!", 4},
       {:focus, "Your mind feels sharp.", 3}],
      [{:jittery, "Your hands are shaking a bit.", 2}],
      3
    }
  end

  def herb_effects("Wild Mushrooms") do
    {
      [{:euphoria, "Everything seems amazing!", 5},
       {:luck, "You feel incredibly lucky!", 3}],
      [{:confusion, "Wait, where are you?", 3},
       {:nausea, "Your stomach churns.", 2}],
      2
    }
  end

  def herb_effects("Stinging Nettles") do
    {
      [{:energy_surge, "Boundless energy courses through you!", 6},
       {:confidence, "Nothing can stop you now!", 5}],
      [{:aggression, "Everything annoys you.", 3},
       {:reckless, "Caution? What's that?", 5}],
      1
    }
  end

  def herb_effects(_), do: {[], [], 999}

Each clause returns a tuple: {positive_effects, negative_effects, overdose_threshold}. The threshold is how many you can consume before things go wrong. Stinging Nettles has a threshold of 1 — powerful but dangerous.

Create test/herb_wars/effects_test.exs:

defmodule HerbWars.EffectsTest do
  use ExUnit.Case

  alias HerbWars.{Effects, GameState, Player}

  describe "herb_effects/1" do
    test "known herb returns positive effects, negative effects, and threshold" do
      {positive, negative, threshold} = Effects.herb_effects("Mint")

      assert length(positive) > 0
      assert length(negative) > 0
      assert is_integer(threshold)
    end

    test "unknown herb returns empty effects with high threshold" do
      {positive, negative, threshold} = Effects.herb_effects("Imaginary Herb")

      assert positive == []
      assert negative == []
      assert threshold == 999
    end
  end
end

Run mix test — all passing.

Building the Event System

Random events trigger when you travel — find money on the ground, get mugged, discover a stash of herbs. Create lib/herb_wars/event_system.ex with the events hardcoded as a module attribute:

defmodule HerbWars.EventSystem do
  @moduledoc """
  Manages random events that trigger during travel.
  """

  alias HerbWars.{GameState, Player, Herb}

  @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}]
    }
  ]

  def all_events, do: @events
end

We’re using string keys in the maps (like "type" instead of :type). This looks unusual, but it’s intentional — in a later chapter we’ll load this data from YAML files, and YAML produces string keys. By using strings now, the rest of our code won’t need to change when we make that switch.

Checking Event Conditions

Not every event should trigger every time. A police raid only makes sense if you’re carrying herbs. Add condition checking to the EventSystem:

  def check_conditions(conditions, game) when is_list(conditions) do
    Enum.all?(conditions, fn condition ->
      check_condition(condition, game)
    end)
  end

  def check_condition(%{"type" => "always"}, _game), do: true

  def check_condition(%{"type" => "has_herbs"}, game) do
    map_size(game.player.inventory) > 0
  end

  def check_condition(%{"type" => "has_money", "min_amount" => min}, game) do
    game.player.money >= min
  end

  def check_condition(%{"type" => "can_carry", "min_space" => min}, game) do
    Player.remaining_inventory_space(game.player) >= min
  end

  def check_condition(_, _), do: false

Each condition type gets its own function clause. The catch-all returns false for unknown conditions. check_conditions/2 uses Enum.all? — every condition must pass for the event to be eligible.

Create test/herb_wars/event_system_test.exs:

defmodule HerbWars.EventSystemTest do
  use ExUnit.Case

  alias HerbWars.{EventSystem, GameState, Player}

  describe "check_condition/2" do
    test "always condition returns true" do
      game = %GameState{player: Player.new()}

      assert EventSystem.check_condition(%{"type" => "always"}, game) == true
    end

    test "has_herbs is true when inventory is not empty" do
      game = %GameState{player: %Player{inventory: %{"Mint" => 5}}}

      assert EventSystem.check_condition(%{"type" => "has_herbs"}, game) == true
    end

    test "has_herbs is false when inventory is empty" do
      game = %GameState{player: %Player{inventory: %{}}}

      assert EventSystem.check_condition(%{"type" => "has_herbs"}, game) == false
    end

    test "has_money checks against min_amount" do
      rich_game = %GameState{player: %Player{money: 500}}
      poor_game = %GameState{player: %Player{money: 100}}
      condition = %{"type" => "has_money", "min_amount" => 200}

      assert EventSystem.check_condition(condition, rich_game) == true
      assert EventSystem.check_condition(condition, poor_game) == false
    end

    test "unknown condition returns false" do
      game = %GameState{player: Player.new()}

      assert EventSystem.check_condition(%{"type" => "nonexistent"}, game) == false
    end
  end
end

Run mix test — all passing.

Applying Event Effects

When an event triggers, its effects modify the game state. Add these to the EventSystem:

  def apply_event(event, game) do
    description = event["description"]
    effects = event["effects"] || []

    updated_game =
      Enum.reduce(effects, game, fn effect, acc_game ->
        apply_effect(effect, acc_game)
      end)

    {:event, description, updated_game}
  end

This Enum.reduce/3 is different from the number-summing example in ch3. Instead of reducing numbers into a sum, we’re reducing a list of effects into a game state. Each effect transforms the game, and the result feeds into the next effect.

Now the individual effect handlers:

  def apply_effect(%{"type" => "money", "amount" => amount}, game) do
    %{game | player: Player.add_money(game.player, amount)}
  end

  def apply_effect(%{"type" => "lose_herb_percentage", "percentage" => pct}, game) do
    new_inventory =
      game.player.inventory
      |> Enum.map(fn {herb, qty} ->
        keep = div(qty * (100 - pct), 100)
        {herb, keep}
      end)
      |> Enum.filter(fn {_, qty} -> qty > 0 end)
      |> Map.new()

    %{game | player: %{game.player | inventory: new_inventory}}
  end

  def apply_effect(%{"type" => "add_herb", "herb" => "random", "quantity" => qty}, game) do
    herb = Enum.random(Herb.all_herbs())

    case Player.add_herb(game.player, herb, qty) do
      {:ok, player} -> %{game | player: player}
      {:error, _} -> game
    end
  end

  def apply_effect(_, game), do: game

Each effect type has its own clause. The catch-all ignores unknown effects. Add tests:

  describe "apply_effect/2" do
    test "money effect adds to player balance" do
      game = %GameState{player: %Player{money: 500}}
      effect = %{"type" => "money", "amount" => 100}

      updated = EventSystem.apply_effect(effect, game)

      assert updated.player.money == 600
    end

    test "negative money effect subtracts from player balance" do
      game = %GameState{player: %Player{money: 500}}
      effect = %{"type" => "money", "amount" => -200}

      updated = EventSystem.apply_effect(effect, game)

      assert updated.player.money == 300
    end

    test "lose_herb_percentage reduces inventory quantities" do
      game = %GameState{player: %Player{inventory: %{"Mint" => 10, "Dandelion" => 4}}}
      effect = %{"type" => "lose_herb_percentage", "percentage" => 30}

      updated = EventSystem.apply_effect(effect, game)

      assert updated.player.inventory["Mint"] == 7
      assert updated.player.inventory["Dandelion"] == 2
    end
  end

  describe "apply_event/2" do
    test "applies all effects and returns description" do
      game = %GameState{player: %Player{money: 500}}
      event = %{
        "description" => "You found money!",
        "effects" => [%{"type" => "money", "amount" => 100}]
      }

      {:event, description, updated} = EventSystem.apply_event(event, game)

      assert description == "You found money!"
      assert updated.player.money == 600
    end
  end

Run mix test — all passing.

Triggering Random Events

Now the dice-rolling logic. Events filter by conditions, then by probability:

  def maybe_trigger_event(%GameState{} = game) do
    case select_random_event(@events, game) do
      nil -> {:no_event, game}
      event -> apply_event(event, game)
    end
  end

  defp select_random_event(events, game) do
    events
    |> Enum.filter(fn event ->
      check_conditions(event["conditions"], game)
    end)
    |> Enum.filter(fn event ->
      probability = event["probability"] || 0
      :rand.uniform(100) <= probability
    end)
    |> random_or_nil()
  end

  defp random_or_nil([]), do: nil
  defp random_or_nil(list), do: Enum.random(list)

The flow: load all events, keep only those whose conditions are met, roll the dice on each one’s probability, pick a random survivor (or nil).

Wiring Events into Travel

Events should trigger when the player travels. Open lib/herb_wars.ex and update the alias to include EventSystem:

  alias HerbWars.{GameState, Player, Herb, UI, EventSystem}

Then update handle_travel/1 to check for events after a successful trip:

  defp handle_travel(game) do
    UI.display_city_list(game.current_city)
    destination = UI.prompt("Where to?")

    case travel(game, destination) do
      {:ok, updated_game} ->
        IO.puts("Traveled to #{destination}. (1 day passed)")

        case EventSystem.maybe_trigger_event(updated_game) do
          {:event, description, game_after_event} ->
            IO.puts(IO.ANSI.yellow() <> "EVENT: #{description}" <> IO.ANSI.reset())
            {:continue, game_after_event}

          {:no_event, game_after_travel} ->
            {:continue, game_after_travel}
        end

      {:error, reason} ->
        IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
        {:continue, game}
    end
  end

Try it — mix run -e "HerbWars.start()". Travel between cities a few times. Events are random, so you might need several trips before one triggers.

Consuming Herbs

Now for the effects system. Players can consume herbs from their inventory for temporary effects — but overdoing it has consequences. Add consume_herb/3 to the Effects module:

  def consume_herb(%GameState{} = game, herb_name, quantity \\ 1) do
    current_qty = Player.get_herb_quantity(game.player, herb_name)

    cond do
      current_qty < quantity ->
        {:error, "You don't have enough #{herb_name}!"}

      quantity < 1 ->
        {:error, "Invalid quantity"}

      true ->
        {:ok, player} = Player.remove_herb(game.player, herb_name, quantity)
        game = %{game | player: player}

        {positive, negative, threshold} = herb_effects(herb_name)

        tracker = game.player.consumption_tracker || %ConsumptionTracker{}
        recent = Map.get(tracker.recent_consumption, herb_name, 0)
        new_recent = recent + quantity

        is_overdose = new_recent > threshold

        {game, messages} = apply_consumption_effects(
          game, herb_name, quantity, positive, negative, is_overdose
        )

        new_tracker = %{tracker |
          recent_consumption: Map.put(tracker.recent_consumption, herb_name, new_recent),
          total_consumed: tracker.total_consumed + quantity
        }
        game = put_in(game.player.consumption_tracker, new_tracker)

        {:ok, game, messages}
    end
  end

The flow: validate the player has enough, remove the herbs, check the effect profile, check consumption history for overdose, apply effects, update the tracker.

Add tests:

  describe "consume_herb/3" do
    test "normal consumption removes herb and applies effects" do
      game = game_with_herbs(%{"Mint" => 5})

      {:ok, updated, _messages} = Effects.consume_herb(game, "Mint", 1)

      assert updated.player.inventory["Mint"] == 4
      assert length(updated.player.active_effects) > 0
    end

    test "cannot consume herbs you don't have" do
      game = game_with_herbs(%{})

      assert {:error, _reason} = Effects.consume_herb(game, "Mint", 1)
    end

    test "overdose applies penalties when exceeding threshold" do
      game = game_with_herbs(%{"Mint" => 10})

      {:ok, updated, messages} = Effects.consume_herb(game, "Mint", 5)

      assert updated.player.money < game.player.money
      assert Enum.any?(messages, &String.contains?(&1, "OVERDOSE"))
    end
  end

  defp game_with_herbs(inventory) do
    %GameState{
      player: %Player{inventory: inventory, money: 1000, active_effects: []},
      days_remaining: 30
    }
  end

Run mix test — three failures. We haven’t written apply_consumption_effects yet.

Consumption Effects

The same herb has different outcomes based on dosage. Add these private functions to the Effects module:

  defp apply_consumption_effects(game, herb, qty, _positive, negative, true = _overdose) do
    messages = [
      "You consume #{qty} #{herb}...",
      IO.ANSI.red() <> "OVERDOSE! Too much #{herb}!" <> IO.ANSI.reset()
    ]

    {game, neg_msgs} = apply_effects(game, negative, qty * 2, herb)
    {game, penalty_msgs} = apply_overdose_penalties(game, qty)

    {game, messages ++ neg_msgs ++ penalty_msgs}
  end

  defp apply_consumption_effects(game, herb, qty, positive, negative, false) when qty > 1 do
    messages = ["You consume #{qty} #{herb}..."]

    {game, pos_msgs} = apply_effects(game, positive, qty, herb)
    {game, neg_msgs} = apply_effects(game, negative, div(qty, 2), herb)

    {game, messages ++ pos_msgs ++ neg_msgs}
  end

  defp apply_consumption_effects(game, herb, 1, positive, negative, false) do
    messages = ["You consume 1 #{herb}..."]

    {game, pos_msgs} = apply_effects(game, positive, 1, herb)

    {game, neg_msgs} =
      if :rand.uniform(100) > 70 do
        apply_effects(game, negative, 1, herb)
      else
        {game, []}
      end

    {game, messages ++ pos_msgs ++ neg_msgs}
  end

  defp apply_effects(game, effect_list, intensity, herb_source) do
    Enum.reduce(effect_list, {game, []}, fn {type, message, duration}, {g, msgs} ->
      effect = %Effect{
        type: type,
        intensity: intensity,
        duration: duration,
        herb_source: herb_source
      }

      current_effects = g.player.active_effects || []
      g = put_in(g.player.active_effects, [effect | current_effects])
      {g, [message | msgs]}
    end)
  end

Three clauses handle the scenarios: overdose (only negative effects, amplified), multi-dose (strong positive, some negative), and single dose (positive with a 30% chance of negative).

Overdose Penalties

When things go wrong, they really go wrong. Add to the Effects module:

  defp apply_overdose_penalties(game, severity) do
    penalties = [
      {:lose_money, severity * 200},
      {:lose_time, severity}
    ]

    penalties = if severity >= 3 do
      penalties ++ [{:inventory_spillage, div(severity, 2)}]
    else
      penalties
    end

    Enum.reduce(penalties, {game, []}, fn penalty, {g, msgs} ->
      {new_g, msg} = apply_penalty(g, penalty)
      {new_g, [msg | msgs]}
    end)
  end

  defp apply_penalty(game, {:lose_money, amount}) do
    loss = min(amount, game.player.money)
    game = update_in(game.player.money, &(&1 - loss))
    {game, "Lost $#{loss} in your confusion!"}
  end

  defp apply_penalty(game, {:lose_time, days}) do
    game = update_in(game.days_remaining, &max(0, &1 - days))
    {game, "You black out for #{days} day(s)!"}
  end

  defp apply_penalty(game, {:inventory_spillage, qty}) do
    if map_size(game.player.inventory) > 0 do
      lost = game.player.inventory
        |> Map.keys()
        |> Enum.take_random(qty)
        |> Enum.map(fn herb ->
          current = game.player.inventory[herb]
          loss = min(current, :rand.uniform(3))
          {herb, loss}
        end)

      game = Enum.reduce(lost, game, fn {herb, loss}, g ->
        {:ok, player} = Player.remove_herb(g.player, herb, loss)
        %{g | player: player}
      end)

      {game, "Dropped items while stumbling around!"}
    else
      {game, ""}
    end
  end

update_in/2 is new. It reaches into a nested structure and transforms a value. update_in(game.player.money, &(&1 - loss)) means “take the current money value and subtract loss.” It’s a shortcut for %{game | player: %{game.player | money: game.player.money - loss}}.

Run mix test — the three consumption tests should now pass.

Effect Decay

Effects wear off over time. Add process_active_effects/1 to the Effects module:

  def process_active_effects(%GameState{} = game) do
    effects = game.player.active_effects || []

    if Enum.empty?(effects) do
      {game, []}
    else
      {remaining, messages} =
        Enum.reduce(effects, {[], []}, fn effect, {eff_acc, msg_acc} ->
          new_duration = effect.duration - 1

          if new_duration <= 0 do
            msg = "#{effect_name(effect.type)} effect wore off."
            {eff_acc, [msg | msg_acc]}
          else
            {[%{effect | duration: new_duration} | eff_acc], msg_acc}
          end
        end)

      game = put_in(game.player.active_effects, remaining)
      game = decay_consumption(game)

      {game, Enum.reverse(messages)}
    end
  end

  defp decay_consumption(game) do
    case game.player.consumption_tracker do
      nil -> game
      tracker ->
        decayed = tracker.recent_consumption
          |> Enum.map(fn {herb, count} -> {herb, max(0, count - 1)} end)
          |> Enum.filter(fn {_, count} -> count > 0 end)
          |> Map.new()

        put_in(game.player.consumption_tracker.recent_consumption, decayed)
    end
  end

  defp effect_name(type) do
    type
    |> Atom.to_string()
    |> String.replace("_", " ")
    |> String.capitalize()
  end

Each day: reduce durations by 1, remove expired effects, decay the consumption tracker (so tolerance fades over time).

Add tests:

  describe "process_active_effects/1" do
    test "decrements effect durations" do
      effect = %Effects.Effect{type: :calm, intensity: 1, duration: 3, herb_source: "Mint"}
      game = %GameState{player: %Player{active_effects: [effect]}}

      {updated, _messages} = Effects.process_active_effects(game)

      [remaining] = updated.player.active_effects
      assert remaining.duration == 2
    end

    test "removes expired effects" do
      effect = %Effects.Effect{type: :calm, intensity: 1, duration: 1, herb_source: "Mint"}
      game = %GameState{player: %Player{active_effects: [effect]}}

      {updated, messages} = Effects.process_active_effects(game)

      assert updated.player.active_effects == []
      assert Enum.any?(messages, &String.contains?(&1, "wore off"))
    end
  end

Run mix test — all passing.

Displaying Effects

Add active_effects_summary/1 to the Effects module for the game loop to display:

  def active_effects_summary(game) do
    effects = game.player.active_effects || []

    if Enum.empty?(effects) do
      nil
    else
      effects
      |> Enum.map(fn e -> "#{effect_name(e.type)} (#{e.duration} days)" end)
      |> Enum.join(", ")
    end
  end

Test it:

  describe "active_effects_summary/1" do
    test "returns nil when no effects are active" do
      game = %GameState{player: %Player{active_effects: []}}

      assert Effects.active_effects_summary(game) == nil
    end

    test "formats active effects with duration" do
      effect = %Effects.Effect{type: :calm, intensity: 1, duration: 3, herb_source: "Mint"}
      game = %GameState{player: %Player{active_effects: [effect]}}

      assert Effects.active_effects_summary(game) == "Calm (3 days)"
    end
  end

Now add two display helpers to lib/herb_wars/ui.ex:

  def display_effect_messages(messages) do
    unless Enum.empty?(messages) do
      IO.puts(IO.ANSI.cyan() <> "Status Updates:" <> IO.ANSI.reset())
      Enum.each(messages, &IO.puts/1)
    end
  end

  def display_active_effects(summary) do
    if summary do
      IO.puts(IO.ANSI.magenta() <> "Active Effects: #{summary}" <> IO.ANSI.reset())
    end
  end

Run mix test — all passing.

Wiring Effects into the Game Loop

Update the alias in lib/herb_wars.ex to include Effects:

  alias HerbWars.{GameState, Player, Herb, UI, EventSystem, Effects}

Add a consume handler:

  defp handle_consume(game) do
    herb_name = UI.prompt("Which herb?")
    quantity = UI.prompt_number("How many?")

    case Effects.consume_herb(game, herb_name, quantity || 1) do
      {:ok, updated_game, messages} ->
        Enum.each(messages, &IO.puts/1)
        {:continue, updated_game}

      {:error, reason} ->
        IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
        {:continue, game}
    end
  end

Add consume to process_command:

  defp process_command(game, "c"), do: handle_consume(game)
  defp process_command(game, "consume"), do: handle_consume(game)

Update display_menu in lib/herb_wars/ui.ex:

  def display_menu do
    IO.puts("  [B]uy  [S]ell  [T]ravel  [C]onsume  [Q]uit\n")
  end

Finally, add effect processing to the game loop. In game_loop/1, add these lines before the status display:

    {game, effect_messages} = Effects.process_active_effects(game)
    UI.display_effect_messages(effect_messages)
    UI.display_active_effects(Effects.active_effects_summary(game))

Playing with Your Creation

mix run -e "HerbWars.start()"

Try these scenarios:

  1. Buy Mint, consume 1 — feel calm, maybe get drowsy
  2. Consume 5 Mint at once — overdose, lose money and time
  3. Try Stinging Nettles — powerful but threshold of 1!
  4. Travel while affected — watch effects decay each turn
  5. Travel a lot — random events trigger along the way

References

What’s Next?

The game mechanics work, but all the event and effect data is hardcoded in Elixir modules. Want to add a new event? You have to edit code and recompile. In the next chapter, we’ll move this data to external YAML files — making the game data-driven and letting you tweak content without touching code.