Your game already works. You can buy, sell, and travel from IEx. But IEx isn’t exactly a polished user experience. Players deserve a real interface — status displays, market listings, menus, and prompts.
The key insight: we don’t touch the game logic. We build a UI layer on top of the pure functions from ch3. This separation — game logic vs. presentation — is the foundation of maintainable code.
One Module, One Responsibility
Each module should do one thing well. Our project now has:
- Player - manages player state (money, inventory)
- Herb - handles herb data and pricing
- GameState - tracks the overall game state
- HerbWars - pure game actions (buy, sell, travel)
Now we need:
- UI - displays information, reads input
Notice what UI doesn’t do: it doesn’t decide whether a purchase is valid, it doesn’t calculate prices, it doesn’t manage state. Those are other modules’ jobs. UI just shows things and asks for things.
Starting the UI Module
Create lib/herb_wars/ui.ex with two basic functions:
defmodule HerbWars.UI do
@moduledoc """
Handles all user interface operations.
"""
def clear_screen do
IO.write(IO.ANSI.clear() <> IO.ANSI.home())
end
def prompt(message) do
IO.gets("#{message} ") |> String.trim()
end
end
clear_screen/0 combines two ANSI escape sequences — the same pattern from display_banner in ch1. prompt/1 writes a message, waits for input, and trims the trailing newline.
Try them in IEx:
iex> HerbWars.UI.clear_screen()
iex> name = HerbWars.UI.prompt("What's your name?")
What's your name? Alice
"Alice"
Reading Numbers
Add prompt_number/1 to the UI module:
def prompt_number(message) do
case prompt(message) |> Integer.parse() do
{number, ""} -> number
_ -> nil
end
end
prompt_number/1 calls prompt/1 to get a string, then pipes it into Integer.parse/1. Integer.parse returns a tuple on success or :error on failure:
iex> Integer.parse("42")
{42, ""} # parsed 42, nothing left over
iex> Integer.parse("42abc")
{42, "abc"} # parsed 42, but "abc" was trailing
iex> Integer.parse("abc")
:error # couldn't parse a number at all
The pattern {number, ""} only matches when the entire input was a valid number — no leftover characters. "5" matches, but "5x" and "hello" fall through to the _ -> nil catch-all. The caller decides how to handle nil — the UI module just reports what it received.
Try it:
iex> HerbWars.UI.prompt_number("How many?")
How many? 5
5
iex> HerbWars.UI.prompt_number("How many?")
How many? abc
nil
Displaying Game Status
Add display_status/1 and its private helpers to the UI module:
def display_status(game) do
status = """
#{IO.ANSI.cyan()}━━━ STATUS ━━━#{IO.ANSI.reset()}
#{money_display(game.player.money)}
City: #{IO.ANSI.yellow()}#{game.current_city}#{IO.ANSI.reset()}
Days Left: #{days_display(game.days_remaining)}
"""
IO.puts(status)
end
defp money_display(money) when money >= 1000 do
IO.ANSI.green() <> "Money: $#{money}" <> IO.ANSI.reset()
end
defp money_display(money) when money >= 200 do
IO.ANSI.yellow() <> "Money: $#{money}" <> IO.ANSI.reset()
end
defp money_display(money) do
IO.ANSI.red() <> "Money: $#{money}" <> IO.ANSI.reset()
end
defp days_display(days) when days > 20 do
IO.ANSI.green() <> "#{days}" <> IO.ANSI.reset()
end
defp days_display(days) when days > 10 do
IO.ANSI.yellow() <> "#{days}" <> IO.ANSI.reset()
end
defp days_display(days) do
IO.ANSI.red() <> "#{days}" <> IO.ANSI.reset()
end
Two new concepts here.
The defp keyword defines a private function — it can only be called from within this module. The public display_status/1 uses these private helpers, but external code can’t call money_display/1 directly. Use private functions for implementation details — your public API stays clean, and you can refactor internals without breaking callers.
money_display/1 uses guard clauses. Instead of one function with nested if-statements:
def money_display(money) do
cond do
money >= 1000 -> # green
money >= 200 -> # yellow
true -> # red
end
end
We have three function clauses with when guards:
defp money_display(money) when money >= 1000 do ... end
defp money_display(money) when money >= 200 do ... end
defp money_display(money) do ... end # catch-all
Elixir tries each clause in order until one matches. This is more declarative — you’re describing cases rather than nesting conditions.
Try it in IEx — build a game state and pass it in:
iex> alias HerbWars.{GameState, Player}
iex> game = %GameState{player: Player.new(), current_city: "Akron",
...> market_prices: %{}, days_remaining: 25}
iex> HerbWars.UI.display_status(game)
You should see colored output — green money (1000 >= 1000) and green days (25 > 20).
Displaying the Market
Add display_market/1 to the UI module:
def display_market(game) do
IO.puts("\n#{IO.ANSI.cyan()}━━━ MARKET (#{game.current_city}) ━━━#{IO.ANSI.reset()}")
game.market_prices
|> Enum.sort_by(fn {_, price} -> price end)
|> Enum.each(fn {herb, price} ->
IO.puts(" #{herb}: $#{price}")
end)
IO.puts("")
end
Enum.sort_by orders herbs cheapest-first. Enum.each is like Enum.map but doesn’t collect results — it’s for side effects like printing. Use Enum.map when you need the transformed list, Enum.each when you just want to do something with each element.
Try it with the same game from above (add some prices this time):
iex> game = %GameState{player: Player.new(), current_city: "Akron",
...> market_prices: %{"Mint" => 20, "Wild Mushrooms" => 85}, days_remaining: 25}
iex> HerbWars.UI.display_market(game)
Menus and City Lists
Add display_menu/0 and display_city_list/1:
def display_menu do
IO.puts(" [B]uy [S]ell [T]ravel [Q]uit\n")
end
def display_city_list(current_city) do
IO.puts("\n#{IO.ANSI.cyan()}━━━ CITIES ━━━#{IO.ANSI.reset()}")
HerbWars.GameState.all_cities()
|> Enum.each(fn city ->
marker = if city == current_city, do: " (here)", else: ""
IO.puts(" #{city}#{marker}")
end)
IO.puts("")
end
That completes the UI module. Every display and input function lives here — the game logic modules don’t know or care how information is presented.
The Handler Pattern
Now let’s wire the UI to our pure game functions. Open lib/herb_wars.ex and update the alias line to include UI:
alias HerbWars.{GameState, Player, Herb, UI}
Then add handle_buy/1:
defp handle_buy(game) do
herb_name = UI.prompt("Which herb?")
quantity = UI.prompt_number("How many?")
case buy(game, herb_name, quantity || 0) do
{:ok, updated_game} ->
IO.puts(IO.ANSI.green() <> "Bought #{quantity} #{herb_name}!" <> IO.ANSI.reset())
{:continue, updated_game}
{:error, reason} ->
IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
{:continue, game}
end
end
This is the first time we’re using case to handle result tuples in application code. Back in ch2, our tests used {:ok, player} = ... to destructure successes — but that syntax crashes if the result is an error. That’s fine in tests where failure means a bug, but here either outcome is valid. The player might type a bad herb name. case lets us handle both branches gracefully.
Notice the pattern: handle_buy/1 is a thin IO wrapper. It doesn’t validate the purchase, check inventory space, or calculate prices — buy/3 does all that. The handler just asks the user for input, calls the pure function, and prints the result. If you ever need to change how buying works, you edit buy/3. If you need to change how it looks, you edit handle_buy/1.
Adding Sell and Travel Handlers
Add handle_sell/1 and handle_travel/1 to lib/herb_wars.ex. They follow the same pattern:
defp handle_sell(game) do
herb_name = UI.prompt("Which herb?")
quantity = UI.prompt_number("How many?")
case sell(game, herb_name, quantity || 0) do
{:ok, updated_game} ->
IO.puts(IO.ANSI.green() <> "Sold #{quantity} #{herb_name}!" <> IO.ANSI.reset())
{:continue, updated_game}
{:error, reason} ->
IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
{:continue, game}
end
end
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)")
{:continue, updated_game}
{:error, reason} ->
IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
{:continue, game}
end
end
All three handlers return {:continue, game} tuples — we’ll use that signal in the game loop next.
Command Processing and the Game Loop
Commands route to handlers using pattern matching. Add process_command/2 to lib/herb_wars.ex:
defp process_command(game, "b"), do: handle_buy(game)
defp process_command(game, "buy"), do: handle_buy(game)
defp process_command(game, "s"), do: handle_sell(game)
defp process_command(game, "sell"), do: handle_sell(game)
defp process_command(game, "t"), do: handle_travel(game)
defp process_command(game, "travel"), do: handle_travel(game)
defp process_command(game, "q"), do: {:quit, game}
defp process_command(game, "quit"), do: {:quit, game}
defp process_command(game, _unknown) do
IO.puts(IO.ANSI.red() <> "Unknown command" <> IO.ANSI.reset())
{:continue, game}
end
Each command (or its abbreviation) has a clause. The final clause with _unknown catches anything else. No giant if-else chain needed.
Now add start/0 and game_loop/1 to tie it all together:
def start do
UI.clear_screen()
game = new_game()
game_loop(game)
end
defp game_loop(game) do
if game.days_remaining <= 0 do
IO.puts("Game over!")
else
UI.display_status(game)
UI.display_market(game)
UI.display_menu()
command = UI.prompt(">") |> String.downcase()
{action, updated_game} = process_command(game, command)
case action do
:continue -> game_loop(updated_game)
:quit -> IO.puts("Thanks for playing!")
end
end
end
The game is now playable:
mix run -e "HerbWars.start()"
This is a stub — we’ll replace the game loop with a proper recursive version and end-game scoring in ch5. For now it’s enough to buy, sell, and travel for a few turns.
The File Structure
Your project now looks like:
lib/
├── herb_wars.ex # Main module: pure actions + UI handlers
└── herb_wars/
├── display.ex # Banner and splash screen (from ch1)
├── game_state.ex # GameState struct
├── herb.ex # Herb data and pricing
├── player.ex # Player struct and operations
└── ui.ex # All display and input
The naming convention matters: HerbWars.Player lives in lib/herb_wars/player.ex. Elixir expects this, and tools like the compiler and editor plugins rely on it.
Testing Across Modules
Most of ch4’s code is IO-heavy — display_status/1, prompt/1, handle_buy/1. UI code is best verified by running the game. But we can test the module integration. Add these to test/herb_wars/game_state_test.exs:
describe "module integration" do
test "purchase flow across modules" do
game = %GameState{
player: Player.new(),
market_prices: %{"Mint" => 20},
days_remaining: 30
}
{:ok, game} = HerbWars.buy(game, "Mint", 5)
assert game.player.money == 900
assert game.player.inventory == %{"Mint" => 5}
end
test "sell flow across modules" do
game = %GameState{
player: %Player{money: 500, inventory: %{"Mint" => 5}},
market_prices: %{"Mint" => 30},
days_remaining: 30
}
{:ok, game} = HerbWars.sell(game, "Mint", 3)
assert game.player.money == 590
assert game.player.inventory == %{"Mint" => 2}
end
end
Run mix test — your two new integration tests pass alongside the rest.
These tests prove the modules work together. The purchase test exercises Player.spend_money/2, Player.add_herb/3, and the GameState struct in one realistic scenario. The sell test does the same for Player.remove_herb/3 and Player.add_money/2.
Why so few tests for this chapter? Most of the code here is IO-driven — display functions, prompts, and command routing. UI code is best verified by running the game (
mix run -e "HerbWars.start()"). Unit tests shine for pure functions; for IO-driven code, manual testing and integration tests are more practical.
When to Create a New Module
Ask yourself:
- Does this code have a distinct responsibility?
- Would I want to test this in isolation?
- Does it have its own state/data?
- Would extracting it make the current module simpler?
If yes to any of these, create a new module.
References
- Modules and functions -
def,defp, and module attributes - alias, require, and import - managing module references
- Module documentation -
@moduledoc,@doc, and ExDoc - Naming conventions - file names, module names, and function names
What’s Next?
In the next post, we tackle the game loop with recursion. You’ll see how Elixir handles state over time without mutable variables, build a proper recursive game loop with end-game scoring, and learn why recursive functions don’t blow up the stack.