Handout #2
In this handout, you will go through the development of a game known as “Bowling Game”. You will first define the requirements of the game using user stories and determine the scope of the implementation. Then, you will apply Test-Driven Development (TDD) to develop the requirements agreed. During the development, you will also learn how to use Elixir standard libraries and how to implement control flows.
Bowling game
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 simplest case, scoring a bowling game consists on adding the number of pins that a player knocks throughout the game. Each player is given 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 scored 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 a bonus equals to 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.
The scoring rules might be 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 approach called “test-driven development”.
Project setup
Write the requirements
Although we will build a solution to the problem incrementally, we have to have a clear idea of the requirements and the to-do things. User stories are an agile way to specify requirements, and test cases can be linked to the user stories as acceptance criteria. Have a look at the following Trello board where the requirements of the Bowling game are listed: https://trello.com/b/QEaqOns0/bowling-game
Create a new Elixir project
Now, let us set-up the Elixir project for the bowling game. 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 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.
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 the 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 with 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.
“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, at 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
.
TIP: if you are lost with the job that the function Enum.reduce does, have a look at this visualization https://lambdabricks.github.io/animating-hofs/
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.
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
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
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?