Building Game of Islands with Elixir and Liveview

Published: (December 17, 2025 at 09:51 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

What is Islands Duel?

Islands Duel is a two‑player strategy game, similar to the classic Battleship game.

  • Setup – Each player has an 11 × 11 board with 5 hidden islands placed on it.
  • Gameplay – Players take turns guessing coordinates on their opponent’s board.
  • Hit or Miss – If you guess a cell that contains part of an island, it’s a hit; otherwise it’s a miss.
  • Forestation – When all cells of an island are hit, that island is forested (destroyed).
  • Winning – The first player to forest all of their opponent’s islands wins!

Islands Duel Demo

Before diving into the technical details, you can try the game here:

https://islands-duel.fly.dev/

And you can find the code for this post here:

https://github.com/hungle00/islands_duel

Why I Built This

I was learning Elixir from the book Functional Web Development with Elixir, OTP, and Phoenix by Lance Halvorsen. The book teaches you how to build a game engine, but it doesn’t provide a complete web UI. So I decided to build one using Phoenix LiveView!

Why LiveView?

LiveView lets you build interactive, real‑time web applications without writing JavaScript. Everything runs on the server in Elixir, and LiveView automatically syncs the UI with the browser via WebSocket. For this game, I wrote zero lines of JavaScript for the game logic—all the clicking, board updates, and player synchronization happen in pure Elixir. That’s the magic of LiveView!

Building the Game Step by Step

Let me walk you through how I built this game. Don’t worry if you’re new to Elixir—I’ll explain each step in simple terms.

Step 1: Letting Users Join a Game

  1. Player 1 clicks “Start a game” → a new game is created with a unique game_id.
  2. Player 1 shares the game_id with Player 2.
  3. Player 2 enters the game_id → they join the same game.

User joins game

When a user visits the game page, we check if the game exists. If not, we create it, then add the player to the game.

# Check if game exists
game_pid = GenServer.whereis(Game.via_tuple(game_id))

if game_pid == nil do
  # Game doesn't exist, create it and join as Player 1
  GameSupervisor.start_game(game_id)
  Game.add_player(Game.via_tuple(game_id), username)
else
  # Game exists, join as Player 2
  Game.add_player(Game.via_tuple(game_id), username)
end

What is GameSupervisor?

Think of GameSupervisor as a manager that watches over all the games. Each game runs as a separate process (like a mini‑program inside your app). The supervisor creates new game processes when players start games and keeps track of all running games.

Step 2: Using mount to Set Up the Game Screen

When a player opens the game page, LiveView calls a function named mount. Think of it as the setup phase—we prepare everything the player needs to see.

What we do in mount

  • Get the player’s name and game ID.
  • Load the game state (whose turn, player names, etc.).
  • Draw two boards on the screen.
def mount(_params, session, socket) do
  game_id  = session["game_id"]
  username = session["current_user"].name

  # Save this info so we can use it later
  socket =
    socket
    |> assign(:game_id, game_id)
    |> assign(:username, username)
    |> assign(:player1_name, nil)
    |> assign(:player2_name, nil)
    |> assign(:player_turn, nil)

  # Only do "live" stuff when the browser is connected
  if connected?(socket) do
    socket = join_game(socket, game_id, username)
    {:ok, socket}
  else
    {:ok, socket}
  end
end

Why connected?(socket)?

LiveView runs twice: once to generate HTML for the initial page load, and again when the browser connects via WebSocket. We only want to join the game when the browser is actually connected, not during the initial HTML generation.

Drawing the boards

In the template (.heex file) we draw two 11 × 11 grids using a simple loop:

<%= for {row, row_idx} <- Enum.with_index(@board) do %>
  <%= for {cell, col_idx} <- Enum.with_index(row) do %>
    <div
      phx-click="cell_click"
      phx-value-row={row_idx}
      phx-value-col={col_idx}
      class={"cell #{cell_class(cell)}"}>
    </div>
  <% end %>
<% end %>

Each cell has phx-click="cell_click" which tells LiveView to call our handle_event function when clicked, and phx-value supplies the coordinate of each cell.

Step 3: Using handle_event to Process Player Clicks

When a player clicks a cell on their opponent’s board, LiveView captures that click and calls handle_event. This is where we process the guess:

def handle_event("cell_click", %{"row" => row, "col" => col}, socket) do
  game_id        = socket.assigns.game_id
  current_player = socket.assigns.player_role

  # Ask the game engine: is the guess a hit or a miss?
  case Game.make_guess(game_id, current_player, {row, col}) do
    {:hit, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "hit", row: row, col: col})

    {:miss, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "miss", row: row, col: col})
  end
end

The Game.make_guess/3 function (implemented in the game engine) determines whether the coordinate hits an island, updates the internal state, and returns the new state along with the result. The LiveView then pushes an event back to the client so the UI can update the cell’s appearance.

Wrap‑Up

  • LiveView lets us keep all game logic on the server, eliminating the need for custom JavaScript.
  • GenServer processes (one per game) handle the core game state, while a Supervisor keeps those processes alive and organized.
  • The result is a smooth, real‑time, two‑player experience that feels like a classic board game—only played in the browser.

Give the demo a try, explore the source code, and feel free to experiment with your own twists on Islands Duel!

Is it a hit or miss?

case Game.guess_coordinate(Game.via_tuple(game_id), current_player, row, col) do
  {:hit, island, win_status} ->
    # We hit an island!
    socket = mark_cell_as_hit(socket, row, col)
    {:noreply, socket}

  {:miss, :none, :no_win} ->
    # We missed
    socket = mark_cell_as_miss(socket, row, col)
    {:noreply, socket}

  :error ->
    # Not our turn or invalid move
    {:noreply, put_flash(socket, :error, "Not your turn!")}
end

How the click data gets to our function

LiveView provides special HTML attributes called bindings that connect your template to Elixir code:

BindingDescription
phx-click="cell_click"When this element is clicked, call handle_event("cell_click", …) in the LiveView
phx-value-row={row}Attach the row value to the click event
phx-value-col={col}Attach the col value to the click event

These values are automatically collected and passed to handle_event as the second parameter (%{"row" => "5", "col" => "3"}). No JavaScript needed—LiveView handles everything!

Step 4: Using handle_info to Update the Screen

When Player 1 makes a move, how does Player 2 see it? They’re on different browsers! LiveView provides a handle_info/2 callback for this purpose. It receives messages from other parts of the system. When Player 1 makes a guess, we send a message to Player 2’s LiveView, and handle_info/2 processes it:

def handle_info({:guess_result, result}, socket) do
  # Another player made a guess, update our screen
  socket =
    socket
    |> update_board_with_result(result)
    |> put_flash(:info, "#{result.player_name} guessed (#{result.row}, #{result.col})")

  {:noreply, socket}
end

Another example: when Player 2 joins the game, we notify Player 1:

def handle_info({:player_added, %{username: username}}, socket) do
  # A new player joined the game!
  socket =
    socket
    |> load_game_state(socket.assigns.game_id)
    |> put_flash(:info, "#{username} joined the game!")

  {:noreply, socket}
end

Step 5: Using PubSub to Connect Two Players

PubSub works like a group chat:

  1. Subscribe – When a player joins a game, they “subscribe” to that game’s channel.
  2. Broadcast – When something happens (a guess, a player joining), we “broadcast” a message to everyone subscribed.

Subscribing (in mount/3)

if connected?(socket) do
  # Join the game's "chat room"
  topic = "game:#{game_id}"
  Phoenix.PubSub.subscribe(IslandsDuel.PubSub, topic)
end

Broadcasting (after a guess)

# Tell everyone in this game what happened
Phoenix.PubSub.broadcast(
  IslandsDuel.PubSub,
  "game:#{game_id}",
  {:guess_result, %{player: current_player, row: row, col: col, result: :hit}}
)

When we broadcast, all players subscribed to "game:#{game_id}" receive the message in their handle_info/2 callback. That’s how both players stay in sync!

The flow

Player 1 clicks a cell

handle_event processes the guess

broadcast sends a message to all players

Player 2's handle_info receives it

Player 2's screen updates automatically

Conclusion

Building Islands Duel taught me that LiveView makes real‑time features surprisingly simple. Instead of writing complex JavaScript, we just:

  • Use mount/3 to set up the page.
  • Use handle_event/3 to handle the user’s action.
  • Use handle_info/2 + PubSub to sync between players.

If you’re learning Elixir and want a fun project, try building a simple multiplayer game. You’ll learn about LiveView, PubSub, and real‑time web apps—all while having fun!

The game‑engine code comes from the book Functional Web Development with Elixir, OTP, and Phoenix (Pragmatic Bookshelf). I highly recommend it if you want to learn Elixir properly.

Happy coding! 🎮

Back to Blog

Related posts

Read more »