In ch5, we built a recursive game loop that threads state through every turn. Each call to game_loop/1 receives a complete, immutable game state and produces a new one. We treated this as an implementation detail — a way to avoid mutable variables.
But there’s something deeper going on. Because Elixir never mutates data, every game state that ever existed is still a valid, self-contained snapshot. If we hold onto those snapshots, we get something powerful: a complete, trustworthy history of everything that happened.
This chapter builds a history system that records every turn, displays it for debugging, and lets you rewind to any previous state. Along the way, you’ll see why immutability isn’t just a constraint — it’s a feature.
Why History Matters
Picture this: a player reports a bug. “I bought 5 Mint, traveled to Verdant Hills, and suddenly my money went negative.” In a mutable system, the state that caused the bug is gone — overwritten by the next action. You’re left adding print statements and trying to reproduce the sequence.
In an immutable system, every state that ever existed is a perfect copy. If you save those copies, you can:
- Inspect: see exactly what the game looked like at any point
- Rewind: jump back to a previous state and try different actions
- Replay: feed a recorded command sequence into the game to reproduce bugs deterministically
This isn’t a theoretical exercise. Production Elixir systems use this same principle — event sourcing, audit logs, and undo systems all rely on the idea that past states are trustworthy because nothing can modify them after the fact.
The History Accumulator
Elixir’s recursive loops make history tracking natural. We add a second argument to game_loop that accumulates a list of past states:
defp game_loop(game, history \\ [])
defp game_loop(%GameState{days_remaining: 0} = game, history) do
end_game(game, history)
end
defp game_loop(game, history) do
UI.display_status(game)
UI.display_market(game)
UI.display_menu()
command = UI.prompt(">") |> String.downcase()
case command do
"history" ->
UI.display_history(history)
game_loop(game, history)
"rewind" ->
rewind(game, history)
_ ->
{action, updated_game} = process_command(game, command)
case action do
:continue -> game_loop(updated_game, [{command, game} | history])
:quit -> IO.puts("Thanks for playing!")
end
end
end
The key line is [{command, game} | history]. Each turn, we prepend a tuple of the command and the current game state (before the command was processed) to the history list. Prepending to a list is O(1) in Elixir — it just creates a new head node pointing to the existing list. Appending would be O(n), copying the entire list each time.
This means history is stored newest-first. That’s a deliberate choice: the most recent states are the ones you usually care about, and they’re right at the front.
The \\ syntax in game_loop(game, history \\ []) sets a default value. When start/0 calls game_loop(game) with one argument, history starts as an empty list. This keeps the public API clean while the internal recursion carries the accumulator.
Displaying History
Let’s add the display function to the UI module. Tests first — we want to verify the formatting logic:
defmodule HerbWars.UITest do
use ExUnit.Case
import ExUnit.CaptureIO
alias HerbWars.{UI, GameState, Player}
describe "display_history/1" do
test "displays turns in chronological order" do
history = [
{"buy", %GameState{
player: %Player{money: 800},
current_city: "Verdant Hills",
days_remaining: 28
}},
{"travel", %GameState{
player: %Player{money: 1000},
current_city: "Meadow Creek",
days_remaining: 29
}}
]
output = capture_io(fn -> UI.display_history(history) end)
assert output =~ "Turn 1"
assert output =~ "travel"
assert output =~ "Meadow Creek"
assert output =~ "Turn 2"
assert output =~ "buy"
assert output =~ "Verdant Hills"
end
test "empty history shows no turns" do
output = capture_io(fn -> UI.display_history([]) end)
assert output =~ "Game History"
refute output =~ "Turn"
end
end
end
Notice we’re using ExUnit.CaptureIO — it captures everything a function prints to stdout, letting us assert on the output without actually displaying anything. The history list has the newest entry first (the “buy” on turn 2), but we expect the display to show them in chronological order (turn 1 first).
Run mix test — two failures. Now implement:
def display_history(history) do
IO.puts("\n--- Game History ---")
history
|> Enum.reverse()
|> Enum.with_index(1)
|> Enum.each(fn {{command, game}, turn} ->
IO.puts("Turn #{turn}: \"#{command}\" | Money: $#{game.player.money} | " <>
"City: #{game.current_city} | Days left: #{game.days_remaining}")
end)
IO.puts("--- End History ---\n")
end
We Enum.reverse/1 the list because history is stored newest-first. Enum.with_index(1) pairs each entry with a turn number starting at 1.
Run mix test — green.
Rewinding to a Previous State
Here’s where immutability really shines. In a mutable system, “going back to a previous state” means carefully undoing every change — and hoping you don’t miss one. In Elixir, the previous state already exists. We just need to pick it from the list and resume the game loop from there.
First, a UI function to display available rewind points and get the player’s choice:
def display_rewind_options(history) do
IO.puts("\n--- Rewind Points ---")
history
|> Enum.reverse()
|> Enum.with_index(1)
|> Enum.each(fn {{command, game}, turn} ->
IO.puts("[#{turn}] Before \"#{command}\" | Money: $#{game.player.money} | " <>
"City: #{game.current_city} | Days left: #{game.days_remaining}")
end)
IO.puts("[0] Cancel")
IO.puts("")
end
Now the rewind function in the main module:
defp rewind(_game, []) do
IO.puts("No history to rewind to.")
game_loop(_game, [])
end
defp rewind(game, history) do
UI.display_rewind_options(history)
case UI.prompt_number("Rewind to which turn?") do
0 ->
game_loop(game, history)
n when n > 0 and n <= length(history) ->
reversed = Enum.reverse(history)
{_command, old_game} = Enum.at(reversed, n - 1)
# Keep only the history up to that point
new_history = Enum.drop(reversed, n) |> Enum.reverse()
IO.puts(IO.ANSI.yellow() <> "Rewound to turn #{n}." <> IO.ANSI.reset())
game_loop(old_game, new_history)
_ ->
IO.puts("Invalid turn number.")
rewind(game, history)
end
end
Think about what’s happening here. When the player picks turn 3, we:
- Reverse the history to chronological order
- Grab the game state from turn 3 — the state before that turn’s command was processed
- Trim the history to only include turns 1 and 2
- Resume the game loop with the old state and trimmed history
The old game state isn’t a reconstruction. It isn’t a diff applied in reverse. It’s the actual struct that existed at that point in the game. Immutability guarantees it hasn’t been touched since it was created.
Testing the Rewind Logic
The rewind logic is worth testing because it manipulates the history list. Let’s extract the pure part — selecting a state and trimming history — into a testable function:
defmodule HerbWars.History do
@moduledoc """
Functions for working with game history.
"""
@doc """
Rewinds history to a given turn number.
Returns {:ok, game_state, trimmed_history} or {:error, reason}.
"""
def rewind_to(history, turn) when turn > 0 do
reversed = Enum.reverse(history)
if turn <= length(reversed) do
{_command, game} = Enum.at(reversed, turn - 1)
trimmed = reversed |> Enum.take(turn - 1) |> Enum.reverse()
{:ok, game, trimmed}
else
{:error, "Turn #{turn} does not exist"}
end
end
def rewind_to(_history, _turn), do: {:error, "Invalid turn number"}
end
Now test it:
defmodule HerbWars.HistoryTest do
use ExUnit.Case
alias HerbWars.{History, GameState, Player}
defp sample_history do
# Newest first: turn 3, turn 2, turn 1
[
{"sell", %GameState{player: %Player{money: 1200}, current_city: "Verdant Hills", days_remaining: 27}},
{"buy", %GameState{player: %Player{money: 800}, current_city: "Verdant Hills", days_remaining: 28}},
{"travel", %GameState{player: %Player{money: 1000}, current_city: "Meadow Creek", days_remaining: 29}}
]
end
describe "rewind_to/2" do
test "rewinding to turn 1 returns the earliest state with empty history" do
{:ok, game, history} = History.rewind_to(sample_history(), 1)
assert game.player.money == 1000
assert game.current_city == "Meadow Creek"
assert history == []
end
test "rewinding to turn 2 preserves turn 1 in history" do
{:ok, game, history} = History.rewind_to(sample_history(), 2)
assert game.player.money == 800
assert game.current_city == "Verdant Hills"
assert length(history) == 1
end
test "rewinding to turn 3 preserves turns 1 and 2" do
{:ok, game, history} = History.rewind_to(sample_history(), 3)
assert game.player.money == 1200
assert length(history) == 2
end
test "rewinding to invalid turn returns error" do
assert {:error, _} = History.rewind_to(sample_history(), 5)
assert {:error, _} = History.rewind_to(sample_history(), 0)
assert {:error, _} = History.rewind_to(sample_history(), -1)
end
test "rewinding with empty history returns error" do
assert {:error, _} = History.rewind_to([], 1)
end
end
end
Run mix test — all green.
Now simplify the rewind/2 function in lib/herb_wars.ex to use the new module:
defp rewind(game, history) do
UI.display_rewind_options(history)
case UI.prompt_number("Rewind to which turn?") do
0 ->
game_loop(game, history)
n ->
case History.rewind_to(history, n) do
{:ok, old_game, trimmed_history} ->
IO.puts(IO.ANSI.yellow() <> "Rewound to turn #{n}." <> IO.ANSI.reset())
game_loop(old_game, trimmed_history)
{:error, reason} ->
IO.puts(IO.ANSI.red() <> reason <> IO.ANSI.reset())
rewind(game, history)
end
end
end
This follows the same extraction pattern from ch5: when a private function has logic worth testing, pull it into its own module. The rewind/2 function in HerbWars handles IO and recursion; History.rewind_to/2 handles the pure data transformation.
Why This Works (And Why It Wouldn’t in Most Languages)
In a language with mutable state, saving “history” means choosing between:
- Deep copying every object at every step (expensive and error-prone)
- Recording deltas and reconstructing state by replaying them (complex and fragile)
- Serializing to disk or database (slow, and you still need to deserialize)
In Elixir, none of this is necessary. When we write [{command, game} | history], we’re not copying game. We’re storing a reference to the existing struct. Since nothing can ever modify that struct, the reference is as good as a copy — but it’s essentially free.
This is called structural sharing. The BEAM (Elixir’s runtime) is designed for this. Data structures share memory with their predecessors wherever possible. A map with one key changed doesn’t copy all the other keys — it points to them in the original.
The practical upside: our history list holding 30 turns of game states uses far less memory than 30 independent copies would. And every snapshot is guaranteed to be accurate, because immutability means no action can retroactively corrupt a past state.
References
- Immutable data - how Elixir handles data without mutation
- ExUnit.CaptureIO - testing functions that print to stdout
- Enum -
reverse/1,with_index/2,at/2,take/2,drop/2 - Default arguments - the
\\syntax for optional parameters
What’s Next?
With history and debugging tools in place, our game is becoming a real project. In the next chapter, we’ll add two major features: random events that trigger when you travel, and an herb consumption system with effects, overdoses, and risk/reward gameplay.