Programming question - Bowling Score

From NoskeWiki
Jump to navigation Jump to search

About

This page is a child of: Programming questions and Python

I was asked this question in a code interview in Sep 2023. It was my first "pair programming" interview, which was a neat concept where I did most of the talking (pretty sure that was the idea!) and my interviewer wrote the code I suggested - making it a more realistic model of working in pairs.

I was pretty fuzzy on the rules of bowling, so it took time to work out how a spare and strike work - and the very last frame is tricky because it kind-of has its own rules. I'll type out the gist of the question below:


PROBLEM: Bowling Score

Create a program, which, given a valid sequence of rolls for one line of American Ten-Pin Bowling, produces the total score for the game. Here are some things that the program will not do:

  • We will not check for valid rolls.
  • We will not check for the correct number of rolls and frames.
  • We will not provide scores for intermediate frames.
  • Depending on the application, this might or might not be a valid way to define a complete story, but we do it here for purposes of keeping the kata light. I think you’ll see that improvements like those above would go in readily if they were needed for real.

We can briefly summarize the scoring for this form of bowling:

  • Each game, or “line” of bowling, includes ten turns, or "frames" for the bowler.
  • In each frame, the bowler gets up to two tries to knock down all the pins.
  • If in two tries, he fails to knock them all down, his score for that frame is the total number of pins knocked down in his two tries.
  • If in two tries he knocks them all down, this is called a "spare" and his score for the frame is ten plus the number of pins knocked down on his next throw (in his next turn).
  • If on his first try in the frame he knocks down all the pins, this is called a "strike". His turn is over, and his score for the frame is ten plus the simple total of the pins knocked down in his next two rolls.
  • If he gets a spare or strike in the last (tenth) frame, the bowler gets to throw one or two more bonus balls, respectively. These bonus throws are taken as part of the same turn. If the bonus throws knock down all the pins, the process does not repeat: the bonus throws are only used to calculate the score of the final frame.
  • The game score is the total of all frame scores.


SOLUTION: In Python3

So this might not be the most elegant solution.. for that, honestly, just ask ChatGPT, but it's the one I came up with and tested. I tried to make it compact but I also wanted to make sure the test cases were readable, hence the array of arrays. Other representations might use symbols like '/' and 'X' to represent a strike and spare - and actually, that might be easier to parse.

bowling.py:

# Question from: https://codingdojo.org/kata/Bowling/
# Solution from: https://andrewnoske.com/wiki/Programming_question_-_Bowling_Score

NUM_FRAMES = 10

def score_in_last_frame(frame: list[int]) -> int:
    has_three_bowls = len(frame) >= 3  # Had extra bowl.
    pins_in_frame = sum(frame) 
    strike = frame[0] == 10
    strike_times_two = strike and len(frame) >= 2 and frame[1] == 10
    strike_times_three = strike_times_two and len(frame) >= 3 and frame[2] == 10
    spare = not strike and (frame[0] + frame[1] == 10)
    if strike_times_three:
        return 30
    elif strike_times_two and has_three_bowls:
        return 20 + (frame[2] * 2)
    elif strike:
        return 10 + (frame[1] * 2)
    elif spare and has_three_bowls:
        return 10 + (frame[2] * 2)
    else:
        return pins_in_frame


def bowling_score(game: list[list[int]]) -> int:
    total: int = 0
    for i, frame in enumerate(game):
        if not frame:  # Sanity check.
            continue
        last_frame = (i == NUM_FRAMES - 1)
        if last_frame:  # Last frame has it's own rules.
            total += score_in_last_frame(frame)
        else:
            pins_in_frame = sum(frame)
            strike = frame[0] == 10
            spare = pins_in_frame == 10 and not strike        
            print(i, 'pins_in_frame=', pins_in_frame,
                ', spare=', spare, ', strike', strike)

            total += pins_in_frame  # Add pins from this frame.
            if spare or strike:     # For spare or strike: add pins from next bowl.
                total += game[i+1][0]  # Add next bowl (1st bowl next frame).
            if strike:            # If strike: add pins from next, next bowls.
                second_last_frame = (i == NUM_FRAMES - 2)
                if second_last_frame:
                    total += game[i+1][1]  # Add 2nd bowl in last frame.
                else:
                    if game[i+1][0] == 10:  # If next bowl was strike: go to next frame.
                        total += game[i+2][0]  # Next next frame, 1st bowl.
                    else:
                        total += game[i+1][1]  # Next frame, 2nd bowl.
    return total


main.py:

import bowling as main

###############################################################################
# TESTS:
###############################################################################

def def_assert_equals(dis):
    print(dis)


def test_empty_game():
    game = [[0, 0]] * 10
    score = main.bowling_score(game)
    assert( score == 0)

def test_all_1s_game():
    game = [[1, 1]] * 10
    score = main.bowling_score(game)
    assert( score == 20)

def test_1_spare_game():
    game = [
        [2, 8], [2, 2], [2, 2], [2, 2], [2, 2],
        [2, 2], [2, 2], [2, 2], [2, 2], [2, 2]]
    score = main.bowling_score(game)
    assert( score == 46+2)

def test_1_strike_game():
    game = [
        [10, 0], [2, 2], [2, 2], [2, 2], [2, 2],
        [2, 2], [2, 2], [2, 2], [2, 2], [2, 2]]
    score = main.bowling_score(game)
    assert( score == 46+4)

def test_perfect_game():
    game = [
        [10], [10], [10], [10], [10],
        [10], [10], [10], [10], [10, 10, 10]]
    score = main.bowling_score(game)
    assert( score == 300)

def test_2_strikes_last_frame_game():
    game = [[0, 0]] * 9         # 0.
    game.append([10, 10, 0])    # 20 -> 20.
    score = main.bowling_score(game)
    assert( score == 20)

def test_spare_last_frame_game():
    game = [[8, 1]] * 9       # 9 * 9  = 81.
    game.append([9, 1, 2])    # 12 + 2 = 14 -> 95. 

###############################################################################

if __name__ == '__main__':
    # Demo input:
    game = [[8, 1]] * 9       # 9 * 9  = 81.
    game.append([9, 1, 2])    # 12 + 2 = 14 -> 95. 
    print('DEMO BOARD:', game)
    score = main.bowling_score(game)
    print('SCORE = ', score)

    # Tests:
    test_empty_game()
    test_all_1s_game()
    test_1_spare_game()
    test_1_strike_game()
    test_perfect_game()
    test_2_strikes_last_frame_game()


PROBLEM: Render Bowling Frame

I decided that a slightly fancier version should show the (intermediate) score for each bowling frame and render out a grid.

SOLUTION: In Python3

This time I decided to use a class and actually I feel it came out cleaner:

class BowlingGame:
  def __init__(self, frames):
    self.frames = frames

  def _is_strike(self, frame):
    return frame[0] == 10

  def _is_spare(self, frame):
    return sum(frame) == 10 and not self._is_strike(frame)

  def score(self) -> list[int]:
    """Calculates a cumulative for each frame, returned as an list of scores.
    
    The total score is the value of the last element."""
    total = 0
    scores = []
    for i, frame in enumerate(self.frames):
      if self._is_strike(frame) and i < 9:  # Not the last frame.
        next_frame = self.frames[i + 1]
        bonus = next_frame[0] + (
          next_frame[1] if len(next_frame) > 1 else self.frames[i + 2][0])
      elif self._is_spare(frame) and i < 9:
        bonus = self.frames[i + 1][0]
      else:
        bonus = 0

      total += sum(frame) + bonus
      scores.append(total)
    return scores

  def display(self):
    """Prints a scoring grid for the bowling game.
    
    Shows the cumulative score after each frame."""
    scores = self.score()

    FRAME_WIDTH = 6  # Frame width in characters.
    # Display the grid:
    frame_pieces = []
    for i, frame in enumerate(self.frames):
      frame_piece = ''
      if self._is_strike(frame):
        frame_pieces.append(' X'.ljust(FRAME_WIDTH))
        # print(f'X  | ', end='')
      else:
        if self._is_spare(frame):
          frame_pieces.append(f' {frame[0]}  / '.ljust(FRAME_WIDTH))
          # print(f'{frame[0]} / | ', end='')
        else:
          frame_pieces.append(f' {frame[0]}  {frame[1]} '.ljust(FRAME_WIDTH))
          # print(f'{frame[0]} {frame[1]} | ', end='')

    # Display the intermediate scores
    score_pieces = []
    for score in scores:
      score_pieces.append(f' {score}'.ljust(FRAME_WIDTH))
      # print(f'{score}  | ', end='')

    print(' |' + '|'.join(frame_pieces) + '|')
    print(' |' + '|'.join(score_pieces) + '|' )
    print(' .... Final Score: ' + str(scores[-1]) + '\n')


if __name__ == '__main__':
  frames = [[10], [7, 3], [9, 0], [10], [0, 8], [8, 2], [0, 6], [10], [10], [10, 8, 1]]   # Expect: 167 (good game).
  # frames = [[10], [10], [10], [10], [10], [10], [10], [10], [10], [10, 10, 10]]        # Expect: 300 (perfect game).
  # frames = [[0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [9, 1, 4]]  # Expect: 14 (one spare at end).
  # frames = [[5,5], [3,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0,0], [0, 0, 0]]  # Expect: 16 (one spare at start).
  game = BowlingGame(frames)
  game.display()


This came rendered out as:

 | X    | 7  / | 9  0 | X    | 0  8 | 8  / | 0  6 | X    | X    | X    |
 | 20   | 39   | 48   | 66   | 74   | 84   | 90   | 120  | 148  | 167  |
 .... Final Score: 167


See Also


Links