Organizing Your Elixir Code: Modules, Separation, and the Art of Not Making a Mess

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:

Now we need:

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)

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:

If yes to any of these, create a new module.

References

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.