I have used Yatzy (Scandinavian variant of Yahtzee) as a programming exercise since 2000, where my version of how to teach Java to freshmen at the Technical University of Denmark saw the first light of day.
Now I am teaching Elixir at my son’s school and Yatzy is on the table again.
The first task is to create functions that can calculate the correct score for a certain slot on the scoresheet given a roll of the dice.
As I was preparing solutions to some of the functions I got worried that the logic was not that easy to grasp, so I started pondering…
But let’s have a look at my first stab at a solution for detecting four of a kind.
def four_of_a_kind(ds) do for d <- 1..6 do if(count(d, ds) >= 4, do: d*4, else: 0) end |> Enum.max end
On the surface this is nice and functional: a little list comprehension over the possible eyes that there could be four off. Then take the max number in the list.
Forget about it not being efficient. The point is that it is relatively easy to understand what is going on.
The problem with this approach becomes more apparent when you try to deal with two pairs…
def two_pairs(ds) do case one_pair(ds) do 0 -> 0 n -> d = div(n,2) case one_pair(ds -- [d,d,d,d,d]) do 0 -> 0 m -> n + m end end end
First you check if there is one pair and then if there is another pair in the remaining dice.
My problem with this solution? It is ugly. Hard to explain. Not the main lines, but the small tricks to make it work like removing all dice showing the first pair in the list.
Patterns to the rescue
It dawned on me that my approach was basically just a reskinning of a very imperative approach to solving the problem. You can dress it up with a list comprehension and a pipe, but that does not change much.
One of the most powerful features of functional programming is pattern matching. So I started looking into how patterns might be able to help.
After a bit of toying around I realised that doincg pattern matching on a sorted list of dice would work.
def four_of_a_kind(ds) do case Enum.sort(ds) do [_,a,a,a,a] -> 4*a [a,a,a,a,_] -> 4*a _ -> 0 end end
This is much better.
The patterns describes exactly what we are looking for. Nice.
The challenge is how this fares with two pairs.
def two_pairs(ds) do case Enum.sort(ds) do [_,a,a,b,b] when a != b -> 2*a + 2*b [a,a,_,b,b] when a != b -> 2*a + 2*b [a,a,b,b,_] when a != b -> 2*a + 2*b _ -> 0 end end
Wow! This is actually readable!!
Fewer lines and no dodgy tricks to make it work.
The only function that requires a bit of care for scoring one pair.
def one_pair(ds) do case Enum.sort(ds) do [_,_,_,a,a] -> 2*a [_,_,a,a,_] -> 2*a [_,a,a,_,_] -> 2*a [a,a,_,_,_] -> 2*a _ -> 0 end end
The order of the patterns are important here. You want to have the most points, i.e., the highest pair if more than one is in the roll.
Hence, you have to look for your pair from the back of the list.
Just throwing list comprehensions and pipes at a problem will not make it nice.
Sometimes you need to dig a little deeper to find a beautiful functional solution.
Pattern matching is really nifty when you use it correctly.
Thinking about how to guide junior beamsters to think correctly when solving problems forces you to find the most explainable solution. And that is often the most beautiful solution.
I forgot to throw in the solution to full house, the straights and yatzy…
def full_house(ds) do case Enum.sort(ds) do [a,a,b,b,b] when a !=b -> 2*a + 3*b [a,a,a,b,b] when a !=b -> 3*a + 2*b _ -> 0 end end def yatzy([h,h,h,h,h]) do 50 end def yatzy(_) do 0 end def small_straight(ds) do case Enum.sort(ds) do [1,2,3,4,5] -> 15 _ -> 0 end end def large_straight(ds) do case Enum.sort(ds) do [2,3,4,5,6] -> 20 _ -> 0 end end