← Back to Go Examples
A Beginner's Build-Along · Go

Learning to Code by Bowling

This guide walks you through writing a bowling score program in Go — first the long way, then the smart way. In the first game, you'll build a working scorer using only if and else, so every rule of bowling shows up explicitly in the code. Once it works and you can see every line, we'll play a second game: rebuild the same program with for loops and a different data shape, and watch it shrink to about a quarter of its size. Two lessons in one project — how to write it, then how to make it small.

The sample game we'll score
×
20
1
7/
39
2
9
48
3
×
66
4
8
74
5
8/
84
6
6
90
7
×
120
8
×
148
9
×81
167
10
Target final score 167
FRAME 01

The rules, in one page.

Before writing any code, put the rules in your head. If you don't have them straight, the code will be tangled no matter how carefully you type.

A game has ten frames. In each frame, you get up to two rolls to knock down ten pins. There are three outcomes:

Strike. You knock down all ten pins on the first roll. The frame ends immediately. The score for that frame is 10 plus your next two rolls, wherever they happen.

Spare. You knock down all ten pins across both rolls. The score for that frame is 10 plus your next one roll.

Open frame. You knock down fewer than ten pins across both rolls. The score is just the sum of the two rolls.

Frame 10 exception

The tenth frame does not play by the rules above. If you strike or spare in the tenth frame, you're given bonus rolls inside the same frame — so frame 10 can have up to three rolls of its own. We'll come back to this. For now, just know it's coming.

FRAME 02

The input file.

Our program will read scores from a text file. Make a file called scores.txt in the same directory as your Go file. One line per frame, numbers separated by spaces. Here's the sample game shown in the header above:

scores.txt10 lines · one per frame
10
7 3
9 0
10
0 8
8 2
0 6
10
10
10 8 1

Line 1 is frame 1: a strike (one roll, value 10). Line 2 is frame 2: a spare (7 pins, then 3 pins). Line 10 is frame 10 with three rolls, because a strike in the tenth frame earns two bonus rolls right there in the frame.

The correct final score for this file is 167. That's your target. Every version of your program should agree on that number, or you have a bug.

FRAME 03

Read the file into memory.

Start with the boilerplate: open the file, read it line by line, and store each line as a list of integers. We'll hold the whole game in a variable called frames, which is a slice of slices — one inner slice per frame.

bowling.go — step 1runnable
package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    file, err := os.Open("scores.txt")
    if err != nil {
        fmt.Println("Error opening scores.txt:", err)
        return
    }
    defer file.Close()

    var frames [][]int
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" {
            continue
        }
        var rolls []int
        for _, p := range strings.Fields(line) {
            n, _ := strconv.Atoi(p)
            rolls = append(rolls, n)
        }
        frames = append(frames, rolls)
    }

    // Confirm the file was read correctly.
    for i, f := range frames {
        fmt.Printf("Frame %d: %v\n", i+1, f)
    }
}

Run it with go run bowling.go. You should see ten lines, one per frame, each showing the rolls it contains. If that works, you're ready to score. If it doesn't, the problem is either your file name or your file location — fix it now, because everything downstream depends on this working.

Note on loops

The rule "only if/else" applies to the scoring logic, not the file reader. Loops for reading input are fine — the point of the exercise is to hand-write each frame's scoring, not to reinvent the file API.

FRAME 04

Score frame 1 as an open frame.

Start with the simplest case: no strikes, no spares. Just add the two rolls. Replace the confirmation loop at the end of your main function with this:

bowling.go — replace the print loop with:
    // Score frame 1 (index 0 in the frames slice)
    total := 0
    var f1 int

    f1 = frames[0][0] + frames[0][1]
    total = total + f1
    fmt.Println("Frame  1:", f1, "  Running total:", total)

Run it. Your frame 1 in scores.txt is a strike (just 10 on the line), so frames[0] only has one element. This code will crash with an index out of range. That's the point — it forces us to check for the strike case next.

Broken behavior is a specification: it tells you what your next branch has to handle.

FRAME 05

Handle spares.

A spare is when the two rolls in the frame add up to 10. The score is 10 plus the first roll of the next frame. Update the frame 1 block:

bowling.go — frame 1 with spare handling
    // Score frame 1
    total := 0
    var f1 int

    if frames[0][0] + frames[0][1] == 10 { // spare
        f1 = 10 + frames[1][0]
    } else { // open frame
        f1 = frames[0][0] + frames[0][1]
    }
    total = total + f1
    fmt.Println("Frame  1:", f1, "  Running total:", total)

This still crashes on our sample file — frame 1 is a strike, and frames[0][0] + frames[0][1] tries to read a second roll that doesn't exist. But now the structure is right. One more branch to add.

FRAME 06

Handle strikes — including two in a row.

A strike is when the first roll is 10. The score is 10 plus the next two rolls. The wrinkle: if the next frame is also a strike, that next frame only has one roll — so we have to reach into the frame after that to find the second bonus roll.

bowling.go — complete frame 1 scoring
    // Score frame 1
    total := 0
    var f1 int

    if frames[0][0] == 10 { // strike
        if frames[1][0] == 10 { // next frame is also a strike
            f1 = 10 + 10 + frames[2][0]
        } else {
            f1 = 10 + frames[1][0] + frames[1][1]
        }
    } else if frames[0][0] + frames[0][1] == 10 { // spare
        f1 = 10 + frames[1][0]
    } else { // open frame
        f1 = frames[0][0] + frames[0][1]
    }
    total = total + f1
    fmt.Println("Frame  1:", f1, "  Running total:", total)

Now run it. Frame 1 is a strike (frames[0][0] == 10). Frame 2 is not a strike (it's 7 3), so we hit the inner else: f1 = 10 + 7 + 3 = 20. Output:

Frame 1: 20 Running total: 20

That matches the scoresheet at the top. Frame 1 is done.

Every strike branch needs a nested check. Every spare branch needs one bonus. Memorize the shape once — you'll type it eight more times.

FRAME 07

Frames 2 through 7 follow the same shape.

Copy the frame 1 block six more times. The only thing you change is the index. Frame 2 uses frames[1], frames[2], frames[3]. Frame 3 uses frames[2], frames[3], frames[4]. And so on.

Here's frame 2 as a full example. Notice how everything is character-for-character identical to frame 1 except the indexes bump by one:

bowling.go — frame 2
    // Score frame 2
    var f2 int

    if frames[1][0] == 10 { // strike
        if frames[2][0] == 10 {
            f2 = 10 + 10 + frames[3][0]
        } else {
            f2 = 10 + frames[2][0] + frames[2][1]
        }
    } else if frames[1][0] + frames[1][1] == 10 { // spare
        f2 = 10 + frames[2][0]
    } else {
        f2 = frames[1][0] + frames[1][1]
    }
    total = total + f2
    fmt.Println("Frame  2:", f2, "  Running total:", total)

Do the same for frames 3 through 7. The indexes for frame N are N−1, N, and N+1. So frame 5 uses frames[4], frames[5], frames[6]. Frame 7 uses frames[6], frames[7], frames[8].

After typing all six blocks, run the program. You should see running totals of 20, 39, 48, 66, 74, 84, 90 through frame 7.

FRAME 08

Frames 8 and 9 lean into frame 10.

Frame 8 uses the same shape as frames 1 through 7. The pattern still works, because a strike in frame 8 that is followed by a strike in frame 9 looks one further ahead — into frame 10, which does exist. No adjustment needed:

bowling.go — frame 8
    // Score frame 8
    var f8 int

    if frames[7][0] == 10 {
        if frames[8][0] == 10 {
            f8 = 10 + 10 + frames[9][0]  // second bonus is first roll of frame 10
        } else {
            f8 = 10 + frames[8][0] + frames[8][1]
        }
    } else if frames[7][0] + frames[7][1] == 10 {
        f8 = 10 + frames[8][0]
    } else {
        f8 = frames[7][0] + frames[7][1]
    }
    total = total + f8
    fmt.Println("Frame  8:", f8, "  Running total:", total)

Frame 9 is where the pattern starts to break down. If frame 9 is a strike, its two bonus rolls come from frame 10 — but frame 10 is a single entry that already holds two or three rolls in one line. So we don't need a nested strike-check anymore. We just grab frames[9][0] and frames[9][1] directly:

bowling.go — frame 9 (note: no nested check)
    // Score frame 9
    var f9 int

    if frames[8][0] == 10 { // strike — bonuses are inside frame 10
        f9 = 10 + frames[9][0] + frames[9][1]
    } else if frames[8][0] + frames[8][1] == 10 {
        f9 = 10 + frames[9][0]
    } else {
        f9 = frames[8][0] + frames[8][1]
    }
    total = total + f9
    fmt.Println("Frame  9:", f9, "  Running total:", total)

Running totals so far: 120 after frame 8, 148 after frame 9. One frame left — and it's the different one.

FRAME 09

Frame 10 is a different game. Here's why.

Frames 1 through 9 all share one property: when you strike or spare, you look outside the current frame to find the bonus. Frame 10 doesn't have that option — there's no frame 11 to look at. So the game solves this a different way: if you strike or spare in frame 10, you get your bonus rolls right there, inside frame 10 itself.

Concretely, this changes three things:

1. Frame 10 can hold more than two rolls.

An open frame 10 has two rolls, like every other frame. A spare in frame 10 gets a third roll for the bonus. A strike in frame 10 gets two more rolls after the strike, for three total. That's why the last line of our sample file has three numbers: 10 8 1.

2. The scoring formula is simpler, not more complex.

For frames 1–9, a strike is scored as "10 plus the next two rolls" — you have to go find those rolls elsewhere. For frame 10, the bonus rolls are already sitting there as parts of frames[9]. You don't add "10 plus bonuses" — you just sum every roll in the frame. The strike, and its two bonuses, are all already recorded.

The mental shift

Frames 1–9: look up the bonus rolls in later frames, then add to 10.

Frame 10: sum all the rolls that are already in the frame.

3. There is no next frame to reach into.

Every previous frame's code does something like frames[i+1][0]. Frame 10 cannot do that — frames[10] doesn't exist, and reading it would crash your program. So the code for frame 10 never references any index above frames[9]. If you find yourself typing frames[10] or worse, stop and re-read this section.

The code.

bowling.go — frame 10 (the tenth bowling frame)
    // Score frame 10 (special: bonuses are inside the frame)
    var f10 int

    if frames[9][0] == 10 { // strike in the 10th
        f10 = frames[9][0] + frames[9][1] + frames[9][2]
    } else if frames[9][0] + frames[9][1] == 10 { // spare in the 10th
        f10 = frames[9][0] + frames[9][1] + frames[9][2]
    } else { // open — only two rolls
        f10 = frames[9][0] + frames[9][1]
    }
    total = total + f10
    fmt.Println("Frame 10:", f10, "  Running total:", total)

Notice the strike and spare branches do the exact same thing — sum all three rolls. They look redundant, and they are. But keep them separate. Writing them as one branch works for scoring, but it hides the reason: two very different game events happen to score the same way. The clarity is worth the duplication.

For our sample game, frame 10 is 10 8 1. First roll is 10 → strike branch → 10 + 8 + 1 = 19. Running total: 148 + 19 = 167.

FRAME 10

Assemble, run, and test.

You now have every piece. The complete program lives in two files in the same directory:

scores.txt — the ten-line file from Frame 02.

bowling.go — the file-reader from Frame 03, followed by all ten scoring blocks (frame 1 from Frame 06, frames 2–7 following the pattern in Frame 07, frames 8 and 9 from Frame 08, frame 10 from Frame 09), and finally a print of the final total.

The last thing to add, right before the closing brace of main:

bowling.go — final line
    fmt.Println("\nFINAL SCORE:", total)
}
Show complete bowling.goexpand to view & copy the whole program
// Bowling the Long Way -- not efficient.

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    file, err := os.Open("scores.txt")
    if err != nil {
        fmt.Println("Error opening scores.txt:", err)
        return
    }
    defer file.Close()

    var frames [][]int
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" {
            continue
        }
        var rolls []int
        for _, p := range strings.Fields(line) {
            n, _ := strconv.Atoi(p)
            rolls = append(rolls, n)
        }
        frames = append(frames, rolls)
    }

    total := 0
    var f1, f2, f3, f4, f5, f6, f7, f8, f9, f10 int

    // Score frame 1
    if frames[0][0] == 10 { // strike
        if frames[1][0] == 10 {
            f1 = 10 + 10 + frames[2][0]
        } else {
            f1 = 10 + frames[1][0] + frames[1][1]
        }
    } else if frames[0][0]+frames[0][1] == 10 { // spare
        f1 = 10 + frames[1][0]
    } else { // open
        f1 = frames[0][0] + frames[0][1]
    }
    total = total + f1
    fmt.Println("Frame  1:", f1, "  Running total:", total)

    // Score frame 2
    if frames[1][0] == 10 {
        if frames[2][0] == 10 {
            f2 = 10 + 10 + frames[3][0]
        } else {
            f2 = 10 + frames[2][0] + frames[2][1]
        }
    } else if frames[1][0]+frames[1][1] == 10 {
        f2 = 10 + frames[2][0]
    } else {
        f2 = frames[1][0] + frames[1][1]
    }
    total = total + f2
    fmt.Println("Frame  2:", f2, "  Running total:", total)

    // Score frame 3
    if frames[2][0] == 10 {
        if frames[3][0] == 10 {
            f3 = 10 + 10 + frames[4][0]
        } else {
            f3 = 10 + frames[3][0] + frames[3][1]
        }
    } else if frames[2][0]+frames[2][1] == 10 {
        f3 = 10 + frames[3][0]
    } else {
        f3 = frames[2][0] + frames[2][1]
    }
    total = total + f3
    fmt.Println("Frame  3:", f3, "  Running total:", total)

    // Score frame 4
    if frames[3][0] == 10 {
        if frames[4][0] == 10 {
            f4 = 10 + 10 + frames[5][0]
        } else {
            f4 = 10 + frames[4][0] + frames[4][1]
        }
    } else if frames[3][0]+frames[3][1] == 10 {
        f4 = 10 + frames[4][0]
    } else {
        f4 = frames[3][0] + frames[3][1]
    }
    total = total + f4
    fmt.Println("Frame  4:", f4, "  Running total:", total)

    // Score frame 5
    if frames[4][0] == 10 {
        if frames[5][0] == 10 {
            f5 = 10 + 10 + frames[6][0]
        } else {
            f5 = 10 + frames[5][0] + frames[5][1]
        }
    } else if frames[4][0]+frames[4][1] == 10 {
        f5 = 10 + frames[5][0]
    } else {
        f5 = frames[4][0] + frames[4][1]
    }
    total = total + f5
    fmt.Println("Frame  5:", f5, "  Running total:", total)

    // Score frame 6
    if frames[5][0] == 10 {
        if frames[6][0] == 10 {
            f6 = 10 + 10 + frames[7][0]
        } else {
            f6 = 10 + frames[6][0] + frames[6][1]
        }
    } else if frames[5][0]+frames[5][1] == 10 {
        f6 = 10 + frames[6][0]
    } else {
        f6 = frames[5][0] + frames[5][1]
    }
    total = total + f6
    fmt.Println("Frame  6:", f6, "  Running total:", total)

    // Score frame 7
    if frames[6][0] == 10 {
        if frames[7][0] == 10 {
            f7 = 10 + 10 + frames[8][0]
        } else {
            f7 = 10 + frames[7][0] + frames[7][1]
        }
    } else if frames[6][0]+frames[6][1] == 10 {
        f7 = 10 + frames[7][0]
    } else {
        f7 = frames[6][0] + frames[6][1]
    }
    total = total + f7
    fmt.Println("Frame  7:", f7, "  Running total:", total)

    // Score frame 8
    if frames[7][0] == 10 {
        if frames[8][0] == 10 {
            f8 = 10 + 10 + frames[9][0] // second bonus is first roll of frame 10
        } else {
            f8 = 10 + frames[8][0] + frames[8][1]
        }
    } else if frames[7][0]+frames[7][1] == 10 {
        f8 = 10 + frames[8][0]
    } else {
        f8 = frames[7][0] + frames[7][1]
    }
    total = total + f8
    fmt.Println("Frame  8:", f8, "  Running total:", total)

    // Score frame 9
    if frames[8][0] == 10 { // strike — bonuses are inside frame 10
        f9 = 10 + frames[9][0] + frames[9][1]
    } else if frames[8][0]+frames[8][1] == 10 {
        f9 = 10 + frames[9][0]
    } else {
        f9 = frames[8][0] + frames[8][1]
    }
    total = total + f9
    fmt.Println("Frame  9:", f9, "  Running total:", total)

    // Score frame 10 (special: bonuses are inside the frame)
    if frames[9][0] == 10 { // strike in the 10th
        f10 = frames[9][0] + frames[9][1] + frames[9][2]
    } else if frames[9][0]+frames[9][1] == 10 { // spare in the 10th
        f10 = frames[9][0] + frames[9][1] + frames[9][2]
    } else { // open — only two rolls
        f10 = frames[9][0] + frames[9][1]
    }
    total = total + f10
    fmt.Println("Frame 10:", f10, "  Running total:", total)

    fmt.Println("\nFINAL SCORE:", total)
}

Run it.

From the directory containing both files:

go run bowling.go

Expected output.

Frame 1: 20 Running total: 20 Frame 2: 19 Running total: 39 Frame 3: 9 Running total: 48 Frame 4: 18 Running total: 66 Frame 5: 8 Running total: 74 Frame 6: 10 Running total: 84 Frame 7: 6 Running total: 90 Frame 8: 30 Running total: 120 Frame 9: 28 Running total: 148 Frame 10: 19 Running total: 167 FINAL SCORE: 167

If a frame is wrong.

The frame-by-frame print exists so you can find bugs quickly. If frame 3 disagrees, the bug is in frame 3's block, not frame 1's — because frame 1 already printed a correct number. Read the block for the wrong frame, and check:

  • Are the indexes off by one? Frame 3 should use frames[2], not frames[3].
  • Did you compare the wrong values in the spare check? frames[i][0] + frames[i][1], not frames[i][0] + frames[i+1][0].
  • In the strike branch, did you remember the nested check for a double strike?

Try changing the game.

Once the sample works, edit scores.txt and see if your program agrees with a score you know. Some good tests:

  • Twelve strikes (10 lines of 10, then a last line of 10 10 10) → perfect game, score 300.
  • Ten open frames of 4 5 → score 90. No bonuses trigger, so this proves your open-frame path.
  • Nine strikes and a final 9 /… try 9 1 10 as the last line and see what you get. This exercises the spare-in-frame-10 branch specifically.

A program you can change without fear is a program you understand.

×  End of first game  ×

Your program works. Now let's play a second game — same rules, same 167, but built with better tools than if and else.

Second Game · Making It Efficient

Same score. A quarter the code

Your working scorer is about 190 lines. In this second game we'll rebuild it using for loops instead of hand-written blocks, and change how the data is stored so the trickiest frames stop being tricky. Same input, same output — but far less to read and far less to break.

FRAME 01

Look at what you wrote. What repeats?

Open your bowling.go file from the first game. Put the scoring block for frame 1 next to the scoring block for frame 2 and read them together:

bowling.go — frames 1 and 2 side by side
    // Score frame 1
    if frames[0][0] == 10 {
        if frames[1][0] == 10 {
            f1 = 10 + 10 + frames[2][0]
        } else {
            f1 = 10 + frames[1][0] + frames[1][1]
        }
    } ...

    // Score frame 2
    if frames[1][0] == 10 {
        if frames[2][0] == 10 {
            f2 = 10 + 10 + frames[3][0]
        } else {
            f2 = 10 + frames[2][0] + frames[2][1]
        }
    } ...

Every character is identical except the indexes. 0, 1, 2 becomes 1, 2, 3. The same is true for frames 3 through 8 — same shape, indexes bumping up by one each block.

When only a value changes between blocks, you're looking at a loop that hasn't been written yet.

The tool that replaces this kind of repetition is the for loop.

FRAME 02

Meet the for loop.

A for loop runs the same block of code many times, with one thing changing on each pass. Go uses the word for for every kind of loop. Some other languages have a separate while keyword — in Go, while doesn't exist. A "while loop" in Go is just a for written with only a condition.

The three shapes of for:

go — the three shapes of for
// Counter loop — runs 8 times with i = 0, 1, 2, ..., 7
for i := 0; i < 8; i++ {
    // use i here
}

// While-style — runs as long as the condition is true
for stillGoing {
    // something in here must eventually set stillGoing = false
}

// Infinite — runs forever until you call break
for {
    // use break to leave
}

The first shape is what we need for the bowling frames. Read it left to right: start with i = 0, keep going while i < 8, and add 1 to i after each pass. That produces the values 0, 1, 2, 3, 4, 5, 6, 7 — exactly the indexes you typed by hand for frames 1 through 8.

Two pieces of shorthand you'll see a lot:

  • i++ means i = i + 1. Adds one to i.
  • total += frameScore means total = total + frameScore. Same pattern works for -=, *=, /=.
FRAME 03

Replace frames 1 through 8 with one block.

Take one of your identical frame blocks and generalize it. Every frames[0] becomes frames[i]. Every frames[1] becomes frames[i+1]. Every frames[2] becomes frames[i+2]. Then wrap the whole thing in a for loop that counts i from 0 to 7:

bowling.go — one loop replaces eight hand-written blocks≈ 120 lines removed
    for i := 0; i < 8; i++ {
        var frameScore int

        if frames[i][0] == 10 { // strike
            if frames[i+1][0] == 10 {
                frameScore = 10 + 10 + frames[i+2][0]
            } else {
                frameScore = 10 + frames[i+1][0] + frames[i+1][1]
            }
        } else if frames[i][0] + frames[i][1] == 10 { // spare
            frameScore = 10 + frames[i+1][0]
        } else { // open
            frameScore = frames[i][0] + frames[i][1]
        }

        total += frameScore
        fmt.Printf("Frame %2d: %d  Running total: %d\n", i+1, frameScore, total)
    }

This one block does the work of eight hand-written blocks from the first game. Frames 9 and 10 still need their own separate code — the loop only covers frames that share the identical pattern.

Keep your hand-written frame 9 and frame 10 blocks from the first game right after this loop, and run the program. Same 167. Same frame-by-frame breakdown. About 120 fewer lines.

FRAME 04

Now stretch the loop. Watch what breaks.

Try to extend the loop to cover all ten frames. Change the loop condition from i < 8 to i < 10. Save. Run.

It crashes. When i reaches 8 (frame 9) and there's a strike, the code tries to read frames[i+2][0], which is frames[10][0]. frames[10] doesn't exist. The program panics.

You could patch it with a special case: if i == 8, do something different. And another for frame 10: if i == 9, do something else. Now your one loop needs two exceptions.

When a loop keeps needing exceptions at the edges, the problem isn't the loop. It's the data.

Our data is stored as ten frames, each holding one to three rolls. That's the shape of a paper scoresheet. But the scoring rules never talk about frames — they talk about "the next roll" and "the roll after that." The data is fighting the algorithm. Fix the data.

FRAME 05

Flatten the data. Watch the exceptions vanish.

Instead of a slice of frames-of-rolls, store one flat slice of rolls — every ball thrown, in order:

the reshape
rolls = [10, 7, 3, 9, 0, 10, 0, 8, 8, 2, 0, 6, 10, 10, 10, 8, 1]

Now "the next roll" is just rolls[i+1]. "The roll after that" is rolls[i+2]. Frame 10's bonus rolls are already sitting at the end of the list, indistinguishable from any other roll. There's no special case to handle — because from the algorithm's point of view, there's nothing special about them anymore.

The file reader gets simpler too. We stop tracking which line each roll came from and just collect every number:

bowling.go — new file reader
    var rolls []int
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        for _, p := range strings.Fields(scanner.Text()) {
            n, _ := strconv.Atoi(p)
            rolls = append(rolls, n)
        }
    }

That inner for is Go's range form: it walks through every word on the line and hands each one to p.

FRAME 06

The scorer collapses to one loop.

Walk a pointer i through the flat rolls array. On each frame, look at rolls[i]. A strike uses one roll, so advance i by 1. A spare or open uses two rolls, so advance by 2. The bonus rolls for scoring aren't consumed by the pointer — they're just read.

bowling.go — the entire scorer10 frames · 1 loop
    total, i := 0, 0
    for frame := 1; frame <= 10; frame++ {
        var s int
        switch {
        case rolls[i] == 10: // strike
            s = 10 + rolls[i+1] + rolls[i+2]
            i++
        case rolls[i]+rolls[i+1] == 10: // spare
            s = 10 + rolls[i+2]
            i += 2
        default: // open
            s = rolls[i] + rolls[i+1]
            i += 2
        }
        total += s
        fmt.Printf("Frame %2d: %2d  Running total: %d\n", frame, s, total)
    }
    fmt.Println("\nFINAL SCORE:", total)

Frame 10 is no longer a special case. Its rolls sit in the flat array like any others, and they get consumed as the pointer moves through them. The strike branch advances i by 1 (only one roll counted), spare and open advance by 2 — that's the whole navigation rule.

A note on switch.

You already know if / else if / else. A switch with no expression at the top does the same job — each case is a boolean, and the first true one runs. It's not required; it just reads better when you have several parallel checks like strike / spare / open. Rewriting this with chained if statements would produce exactly the same behavior.

Here's the whole compact program in one file — the flat reader from Frame 05 plus this scorer, about 40 lines doing exactly what the ~190-line first-game version did:

Show complete bowling_compact.goexpand to view & copy the whole program
// Bowling the Smart Way -- one loop over a flat list of rolls.

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    file, err := os.Open("scores.txt")
    if err != nil {
        fmt.Println("Error opening scores.txt:", err)
        return
    }
    defer file.Close()

    // Read every number into one flat slice of rolls.
    var rolls []int
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        for _, p := range strings.Fields(scanner.Text()) {
            n, _ := strconv.Atoi(p)
            rolls = append(rolls, n)
        }
    }

    // Score all ten frames with one loop and a moving pointer.
    total, i := 0, 0
    for frame := 1; frame <= 10; frame++ {
        var s int
        switch {
        case rolls[i] == 10: // strike
            s = 10 + rolls[i+1] + rolls[i+2]
            i++
        case rolls[i]+rolls[i+1] == 10: // spare
            s = 10 + rolls[i+2]
            i += 2
        default: // open
            s = rolls[i] + rolls[i+1]
            i += 2
        }
        total += s
        fmt.Printf("Frame %2d: %2d  Running total: %d\n", frame, s, total)
    }
    fmt.Println("\nFINAL SCORE:", total)
}
FRAME 07

Run both. Prove they agree.

Save the compact version as bowling_compact.go. Keep your first-game version as bowling.go. Point both at the same scores.txt. Run each one:

go run bowling.go
go run bowling_compact.go

Every frame score should match. Every running total should match. Both should end on FINAL SCORE 167. If they don't, one of them has a bug — and because both print frame-by-frame, you'll see exactly which frame first disagrees.

This is how you refactor safely: keep the old version running, build the new one alongside, and use output comparison as a free test suite.

The payoff

Verbose version: about 190 lines. Compact version: about 40 lines. Same behavior on every possible game — perfect game, all-open game, spare in the tenth, all of it.

You didn't get better at bowling. You got better at seeing what a program is really doing underneath all the words.

The three questions that took you there.

The moves you made, in the order you made them:

  1. What varies? Only an index changed between blocks — that's a for loop you haven't written yet.
  2. Where does the loop break? Frame 9 and 10 needed exceptions — signal that the data was fighting the algorithm.
  3. Is the data shaped for the problem? The rules talked about rolls, not frames, so we stored rolls. The exceptions dissolved.

Those three questions transfer to almost anything you'll ever write. FizzBuzz, Roman numerals, any nested if tree you've already got sitting in your code. The bowling scorer was just the vehicle — the questions are the lesson.

The point wasn't to write short code. It was to see what the long code was actually saying.