Building Game of Islands with Elixir and Liveview
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!
Before diving into the technical details, you can try the game here:
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
- Player 1 clicks “Start a game” → a new game is created with a unique
game_id. - Player 1 shares the
game_idwith Player 2. - Player 2 enters the
game_id→ they join the same 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:
| Binding | Description |
|---|---|
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:
- Subscribe – When a player joins a game, they “subscribe” to that game’s channel.
- 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/3to set up the page. - Use
handle_event/3to 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! 🎮
