This post belongs to the Building Phoenix Battleship series.
Let the battle begin!
After finishing last part of these series, we have both players placing their corresponding ships and ready to start the battle. On every turn, one of the players clicks on a cell of the opponent's board grid, trying his best to hit one of the rival ships, until one of the players gets all of his ships destroyed, losing the game. Whereas this might look like a simple process, actually it is more complex, so let's do this!
Open fire!
We are going to start imagining that everything goes ok, and both players fight until one of them loses all of his ships.
Every turn, when the player clicks on an opponent's cell, it sends a game:shoot
message, along with the clicked coordinates,
to the GameChannel
through the socket. Let's implement its handler function:
# web/channels/game_channel.ex
defmodule Battleship.GameChannel do
use Phoenix.Channel
...
def handle_in("game:shoot", %{"x" => x, "y" => y}, socket) do
player_id = socket.assigns.player_id
game_id = socket.assigns.game_id
case Game.player_shot(game_id, player_id, x: x, y: y) do
{:ok, %Game{over: true} = game} ->
broadcast(socket, "game:over", %{game: game})
{:noreply, socket}
{:ok, game} ->
opponent_id = Game.get_opponents_id(game, player_id)
broadcast(socket, "game:player:#{opponent_id}:set_game", %{game: Game.get_data(game_id, opponent_id)})
{:reply, {:ok, %{game: Game.get_data(game_id, player_id)}}, socket}
_ ->
{:error, {:error, %{reason: "Something went wrong while shooting"}}, socket}
end
end
...
end
We get both the player_id
and the game_id
from the socket assigns
and use them along with the received coordinates
to call the Game.player_shot/3
function to perform the shot. Depending on the result, it does one of the
following:
- If everything goes
:ok
and the game is over, it broadcasts thegame:over
message through the socket, along with the updated game's state. - On the other hand, if the game is still not over, it gets the opponent's id and broadcasts him the updated game state, reflecting the shot result. It returns the game to the attacker as well.
- If by any chance, there is an error, it notifies it to the player.
Having this explained, we can now move on to the Game.player_shot
implementation:
# lib/battleship/game.ex
defmodule Battleship.Game do
use GenServer
...
def player_shot(id, player_id, x: x, y: y), do: try_call(id, {:player_shot, player_id, x: x, y: y})
...
def handle_call({:player_shot, player_id, x: x, y: y}, _from, game) do
opponent_id = get_opponents_id(game, player_id)
{:ok, result} = Board.take_shot(opponent_id, x: x, y: y)
game = game
|> udpate_turns(player_id, x: x, y: y, result: result)
|> check_for_winner
Battleship.Game.Event.player_shot
{:reply, {:ok, game}, game}
end
...
defp udpate_turns(game, player_id, x: x, y: y, result: result) do
%{game | turns: [%{player_id: player_id, x: x, y: y, result: result} | game.turns]}
end
defp check_for_winner(game) do
attacker_board = Board.get_data(game.attacker)
defender_board = Board.get_data(game.defender)
cond do
attacker_board.hit_points == 0 ->
%{game | winner: game.defender, over: true}
defender_board.hit_points == 0 ->
%{game | winner: game.attacker, over: true}
true ->
game
end
end
...
end
It gets the opponent_id
and calls the Board.take_shot/2
function which we are going to implement
in a moment. With the result, it updates the game's turns and checks if there is a winner.
The private check_for_winner/1
function gets both players and checks if any of them
has 0 hit points, wich means that the other player is the winner, and the game is over, updating the game
and returning it. Battleship.Game.Event.player_shot
triggers an event, so the list of
current games in the lobby gets updated, but we are going to talk about it later, so let's define the
Board.take_shot/2
function:
# lib/battleship/game/board.ex
defmodule Battleship.Game.Board do
@grid_value_ship "/"
@grid_value_water_hit "O"
@grid_value_ship_hit "*"
...
def take_shot(player_id, x: x, y: y) do
result = player_id
|> get_data
|> Map.get(:grid)
|> Map.get(Enum.join([y, x], ""))
|> shot_result
result
|> add_result_to_board(player_id, coords)
|> update_hit_points
{:ok, result}
end
def get_data(player_id), do: Agent.get(ref(player_id), &(&1))
defp shot_result(@grid_value_ship), do: @grid_value_ship_hit
defp shot_result(@grid_value_ship_hit), do: @grid_value_ship_hit
defp shot_result(_current_value), do: @grid_value_water_hit
defp add_result_to_board(result, player_id, coords) do
Agent.update(ref(player_id), &(put_in(&1.grid[coords], result)))
get_data(player_id)
end
defp update_hit_points(board) do
hits = board.grid
|> Map.values
|> Enum.count(&(&1 == @grid_value_ship_hit))
hit_points = Enum.reduce(board.ships, 0, &(&1.size + &2)) - hits
Agent.update(ref(board.player_id), fn(_) -> %{board | hit_points: hit_points} end)
{:ok, get_data(board.player_id)}
end
...
end
This is a nice example of how Elixir's pipe operator and pattern matching can tell the story of what is going on.
So in take_shot/2
it uses the opponent's player_id
to get the state of its board's agent process. Then it gets the :grid
key which is the map with the all the board cells, and gets the cell corresponding to joining the coordinates. With the
current value of this cell, it calls the shot_result
private function to calculate the shot result. So for instance, if the cell has
a value of a ship (@grid_value_ship
), the result is a ship hit (@grid_value_ship_hit
). Then it calls add_result_to_board
,
which updates the agent process state, setting the result into the corresponding grid cell. Finally, by calling update_hit_points
it calculates and updates the remaining hit points, which is the result of subtracting the total count of @grid_value_ship_hit
in the
grid map to the sum of the sizes of all the ships on the board.
So, from a player's perspective, we are done. Players can create new games, join them, place their ships, start shooting in turns and win a game. However, from a development perspective, we are not done yet. What if a player leaves the game before ending? What if the game process ends accidentally? What if a board process terminates due to an unexpected error? We are going to cover all this in the next part, meanwhile feel free to take a look to the final (but still in progress, though) source code or challenge a friend to a battleship game.
Happy coding!