Bowling Scores the SQL Way

Bowling Scores the SQL Way

The scoring rules for American Bowling are simple on paper, but can be a chore to calculate by hand. This is a fun exercise to calculate the scores in mass using SQL. Because if you can’t do it in SQL, you’re life is too easy!

If you don’t know the rules, here’s a quick summary: https://www.kidslearntobowl.com/how-to-keep-score/

Data Preparation

Making a table to store the raw data for the individual games.

We’re not going into full data modelling by adding players and timestamps. We’re keeping it simple for this example.

DROP TABLE IF EXISTS game_rolls;

CREATE TABLE game_rolls (
    game int,
    frame int,
    roll int,
    pins int
);

Add a Perfect Game

A perfect game is 12 strikes in a row. 10 strikes in the first 9 frames and 2 strikes in the 10th frame.

-- Perfect Game
-- Total: 300
INSERT INTO game_rolls (game, frame, roll, pins)
VALUES
(1,  1, 1, 10),
(1,  2, 1, 10),
(1,  3, 1, 10),
(1,  4, 1, 10),
(1,  5, 1, 10),
(1,  6, 1, 10),
(1,  7, 1, 10),
(1,  8, 1, 10),
(1,  9, 1, 10),
(1, 10, 1, 10),
(1, 10, 2, 10),
(1, 10, 3, 10);

Add an Unlucky Second Game

We managed to knock down 1 pin each frame and missed the second roll. Repeat this for all 10 frames.

-- 1 pin each frame.
-- Total: 10
INSERT INTO game_rolls (game, frame, roll, pins)
VALUES
(2,  1, 1,  1),
(2,  1, 2,  0),
(2,  2, 1,  1),
(2,  2, 2,  0),
(2,  3, 1,  1),
(2,  3, 2,  0),
(2,  4, 1,  1),
(2,  4, 2,  0),
(2,  5, 1,  1),
(2,  5, 2,  0),
(2,  6, 1,  1),
(2,  6, 2,  0),
(2,  7, 1,  1),
(2,  7, 2,  0),
(2,  8, 1,  1),
(2,  8, 2,  0),
(2,  9, 1,  1),
(2,  9, 2,  0),
(2, 10, 1,  1),
(2, 10, 2,  0);

Add Gutter Ball and Spare

Now, we’re showing off our skills. Throw the first ball into the gutter on purpose and take down the rest in the second roll for a spare.

-- 0 then 10 each frame
-- Total: 100
INSERT INTO game_rolls (game, frame, roll, pins)
VALUES
(3,  1, 1,  0),
(3,  1, 2, 10),
(3,  2, 1,  0),
(3,  2, 2, 10),
(3,  3, 1,  0),
(3,  3, 2, 10),
(3,  4, 1,  0),
(3,  4, 2, 10),
(3,  5, 1,  0),
(3,  5, 2, 10),
(3,  6, 1,  0),
(3,  6, 2, 10),
(3,  7, 1,  0),
(3,  7, 2, 10),
(3,  8, 1,  0),
(3,  8, 2, 10),
(3,  9, 1,  0),
(3,  9, 2, 10),
(3, 10, 1,  0),
(3, 10, 2, 10);

Add Realistic Game with Strikes and Spares

This is my typical night out, never quite getting halfway to perfection (300). If I spent more time bowling and less time writing SQL maybe I’d get better!

INSERT INTO game_rolls (game, frame, roll, pins)
VALUES
(4,  1, 1,  7),  -- First frame: 7 pins
(4,  1, 2,  2),  -- Spare with 2 pins        =>             = 9

(4,  2, 1,  10), -- Second frame: Strike     =>     9+10+5+4=28

(4,  3, 1,  5),  -- Third frame: 5 pins
(4,  3, 2,  4),  -- Open frame with 4 pins   =>         28+9=37

(4,  4, 1,  8),  -- Fourth frame: 8 pins
(4,  4, 2,  1),  -- Open frame with 1 pin    =>         37+9=46

(4,  5, 1,  10), -- Fifth frame: Strike      =>    46+10+6+3=65

(4,  6, 1,  6),  -- Sixth frame: 6 pins
(4,  6, 2,  3),  -- Open frame with 3 pins   =>         65+9=74

(4,  7, 1,  9),  -- Seventh frame: 9 pins
(4,  7, 2,  0),  -- Open frame with 0 pins   =>         74+9=83

(4,  8, 1,  10), -- Eighth frame: Strike     =>     83+10+9=102

(4,  9, 1,  7),  -- Ninth frame: 7 pins
(4,  9, 2,  2),  -- Open frame with 2 pins   =>       102+9=111

(4, 10, 1,  10), -- Tenth frame: Strike
(4, 10, 2,  10), -- Bonus roll: Strike
(4, 10, 3,  8);  -- Bonus roll: 8 pins       => 111+10+10+8=139

Query to Calculate Scores

There are many ways to calculate the scores. We’re going to make use of Window Functions to look ahead for bonus pins and look behind for spares.

SELECT
    *,
    COALESCE(LEAD(pins, 1) OVER (w), 0)              AS next_pins,
    COALESCE(LEAD(pins, 2) OVER (w), 0)              AS next_next_pins,
    roll = 1 AND pins = 10                           AS is_strike,
    roll = 2 AND (pins + LAG(pins, 1) OVER (w) = 10) AS is_spare
FROM game_rolls
WINDOW w AS (PARTITION BY game ORDER BY game, frame, roll)
;

Results:

gameframerollpinsnext_pinsnext_next_pinsis_strikeis_spare
4117210falsefalse
4122105falsefalse
4211054truefalse
431548falsefalse
432481falsefalse
4418110falsefalse
4421106falsefalse
4511063truefalse
461639falsefalse
462390falsefalse
4719010falsefalse
4720107falsefalse
4811072truefalse
4917210falsefalse
49221010falsefalse
410110108truefalse
41021080falsefalse
4103800falsefalse

That looks right. The future pins are correct and the strikes and spares are correctly identified.

If we wanted, we could make a view, but we’re going to continue with the query by using Common Table Expressions (CTE).

We’ll call the first CTE stats and build on it with the next CTE to calculate the bonus based on the rules of strikes and spares. Strikes get the next two rolls and spares get the next roll.

There is a special condition for the last frame. We accept the pins as final since there are no future frames to look at. We don’t have to ‘wait’ to score the last frame. We can score it immediately.

I have to admit, this was the hardest part to figure out and I was stuck on it for longer than I’d like to admit. The perfect game scenario was totalling 320 for the longest time! (Perfect games of 12 strikes in a row are 300 points, not 320)

WITH stats AS (
    SELECT
        *,
        COALESCE(LEAD(pins, 1) OVER (w), 0)              AS next_pins,
        COALESCE(LEAD(pins, 2) OVER (w), 0)              AS next_next_pins,
        roll = 1 AND pins = 10                           AS is_strike,
        roll = 2 AND (pins + LAG(pins, 1) OVER (w) = 10) AS is_spare,
        frame = (max(frame) OVER (PARTITION BY game))    AS is_last_frame
    FROM game_rolls
    WINDOW w AS (PARTITION BY game ORDER BY game, frame, roll)
)
SELECT
    *,
    CASE
        WHEN is_last_frame THEN pins
        WHEN is_strike     THEN pins + next_pins + next_next_pins 
        WHEN is_spare      THEN pins + next_pins
    ELSE
        pins
    END
    AS score
FROM stats
;

Results with pins per frame:

gameframerollpinsnext_pinsnext_next_pinsis_strikeis_spareis_last_framescore
4117210falsefalsefalse7
4122105falsefalsefalse2
4211054truefalsefalse19
431548falsefalsefalse5
432481falsefalsefalse4
4418110falsefalsefalse8
4421106falsefalsefalse1
4511063truefalsefalse19
461639falsefalsefalse6
462390falsefalsefalse3
4719010falsefalsefalse9
4720107falsefalsefalse0
4811072truefalsefalse19
4917210falsefalsefalse7
49221010falsefalsefalse2
410110108truefalsetrue10
41021080falsefalsetrue10
4103800falsefalsetrue8

That looks good. The scores are correct and the last frame is scored correctly.

We could simply SUM everything at this point:

WITH stats AS (
    SELECT
        *,
        COALESCE(LEAD(pins, 1) OVER (w), 0)              AS next_pins,
        COALESCE(LEAD(pins, 2) OVER (w), 0)              AS next_next_pins,
        roll = 1 AND pins = 10                           AS is_strike,
        roll = 2 AND (pins + LAG(pins, 1) OVER (w) = 10) AS is_spare,
        frame = (max(frame) OVER (PARTITION BY game))    AS is_last_frame
    FROM game_rolls
    WINDOW w AS (PARTITION BY game ORDER BY game, frame, roll)
),
scores AS (
    SELECT
        *,
        CASE
            WHEN is_last_frame THEN pins
            WHEN is_strike     THEN pins + next_pins + next_next_pins 
            WHEN is_spare      THEN pins + next_pins
        ELSE
            pins
        END
        AS score
    FROM stats
)
SELECT
    game,
    SUM(score)
FROM scores
GROUP BY game
;

Results final SUM:

gamesum
1300
210
3100
4139

Notice the use of CTEs again so we can build on the previous step. This is an important pattern that I follow strictly when building up queries. It allows me to extract parts into views in the future and provides a good place for meaningful names.

In Bowling, we normally to see a running score frame by frame. We can do this by adding a cumulative score to each frame with yet another Window Function.

WITH stats AS (
    SELECT
        *,
        COALESCE(LEAD(pins, 1) OVER (w), 0)              AS next_pins,
        COALESCE(LEAD(pins, 2) OVER (w), 0)              AS next_next_pins,
        roll = 1 AND pins = 10                           AS is_strike,
        roll = 2 AND (pins + LAG(pins, 1) OVER (w) = 10) AS is_spare,
        frame = (max(frame) OVER (PARTITION BY game))    AS is_last_frame
    FROM game_rolls
    WINDOW w AS (PARTITION BY game ORDER BY game, frame, roll)
),
scores AS (
    SELECT
        *,
        CASE
            WHEN is_last_frame THEN pins
            WHEN is_strike     THEN pins + next_pins + next_next_pins 
            WHEN is_spare      THEN pins + next_pins
        ELSE
            pins
        END
        AS score
    FROM stats
),
sums AS (
    -- Note: This CTE will get optimized away by the query planner.
    -- Nothing is calling it, and we can choose which to call in the final query.
    SELECT
        game,
        SUM(score)
    FROM scores
    GROUP BY game
),
cumulative_scores AS (
    SELECT
        *,
        SUM(score) OVER (w) AS cumulative_score,
        CASE
            WHEN is_strike THEN 'X'
            WHEN is_spare  THEN '  /'
        ELSE REPEAT('  ', roll - 1) || pins
        END display
    FROM scores
    WINDOW w AS (PARTITION BY game ORDER BY game, frame, roll)
)
SELECT
    game,
    frame,
    display,
    cumulative_score
FROM cumulative_scores
;

Results Game 1:

gameframedisplaycumulative_score
11X30
12X60
13X90
14X120
15X150
16X180
17X210
18X240
19X270
110X280
11010290
11010300

Results Game 2:

gameframedisplaycumulative_score
2111
2101
2212
2202
2313
2303
2414
2404
2515
2505
2616
2606
2717
2707
2818
2808
2919
2909
210110
210010

Results Game 3:

gameframedisplaycumulative_score
3100
31/10
32010
32/20
33020
33/30
34030
34/40
35040
35/50
36050
36/60
37060
37/70
38070
38/80
39080
39/90
310090
310/100

Results Game 4:

I’m putting this in a preformatted text block so you can see the formatting. Markdown tables aren’t doing this justice.

|game|frame|display|cumulative_score|
|----|-----|-------|----------------|
|   4|    1|7      |               7|
|   4|    1|  2    |               9|
|   4|    2|X      |              28|
|   4|    3|5      |              33|
|   4|    3|  4    |              37|
|   4|    4|8      |              45|
|   4|    4|  1    |              46|
|   4|    5|X      |              65|
|   4|    6|6      |              71|
|   4|    6|  3    |              74|
|   4|    7|9      |              83|
|   4|    7|  0    |              83|
|   4|    8|X      |             102|
|   4|    9|7      |             109|
|   4|    9|  2    |             111|
|   4|   10|X      |             121|
|   4|   10|  10   |             131|
|   4|   10|    8  |             139|

I narrowed the columns a little to make it easier to follow. In the final query, I would include all the columns to let the client decide how to display it.

Conclusion

If you wanted to use this using a Business Intelligence (BI) tool, you should create a view for each CTE and then query the final view.

Should bowling be calculated in SQL? I’ll leave that up to you. It was a fun exercise to figure out the logic and I hope you enjoyed the ride!