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:
- Buy Mint, consume 1 — feel calm, maybe get drowsy
- Consume 5 Mint at once — overdose, lose money and time
- Try Stinging Nettles — powerful but threshold of 1!
- Travel while affected — watch effects decay each turn
- Travel a lot — random events trigger along the way
References
- Enum.reduce/3 - accumulating through collections
- update_in/2 - transforming nested data structures
- put_in/2 - setting values in nested structures
- Enum.all?/2 - checking if all elements satisfy a condition
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.