Elixir standard libraries and control flow

Bowling is a popular sport in which a group of players take turns to roll a bowling ball on a wooden lane towards a group ten pins at the end of the lane. The goal of the game is simply to knock down as many pins as possible.

In the most simple case, scoring a bowling game consists on adding the number of pins that a player knocks through out the game. Each player is given up to two opportunities (or three in the last frame as we will later see) to knock the 10 pins down in what is called a frame.

A bowling game consists of 10 frames and each frame is score individually. The score for the frame is the total number of pins knocked down, plus bonuses for strikes and spares.

A spare is when the player knocks down the 10 pins in the two rolls of a frame. In the case of a spare, the score for the frame is 10 plus the number of pins knocked down in the first turn of the next frame.

A strike is when the player knocks down all 10 pins on the first roll of a frame. In the case of a strike, the score for the frame is 10 plus the number of pins knocked down in the next two turns.

As a consequence of the scoring rules for spares or strikes, a player could get to roll three times in the tenth frame. However, no more than three balls can be rolled in the tenth frame.

As you will notice later, the scoring rules are difficult to understand in a single shot. That is why we will try to simplify the development process by tackling the problem incrementally, slowly taking baby steps, as prescribed by the approached called “test-driven development”.

0. Project setup

As a first step, let us setup our Elixir project. To this end, on a terminal window, start by executing the command:

mix new bowling

As you should probably know, mix is one of the tools included within the Elixir platform. The command above, for instance, creates a “new” project. Following the philosophy “Convention over configuration” the tool would understand that the name of the project is “bowling” and would generate the skeleton of an application using the same name in several parts of the project. The code of the application will be generated in put within a folder called bowling. Change to the project root directory by using the command cd bowling and open that directory with your editor or IDE.

In this practical, we will be working with two files lib/bowling.ex (which will hold the application code) and tests/bowling_test.exs. Open those two files and have a look.

1. Gutter game

Let us first consider the very unlikely scenario where the player misses all the opportunities, which would result in a score of 0. In fact, we say that the bowler played a “gutter ball” when the ball goes straight to one of the gutters which are at the sides of the alley. To represent the whole game, I propose you to use a single list of 10 lists. Thus, each inner list corresponds to a frame. The snippet below captures the intuition above.

defmodule BowlingTest do
  use ExUnit.Case

  test "gutter game" do
    game = List.duplicate([0, 0], 9) ++ [[0, 0, nil]]
    assert Bowling.score(game) == 0
  end
end

The function List.duplicate/2 creates a list with a given number of copies of the specified list element. In the example above, we are going to create a list with 9 frames, each frame representing two turns with 0 pins. Note that we append the 10th frame to the list representing the game, but this frame consists of three elements: the two regular turns plus one additional turn which is used to award good players. spares or strikes happening during the last frame. Do not worry about the latter rule, we will come back to this later. What is important to understand is that in a gutter game, the bowler hits 0 pins in all the 20 opportunities he/she has during the game.

As usual, you can use the command mix test to run the test. Please take a time to read the output of the test framework (i.e. ExUnit) to figure out what is wrong with our implementation so far.

It is often said that you need to start your development, always with a red test and that you should be worried if your development starts with a green test. In any case, at this stage, the test framework will usually provide us enough information to launch the process.

First, you will notice that in the test we refer to a module called Bowling, which should be already in place because a file with such module was created as part of the initial project by the command mix new. Second, we assume there is a function Bowling.score/1, because we are using it within the test. That, however, cannot be guessed by mix new. Henceforth, let us replace the content of file lib/bowling.ex with the following code snippet:

defmodule Bowling do
  def score(game) do
  end
end

Although the code above looks simplistic at this point, that is all we can infer from the feedback given by the test framework. Do not try to look ahead, guessing other requirements. The idea is that new requirements will be revealed as we incrementally add test cases.

If you run the test again, you will notice that the feedback provided by ExUnit changes. Now we get to know that Bowling.score/1 returns a number and not just nil. Moreover, we only know that the number is 0 in this case. Then, the only thing we need is to change the function body as shown in the snippet below.

  def score(game) do
    0
  end

Kudos! You are done with the first test case.

2. “All ones” game

In a very simple scenario, the player has 20 turns to hit the pins. Let us assume the case where the player does better than a “gutter game” (see previous test case), and hits one pin in every turn. In this case, the overall score for this gamer would be 20.

Let us now copy the snippet below.

defmodule BowlingTest do
  use ExUnit.Case

  test "gutter game" do
    game = List.duplicate([0, 0], 9) ++ [[0, 0, nil]]
    assert Bowling.score(game) == 0
  end

  test "'all ones' game" do
    game = List.duplicate([1, 1], 9) ++ [[1, 1, nil]]
    assert Bowling.score(game) == 20
  end  
end

Please note that we keep the test capturing the “gutter game” scenario and add a new test for the “all ones” case. Since the code for the new test case is quite similar to the previous case, I would not comment any further about it.

Of course, running mix test on the terminal window would result in the second test failing. And now, we cannot just change the body of Bowling.score/1 to return 1 instead of 0 for obvious reasons. However, in this moment we have additional information in the test cases to infer that we need to add the number of pins hit at every turn, and that the result of this sum will be the overall score. There are of course several ways to implement this. However, I ask to consider the code shown in the snippet below.

defmodule Bowling do
  def score(game) do
    List.flatten(game)
    |> Enum.filter(fn e -> is_number(e) end)    # &is_number/1
    |> Enum.reduce(0, fn n, acc -> n + acc end) # &+/2
  end
end

Since the game is a list of lists, my code starts by flattening the data structure. Flattening means the list of lists will be transformed into a single list that contains the elements of the nested lists. For instance, if we evaluate the expression List.flatten([[1],[2],[3,4]]) we would get the list [1,2,3,4]. Now, remember that the list corresponding to the last frame has three elements. The third element in our two test cases is nil, such that we have to filter it out. To this end, we use the function Enum.filter/2 with the anonymous function fn e -> is_number(e) end, that I suppose is self-descriptive. Note, however, that the same anonymous function can also be expressed as &is_number/1. Use the version that you prefer. Finally, we just need to add all the numbers. To this end, we use the function Enum.reduce/3 which, as mentioned before, corresponds with our old friend foldl. Please also note that the anonymous function fn n, acc -> n + acc end can also be written as &+/2.

Run the test. Everything should be under control now.

From this point on, I will just share with you a test case and a small explanation of the underlying scoring rule. I would like you to try changing the implementation of Bowling.score/1 to get each test case passing, incrementally.

3. Game with one ‘spare’

As mentioned before, a spare is when the player knocks down the 10 pins in the two rolls of a frame. In the case of a spare, the score for the frame is 10 plus the number of pins knocked down in the first turn of the next frame.

With this in mind, we can now move to coding. The following snippet captures a game where the player has a spare in the first frame. Use the test below to guide in implementing the corresponding score computation.

  test "one spare" do
    game = [[5,5],[3,0]] ++ List.duplicate([0, 0], 7) ++ [[0, 0, nil]]
    assert Bowling.score(game) == 16
  end

4. Game with one ‘strike’

We also know that strike is when the player knocks down all 10 pins on the first roll of a frame. In the case of a strike, the score for the frame is 10 plus the number of pins knocked down in the next two turns.

As before, you have now to consider the following snippet that exemplifies a game with a strike in the first frame to guide you in updating the implementation to support the scoring of strikes.

  test "one strike" do
    game = [[10,nil],[3,4]] ++ List.duplicate([0, 0], 7) ++ [[0, 0, nil]]
    assert Bowling.score(game) == 24
  end

5. Perfect game

We are almost done. To complete the implementation, consider the following test that captures the situation where the bowler plays the perfect game: he/she hits a strike at every turn.

  test "perfect game" do
    game = List.duplicate([10,nil], 9) ++ [[10,10,10]]
    assert Bowling.score(game) == 300    
  end

Interestingly, the 5 tests above cover well the overall requirements of the bowling scoring but some edge cases exist. Can you find an edge case?