181 Bowling Scores

Description

Whether it is real or on the Wii, bowling is a fun game. (Okay, for the sake of the quiz, let’s assume it’s a fun game.) But I’ve known folks who just don’t understand how to score properly. They can count pins knocked down, and know that getting all ten pins in one roll is good, but they still can’t keep score.

Your task this week is to tally scores for these people. The input will be the player’s name and the number of pins felled from each roll. For example:

ruby bowling_scores.rb John 6 2 7 1 10 9 0 8 2 10 10 3 5 7 2 5 5 8

Your should tally the per-frame scores and generate output in table form, such as:

John's final score: 140

Frame     Roll  Roll    Score
   1        6     2        8
   2        7     1       16
   3        X             35
   4        9     -       44
   5        8     /       64
   6        X             87
   7        X            105
   8        3     5      113
   9        7     2      122
  10        5     /      140
   *        8

Note that you should make use of typical bowling symbols: X for a strike, / for a spare, and - for zero. Also, if extra balls were thrown at the end (to supplement a strike or spare in the final frame), list those as frame * like the above, but without a score.

Extra credit: Generate ascii or graphical output that looks more like the traditional bowling score form, which can be seen on this page.

Summary

Calculating bowling scores seems to be trivial, at least until you start dealing with the exceptions. Strikes and spares can’t be scored until at least one ball from the next frame is thrown, and of course the next frame’s score must include the current frame’s score. Similarly, strikes and spares in the tenth frame require additional balls be thrown, but not counted as an eleventh frame. There are simple exceptions, but it’s perhaps because it seems too simple a problem that some of these exceptions and edge cases are forgotten or handled improperly.

I’m going to look at the solution from Douglas Seifert; it was well documented, easy to read, and passed most of the edge cases I tested (i.e. dealing primarily with strikes, spares and the tenth frame). Let’s look first at the main code and work backwards:

if __FILE__ == $0
  name, *pins = *ARGV
  game = BowlingGame.new(name)
  pins.inject(game) {|game, p| game.score_roll(p.to_i); game}
  game.print_score_sheet
end

Inside of the standard “Am I running from command-line?” test, Douglas first separates the name argument from all the others, then constructs a new BowlingGame object for the player. The next line scores each roll, though I don’t understand the reason for using inject over the simpler and more typical code which accomplishes the same task:

  pins.each { |p| game.score_roll(p.to_i) }

In any case, once all pins have been scored, the game score sheet is displayed.

Now let’s look at the BowlingGame class, starting with initialization:

# Create a bowling game for the given named player
def initialize(name)
  @name = name
  @frames = Array.new(10) { |i| Frame.new(i+1) }
  @working = Array.new
end

The player’s name is remembered, and ten Frame objects are constructed with appropriate frame numbers. A “working” frame array is created, initially empty. The working array will keep references to frames when a strike or spare was rolled, until all the bonus pins have been counted.

The method score_roll is where the bulk of the work is accomplished. Each roll’s number of pins felled is the parameter.

# Score a roll of the given number of pins
def score_roll(pins)

Finding the current frame is simply looking for the first frame that isn’t finished. Finished frames are those containing strikes, spares, or open (i.e. neither a strike or spare, but two balls have been scored for the frame).

  # Find the current frame
  frame = @frames.find {|f| !f.finished? }

Next, a quick sanity check against too many reported scores. If all frames are finished (i.e. frame is nil) and there are no working frames (i.e. not waiting on bonus points for a spare or strike), but there is still input, then there was too many input values provided.

  # If we have no current frame and nothing is working, we are
  # scoring too many rolls
  if frame.nil? && @working.empty?
    raise "Too many rolls are being scored in this game."
  end

The next part was a little tricky to follow at first, but does make sense. We delete any working frames if they’re not working… but note that the “not working” condition is checked after a call to f.bonus, which will store the bonus points for working frames (i.e. strikes and spares). The call to bonus can change the frame’s working status (which it should do after one bonus roll for spares, and two bonus rolls for strikes).

  # Score bonus pins for strikes and spares that are working
  @working.delete_if {|f| f.bonus(pins); !f.working? }

Keep in mind that @working needs to be an array, rather than a single frame. Two sequential strikes, or a strike followed by a spare, leaves two frames waiting for bonus points, so we need the array.

Finally, we score the current round. We skip this part if there is no frame (which implies the roll is just for bonus points, as the comment suggest). If there is a frame, we call score_roll on it, then append it to @working if it was a strike or a spare and needs bonus points.

  # If we found no current frame, we are in bonus rolls of
  # the tenth frame
  return if !frame

  # Score this ball on the current frame and move it to
  # working if we rolled a spare or strike
  frame.score_roll(pins)
  if frame.spare? || frame.strike?
    @working << frame
  end
end

That’s it for the main game. The rest of BowlingGame is quite simple and needs little explanation, so I’ll pass describing it here, except to say that I was pleased to see output more like a typical bowling game scoring table:

John+---+---+---+---+---+---+---+---+---+---+
| 62| 71|  X| 9-| 8/|  X|  X| 35| 72|5/8|140|
|  8| 16| 35| 44| 64| 87|105|113|122|140|   |
+---+---+---+---+---+---+---+---+---+---+---+

In each square, the characters in the top row represent the individual rolls (e.g. “62” means two rolls: 6 pins followed by 2 pins). The bottom row is the accumulating score. The sizes work out just perfectly, since there can never be more than three rolls used per frame, and the score is capped at three digits. Highly compact and complete.

I don’t want to skip out completely on the Frame object. Most of it is concerned with status information (e.g. methods like strike?) and display, but let’s take a look at the score_roll method, since this goes hand-in-hand with BowlingGame#score_roll.

To start, we keep track of the first roll for a frame in @first_pin.

def score_roll(pins)
  @first_pin ||= pins

For anyone unfamiliar with this little technique, realize that this line is the same as:

  @first_pin = @first_pin || pins

When you see that @first_pin is initialized with nil (in the initializer for Frame), you should realize this technique allows us to assign a value to @first_pin once. After the first assignment, it won’t change again.

Back to the bowling, note that @first_pin is used only to help with proper display; it has no direct effect on the scoring process. Let’s now move onto the rest of score_roll, which is a simple state machine.

  if @state.nil?
    @score += pins
    if @score == 10
      @state = :strike
    else
      @state = :incomplete
    end

Our first section of this state machine is when @state is nil, which is only the case when the frame is first created, before any rolls have been scored. In this case, we update the score and change the state, either to a strike (when all ten pins have been knocked down) or incomplete. Now let’s see how to handle the incomplete state.

  elsif @state == :incomplete
    @score += pins
    if @score > 10
      raise "Illegal roll in incomplete frame with score #{@score - pins}: #{pins}"
    end
    if @score == 10
      @state = :spare
    else
      @state = :open
    end

Again, we update the score, but also check that the score looks reasonable, and throw an exception if not. If the score is now ten, it’s a spare; it can’t be a strike, since to be in the incomplete state, it must have score at least one ball prior. If the score is other than ten, it’s called an open frame (i.e. the frame is finished, with a simple score, needing no bonus points).

  end
end

There are no other states to handle; any other state is ignored. Actually, score_roll should never be called on frames in any other state. An exception here could be used to check that claim, or at least sufficient unit tests and/or code coverage tools.

Another error check might be worthwhile, that every pin count passed into score_roll (originating from the command-line) is strictly within the range zero to ten, inclusive. Right now, I can call the script like so:

ruby score.rb -16 6 -16 6 

No complaints will be generated; the error check in score_roll is good, but not sufficient for all cases. Still, this is Ruby Quiz, and we’re not gonna get too picky about error checking. But it is something to keep in mind for the next time you’re programming a bowling scorekeeper.

An error check I would not include is for incomplete games. I thought about such a thing initially, but it’s nice to be able to see the scorecare for a game in progress.

Great solutions, everyone! No quiz this week due to work load, but will be back next week.


Wednesday, February 04, 2009