DEV Community

Cover image for Building Game of Islands with Elixir and Liveview
hungle00
hungle00

Posted on

Building Game of Islands with Elixir and Liveview

What is Islands Duel?

Islands Duel is a two-player strategy game, similar to the classic Battleship game. Here's how it works:

  1. Setup: Each player has an 11x11 board with 5 hidden islands placed on it
  2. Gameplay: Players take turns guessing coordinates on their opponent's board
  3. Hit or Miss: If you guess a cell that contains part of an island, it's a "hit". Otherwise, it's a "miss"
  4. Foresting: When all cells of an island are hit, that island is "forested" (destroyed)
  5. 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 build 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, updating boards, and syncing between players happens 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

First, we need a way for two players to join the same game. Here's what happens:

  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 we 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
Enter fullscreen mode Exit fullscreen mode

What is GameSupervisor?
Think of GameSupervisor as a manager who watches over all the games. Each game runs as a separate process (like a mini-program inside your app). The supervisor will create new game processes when players start games and keep 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
Enter fullscreen mode Exit fullscreen mode

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 11x11 grids using a simple loop:

<!-- Player 1's board -->
<div class="grid grid-cols-11">
  <%= for row <- 0..10 do %>
    <%= for col <- 0..10 do %>
      <div phx-click="cell_click" phx-value-row={row} phx-value-col={col}>
        <!-- Cell content -->
      </div>
    <% end %>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

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

Step 3: Using handle_event to Handle 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 this 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
end
Enter fullscreen mode Exit fullscreen mode

How the click data gets to our function:

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

  • 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

Here's the tricky part: when Player 1 makes a move, how does Player 2 see it? They're on different browsers!

Liveview provides us handle_info function 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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In these two examples, you might wonder: how do these functions know when to update? How do they receive the messages? That's where PubSub comes in!

Step 5: Using PubSub to Connect Two Players

PubSub is like a group chat. Here's how it works:

  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):

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

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}}
)
Enter fullscreen mode Exit fullscreen mode

When we broadcast, all players subscribed to "game:#{game_id}" will receive this message in their handle_info function. That's how both players stay in sync!

The flow looks like this:

Player 1 clicks a cell
    ↓
handle_event processes the guess
    ↓
broadcast sends message to all players
    ↓
Player 2's handle_info receives it
    ↓
Player 2's screen updates automatically
Enter fullscreen mode Exit fullscreen mode

Conclusion

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

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

If you're learning Elixir and want a fun project, I recommend building a simple multiplayer game. You'll learn about LiveView, PubSub, and how real-time web apps work—all while having fun!

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

Happy coding! 🎮

Top comments (0)