Brewing Board Games With Elixir

Board game with cubes and figurines.
Tim Thomas

Engineer

Tim Thomas

Your digital product needs to stand out from the competition and attract users at the same time. We help our partners do just that. Book a free consult to learn how we can do it for you.

Board games have captivated players for millenia, from ancient pastimes to modern staples like Monopoly, which we’ll build (parts of) in this post. As a former professional (and current hobbyist) game developer, I love to combine my passions, but digitizing these games can bring unique challenges. While each development ecosystem has its pros and cons, Elixir is standout choice.

As a functional language, Elixir emphasizes immutability and declarative code, so it treats computation as the evaluation of functions, avoiding mutable data and changing states. This approach leads to more predictable and maintainable code, which is crucial for game development.

Structuring Games

In Elixir, we use modules to organize our code into reusable units, much like folders for related functions. Structs are similar to templates that define the shape of our data, making it easy to work with structured information like game characters or settings. Elixir’s rich data structures, like lists, maps, and tuples, allow us to represent complex game states clearly and efficiently. We won’t flesh out the full game logic here, but I find these concepts expressive for representing game concepts clearly and concisely.

defmodule Board do
  defstruct spaces: [
    %{name: "Go", type: :start},
    %{name: "Mediterranean Avenue", type: :property, price: 60},
    %{name: "Jail", type: :jail},
    %{name: "Chance", type: :chance},
    # ...etc.
  ]
end

defmodule Game do
  defstruct [:board, :players]

  def start(players) do
    %Game{board: %Board{}, players: players}
  end
end

defmodule Player do
  defstruct name: "Gary", color: nil, money: 1500, position: nil
end

Immutability

Now that we have some structures for our game, let’s move to adding some logic. Maintaining a consistent state of the board game’s “board” ensures predictable and controlled state changes, reducing the risk of bugs, and making the code more expressive. Each game move can be a pure function, maintaining consistency.

In this example, let’s create a new Game with two players, and perform a couple of moves:

blue_player = %Player{color: :blue}
red_player = %Player{color: :red}

game = Game.start([blue_player, red_player])

{:ok, game} = game
  |> Rules.move_spaces(blue_player, 2)
  |> Rules.move_spaces(red_player, 3)

Crucially, each call to Rules.move_spaces/3 doesn’t modify the game object in-place, but instead returns a new, modified game state. This means we can perform atomic operations on the game without worrying about changing its state. Unless we redefine game (which, to be clear, we are doing above), we can even reuse the original, unmodified game state!

Pattern Matching

Elixir’s pattern matching is ideal for defining game rules and handling moves, making your code concise and easy to read. If you’ve ever played Monopoly, you may recall that you can’t move your token if you’re in jail. Elixir makes it trivial to express that with pattern matching.

In the following example, we’ll implement the Rules.move_spaces/3 function. In other languages, we might need guard clauses (e.g. if (player.isInJail) throw Error('Player is in jail')) to prevent jailed players from moving, but Elixir affords us a cleaner option in the form of pattern matching, which keeps the logic separate:

defmodule Rules do
  def move_spaces(game, %{location: :jail} = player, _spaces) do
    {:error, "Player is in jail"}
  end

  def move_spaces(game, player, spaces) do
    # TODO: Implement moving.
    Logger.info("Player #{player.color} moved #{spaces} spaces")
    {:ok, game}
  end
end

Testing

Elixir’s robust testing framework, ExUnit, makes writing and running tests straightforward, ensuring that game logic is accurate and reliable. Testing is seamlessly integrated into the development process (and maybe even your IDE!)), making game code clearer and more maintainable. Let’s write some tests for our “jail” scenario:

defmodule RulesTests do
  describe "move_spaces/3" do
    test "a player can move", %{game: game} do
      player = %Player{}
      assert {:ok, _game} = Rules.move_spaces(game, player, 2)
    end

    test "a player in jail cannot move", %{game: game} do
      jailed_player = %Player{location: :jail}
      assert {:error, _msg} = Rules.move_spaces(game, jailed_player, 2)
    end
  end
end

Conveniently, our tests actually match our pattern-matched function definitions nicely! I find tests to be an excellent way of representing game state changes from player moves to more complex interactions like combat.

Are You on “Board” Yet?

Functional languages, with their immutability and pure functions, are ideal for developing games because they naturally align with the clear, rule-based logic of game mechanics. Elixir, in particular, stands out with its expressiveness, pattern matching, and exceptional testing capabilities, making it easy to represent game states and transitions clearly. Leveraging Elixir’s strengths allows developers to create engaging, reliable, and maintainable digital versions of board games—and much more. Whether you’re building strategy games, simulations, or any interactive experience, Elixir’s power to declaratively express domain logic makes it a compelling choice. Its elegance and robust ecosystem can help developers of any experience level bring complex ideas to life…and DockYard can help you take your project to the next level.

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box