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.
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.
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.
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:
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.
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.
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.
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.
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:
// 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.
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:
// 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.
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.
// 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:
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.
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:
// 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 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:
// 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:
// 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.
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:
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.
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.
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.
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.
// 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.
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:
fmt.Println("\nFINAL SCORE:", total)
}
// 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) }
From the directory containing both files:
go run bowling.go
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:
frames[2], not frames[3].frames[i][0] + frames[i][1], not frames[i][0] + frames[i+1][0].Once the sample works, edit scores.txt and see if your program agrees with a score you know. Some good tests:
10, then a last line of 10 10 10) → perfect game, score 300.4 5 → score 90. No bonuses trigger, so this proves your open-frame path.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.
Your program works. Now let's play a second game — same rules, same 167, but built with better tools than if and else.
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.
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:
// 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.
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:
// 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 -=, *=, /=.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:
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.
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.
Instead of a slice of frames-of-rolls, store one flat slice of rolls — every ball thrown, in order:
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:
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.
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.
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.
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:
// 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) }
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.
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 moves you made, in the order you made them:
for loop you haven't written yet.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.