This guide walks you through writing a bowling score program in C — first the long way, then the smart way, then the good-looking 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. In the second game you'll rebuild it with a for loop over a flat array and watch it shrink to a fraction of its size. In the third game you'll stop printing lines of text and start drawing — a real scoresheet, in color, painted straight onto your terminal with ANSI escape codes.
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 C 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's numbers in an array. C has no growable list, so you must decide the size up front. A game has ten frames and no frame ever holds more than three rolls, so a fixed [10][3] array of int is exactly the right shape.
#include <stdio.h> int main(void) { FILE *fp = fopen("scores.txt", "r"); if (fp == NULL) { printf("Error opening scores.txt\n"); return 1; } int frames[10][3] = {0}; char line[128]; int f = 0; while (f < 10 && fgets(line, sizeof line, fp) != NULL) { int n, offset = 0, consumed, count = 0; while (count < 3 && sscanf(line + offset, "%d%n", &n, &consumed) == 1) { frames[f][count] = n; count++; offset += consumed; } if (count == 0) { continue; /* blank line -- skip it */ } f++; } fclose(fp); /* Confirm the file was read correctly. */ for (int i = 0; i < 10; i++) { printf("Frame %2d: %d %d %d\n", i + 1, frames[i][0], frames[i][1], frames[i][2]); } return 0; }
Compile and run it:
gcc -Wall -Wextra -std=c99 -o bowling bowling.c ./bowling
You should see ten lines, one per frame. 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.
= {0} zeroes the whole array. Writing int frames[10][3] = {0}; sets every one of the thirty slots to zero. Without that initializer they'd contain whatever garbage was already in that memory. This matters more than it looks — you'll see why in the next frame.
%n tells you how far sscanf got. Normally sscanf just hands back the values it parsed. The %n conversion instead stores how many characters it consumed so far into consumed. We add that to offset so the next pass starts where the last one stopped, which is how we walk across a line of unknown length without knowing how many numbers are on it.
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 stdio.
Start with the simplest case: no strikes, no spares. Just add the two rolls. Replace the confirmation loop at the end of main with this:
/* Score frame 1 (index 0 in the frames array) */ int total = 0; int f1; f1 = frames[0][0] + frames[0][1]; total = total + f1; printf("Frame 1: %2d Running total: %d\n", f1, total);
Run it. Frame 1 in scores.txt is a strike — just 10 on the line — so only frames[0][0] was ever filled in. You'd expect that to be a problem.
It isn't. The program prints Frame 1: 10 and carries on happily. frames[0][1] is zero, because we zeroed the array, so 10 + 0 is a perfectly legal sum of two numbers. No crash, no warning, no complaint.
C won't tell you the answer is wrong. It will just quietly give you a wrong answer.
That's the single most important thing to internalize about writing C. In a language with bounds-checked lists, this same mistake would have stopped the program and pointed at the line. Here, the only thing standing between you and a wrong final score is you knowing the rules and checking the number at the end. Our target is 167. Right now the program says 10. Keep going.
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 */ int total = 0; int f1; 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; printf("Frame 1: %2d Running total: %d\n", f1, total);
Read the condition out loud: "if roll one plus roll two equals ten, it's a spare, so score ten plus the first roll of the next frame; otherwise just add the two rolls." The code and the rule are the same sentence. That's the whole point of writing it the long way first.
Our frame 1 is a strike, so this still prints 10 — a strike also has frames[0][0] + frames[0][1] == 10 when the second slot is zero, which means the spare branch runs and gives 10 + 7 = 17. Still wrong, but wrong in a new way. Strikes need to be checked before spares.
A strike is ten pins on the first roll. Its score is 10 plus the next two rolls — and "the next two rolls" is the tricky part, because if the next frame is also a strike, its second roll doesn't exist. You have to reach into the frame after that.
/* Score frame 1 */ if (frames[0][0] == 10) { /* strike */ if (frames[1][0] == 10) { /* two strikes in a row */ f1 = 10 + 10 + frames[2][0]; } else { f1 = 10 + frames[1][0] + frames[1][1]; } } else if (frames[0][0] + frames[0][1] == 10) { f1 = 10 + frames[1][0]; } else { f1 = frames[0][0] + frames[0][1]; } total = total + f1; printf("Frame 1: %2d Running total: %d\n", f1, total);
Now run it. Frame 1 scores 20: ten for the strike, plus 7 and 3 from frame 2. That matches the scoresheet at the top of this page. One frame down, nine to go.
Notice the order of the branches. Strike is checked first, spare second, open last. Get that order wrong and a strike gets scored as a spare, exactly as it did a moment ago. In an if / else if / else chain, order is logic.
Here's the tedious part, and it's tedious on purpose. Copy the frame 1 block seven more times. In each copy, bump every index by one and rename the variable. Frame 2 reads frames[1], frames[2], frames[3]. Frame 3 reads frames[2], frames[3], frames[4]. And so on.
/* 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; printf("Frame 2: %2d Running total: %d\n", f2, total);
Put the number 2 next to the number 1 and squint. The shape is identical. The only thing that changed is a number that went up by one in nine places. Hold that thought — it's the entire second half of this guide.
Frame 9's strike bonus needs the next two rolls, and those live inside frame 10 — which is allowed to have three rolls of its own. So frame 9 reads frames[9][0] and frames[9][1] directly, with no double-strike lookahead:
/* Score frame 9 -- bonuses live inside frame 10 */ if (frames[8][0] == 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]; }
And frame 10 doesn't look ahead at all, because there's nothing after it. Its bonus rolls are already sitting in its own slots — you just add up whatever it holds:
/* Score frame 10 -- bonus rolls 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]; }
Yes, the first two branches are identical. Leave them that way for now. They mean different things even though they compute the same thing, and collapsing them would hide a rule you'd have to re-derive later.
/* Bowling the Long Way -- one hand-written block per frame. */ #include <stdio.h> int main(void) { FILE *fp = fopen("scores.txt", "r"); if (fp == NULL) { printf("Error opening scores.txt\n"); return 1; } int frames[10][3] = {0}; char line[128]; int f = 0; while (f < 10 && fgets(line, sizeof line, fp) != NULL) { int n, offset = 0, consumed, count = 0; while (count < 3 && sscanf(line + offset, "%d%n", &n, &consumed) == 1) { frames[f][count] = n; count++; offset += consumed; } if (count == 0) { continue; } f++; } fclose(fp); int total = 0; int f1, f2, f3, f4, f5, f6, f7, f8, f9, f10; /* 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]; } } else if (frames[0][0] + frames[0][1] == 10) { f1 = 10 + frames[1][0]; } else { f1 = frames[0][0] + frames[0][1]; } total = total + f1; printf("Frame 1: %2d Running total: %d\n", f1, 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; printf("Frame 2: %2d Running total: %d\n", f2, 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; printf("Frame 3: %2d Running total: %d\n", f3, 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; printf("Frame 4: %2d Running total: %d\n", f4, 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; printf("Frame 5: %2d Running total: %d\n", f5, 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; printf("Frame 6: %2d Running total: %d\n", f6, 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; printf("Frame 7: %2d Running total: %d\n", f7, 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; printf("Frame 8: %2d Running total: %d\n", f8, total); /* Score frame 9 -- bonuses live inside frame 10 */ if (frames[8][0] == 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; printf("Frame 9: %2d Running total: %d\n", f9, total); /* Score frame 10 -- bonus rolls are inside the frame */ if (frames[9][0] == 10) { f10 = frames[9][0] + frames[9][1] + frames[9][2]; } else if (frames[9][0] + frames[9][1] == 10) { f10 = frames[9][0] + frames[9][1] + frames[9][2]; } else { f10 = frames[9][0] + frames[9][1]; } total = total + f10; printf("Frame 10: %2d Running total: %d\n", f10, total); printf("\nFINAL SCORE: %d\n", total); return 0; }
gcc -Wall -Wextra -std=c99 -o bowling bowling.c ./bowling
That's about 180 lines of C, and it works. Every rule of bowling is visible on the page. Nothing is hidden in a clever abstraction. If a frame comes out wrong, you know exactly which block to read, because the frame number is in the variable name.
Working and obvious beats short and clever — until you have to change it.
Put your frame 1 block and your frame 2 block side by side. Read them line for line. Every single line is the same shape. The only difference is that the numbers in the square brackets each went up by one.
Whenever you find yourself typing the same shape over and over with only a number changing, you have found a loop that hasn't been written yet. The changing number is the loop counter.
for loop is.In C, a for loop has three parts inside the parentheses, separated by semicolons:
for (int i = 0; i < 8; i++) { printf("i is %d\n", i); }
int i = 0 — the setup. Runs once, before anything else.i < 8 — the test. Checked before every pass. While it's true, the body runs.i++ — the step. Runs after every pass. i++ is shorthand for i = i + 1.So that loop prints 0 through 7 — eight passes. It stops at 8 because 8 is not less than 8. This "start at zero, stop before the count" pattern is how nearly every C loop over an array is written, and it lines up exactly with array indexing: an array of 8 items has valid indexes 0 through 7.
You'll also want total += frameScore, which is shorthand for total = total + frameScore. The same trick works for -=, *=, and /=.
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 it in a loop that counts i from 0 to 7:
for (int i = 0; i < 8; i++) { int frame_score; if (frames[i][0] == 10) { /* strike */ if (frames[i + 1][0] == 10) { frame_score = 10 + 10 + frames[i + 2][0]; } else { frame_score = 10 + frames[i + 1][0] + frames[i + 1][1]; } } else if (frames[i][0] + frames[i][1] == 10) { /* spare */ frame_score = 10 + frames[i + 1][0]; } else { /* open */ frame_score = frames[i][0] + frames[i][1]; } total += frame_score; printf("Frame %2d: %2d Running total: %d\n", i + 1, frame_score, total); }
This one block does the work of eight hand-written blocks from the first game. Keep your hand-written frame 9 and frame 10 blocks right after the loop and run it. Same 167. Same frame-by-frame breakdown. About 110 fewer lines.
Try to extend the loop to cover all ten frames. Change i < 8 to i < 10. Save. Compile. Run.
In most languages this is where the program dies. When i reaches 8 and there's a strike, the code reads frames[i+2][0], which is frames[10][0] — one past the end of an array declared with ten slots. A bounds-checked language stops right there and names the line.
C does not. It computes the address, reads whatever bytes live there, and hands them to you as an int. Your program keeps running and prints a final score. The score is wrong, and nothing on your screen says so.
The bug that crashes is a gift. The bug that returns a plausible number is the one that ships.
Compile with -fsanitize=address and run again if you want to see it caught:
gcc -Wall -Wextra -std=c99 -fsanitize=address -g -o bowling bowling.c ./bowling
Now you get a proper report — global-buffer-overflow, with the exact line. AddressSanitizer is the closest thing C has to the bounds checking other languages give you for free, and it costs you one compiler flag while developing. Use it.
But the sanitizer only tells you the read was illegal. It doesn't tell you what to do instead. You could patch the loop with a special case for i == 8, and another for i == 9. 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 — 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 2-D array of frames-of-rolls, store one flat array 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 caring which line a roll came from and just collect every number in the file:
/* Read every number in the file into one flat array of rolls. */ int rolls[24] = {0}; int nrolls = 0; int pins; while (nrolls < 24 && fscanf(fp, "%d", &pins) == 1) { rolls[nrolls] = pins; nrolls++; } fclose(fp);
Six lines instead of fourteen. fscanf with "%d" skips any whitespace — spaces, tabs, newlines, all the same to it — so the line structure of the file simply stops mattering. It returns 1 each time it successfully reads a number, and something else at end of file, which is what ends the loop.
A bowling game holds at most 21 rolls: nine open frames of two rolls each, plus three in the tenth. We size the array at 24 to leave headroom, and we keep nrolls < 24 in the loop condition so a corrupted file with a thousand numbers in it can't run off the end of the array. That check is not optional. It is the entire difference between a program and a security hole.
Every time you write to an array in a loop, the loop condition must contain the array's size. Not "should" — must. In a language with growable lists this is handled for you. In C it is your job, every time, forever.
Walk a pointer i through the flat 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 used for scoring aren't consumed by the pointer — they're just read.
int total = 0; int i = 0; for (int frame = 0; frame < 10; frame++) { int score; if (rolls[i] == 10) { /* strike */ score = 10 + rolls[i + 1] + rolls[i + 2]; i += 1; } else if (rolls[i] + rolls[i + 1] == 10) { /* spare */ score = 10 + rolls[i + 2]; i += 2; } else { /* open frame */ score = rolls[i] + rolls[i + 1]; i += 2; } total += score; }
Frame 10 is no longer a special case. Its rolls sit in the flat array like any others and get consumed as the pointer moves through them. The strike branch advances i by 1 because only one roll was thrown; spare and open advance by 2. That's the whole navigation rule.
And notice what the zeroed array buys us here. On an open tenth frame the loop still reads rolls[i+2] in the spare test — a slot past the last real roll. Because we declared rolls[24] = {0} and the game never fills more than 21, that read lands on a real, initialized zero inside our own array. It's in bounds and it's harmless. That is not luck; it's why the array was sized with slack in the first place.
Ten lines of Frame 3: 9 is a correct answer that nobody wants to read. A bowling score belongs in a grid. So let's draw one — in the terminal, in color, with no library, using nothing but printf.
This is the actual output of the program you're about to write:
Every character on that screen came out of a printf call. The colors are ANSI escape codes. The lines are Unicode box-drawing characters. Both are just bytes in a string — your terminal is what turns them into a picture.
Your terminal reads the bytes your program prints and shows them as characters. But a few byte sequences are reserved as instructions instead of text. They all start with the escape character, written in C as \033 (octal 27), followed by [.
So "\033[1;31m" is not seven characters of text. It's a command that means "from now on, print in bold red." The terminal swallows it and changes state. Nothing is displayed for it — it takes up zero columns on screen.
Try it in one line before you build anything else:
#include <stdio.h> int main(void) { printf("\033[1;31mred and bold\033[0m and back to normal\n"); return 0; }
Compile it, run it, and you should see half a red line. If you see literal [1;31m garbage instead, your terminal isn't interpreting escapes — that happens when output is piped to a file or into a program that doesn't understand them. We'll deal with that at the end.
| Escape code | What it does |
|---|---|
| \033[0m | Reset — back to the terminal's default color and weight |
| \033[1;31m | Bold red |
| \033[1;32m | Bold green |
| \033[1;33m | Bold yellow |
| \033[1;36m | Bold cyan |
| \033[0;37m | Plain white |
| \033[38;5;240m | 256-color palette, color 240 — a dark gray |
| \033[2J | Erase the entire screen |
| \033[H | Move the cursor to the top-left corner |
The first six are the classic sixteen-color codes: 30–37 set the foreground, and a leading 1; makes it bold. The 38;5;n form reaches into a 256-color palette, which is where the muted grays for the grid lines come from — there's no "dim gray" in the original sixteen.
\033[2J\033[H is the pair you print together to clear the screen and start drawing from the top. It's what clear does.
Nobody should have to read \033[38;5;243m twice. Put every escape code behind a #define at the top of the file, named for what it means rather than what color it is:
#include <stdio.h> #define CLEAR "\033[2J\033[H" #define BORDER "\033[38;5;240m" #define LABEL "\033[38;5;250m" #define STRIKE "\033[1;31m" #define SPARE "\033[1;33m" #define GUTTER "\033[38;5;243m" #define PINS "\033[0;37m" #define RUNNING "\033[1;36m" #define FINAL "\033[1;32m" #define RESET "\033[0m"
Naming them STRIKE and SPARE instead of RED and YELLOW means that if you later decide strikes should be magenta, you change one line and every strike on the sheet follows. That's the same instinct as naming a variable frame_score instead of x.
Two string literals sitting next to each other in the source are joined by the compiler into one string, before your program ever runs. "abc" "def" is "abcdef". Since each #define above expands to a string literal, you can write this:
printf(BORDER "┌───────┐" RESET "\n");
and the compiler builds one single string out of all four pieces. No strcat, no format specifiers, no runtime cost whatsoever. This is why the drawing code below stays readable — the colors read like markup wrapped around the text.
This is the mistake everyone makes the first time, so let's make it deliberately. You want each roll in a box three columns wide. printf has field widths for exactly that, so you reach for one:
char cell[16]; sprintf(cell, "%s%c%s", STRIKE, 'X', RESET); printf("|%-3s|", cell); /* three columns wide... right? */
Run that and your grid falls apart. %-3s pads a string out to three bytes, and cell already holds twelve of them — seven for the color code, one for the X, four for the reset. It's well past three already, so printf adds no padding at all. But on screen only one column is visible, because the other eleven bytes are instructions, not characters.
printf counts bytes. Your eyes count columns. Escape codes are where those two numbers stop agreeing.
The fix is to never let a color code inside a width-counted field. Print the padding, the color, the character, the reset, and the padding as separate pieces — then the only thing occupying a column is the character itself:
/* Print one 3-wide roll box, colored by what the mark means. */ static void roll_cell(char mark) { const char *color = PINS; if (mark == 'X') { color = STRIKE; } else if (mark == '/') { color = SPARE; } else if (mark == '-') { color = GUTTER; } printf(" %s%c%s ", color, mark, RESET); }
A literal space, the color, one character, the reset, a literal space. Three visible columns every single time, no matter which color came back. The const char *color is a pointer to one of our #define strings — we're not copying anything, just pointing at whichever literal applies.
The same rule governs the totals row, where the number varies in width: printf(RUNNING " %3d " RESET, running[f]). The %3d is inside the color, not the other way round, and 2 + 3 + 2 gives seven columns whether the score is 9 or 148.
The grid lines aren't dashes and pipes. They're Unicode box-drawing characters, and they connect to each other seamlessly because that's what they were designed for:
| Character | Name | Where it goes |
|---|---|---|
| ┌ ┬ ┐ | down and right / down and horizontal / down and left | the top border |
| ├ ┼ ┤ | vertical and right / cross / vertical and left | row separators |
| └ ┴ ┘ | up and right / up and horizontal / up and left | the bottom border |
| │ ─ | vertical / horizontal | everything in between |
In C they're just characters in a string literal: printf("┌───┐\n");. Each one is three bytes of UTF-8, but the terminal renders it as a single column, which is all we care about.
A real scoresheet splits each frame into small roll boxes on top and one wide total box underneath. So each frame's cell is seven columns wide, divided as three + divider + three, and the row that separates rolls from totals has to change shape depending on which way the lines connect:
┌───────┬ the frame number sits in the full 7 columns ├───┬───┼ split into two roll boxes -- ┬ opens a new divider │ X │ / │ the rolls ├───┴───┼ rejoin -- ┴ closes the divider above it │ 39 │ the running total, back to full width └───────┴ the bottom
Read the third and fifth lines carefully. ┬ means "a line starts going down from here" and ┴ means "a line stops here." Get those backwards and the grid looks subtly broken in a way that's maddening to spot. Frame 10 is the same idea with three roll boxes instead of two, so it's eleven columns wide: 3 + 1 + 3 + 1 + 3.
The scorer already walks every roll in order. That's the only pass where you know which frame a roll belongs to — so that's where you record what each box should display. Add three arrays alongside the scoring:
char marks[10][3] = { {0} }; /* the character in each box */ int cells[10] = {0}; /* how many boxes this frame uses */ int running[10] = {0}; /* running total after this frame */ int start[10] = {0}; /* index of this frame's first roll */
A scoresheet doesn't print pin counts as plain numbers. It has its own three-symbol alphabet, so write the translation once:
/* Turn one roll into the mark a scoresheet would show. */ static char mark_for(int pins) { if (pins == 10) { return 'X'; } if (pins == 0) { return '-'; } return (char)('0' + pins); }
That last line is a C idiom worth knowing. Characters are small integers, and the digits '0' through '9' are guaranteed to sit next to each other in order. So '0' + 7 is the character '7'. Adding a number to '0' is how you turn a single digit into its character, and subtracting '0' is how you go back.
Now fill the arrays inside the scoring loop you already wrote — each branch already knows what it is, so it knows what to draw:
for (frame = 0; frame < 10; frame++) { int score; start[frame] = i; if (rolls[i] == 10) { /* strike */ score = 10 + rolls[i + 1] + rolls[i + 2]; marks[frame][0] = ' '; /* a strike leaves the first box empty */ marks[frame][1] = 'X'; cells[frame] = 2; i += 1; } else if (rolls[i] + rolls[i + 1] == 10) { /* spare */ score = 10 + rolls[i + 2]; marks[frame][0] = mark_for(rolls[i]); marks[frame][1] = '/'; cells[frame] = 2; i += 2; } else { /* open frame */ score = rolls[i] + rolls[i + 1]; marks[frame][0] = mark_for(rolls[i]); marks[frame][1] = mark_for(rolls[i + 1]); cells[frame] = 2; i += 2; } total += score; running[frame] = total; }
Note the strike: marks[frame][0] is a space and the X goes in the second box. That's the convention on a real scoresheet, and it's why the strike columns in the picture above look like they're leaning right.
Here's the price of flattening the data. The scoring loop treats frame 10 like any other frame — which is exactly what made the score come out right — but the display genuinely is different. Frame 10 owns its bonus rolls and shows up to three of them in its own boxes.
So we recorded start[]. After the loop, go back to where frame 10 began and rebuild just its boxes by hand:
/* The tenth frame shows its own bonus rolls, so redo its boxes by hand. */ { int t = start[9]; int r1 = rolls[t]; int r2 = rolls[t + 1]; int r3 = rolls[t + 2]; cells[9] = (nrolls - t >= 3) ? 3 : 2; marks[9][0] = mark_for(r1); if (r1 == 10) { marks[9][1] = mark_for(r2); /* fresh rack -- not a spare */ } else if (r1 + r2 == 10) { marks[9][1] = '/'; } else { marks[9][1] = mark_for(r2); } if (cells[9] == 3) { if (r1 == 10 && r2 != 10 && r2 + r3 == 10) { marks[9][2] = '/'; } else { marks[9][2] = mark_for(r3); } } }
The bare { } around that code is a plain block. It exists only so t, r1, r2 and r3 live and die inside it instead of hanging around in main for the rest of the function. Free scoping, no function call.
cells[9] = (nrolls - t >= 3) ? 3 : 2; uses the ternary operator: condition ? value if true : value if false. It's an if/else that produces a value instead of running statements. If there are three or more rolls left from where frame 10 started, it used three boxes.
The two subtle rules, both of which look wrong until you think about them:
r1 is a strike, r2 can never make a spare with it — the second box shows a plain count, not a /.r2 and r3 are a fresh pair against a fresh rack. If they add to ten, the third box gets the /. Unless r2 was itself a strike, in which case r3 starts another new rack and the third box shows its own mark.None of this changes the score by a single pin. It's purely about what a bowler expects to see printed. Which is most display code, most of the time.
Everything is in the arrays now. The drawing function does nothing but walk them and emit rows — seven of them, top border to bottom border. Each row is the same pattern: print the piece for frames 1 through 9 in a loop, then print frame 10's wider piece on its own.
static void draw_scoresheet(char marks[10][3], int cells[10], int running[10], int total) { int f, c; /* Top border */ printf(BORDER "┌"); for (f = 0; f < 9; f++) { printf("───────┬"); } printf("───────────┐" RESET "\n"); /* Frame numbers */ printf(BORDER "│" RESET); for (f = 0; f < 9; f++) { printf(LABEL " %d " RESET BORDER "│" RESET, f + 1); } printf(LABEL " 10 " RESET BORDER "│" RESET "\n"); /* Split the cells for the individual rolls */ printf(BORDER "├"); for (f = 0; f < 9; f++) { printf("───┬───┼"); } printf("───┬───┬───┤" RESET "\n"); /* The rolls */ printf(BORDER "│" RESET); for (f = 0; f < 10; f++) { for (c = 0; c < cells[f]; c++) { roll_cell(marks[f][c]); printf(BORDER "│" RESET); } /* An open tenth frame only used two boxes -- blank the third. */ if (f == 9 && cells[f] == 2) { printf(" " BORDER "│" RESET); } } printf("\n"); /* Rejoin the cells for the running totals */ printf(BORDER "├"); for (f = 0; f < 9; f++) { printf("───┴───┼"); } printf("───┴───┴───┤" RESET "\n"); /* Running totals */ printf(BORDER "│" RESET); for (f = 0; f < 9; f++) { printf(RUNNING " %3d " RESET BORDER "│" RESET, running[f]); } printf(FINAL " %3d " RESET BORDER "│" RESET "\n", running[9]); /* Bottom border */ printf(BORDER "└"); for (f = 0; f < 9; f++) { printf("───────┴"); } printf("───────────┘" RESET "\n"); printf("\n " STRIKE "X" RESET LABEL " strike " RESET SPARE "/" RESET LABEL " spare " RESET GUTTER "-" RESET LABEL " gutter" RESET "\n"); printf(" " LABEL "FINAL SCORE" RESET " " FINAL "%d" RESET "\n\n", total); }
Two details to notice.
The rolls row is driven by cells[f], not by a constant. That's what lets frame 10 print three boxes while everyone else prints two, without a single if (f == 9) in the loop body. The one if that is there handles the opposite case: an open tenth frame that used only two boxes still has to fill the third with blanks, or the right-hand border of the grid won't line up with the borders above and below it.
That last printf spans three source lines with no commas between them. Same string-literal gluing from Frame 03 — the compiler joins all fourteen pieces into one string. Since none of them contain a format specifier, there's nothing to pass.
main. printf(CLEAR);
printf("\n " LABEL "BOWLING" RESET GUTTER " -- scores.txt" RESET "\n\n");
draw_scoresheet(marks, cells, running, total);
return 0;
Compile and run:
gcc -Wall -Wextra -std=c99 -o bowling_ansi bowling_ansi.c ./bowling_ansi
The screen clears and the scoresheet appears — 167, in a grid, in color, from about 200 lines of C with no dependencies beyond stdio.h.
/* Bowling, drawn on the terminal -- flat rolls, one loop, ANSI scoresheet. */ #include <stdio.h> #define CLEAR "\033[2J\033[H" #define BORDER "\033[38;5;240m" #define LABEL "\033[38;5;250m" #define STRIKE "\033[1;31m" #define SPARE "\033[1;33m" #define GUTTER "\033[38;5;243m" #define PINS "\033[0;37m" #define RUNNING "\033[1;36m" #define FINAL "\033[1;32m" #define RESET "\033[0m" /* Print one 3-wide roll box, colored by what the mark means. */ static void roll_cell(char mark) { const char *color = PINS; if (mark == 'X') { color = STRIKE; } else if (mark == '/') { color = SPARE; } else if (mark == '-') { color = GUTTER; } printf(" %s%c%s ", color, mark, RESET); } /* Turn one roll into the mark a scoresheet would show. */ static char mark_for(int pins) { if (pins == 10) { return 'X'; } if (pins == 0) { return '-'; } return (char)('0' + pins); } static void draw_scoresheet(char marks[10][3], int cells[10], int running[10], int total) { int f, c; /* Top border */ printf(BORDER "┌"); for (f = 0; f < 9; f++) { printf("───────┬"); } printf("───────────┐" RESET "\n"); /* Frame numbers */ printf(BORDER "│" RESET); for (f = 0; f < 9; f++) { printf(LABEL " %d " RESET BORDER "│" RESET, f + 1); } printf(LABEL " 10 " RESET BORDER "│" RESET "\n"); /* Split the cells for the individual rolls */ printf(BORDER "├"); for (f = 0; f < 9; f++) { printf("───┬───┼"); } printf("───┬───┬───┤" RESET "\n"); /* The rolls */ printf(BORDER "│" RESET); for (f = 0; f < 10; f++) { for (c = 0; c < cells[f]; c++) { roll_cell(marks[f][c]); printf(BORDER "│" RESET); } /* An open tenth frame only used two boxes -- blank the third. */ if (f == 9 && cells[f] == 2) { printf(" " BORDER "│" RESET); } } printf("\n"); /* Rejoin the cells for the running totals */ printf(BORDER "├"); for (f = 0; f < 9; f++) { printf("───┴───┼"); } printf("───┴───┴───┤" RESET "\n"); /* Running totals */ printf(BORDER "│" RESET); for (f = 0; f < 9; f++) { printf(RUNNING " %3d " RESET BORDER "│" RESET, running[f]); } printf(FINAL " %3d " RESET BORDER "│" RESET "\n", running[9]); /* Bottom border */ printf(BORDER "└"); for (f = 0; f < 9; f++) { printf("───────┴"); } printf("───────────┘" RESET "\n"); printf("\n " STRIKE "X" RESET LABEL " strike " RESET SPARE "/" RESET LABEL " spare " RESET GUTTER "-" RESET LABEL " gutter" RESET "\n"); printf(" " LABEL "FINAL SCORE" RESET " " FINAL "%d" RESET "\n\n", total); } int main(void) { FILE *fp = fopen("scores.txt", "r"); if (fp == NULL) { printf("Error opening scores.txt\n"); return 1; } /* Read every number in the file into one flat array of rolls. */ int rolls[24] = {0}; int nrolls = 0; int pins; while (nrolls < 24 && fscanf(fp, "%d", &pins) == 1) { rolls[nrolls] = pins; nrolls++; } fclose(fp); char marks[10][3] = { {0} }; int cells[10] = {0}; int running[10] = {0}; int start[10] = {0}; int total = 0; int i = 0; int frame; for (frame = 0; frame < 10; frame++) { int score; start[frame] = i; if (rolls[i] == 10) { /* strike */ score = 10 + rolls[i + 1] + rolls[i + 2]; marks[frame][0] = ' '; marks[frame][1] = 'X'; cells[frame] = 2; i += 1; } else if (rolls[i] + rolls[i + 1] == 10) { /* spare */ score = 10 + rolls[i + 2]; marks[frame][0] = mark_for(rolls[i]); marks[frame][1] = '/'; cells[frame] = 2; i += 2; } else { /* open frame */ score = rolls[i] + rolls[i + 1]; marks[frame][0] = mark_for(rolls[i]); marks[frame][1] = mark_for(rolls[i + 1]); cells[frame] = 2; i += 2; } total += score; running[frame] = total; } /* The tenth frame shows its own bonus rolls, so redo its boxes by hand. */ { int t = start[9]; int r1 = rolls[t]; int r2 = rolls[t + 1]; int r3 = rolls[t + 2]; cells[9] = (nrolls - t >= 3) ? 3 : 2; marks[9][0] = mark_for(r1); if (r1 == 10) { marks[9][1] = mark_for(r2); } else if (r1 + r2 == 10) { marks[9][1] = '/'; } else { marks[9][1] = mark_for(r2); } if (cells[9] == 3) { if (r1 == 10 && r2 != 10 && r2 + r3 == 10) { marks[9][2] = '/'; } else { marks[9][2] = mark_for(r3); } } } printf(CLEAR); printf("\n " LABEL "BOWLING" RESET GUTTER " -- scores.txt" RESET "\n\n"); draw_scoresheet(marks, cells, running, total); return 0; }
Keep the first-game program as bowling.c and the new one as bowling_ansi.c. Point both at the same scores.txt:
gcc -Wall -Wextra -std=c99 -o bowling bowling.c gcc -Wall -Wextra -std=c99 -o bowling_ansi bowling_ansi.c ./bowling ./bowling_ansi
The running totals in the last row of the grid must match the running totals the old program printed, frame for frame. Both must end on 167. If they don't, one of them has a bug — and because both report every frame, you'll see exactly which frame first disagrees.
Now edit scores.txt and check the interesting games. These are the ones that catch bugs:
| scores.txt | What it tests | Correct score |
|---|---|---|
ten lines of 10, last one 10 10 10 | a perfect game — every strike bonus chains | 300 |
ten lines of 9 0 | no bonuses at all; gutter marks render | 90 |
ten lines of 5 5, last one 5 5 5 | every frame a spare, including the tenth | 150 |
ten lines of 0 0 | a zero game — the open tenth frame's blank third box | 0 |
Run each one through both programs. Four games, eight runs, about a minute of work — and it covers every branch you wrote. That's not a substitute for a real test suite, but it is the difference between "it worked on my one example" and "it works."
Comparing two independent implementations on the same input is the cheapest test suite in existence.
Try this:
./bowling_ansi > sheet.txt cat sheet.txt
The file is full of literal \033[38;5;240m noise, because we printed escape codes to something that isn't a screen. Every well-behaved command-line tool checks first. ls does it, grep does it, git does it — that's why their output is colored in your terminal and plain when you pipe it.
The check is one function, isatty, which answers "is this file descriptor attached to a terminal?" Standard output is descriptor 1:
#include <stdio.h> #include <unistd.h> /* for isatty */ static int use_color = 1; /* Return the escape code, or an empty string when color is off. */ static const char *c(const char *code) { return use_color ? code : ""; } int main(void) { use_color = isatty(1); ... }
Then print colors through c() — printf("%sX%s", c(STRIKE), c(RESET)) — instead of gluing the literals in directly. You lose the free compile-time string concatenation, which is exactly the tradeoff: gluing is faster and prettier, isatty is correct in more places. For a program that only ever draws to your own terminal, the version we wrote is fine. For anything you'd hand to someone else, add the check.
unistd.h is POSIX, not ANSI C — it's there on Linux and macOS, not on plain Windows. Windows terminals also need a one-time call to enable escape-code processing before any of this renders. If you're on Windows, WSL or Git Bash will run everything on this page unchanged.
The bowling scorer was the vehicle. The three questions are the lesson, and they transfer to nearly anything you'll write:
for loop you haven't written yet.And the C-specific ones, which you won't get from a language that holds your hand:
-Wall -Wextra always, and -fsanitize=address while you're developing.printf field widths count bytes, not visible columns. The moment escape codes or UTF-8 enter a format string, you have to do the alignment yourself.The point wasn't to write short code. It was to see what the long code was actually saying.