Connect Four in Elixir (Part 2)
In Part 1 of Connect Four in Elixir we looked at setting up the project and printing out the 7-by-6 board grid. Now let’s look at handling the players’ moves and updating the game state.
In Connect Four, two players alternate turns, and a move involves dropping a colored token into the top of the grid, where it falls down to the first empty space. To process a move, we need to know which player, and the column they are choosing. Typically the players use red and black tokens.
Game
In IEx, it might look like this:
Let’s add that function to lib/connect_four/game.ex
:
Recall that Game is a GenServer, and here we’re using GenServer.call/2 with the registered name of the Game process, and the message to send.
The :ok
in the case statement is arbitrary – you define what the reply from the handle_call
function will be.
The three-element tuple {:move, player, column}
is also arbitrary, you can structure the message however you want. It just needs to match in handle_call
:
(The variable names can be different, but this will only match a tuple with the atom :move
in the first position, and then two additional values.)
We’re not doing anything with the process ID of the sending process so it is ignored by adding an underscore: _from
.
The state is passed to handle_call
and we can either make a change and return a different state, or just return the same state.
When that call comes in, we need to tell the Board to place the token into one of the spaces.
Because this is a call
, (and not a cast,) we must return something. The allowed return values are shown in GenServer.handle_call/3
and we will be sending {:reply, reply, new_state}
.
Let’s assume everything went well and the move was accepted:
# 1
: Here we see the state held in the Game being updated. We’ll keep track of the player who moved last, so that later we can do some error handling if the same player tries to move twice in a row.
# 2
: Here we see the :ok
that we matched on in the move/2
function.
This means we’ll need to write a place_token
function in our Board
module that replies with :move_accepted
if all goes well. For now we’ll just hard-code the return value so we can see this work.
In lib/connect_four/board.ex
:
Let’s try it out in IEx:
Go ahead and commit these changes.
What’s happening here? This is what the high level sequence diagram looks like:
But there’s more going on – Game is a GenServer with a client API and server callbacks.
(With apologies for misusing the symbols in a sequence diagram…) The code for the Game’s Client API and Server Callbacks all lives in the ConnectFour.Game module, but it gets executed in two different processes, in this case the IEx.Evaluator process and the ConnectFourGame process.
To prove it, add some code to the handle_call function in the Game module:
If you then look in the Observer, on the Processes tab (click the Pid column to sort by Pid) you’ll see that PID 96 is the ConnectFourGame process…
… and PID 140 is the IEx.Evaluator process.
(Revert these changes with git checkout lib/connect_four/game.ex
– another option would be to log the messages at the debug level, but I didn’t find usage info for Logger at a glance.)
Board
Now let’s look at the place_token
function and see how to modify the state of the appropriate space on the board.
The player only selects the column. It’s up to us to figure out which row the game piece will fall down to in a vertically suspended grid and determine the row number.
At first, let’s just hard-code row number 1 and update the agent for row 1 in the specified column. We still need to return :move_accepted
as before.
In lib/connect_four/board.ex
:
And try it out in IEx
Here you can see the ‘R’ indicating a red game piece in the first (bottom) row of the third column.
But of course we can’t always use row 1 – we need to calculate the lowest empty row for the specified column. How about this?
# 1
: While you can have optional parameters with default values, they have to go at the end. Since it’s the row we need to default, and everything else is (row,col) it would be too confusing to have this one function be (col, row // 1).
# 2
: Note the recursion in the first_empty
function – if the space is not empty, it calls itself with the next row up.
# 3
: &(&1)
is function capture syntax for the identity function, equivalent to fn x -> x end
. There is no ‘plain’ Agent.get
, you always have to provide a function that produces the value.
# 4
: Recall that the state of each space was set to Empty
when it was created.
And try this out:
This shows that the second move in a column correctly detects that the first (bottom) row is filled and places the game piece in the second row up.
And commit the changes:
Conclusion
We’ve seen how a GenServer works behind the scenes, and how to find the first empty row in a column, and how to update the Agent that holds the state of each space on the board.
Next time we’ll add some error handling. What if the players don’t alternate turns? What if the column they select is already full? (You can try it by making seven moves in the same column.) We might also try to get rid of the conditional logic in first_empty
. And then we’ll need to detect a “win” and stop the game.
The code for this example is available at https://github.com/wsmoak/connect_four/tree/20151024 and is Apache licensed.
Copyright 2015 Wendy Smoak - This post first appeared on http://wsmoak.github.io and is CC BY-NC licensed.
References
- Part 1 of Connect Four in Elixir
- GenServer.handle_call/3
- PlantUML Sequence Diagrams
- [PlantUML Image Generator][plantuml-server]