What is Islands Duel?
Islands Duel is a two-player strategy game, similar to the classic Battleship game. Here's how it works:
- Setup: Each player has an 11x11 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"
- Foresting: 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:
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:
- 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 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
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
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>
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
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, callhandle_event("cell_click", ...)in the LiveView -
phx-value-row={row}— Attach therowvalue to the click event -
phx-value-col={col}— Attach thecolvalue 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
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
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:
- 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):
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}" 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
Conclusion
Building Islands Duel taught me that LiveView makes real-time features surprisingly simple. Instead of writing complex JavaScript, we just:
- Use
mountto set up the page - Use
handle_eventto handle the user's action - 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)