Bowl City

Posted on 17 Apr 2013 in Python JavaScript d3.js Node.js

Another code challenge! This time the challenge is to read symbolic bowling scores from a file, translate them to numeric scores using proper rules, then get the average score per game across the data set. At first I was trying to come up with a clever solution to this problem. Yeah... not so much. Good enough is good enough. This time I solved it using using Python and also with JavaScript on Node. I also decided to chart the scores using d3.js.

The Bowling Data

The data consists of 200,000 bowling games to score.

1080632223x01814262
905171513145x51093/7
7/1/700/525/70082133-
9/51451253726/018150-
2654335/5002819/42x7/

Scoring in Python

"""
This script reads a dataset of simulated bowling games.
It scores each game, then returns the average.
"""

import gzip


def score(game):
    # List to hold our scores for this game.
    scores = []

     # Allow us to mark frame boundries
    frame = False

    # Loop and enumerate the game so we can look forward in the
    # list to score strikes and spares.
    for i, roll in enumerate(game):

        # If frame has been set to true, we will be skipping this ball
        # because it is the second roll of a frame and has already been
        # accounted for.
        if frame is True:
            frame = False
            continue

        # Keeps us scoring the bonus roll.
        if len(scores) < 10:

            # Strike
            if roll == 'x':
                if game[i + 1] == 'x' and game[i + 2] == 'x':
                    # Turkey! Three consecutive strikes.
                    scores.append(30)
                elif game[i + 1] == 'x' and game[i + 2].isdigit():
                    # Double! Two consecutive strikes.
                    scores.append(20 + int(game[i + 2]))
                elif game[i + 1].isdigit() and game[i + 2] == '/':
                    # Strike followed by a spare.
                    scores.append(20)
                elif game[i + 1].isdigit() and game[i + 2].isdigit():
                    # Strike followed by open frame.
                    scores.append(10 + int(game[i + 1]) + int(game[i + 2]))

            # Spare
            elif roll.isdigit() and game[i + 1] == '/':
                if game[i + 2] == 'x':
                    # Spare followed by a strike
                    scores.append(20)
                elif game[i + 2].isdigit():
                    # Spare followed by numeric (non-strike) roll
                    scores.append(10 + int(game[i + 2]))
                frame = True

            # Open frame. Add the two rolls and set frame to True.
            else:
                scores.append(int(roll) + int(game[i + 1]))
                frame = True

    # Return the total score for the game.
    return sum(scores)


def unzip_data(f):
    # Open the file for reading and assign it.
    with gzip.open(f) as bowlarama:
        return bowlarama.read().splitlines()


def avg_score(filename):
    # Split the scores on new lines, then map each one
    # to the score function, then sum the results
    # to get the total score

    games = unzip_data(filename)

    total_score = round(sum(map(score, games)) / float(len(games)), 2)
    return total_score


# Print the average of all the bowling games
print avg_score('bowlarama.txt.gz')

Scoring in JavaScript

var zlib = require('zlib');
var fs = require('fs');

function score(game) {
    // Scores one bowling game score, returns the total

    var scores = [], frame = false, i;

    // Loop over the game string as an array
    for (i = 0; i < game.length; i++) {
        var roll = game[i];
        // Check to see if we are mid frame, skip this ball if we are.
        if (frame) {
            frame = false;
            continue;
        }
        // Keeps us from scoring the bonus third roll.
        if (scores.length < 10) {

            // Strike
            if (roll === 'x') {
                if (game[i + 1] === 'x' && game[i + 2] === 'x') {
                    // Turkey! Three consecutive strikes.
                    scores.push(30);
                } else if (game[i + 1] === 'x' && typeof +game[i + 2] === 'number') {
                    // Double! Two consecutive strikes.
                    scores.push(20 + +game[i + 2]);
                } else if (typeof +game[i + 1] === 'number' && game[i + 2] === '/') {
                    // Strike followed by a spare.
                    scores.push(20);
                } else if (typeof +game[i + 1] === 'number' && typeof +game[i + 2] === 'number') {
                    // Strike followed by open frame.
                    scores.push(10 + +game[i + 1] + +game[i + 2]);
                } else {
                    return "Error: " + roll;
                } // Do not need to set frame to true since this is a strike i.e. one roll frame

            // Spare
            } else if (typeof +roll === 'number' && game[i + 1] === '/') {
                // Spare followed by a strike
                if (game[i + 2] === 'x') {
                    scores.push(20);
                // Spare followed by numeric (non-strike) roll
                } else if (typeof +game[i + 2] === 'number') {
                    scores.push(10 + +game[i + 2]);
                } else {
                    return "Error: " + roll;
                }
                // Set frame to true because we are including two rolls in this frame and we want to
                // skip the second ball in the function since the scoring is done in reference to the first ball
                frame = true;

            // Open frame.
            } else {
                // Add the two rolls.
                scores.push(+roll + +game[i + 1]);
                frame = true;
            }
        }
    }
    // Return the total score for the game using reduce by sum on the scores array
    return scores.reduce(function(a, b){ return a + b; });
}

function inputfile(callback, filename) {
    // Read a file and issue callback when complete.

    fs.readFile(filename, function (err, content) {
        if (err) return callback(err);
        callback(content);
    });
}

// Read the bowling scores dataset and process the dataset on callback.
inputfile(function (data) {
    zlib.unzip (data, function(err, data) {

        // If no errors, we have data
        if (!err) {
            // Set the dataset to a string and split it on new line.
            var mass = data.toString().split('\n'), i, game_scores = [];

            // Store the length before loop. Minus one because the last item
            // is garbage from the \n split.
            var games = mass.length - 1;

             // Process each bowling game into an integer equal to the score.
            for (i = 0; i < games; i++) {
                // Push the score to an array so we can reduce it to get the sum of the scores.
                game_scores.push(score(mass[i]));
            }

            // Take the sum of scores and divide by the number of games to get the average score
            console.log(Math.floor((game_scores.reduce(function(a, b){
                return a + b;
            }) / game_scores.length) * 100) / 100);
        }
    });
}, "bowlarama.txt.gz");

Results

The results on each came back the same. Seeing as I wrote the Python version first, then essentially ported it over... no surprises there. PyPy 2.0.0-beta2 beats out Node v0.10.4 and Python 2.7.3.

$ time python bowlarama.py
91.46
python bowlarama.py  4.60s user 0.03s system 96% cpu 4.784 total

$ time pypy bowlarama.py
91.46
pypy bowlarama.py  0.79s user 0.06s system 96% cpu 0.882 total

$ time node bowlarama.js
91.46
node bowlarama.js  0.78s user 0.11s system 94% cpu 0.932 total

Score Distribution

So there you have it, a slightly skewed distribution. Now to cut down the mightiest tree in the forest with an herring.