FourX: The Board and Winning Positions
The Board
In this article, I go through how I implemented the board structure and algorithms for finding winning positions, aka four connecting discs.
Interestingly, Elixir doesn't have arrays as a native data type. List exists as a linked list implementation, which means O(n) access time.
Hence, I opted to use a map instead.
To make calculations easier, I represent the board as a flat map.
The length of the map is n = nx * ny, where nx is the number of elements in each row and ny is the number of elements in each column.
Every nx number of elements is a new row.
The usual Connect Four board has the dimensions {nx: 7, ny: 6}, with the following visual representation:
0 1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31 32 33 34
35 36 37 38 39 40 41
Each position on the board can have one of three states: nil | 0 | 1.
nil: slot is empty0: disc placed by Player 11: disc placed by Player 2
The board is initiated with all values as nil:
def create_board(nx, ny) do
len = nx * ny - 1
Map.new(0..len, &{&1, nil})
end
Find Adjacent Discs
Four Ways to Win
There are four directions/ways discs can be connected: vertical, horizontal, diagonal_right, and diagonal_left.
To check if two discs {curr, next} are adjacent in a specified direction, a simple condition can be used: curr + diff === next where diff is defined by which direction we're checking.
To visualise it better, consider this fictitious board with four winning positions in different directions:
- - - 3 4 5 6
- 8 - - - - -
14 - 16 - - - 20
21 - - 24 - 26 -
28 - - - 32 - -
35 - - 38 - - -
vertical:[14, 21, 28, 35]. The difference between each element isnx = 7horizontal:[3, 4, 5, 6]. The difference between each element is1diagonal_right:[8, 16, 24, 32]. The difference between each element isnx + 1 = 8diagonal_left:[20, 26, 32, 38]. The difference between each element isnx - 1 = 6
The start point of our algorithm, the function find_adjacent/3, matches the directions accordingly:
def find_adjacent(map, nx, direction) do
diff = case direction do
:vertical -> nx
:horizontal -> 1
:diagonal_right -> nx + 1
:diagonal_left -> nx - 1
end
find_adjacent(Map.keys(map), nx, diff, 0, 1)
end
Adjacent discs must be exactly one row apart, with the exception of horizontal where discs must be on the same row.
This is important to check as there would be broken patterns otherwise.
Consider the following board of calculated winning positions with no consideration of row number:
- - - - - - 6
7 8 9 - 11 - -
- - - - - 19 -
- - 23 - - - 27
- 29 - - - - -
35 - - - - - 41
horizontal:[6, 7, 8, 9]. The difference between each element is1diagonal_right:[11, 19, 27, 35]. The difference between each element isnx + 1 = 8, but27and35are two rows apartdiagonal_left:[23, 29, 35, 31]. The difference between each element isnx - 1 = 6, but35and41are on the same row (zero rows apart)
I implemented a simple recursive get_row/2 function to get the row number of a given position.
Each time it calls itself, it adds 1 and subtracts diff from x until it becomes negative.
# 1-indexed
defp get_row(x, diff) do
if x < 0 do 0 else 1 + get_row(x - diff, diff) end
end
Finding Adjacent Elements
It's finally time to jump to the main part of the algorithm. Take a look at the following code:
def find_adjacent(values, nx, diff, start_index, consecutive) do
start = Enum.at(values, start_index)
adj = Enum.find(values, fn x ->
row_diff = if diff === 1 do 0 else consecutive end
next = start + consecutive * diff
next === x and get_row(next, nx) - get_row(start, nx) === row_diff
end)
cond do
consecutive === 4 -> {start, consecutive}
adj !== nil -> find_adjacent(values, nx, diff, start_index, consecutive + 1)
true -> find_adjacent(values, nx, diff, start_index + 1, 1)
end
end
Note: values refers to an Enumerable containing positions of a player's discs.
Currently, it is retrieved by using Map.filter(fn {_k, v} -> v === player end).
The start_index parameter is used to track the current element being checked, referred to as start.
consecutive is used to track how many adjacent elements we are away from start.
We check for adjacent elements using Enum.find/2.
Upon successful match, the function calls itself while incrementing consecutive by 1 so it knows to check the next spot in the next recursion.
The conditions for an element, next to be considered adjacent are as follows:
next === start + consecutive * diffget_row(next, nx) - get_row(start, nx) === row_diff, whererow_diff = 1for all directions except forhorizontalwhererow_diff = 0
If consecutive reaches the value 4, it means that find_adjacent/5 has found a winning position and returns.
If no adjacent element is found, find_adjacent/5 goes to the next element on the board by calling itself with start_index + 1 and restarts the consecutive counter.
End Case
You may have noticed that I didn't cover the scenario where find_adjacent/5 didn't manage to find any winning posiitons.
For that, I created a separate find_adjacent/5 function with a guard checking if start_index is at the end.
Technically I could just do this in the main function, but I didn't want to pollute it.
In a future refactor, perhaps.
def find_adjacent(values, _nx, _diff, start_index, consecutive) when
length(values) <= start_index + 1
do
{Enum.at(values, start_index), consecutive}
end
Closing Thoughts
It was fun implementing the board and find_adjacent algorithm with new constraints due to the nature of functional programming.
I learned to think about state in a different manner and was forced to pick up recursion again... sigh
The code might not be the most efficient, but I'm glad I managed to write it and hope to learn how to optimise it as I continue exploring :)