Recursion Without the Headache: Managing Game State in Elixir

“Use recursion for your game loop.”

Many programming languages support recursion, but for the vast majority of my career, I’ve avoided it. Even when it seemed intuitive, I’d get feedback from other developers that this was exotic. Elixir changes that and turns recursion into an idiomatic approach.

The Game Loop Concept

Every game has the same basic structure:

  1. Display current state
  2. Get player input
  3. Process action
  4. Update state
  5. Check win/lose conditions
  6. Repeat until game ends

In languages with mutable variables, you might use a while loop that modifies a gameState object. In Elixir, we call a function that calls itself with updated state.

A Simple Recursive Example

Before building the game loop, let’s see recursion in its simplest form. Try this in IEx:

iex> defmodule Rocket do
...>   def countdown(n) when n <= 0 do
...>     IO.puts("Blast off!")
...>   end
...>
...>   def countdown(n) do
...>     IO.puts(n)
...>     countdown(n - 1)
...>   end
...> end

iex> Rocket.countdown(3)
3
2
1
Blast off!

Walk through the calls:

  1. countdown(3) — prints “3”, calls countdown(2)
  2. countdown(2) — prints “2”, calls countdown(1)
  3. countdown(1) — prints “1”, calls countdown(0)
  4. countdown(0) — matches the when n <= 0 clause, prints “Blast off!” and stops

The first function clause is our base case. It stops the recursion. Without it, we’d loop forever. Try Rocket.countdown(10) to see a longer sequence.

Tail-Call Optimization

You might worry: doesn’t each recursive call add to the stack? In most languages, yes. But Elixir uses tail-call optimization.

When a function’s last operation is calling another function (or itself), Elixir doesn’t add a new stack frame. It reuses the current one. This means:

def game_loop(state) do
  new_state = process_turn(state)
  game_loop(new_state)  # This is a tail call
end

…can run forever without stack overflow. The key is that game_loop(new_state) is the last thing in the function.

Building Our Game Loop

In ch4, our game loop was a simple stub with an if-statement. Let’s replace it with proper pattern-matching recursion. Update game_loop in lib/herb_wars.ex:

defp game_loop(%GameState{days_remaining: 0} = _game) do
  IO.puts("Game Over!")
end

defp game_loop(game) do
  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

Notice the pattern matching on the function head: game_loop(%GameState{days_remaining: 0} = _game). When time runs out, Elixir matches the first clause and prints “Game Over!” — that’s our base case. Otherwise, the second clause runs the main loop. The base case is explicit and impossible to forget — it’s right there in the function signature. We’ll add proper end-game scoring later.

start/0, new_game/0, the handlers, and process_command/2 are already defined from ch3 and ch4. The recursive game loop is the missing piece that ties them together.

State Flows Through

Here’s the mental model that makes this click:

game_loop(game_state_day_30)
  |
  v
game_loop(game_state_day_29)
  |
  v
game_loop(game_state_day_28)
  |
  ...
  v
game_loop(game_state_day_0)  # Matched by days_remaining: 0 clause
  |
  v
"Game Over!"

Each call to game_loop receives a complete, immutable game state. It processes the turn and creates a new state for the next iteration. No shared mutable variable to track.

The full flow each turn:

  1. game_loop/1 displays status and reads a command
  2. process_command/2 routes to a handler (from ch4)
  3. Handlers call pure functions (buy/3, sell/3, travel/2 from ch3) and return {:continue, game} or {:quit, game}
  4. The loop recurses with the updated state — or stops when days reach 0

All validation, state changes, and error handling live in the pure functions from ch3. The handlers from ch4 collect input and display results. The recursive loop here ties it together. Each layer has a clear job.

Why This Approach Works

Let’s compare approaches:

Mutable state approach:

let gameState = initGame();
while (gameState.daysRemaining > 0) {
  gameState = processCommand(gameState, getInput());
}
// What if something modifies gameState unexpectedly?
// What if processCommand has side effects on shared state?

Recursive approach:

defp game_loop(%{days_remaining: 0} = _game), do: IO.puts("Game Over!")
defp game_loop(game) do
  updated = game |> display() |> get_input() |> process()
  game_loop(updated)
end
# Each iteration gets a fresh, complete state
# No hidden mutations, no shared state bugs

The recursive version is explicit about data flow. You can trace exactly how state transforms from one turn to the next.

References

What’s Next?

Our game loop works, but what happens when something goes wrong? In the next chapter, we’ll add a history system that records every turn. Because Elixir’s data is immutable, every snapshot is a perfect copy of the game at that moment — which means we can rewind to any previous state. It’s time travel for debugging.