Bowling Again? In Lua??
Last night at FGNO we were reviewing a bowling exercise we had all tried. Today, I want to try something … in Lua.
As always, last night, Tuesday night, was the weekly meeting of Friday Geeks Night Out. For the past few sessions we have been exploring what would happen if one did the classic bowling game exercise, but with the requirement to produce a GUI that looks credibly like an actual bowling scoresheet or scoreboard. It has been enlightening.
Last night, we were looking at some code for the scoring side of the system, not the display side. The code in hand had been test-driven, and it had started with the low-numbered frames and put off the tenth frame, which is special until the last. Some issues arose in part because of assumptions about the other frames that did not hold true for the tenth.
It popped into my head to ask “What if instead of starting with an ordinary frame, we started with the tenth?” Today, I plan to do that, in Lua.
I have found a simple testing framework for Lua and will be using it below. I begin with my standard “hookup” test to be sure things are working.
function Tests:test_hookup()
self:assert_equals(2 + 1, 4)
end
That fails as expected. I fix it and carry on.
Let’s make some tentative design decisions. Randomly:
- There will be Frames.
- Frames will know their own score, when they have enough information.
- Frames will not know about other frames, at any time.
- We’ll just test up to the point of various total game scores.
- We’ll start with the tenth frame, whatever that means.
- Then we’ll do lesser frames, if they are different.
- Finally we’ll build up to ten.
- No GUI for us, this is just about starting differently.
So I will want a Frame class—or is it a TenthFrame class?—to begin with. I think we’ll begin with situations where the Frame can know its score and work up to the cases where it can’t know (yet).
I’m going to call it Frame, but I am thinking about tenth frame in my “mind”.
function Tests:test_open_frame()
local frame = Frame()
frame:roll(3)
frame:roll(4)
self:assert_equals(frame:score(), 7)
end
Pretty aggressive first test, but I think I’m up to it.
Frame = class()
function Frame:roll(pins)
end
function Frame:score()
end
This gives me the expected result:
Actual: nil, Expected: 7. Tests:test_open_frame
OK, let’s init and score for real. I think the simplest thing to do is just to keep the rolls in an array and sum them.
Frame = class()
function Frame:init()
self.rolls = {}
end
function Frame:roll(pins)
self.rolls[#self.rolls+1] = pins
end
function Frame:score()
local sum = 0
for i, pins in ipairs(self.rolls) do
sum = sum + pins
end
return sum
end
Green. Simple yet compelling little game you have here, Jeffries … Ship it.
OK, what’s next? There’s a problem: I know too much. I can’t really simulate what would happen to a developer who has never scored bowling: I’ve done it many times.
Let’s see. We can just go ahead and provide the rolls and test the scores for spare and strike, I guess. And maybe, as a nod to the GUI, we’ll provide is_spare
and is_strike
methods. Maybe.
Oh! This is interesting! Here’s the spare test:
function Tests:test_spare()
local frame = Frame()
frame:roll(3)
frame:roll(7)
frame:roll(5)
self:assert_equals(frame:score(), 15)
end
I think this will pass. It does! I was going to test is_spare
but it’s now clear that strike will pass as well:
function Tests:test_strike()
local frame = Frame()
frame:roll(10)
frame:roll(7)
frame:roll(5)
self:assert_equals(frame:score(), 22)
end
That does pass. Fascinating!
Now we might imagine some higher-level mechanism that doesn’t send rolls to the frame if it shouldn’t see them, such as the fact that you won’t be allowed to roll a third ball in the tenth frame if you don’t mark. But we don’t have such a mechanism. Let’s write a test where we roll three times and the frame still gets the right score:
function Tests:test_open_with_additional_roll()
local frame = Frame()
frame:roll(3)
frame:roll(4)
frame:roll(5)
self:assert_equals(frame:score(), 7)
end
This will fail:
Actual: 12, Expected: 7. Tests:test_open_with_additional_roll
So, we want our frame to ignore that third roll, but only if it is open. (If it is strike or spare, we want to count the roll.)
OK then:
function Frame:roll(pins)
if #self.rolls == 2 and self:is_open() then
return
end
self.rolls[#self.rolls+1] = pins
end
function Frame:is_open()
return #self.rolls > 1 and self.rolls[1]+self.rolls[2] < 10
end
My tests are all green. Ship it again!
What else could we test on this Frame? I don’t generally test for things that can’t happen, like seeing rolls of 6 and 5 as the first two. But given the code we have, which I do somewhat remember, so far, let’s test rolling four times in a strike frame, because that should give us a failing test.
function Tests:test_four_rolls_to_strike()
local frame = Frame()
frame:roll(10)
frame:roll(7)
frame:roll(5)
frame:roll(5)
self:assert_equals(frame:score(), 22)
end
This should fail with 27.
Actual: 27, Expected: 22. Tests:test_four_rolls_to_strike
I think the Frame needs to decide whether to accept a roll. Currently, roll
is this:
function Frame:roll(pins)
if #self.rolls == 2 and self:is_open() then
return
end
self.rolls[#self.rolls+1] = pins
end
I want this:
function Frame:roll(pins)
if self:wants_more() then
self.rolls[#self.rolls+1] = pins
end
end
And when do we want rolls? Let’s tally the times:
- When #rolls < 2;
- When #rolls is 2 and rolls[1] is 10 or the sum of the rolls is 10;
- Not otherwise.
This is perhaps a bit counter-intuitive. We could rewrite it this way:
- When #rolls < 2;
- When #rolls is 2 and Frame is_strike or is_spare;
- Not otherwise.
That’s more bowling-style, I guess. But we could also write it this way:
- When #rolls < 2;
- When #rolls is 2 and sum(rolls) >= 10
- Not otherwise.
The “express intentions” side of me calls for the strike/spare version and some other side calls for the simpler final version. We’ll express intentions: it’s probably the thing to do. Would you settle for this version, though?
- When #rolls < 2;
- When #rolls is 2 and frame is not open;
- Not otherwise.
Why? Because we have is_open
already. Do the simplest thing, my perversion of Beck’s question “What is the simplest thing that could possibly work?” He asked it to make us think. I decided just to generally try that thing and work from there.
When I implement this:
function Frame:wants_more()
return #self.rolls < 2 or not self:is_open()
end
function Frame:is_open()
return #self.rolls > 1 and self.rolls[1]+self.rolls[2] < 10
end
The test fails. My mistake? Never accept rolls beyond 3. Therefore:
function Frame:wants_more()
if #self.rolls == 3 then
return false
end
return #self.rolls < 2 or not self:is_open()
end
Green. We can rephrase that:
function Frame:wants_more()
return #self.rolls < 3 and (#self.rolls < 2 or not self:is_open())
end
Possibly more clear the other way but the one line vs four appeals to me more.
I think we’re done with this Frame (tenth frame). I’d like us to turn our attention to the ten-frame situation. Some object, a BowlingGame perhaps, needs to have ten frames and feed rolls into it. Now here is where my lack of inexperience fails me. I cannot fall into the trap of inventing a new kind of lesser frame, because I know that this one can and in my view should be made to work.
Let’s speculate, though, about what this BowlingGame might do:
- Have ten frames;
- When a roll is made, pass the roll to whichever frame or frames wants it.
- Somehow do pass it to frames that want it for bonus purposes, and do not pass it to frames that shouldn’t see it.
This thinking seems to lead to the game asking questions of the frames and making decisions about who should see things. Are you satisfied, are you interested in rolls that belong to you, are you just interested in bonus balls, something like that.
I could fall into that trap. I surely have fallen into that trap. But I happen to know another way.
For every roll into the game, we will pass it to each frame, starting with 1 and going up. The frame will return a result from roll
, namely whether the roll belongs to it, not as a bonus but as a proper scoring roll in that frame.
If the result is that the roll has been consumed by the frame, the game stops sending it on down. Otherwise it passes it to the next frame, up until 10. (No matter what 10 says, there is no 11 to pass it to, so we stop.)
I’m going to drive that behavior into Frame with a few tests before I build another class.
function Tests:test_frame_consumed_open()
local frame = Frame()
self:assert_equals(frame:consumed_roll(5), true)
self:assert_equals(frame:consumed_roll(4), true)
self:assert_equals(frame:consumed_roll(3), false)
end
Here we return true
to the first two consumed_roll
calls, because our frame wants those values, and false to the third, because it doesn’t even care about the third.
I do not love this but:
function Frame:consumed_roll(pins)
if self:wants_more() then
self:roll(pins)
if #self.rolls == 1 then return true end
if #self.rolls == 3 then return false end
return self:is_open()
else
return false
end
end
That does pass the test. Let’s write the strike and spare versions before we try to rationalize what we have here.
function Tests:test_frame_consumed_spare()
local frame = Frame()
self:assert_equals(frame:consumed_roll(5), true, "1")
self:assert_equals(frame:consumed_roll(5), true, "2")
self:assert_equals(frame:consumed_roll(3), false, "3")
end
I think this fails and I think I know what I’d like to have to fix it. Run tests. #2 failed. Try this. (I am flailing. I think I can make it work but I’m just hacking, really.)
function Frame:consumed_roll(pins)
if self:wants_more() then
self:roll(pins)
if #self.rolls == 1 then return true end
if #self.rolls == 3 then return false end
return self.rolls[1] ~= 10
else
return false
end
end
Test passes. I think strike will also pass, and it does.
function Tests:test_frame_consumed_strike()
local frame = Frame()
self:assert_equals(frame:consumed_roll(10), true, "1")
self:assert_equals(frame:consumed_roll(5), false, "2")
self:assert_equals(frame:consumed_roll(3), false, "3")
end
I think we can try a game or two.
function Tests:test_perfect()
game = {}
local function game_roll(pins)
local i = 1
local consumed = false
while i <= 10 and not consumed do
consumed = game[i]:roll(pins)
i = i + 1
end
end
for i = 1, 10 do
table.insert(game, Frame())
end
for rolls = 1, 12 do
game_roll(10)
end
local total = 0
for i, frame in ipairs(game) do
total = total + frame:score()
end
self:assert_equals(total, 300)
end
Green! Take that! One more:
function Tests:test_alternating()
game = {}
local function game_roll(pins)
local i = 1
local consumed = false
while i <= 10 and not consumed do
consumed = game[i]:roll(pins)
i = i + 1
end
end
for i = 1, 10 do
table.insert(game, Frame())
end
for i = 1, 5 do
game_roll(6)
game_roll(4)
game_roll(10)
end
local total = 0
for i, frame in ipairs(game) do
total = total + frame:score()
end
self:assert_equals(total, 200)
end
Game of alternating strikes and spares is 200. Green. Woot!
Long article, time to wrap up.
Summary
Well,, obviously we aren’t done with a bowling game, since the last two tests just tested some embedded function game_roll
. But that function is essentially all the game has to do to handle scoring: just send the pins to everyone until someone consumes them and then stop sending.
I draw two conclusions here.
First, my assumption is correct: starting with the tenth frame can lead to a very nice simple result.
Second, however, at least part of that outcome happens only if one recognizes that the tenth frame’s behavior is not unique, that is, we need no other kind of frame.
Recognizing that requires one to set aside what may be a long-standing certainty The tenth frame is special. The thing is, it isn’t special, it’s just the same as the others. Its display is special: it looks different on the score sheet. But it’s exactly the same as the others.
Every frame scores:
- The first two rolls it sees if it is open;
- The first three rolls it sees if it is not.
That’s really what is going on, except for the question of what it sees, and we’ve shown that that can be dealt with via the notion of consuming
a roll or not.
It would be fun to have a large number of programmers who have never solved bowling work from the instruction to start by testing the tenth frame, and then to see how many of them find this kind of solution. I suspect that experiment will never be done.
But this was fun, and certainly for me starting with the tenth frame was productive. No conclusion about anyone else.
See you next time!