Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Distributed Rock Paper Scissors

deprecated_distributed_rock_paper_scissors.livemd

Distributed Rock Paper Scissors

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Return Home Report An Issue

Overview

This is a instructor follow along exercise. It will be performed while following along with a instructor who can explain the concepts as you walk through them.

In this exercise, you’re going to create a distributed RockPaperScissors game where players can all play on the same network.

Players should be able to create a player that represents them. We use :player1 and :player2 here, but these players could be named under any atom.

RockPaperScissors.Player.start_link(:player1)
{:ok, #PID<0.160.0>}
RockPaperScissors.Player.start_link(:player2)
{:ok, #PID<0.172.0>}

Each player process should store their active stats and current guess.

classDiagram
  class Player {
    wins: 0
    loses: 0
    draws: 0
    guess: 0
  }

This should be defined as a struct that we can view with RockPaperScissors.Player.get/1.

RockPaperScissors.Player.get(:player1)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 0, wins: 0}

RockPaperScissors.Player.get(:player2)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 0, wins: 0}

Players should be able to enter their guess of :rock, :paper, or :scissors. This should update their current :guess state.

RockPaperScissors.Player.guess(:player1, :rock)
:ok

RockPaperScissors.Player.get(:player1)
%RockPaperScissors.Player{draws: 0, guess: :rock, loses: 0, wins: 0}

Players should be able to use their current guess to play against another player. Upon playing, each players :guess should be reset and their stats should be updated according to who won.

RockPaperScissors.Player.guess(:player1, :rock)
:ok

RockPaperScissors.Player.guess(:player2, :scissors)
:ok

RockPaperScissors.play(:player1, :player2)
:ok

RockPaperScissors.Player.get(:player1)
%RockPaperScissors.Player{wins: 1, loses: 0, draws: 0, guess: nil}

RockPaperScissors.Player.get(:player2)
%RockPaperScissors.Player{wins: 0, loses: 1, draws: 0, guess: nil}

Start New Mix Project

Create a new rock paper scissors project with mix.

mix new rock_paper_scissors --sup

Create Player Module

Create a RockPaperScissors.Player module in the lib/rock_paper_scissors folder. This module will use Agent to store state and define a struct.

defmodule RockPaperScissors.Player do
  use Agent
  defstruct [wins: 0, loses: 0, draws: 0, guess: nil]
end

Create the associated module file under test/rock_paper_scissors. We’ll alias Player to make it easier to write tests.

defmodule RockPaperScissors.PlayerTest do
  use ExUnit.Case
  alias RockPaperScissors.Player
end

Start_link/1

Write the first test for start_link/1.

  describe "Player" do
    test "start_link/1" do
      {:ok, pid} = Player.start_link(:player1)
      assert is_pid(pid)
    end
  end

Implement the code to make the test pass.

  def start_link(player) do
    Agent.start_link(fn -> %__MODULE__{} end, name: player)
  end

Get/1

Add the test for get/1.

test "get/1" do
  Player.start_link(:player1)
  assert %Player{wins: 0, loses: 0, draws: 0, guess: nil} = Player.get(:player1)
end

Implement the code to make the test pass.

def get(player_name) do
  Agent.get(player_name, fn player -> player end)
end

Guess/2

Add the test for guess/2.

test "guess/1" do
  Player.start_link(:player1)
  Player.guess(:player1, :rock)
  assert %Player{wins: 0, loses: 0, draws: 0, guess: :rock} = Player.get(:player1)
end

Implement the code to make the test pass.

def guess(player_name, guess) when guess in [:rock, :paper, :scissors] do
  Agent.update(player_name, fn player -> %{player | guess: guess} end)
end

Play/1

Add the test for play/1. We’ll start by writing just a simple test for draws rather than handling wins, draws, and losses all at once.

test "play/1 _ draw with :rock" do
  Player.start_link(:player1)
  Player.start_link(:player2)
  Player.guess(:player1, :rock)
  Player.guess(:player2, :rock)
  Player.play(:player1, :player2)
  assert %Player{wins: 0, loses: 0, draws: 1, guess: nil} = Player.get(:player1)
  assert %Player{wins: 0, loses: 0, draws: 1, guess: nil} = Player.get(:player2)
end

Implement the code to make the test pass.

def play(player1_name, player2_name) do
  Agent.update(player1_name, fn player ->
    %{player | draws: player.draws + 1, guess: nil}
  end)

  Agent.update(player2_name, fn player ->
    %{player | draws: player.draws + 1, guess: nil}
  end)
end

You may wish to create a private draw/1 function to reduce repeated code.

defp draw(player_name) do
  Agent.update(player_name, fn player ->
    %{player | draws: player.draws + 1, guess: nil}
  end)
end

def play(player1_name, player2_name) do
  draw(player1_name)
  draw(player1_name)
end

Write a test to handle when the first player wins.

test "play/1 _ :rock beats :scissors" do
  Player.start_link(:player1)
  Player.start_link(:player2)
  Player.guess(:player1, :rock)
  Player.guess(:player2, :scissors)
  Player.play(:player1, :player2)
  assert %Player{wins: 1, loses: 0, draws: 0, guess: nil} = Player.get(:player1)
  assert %Player{wins: 0, loses: 1, draws: 0, guess: nil} = Player.get(:player2)
end

Implement the code to handle when the first player wins. You’ll notice that we’re leaving the beats?/2 function minimal for now.

def beats?(guess1, guess2) do
  guess1 === :rock and guess2 === :scissors
end

def play(player1_name, player2_name) do
  guess1 = Agent.get(player1_name, fn player -> player.guess end)
  guess2 = Agent.get(player2_name, fn player -> player.guess end)
  if (beats?(guess1, guess2)) do
    Agent.update(player1_name, fn player ->
      %{player | wins: player.wins + 1, guess: nil}
    end)
    Agent.update(player1_name, fn player ->
      %{player | loses: player.lose + 1, guess: nil}
    end)
  else
    draw(player1_name)
    draw(player1_name)
  end
end

Now that our test is passing it’s time to refactor.

defp win(player_name) do
  Agent.update(player_name, fn player ->
    %{player | wins: player.wins + 1, guess: nil}
  end)
end

defp lose(player_name) do
  Agent.update(player_name, fn player ->
    %{player | loses: player.lose + 1, guess: nil}
  end)
end

defp get_guess(player_name) do
  Agent.get(player_name, fn player -> player.guess end)
end

def play(player1_name, player2_name) do
  guess1 = get_guess(player1_name)
  guess2 = get_guess(player2_name)
  if (beats?(guess1, guess2)) do
    win(player_1_name)
    lose(player_2_name)
  else
    draw(player1_name)
    draw(player1_name)
  end
end

Beats?/2

We could write many higher level tests to ensure the play function works for many different outcomes, or we could write many lower level unit tests on the beats?/2 function.

In general, unit testing the beats?/2 function will be faster and cheaper. The tests will run faster and be easier to write. However, higher level tests are more comprehensive and catch more bugs.

We’re going to unit test the beats?/2 function for now, however this is not an endorsement for one option or the other. Each choice has their place.

test "beats?/1" do
  assert Player.beats?(:rock, :scissors)
  assert Player.beats?(:scissors, :paper)
  assert Player.beats?(:paper, :rock)

  refute Player.beats?(:rock, :paper)
  refute Player.beats?(:scissors, :rock)
  refute Player.beats?(:paper, :scissors)

  refute Player.beats?(:rock, :rock)
  refute Player.beats?(:paper, :paper)
  refute Player.beats?(:scissors, :scissors)
end

Which drives us to expand the beats?/2 function.

defp beats?(guess1, guess2) do
  case {guess1, guess2} do
    {:rock, :scissors} -> true
    {:scissors, :paper} -> true
    {:paper, :rock} -> true
    _ -> false
  end
end

We still need to ensure that the second player can beat the first player. We can copy paste the first test, but change who wins.

test "play/1 _ :rock beats :scissors _ player2 wins" do
  Player.start_link(:player1)
  Player.start_link(:player2)
  Player.guess(:player1, :scissors)
  Player.guess(:player2, :rock)
  Player.play(:player1, :player2)
  assert %Player{wins: 0, loses: 1, draws: 0, guess: nil} = Player.get(:player1)
  assert %Player{wins: 1, loses: 0, draws: 0, guess: nil} = Player.get(:player2)
end

Now that there are more than 2 paths, if is no longer a suitable for control flow.

If the control flow lends itself to pattern matching, we can use case, if not we can use cond. We’ll choose cond for this example, but you might be able to make the code more clear with a different approach.

def play(player1_name, player2_name) do
  guess1 = get_guess(player1_name)
  guess2 = get_guess(player2_name)

  cond do
    beats?(guess1, guess2) ->
      win(player1_name)
      lose(player2_name)

    beats?(guess2, guess1) ->
      lose(player1_name)
      win(player2_name)

    guess1 === guess2 ->
      draw(player1_name)
      draw(player2_name)
  end
end

Final Player Module

Putting everything together you should end with a module that looks something like this.

defmodule RockPaperScissors.Player do
  use Agent

  defstruct wins: 0, loses: 0, draws: 0, guess: nil

  def start_link(player) do
    Agent.start_link(fn -> %__MODULE__{} end, name: player)
  end

  def get(player_name) do
    Agent.get(player_name, fn player -> player end)
  end

  def guess(player_name, guess) when guess in [:rock, :paper, :scissors] do
    Agent.update(player_name, fn player -> %{player | guess: guess} end)
  end

  def play(player1_name, player2_name) do
    guess1 = get_guess(player1_name)
    guess2 = get_guess(player2_name)

    cond do
      beats?(guess1, guess2) ->
        win(player1_name)
        lose(player2_name)

      beats?(guess2, guess1) ->
        lose(player1_name)
        win(player2_name)

      guess1 === guess2 ->
        draw(player1_name)
        draw(player2_name)
    end
  end

  defp get_guess(player_name) do
    Agent.get(player_name, fn player -> player.guess end)
  end

  def beats?(guess1, guess2) do
    case {guess1, guess2} do
      {:rock, :scissors} -> true
      {:scissors, :paper} -> true
      {:paper, :rock} -> true
      _ -> false
    end
  end

  defp win(player_name) do
    Agent.update(player_name, fn player ->
      %{player | wins: player.wins + 1, guess: nil}
    end)
  end

  defp lose(player_name) do
    Agent.update(player_name, fn player ->
      %{player | loses: player.loses + 1, guess: nil}
    end)
  end

  defp draw(player) do
    Agent.update(player, fn player_struct ->
      %{player_struct | draws: player_struct.draws + 1, guess: nil}
    end)
  end
end

Running The Project

You can run your project with iex -S mix.

Then play a game of rock paper scissors in the terminal.

iex(1)> alias RockPaperScissors.Player
RockPaperScissors.Player
iex(2)> Player.start_link(:player1)
{:ok, #PID<0.172.0>}
iex(3)> Player.start_link(:player2)
{:ok, #PID<0.174.0>}
iex(4)> Player.guess(:player1, :rock)
:ok
iex(5)> Player.guess(:player2, :scissors)
:ok
iex(6)> Player.play(:player1, :player2)
:ok
iex(7)> Player.get(:player1)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 0, wins: 1}
iex(8)> Player.get(:player2)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 1, wins: 0}

Cross Node Playing

When we start the project it creates a node.

By default Agent allows us to do cross-node requests on the same network so long as we know the name of the node.

flowchart
subgraph Network
  n1[node 1]
  n2[node 2]
  n1 --> p1[:player1 process]
  n2 --> p2[:player2 process]
end

We can start a named node using the --sname flag in the terminal. In order to communicate, each node must also share a --cookie flag with the same value.

Let’s start one node called player1_node.

iex --sname player1_node --cookie secret -S mix

Let’s do the name in another terminal for player2_node.

iex --sname player2_node --cookie secret -S mix

You’ll notice that the iex terminal has the name of your computer in it now. Mine says LAPTOP-1G9V4CP8 but yours will be different.

iex(player1_node@LAPTOP-1G9V4CP8)1>

In each node we can spawn a process for that player. Either node could store the processes. For example we could have a single node containing both processes.

flowchart
subgraph Node1
  p1[:player1 process]
  p2[:player2 process]
end
subgraph Node2
end

But we’re going to have each node store the process that it corresponds to.

flowchart
subgraph Node2
  p2[:player2 process]
end
subgraph Node1
  p1[:player1 process]
end

Now we can start each process like so. Make sure spawn the correct process in the correct node.

iex(player1_node@LAPTOP-1G9V4CP8)1> RockPaperScissors.Player.start_link(:player1)
iex(player2_node@LAPTOP-1G9V4CP8)1> RockPaperScissors.Player.start_link(:player2)

The goal is to communicate from one node to another node’s process.

flowchart LR
subgraph Node2
  p2[:player2 process]
end
subgraph Node1
  p1[:player1 process]
end
Node1 --message--> p2

Let’s start simple and try to get the state of :player2 from player1_node.

The following won’t work, because Agent needs the name of the node to access on the network.

iex(player1_node@LAPTOP-1G9V4CP8)1> RockPaperScissors.Player.get(:player2)

** (exit) exited in: GenServer.call(:player2, {:get, #Function<2.84454955/1 in RockPaperScissors.Player.get/1>}, 5000)

** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
(elixir 1.13.2) lib/gen_server.ex:1019: GenServer.call/3

Instead, you can replace :player2 with {:player2, :"player2_node@COMPUTER-NAME"} where COMPUTER-NAME is the name of your computer. This tells the agent which named node the process is stored under.

flowchart LR
subgraph P1N[player1_node]
  p1[:player1 process]
end
subgraph P2N[player2_node]
  p2[:player2 process]
end
P1N --get--> p2

This works network wide, so you could even run a node on a separate computer under the same network and Agent would know how to find the node.

flowchart RL
Network
subgraph Computer1
  subgraph P1N[player1_node]
    p1[:player1 process]
  end
end
subgraph Computer2
  subgraph P2N[player2_node]
    p2[:player2 process]
  end
end

P1N --get--> Network --get--> P2N
P2N --payload--> Network --payload--> P1N

You should be able to play the same game of rock paper scissors, but now across two nodes on the network.

Any communication that requires another node should use {player, node_name} syntax. so :player1 becomes {:player1, :"player1_node@COMPUTER-NAME"} and :player2 becomes {:player2, :"player2_node@COMPUTER-NAME"}.

iex(player1_node@COMPUTER-NAME)> Player.start_link(:player1)
{:ok, #PID<0.160.0>}
iex(player2_node@COMPUTER-NAME)> Player.start_link(:player2)
{:ok, #PID<0.160.0>}
iex(player1_node@COMPUTER-NAME)> Player.guess(:player1, :rock)
:ok
iex(player2_node@COMPUTER-NAME)> Player.guess(:player2, :scissors)
:ok
iex(player1_node@COMPUTER-NAME)> Player.play(:player1, {:player2, :"player2_node@COMPUTER-NAME"})
:ok
iex(player1_node@COMPUTER-NAME)> Player.get(:player1)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 0, wins: 1}
iex(player2_node@COMPUTER-NAME)> Player.get(:player2)
%RockPaperScissors.Player{draws: 0, guess: nil, loses: 1, wins: 0}

Edge Cases (Your Turn)

There are several bugs still present with the RockPaperScissors module.

  • guesses other than :rock, :paper, or :scissors are allowed.
RockPaperScissors.Player.guess(:player1, "any value!")
%RockPaperScissors.Player{draws: 1, guess: "any value!", loses: 0, wins: 0}
  • Neither player guessing is considered a draw when it should not be allowed.
RockPaperScissors.Player.play(:player1, :player2)

RockPaperScissors.Player.get(:player1)
%RockPaperScissors.Player{draws: 1, guess: nil, loses: 0, wins: 0}

RockPaperScissors.Player.get(:player2)
%RockPaperScissors.Player{draws: 1, guess: nil, loses: 0, wins: 0}
  • One player without a guess causes a crash.
  RockPaperScissors.Player.guess(:player1, :rock)
  RockPaperScissors.Player.play(:player1, :player2)

** (CondClauseError) no cond clause evaluated to a truthy value (rock_paper_scissors 0.1.0) lib/rock_paper_scissors/player.ex:35: RockPaperScissors.Player.play/2

Your Turn

In your project, write ExUnit tests to catch the bugs noted above, and implement the necessary changes to fix the bugs.

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, file_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(progress_path, Jason.encode!(Map.put(existing_progress, file_name, completed)))
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout solutions
$ git checkout -b distributed-rock-paper-scissors-exercise
$ git add .
$ git commit -m "finish distributed rock paper scissors exercise"
$ git push origin distributed-rock-paper-scissors-exercise

Create a pull request from your distributed-rock-paper-scissors-exercise branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your instructor by including @BrooklinJazz in your PR description to get feedback. You (or your instructor) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.