This guide walks you through writing a bowling score program in Python — 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 around a flat list and one loop, 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 onto your terminal with ANSI escape codes and f-strings.
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 Python 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.
Open the file, walk it line by line, and turn each line into a list of integers. We'll hold the whole game in a variable called frames — a list of lists, one inner list per frame.
with open("scores.txt") as f: frames = [] for line in f: line = line.strip() if line == "": continue frames.append([int(pins) for pins in line.split()]) # Confirm the file was read correctly. for i, frame in enumerate(frames): print(f"Frame {i + 1}: {frame}")
Run it with python3 bowling.py. 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.
with open(...) as f opens the file and guarantees it gets closed again when the block ends — even if an error is raised inside. You'll see plain open() without with in older code; don't write it that way.
Looping over a file gives you its lines. for line in f hands you one line at a time, and it reads them lazily rather than loading the whole file into memory. On a ten-line scoresheet that doesn't matter. On a ten-gigabyte log it's the difference between working and not.
[int(pins) for pins in line.split()] is a list comprehension. Read it right-to-left: take line.split(), which chops the line into pieces on whitespace; for each piece, call int() on it; collect the results into a list. It's the same thing as a three-line for loop with an append, written as one expression.
enumerate() gives you the index and the item together. Without it you'd be writing for i in range(len(frames)) and then indexing back into the list, which is more typing and easier to get wrong.
The rule "only if/else" applies to the scoring logic, not the file reader. Loops and comprehensions for reading input are fine — the point of the exercise is to hand-write each frame's scoring, not to avoid Python's standard library.
Start with the simplest case: no strikes, no spares. Just add the two rolls. Replace the confirmation loop at the end of your file with this:
# Score frame 1 (index 0 in the frames list) total = 0 f1 = frames[0][0] + frames[0][1] total = total + f1 print(f"Frame 1: {f1:2d} Running total: {total}")
Run it. Frame 1 in scores.txt is a strike — just 10 on the line — so frames[0] holds exactly one number. The program stops:
Traceback (most recent call last):
File "bowling.py", line 12, in <module>
f1 = frames[0][0] + frames[0][1]
~~~~~~~~~^^^
IndexError: list index out of range
Good. That is exactly what should happen, and it's worth appreciating for a second. Python checked the index, found there was no second roll, stopped immediately, and told you the file, the line, and the expression — with little carets pointing at the specific subscript that failed.
Broken behavior is a specification: it tells you what your next branch has to handle.
The crash is a message from your program saying "frame 1 doesn't have two rolls, and you never said what to do about that." So let's say what to do about it.
Not every language does this for you. The C version of this same guide hits the identical bug at the identical line — and prints a wrong score instead of stopping, because C doesn't check. A crash that names the line is a feature you're paying for elsewhere. Enjoy it.
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 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 print(f"Frame 1: {f1:2d} Running total: {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.
Run it. Same IndexError, same line. Both branches still reach for frames[0][1], and it still isn't there. A strike doesn't have a second roll at all, so no amount of adding-up-to-ten logic will help. Strikes need to be tested first, before anything touches the second roll.
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, it has no second roll either. 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] elif 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 print(f"Frame 1: {f1:2d} Running total: {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 either crashes or gets scored as a spare. In an if / elif / else chain, order is logic — only the first true branch runs, and every branch after it is skipped without even being evaluated.
elif
Python spells "else, if" as one word: elif. It matters more than it looks. Writing else: and then a nested if on the next line would work identically, but each one would indent your code one level deeper. elif keeps a chain of three conditions flat and readable instead of a staircase drifting off the right side of the screen.
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] elif frames[1][0] + frames[1][1] == 10: f2 = 10 + frames[2][0] else: f2 = frames[1][0] + frames[1][1] total = total + f2 print(f"Frame 2: {f2:2d} Running total: {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] elif 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 list — 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] elif 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.""" with open("scores.txt") as f: frames = [] for line in f: line = line.strip() if line == "": continue frames.append([int(pins) for pins in line.split()]) total = 0 # 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] elif 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 print(f"Frame 1: {f1:2d} 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] elif frames[1][0] + frames[1][1] == 10: f2 = 10 + frames[2][0] else: f2 = frames[1][0] + frames[1][1] total = total + f2 print(f"Frame 2: {f2:2d} 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] elif frames[2][0] + frames[2][1] == 10: f3 = 10 + frames[3][0] else: f3 = frames[2][0] + frames[2][1] total = total + f3 print(f"Frame 3: {f3:2d} 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] elif frames[3][0] + frames[3][1] == 10: f4 = 10 + frames[4][0] else: f4 = frames[3][0] + frames[3][1] total = total + f4 print(f"Frame 4: {f4:2d} 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] elif frames[4][0] + frames[4][1] == 10: f5 = 10 + frames[5][0] else: f5 = frames[4][0] + frames[4][1] total = total + f5 print(f"Frame 5: {f5:2d} 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] elif frames[5][0] + frames[5][1] == 10: f6 = 10 + frames[6][0] else: f6 = frames[5][0] + frames[5][1] total = total + f6 print(f"Frame 6: {f6:2d} 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] elif frames[6][0] + frames[6][1] == 10: f7 = 10 + frames[7][0] else: f7 = frames[6][0] + frames[6][1] total = total + f7 print(f"Frame 7: {f7:2d} 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] elif frames[7][0] + frames[7][1] == 10: f8 = 10 + frames[8][0] else: f8 = frames[7][0] + frames[7][1] total = total + f8 print(f"Frame 8: {f8:2d} Running total: {total}") # Score frame 9 -- bonuses live inside frame 10 if frames[8][0] == 10: f9 = 10 + frames[9][0] + frames[9][1] elif frames[8][0] + frames[8][1] == 10: f9 = 10 + frames[9][0] else: f9 = frames[8][0] + frames[8][1] total = total + f9 print(f"Frame 9: {f9:2d} Running total: {total}") # 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] elif 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 print(f"Frame 10: {f10:2d} Running total: {total}") print(f"\nFINAL SCORE: {total}")
python3 bowling.py
That's about 130 lines of Python, 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.
{f1:2d}.An f-string is a string with an f in front of it, and anything inside { } is an expression Python evaluates and drops into the text. The part after the colon is a format spec: 2d means "as a decimal integer, at least 2 characters wide, padded with spaces on the left." That's what keeps the numbers in a straight column whether a frame scores 9 or 30. Remember it — the third game leans on it hard.
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 Python.Python's for doesn't count — it walks. You give it something to walk through and it hands you each item in turn:
for i in range(8): print(f"i is {i}")
range(8) produces the numbers 0 through 7 — eight of them, starting at zero and stopping before 8. That's the same convention as list indexing: a list of 8 items has valid indexes 0 through 7, so range(len(my_list)) is always exactly the right set of indexes.
You'll also want total += frame_score, which is shorthand for total = total + frame_score. 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 walks i from 0 to 7:
for i in range(8): 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] elif 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 print(f"Frame {i + 1:2d}: {frame_score:2d} Running total: {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 90 fewer lines.
Try to extend the loop to cover all ten frames. Change range(8) to range(10). Save. Run.
It crashes. When i reaches 8 (frame 9) and there's a strike, the code reads frames[i+2][0], which is frames[10][0]. There is no eleventh frame. IndexError, same as before.
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 list of frames-of-rolls, store one flat list 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 collapses to a single line, and it's worth savoring:
def read_rolls(path): """Every number in the file, in order, as one flat list.""" with open(path) as f: return [int(pins) for pins in f.read().split()]
Read the last line inside-out. f.read() gives you the entire file as one string. .split() with no argument splits on any run of whitespace — spaces, tabs, newlines, all treated the same — so the line structure of the file simply stops existing. Then the comprehension turns each piece into an int.
That's the whole reader. Fourteen lines of C, or eight lines of the first-game Python, become one expression — not because it's been compressed, but because once you stopped caring which line a roll came from, there was nothing left to write.
When a piece of code feels tedious, check whether you're preserving structure you no longer need. Half of "making it Pythonic" is just noticing that you've been carefully maintaining information the rest of the program never asks for.
Walk a pointer i through the flat list. 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.
total = 0 i = 0 for frame in range(10): if rolls[i] == 10: # strike score = 10 + sum(rolls[i + 1:i + 3]) i += 1 elif 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 list 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.
sum(rolls[i+1:i+3]) means "add up the two items starting at i+1." You could write rolls[i+1] + rolls[i+2] and get the same answer, so why the slice? Because of a Python behavior that surprises people the first time they meet it:
>>> nums = [1, 2, 3] >>> nums[5] IndexError: list index out of range >>> nums[1:99] [2, 3]
Indexing past the end raises. Slicing past the end doesn't — it just gives you however much was actually there. So the slice version keeps working on a truncated or half-finished game, where the indexed version would crash on the last frame. Same result on valid input, more forgiving on invalid input, and it reads closer to the rule: "ten plus the next two rolls."
That's a real judgment call, not a free win. Silent tolerance for short input is exactly what you don't want when a truncated file should be an error. Here we're scoring a game that may still be in progress, so forgiving is right.
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 at all, using nothing but print.
This is the actual output of the program you're about to write:
Every character on that screen came out of a print call. The colors are ANSI escape codes. The lines are Unicode box-drawing characters. Both are just characters in a string — your terminal is what turns them into a picture.
Your terminal reads the characters your program prints and shows them on screen. But a few sequences are reserved as instructions instead of text. They all start with the escape character, written in Python as \033, 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:
print("\033[1;31mred and bold\033[0m and back to normal")
Run that 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 the clear command does.
Nobody should have to read \033[38;5;243m twice. Put every escape code in a named constant at the top of the file, named for what it means rather than what color it is:
CLEAR = "\033[2J\033[H" BORDER = "\033[38;5;240m" LABEL = "\033[38;5;250m" STRIKE = "\033[1;31m" SPARE = "\033[1;33m" GUTTER = "\033[38;5;243m" PINS = "\033[0;37m" RUNNING = "\033[1;36m" FINAL = "\033[1;32m" 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.
Python has no real constants — nothing stops you reassigning STRIKE later. The ALL-CAPS name is a convention meaning "this is set once at import and never touched again," and every Python programmer reads it that way.
Multiplying a string repeats it. "───────┬" * 9 builds nine identical cells in one expression. The C version of this guide needs a for loop to do the same thing. Here it's an operator.
Adjacent string literals are joined automatically. Just like in C, "abc" "def" is "abcdef" — which is how you split one long f-string across several source lines without a + on every line. We use it for the legend at the bottom of the sheet.
This is the mistake everyone makes the first time, so let's make it deliberately. You want each roll centered in a box three columns wide. f-strings have a format spec for exactly that, so you reach for one:
cell = f"{STRIKE}X{RESET}" print(f"|{cell:^3}|") # centered in three columns... right?
Run that and your grid falls apart. :^3 centers a string within three characters, and cell is already twelve characters long — seven for the color code, one for the X, four for the reset. It's well past three, so Python adds no padding at all. But on screen only one column is visible, because the other eleven characters are instructions, not text.
Format specs count characters. 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. Build the box out of separate pieces, so the only thing occupying a column is the character itself:
def roll_cell(mark): """One 3-wide roll box, colored by what the mark means.""" color = PINS if mark == "X": color = STRIKE elif mark == "/": color = SPARE elif mark == "-": color = GUTTER return f" {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 same rule governs the totals row, where the number itself varies in width: f"{RUNNING} {score:3d} {RESET}". The :3d is applied to the number, inside the color — not to the whole colored string. Two spaces plus three digits plus two spaces gives seven columns whether the score is 9 or 148.
Format specs are for data. Color codes wrap around the formatted result. The moment you find yourself passing an already-colored string to :<10, .ljust(), or textwrap, the alignment is already broken — you just might not notice until the input changes length.
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 Python they're ordinary characters in an ordinary string — print("┌───┐") works with no encoding ceremony at all, because Python 3 strings are Unicode by default and print encodes as UTF-8 on the way out.
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 second and fourth 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.
A scoresheet doesn't print pin counts as plain numbers. It has its own three-symbol alphabet, so write the translation once:
def mark_for(pins): """Turn one roll into the mark a scoresheet would show.""" if pins == 10: return "X" if pins == 0: return "-" return str(pins)
Now have the scorer build a list of marks alongside the running totals. Each branch already knows what it is, so it knows what to draw:
def score_game(rolls): """Score ten frames. Returns (marks, running totals, final total).""" marks = [] running = [] total = 0 i = 0 tenth_start = 0 for frame in range(10): if frame == 9: tenth_start = i if rolls[i] == 10: # strike score = 10 + sum(rolls[i + 1:i + 3]) marks.append([" ", "X"]) # a strike leaves the first box empty i += 1 elif rolls[i] + rolls[i + 1] == 10: # spare score = 10 + rolls[i + 2] marks.append([mark_for(rolls[i]), "/"]) i += 2 else: # open frame score = rolls[i] + rolls[i + 1] marks.append([mark_for(rolls[i]), mark_for(rolls[i + 1])]) i += 2 total += score running.append(total) marks[9] = tenth_frame_marks(rolls[tenth_start:]) return marks, running, total
Note the strike: the first box 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.
The function returns three things at once. Python builds that into a tuple automatically, and the caller unpacks it with marks, running, total = score_game(rolls) — no container class, no output parameters.
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 tenth_start. After the loop, rolls[tenth_start:] slices out everything from where frame 10 began to the end of the list — which is precisely frame 10's rolls, however many there are:
def tenth_frame_marks(tenth): """The tenth frame shows its own bonus rolls, so its boxes are special.""" r1, r2 = tenth[0], tenth[1] boxes = [mark_for(r1)] if r1 != 10 and r1 + r2 == 10: boxes.append("/") else: boxes.append(mark_for(r2)) # after a strike the rack is fresh if len(tenth) == 3: r3 = tenth[2] if r1 == 10 and r2 != 10 and r2 + r3 == 10: boxes.append("/") else: boxes.append(mark_for(r3)) return boxes
Because the function takes a plain list and returns a plain list, it's the easiest thing in the program to test — you can call it with [10, 8, 1] or [5, 5, 5] in the REPL and check the boxes come back right, without a file or a game anywhere in sight.
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 /. That's what the r1 != 10 in the first condition is protecting against.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 lists now. The drawing function does nothing but walk them and print rows — seven of them, top border to bottom border. Each row follows the same pattern: build the piece for frames 1 through 9 with a repeated string or a generator expression, then append frame 10's wider piece.
def draw_scoresheet(marks, running, total): """Paint the whole scoresheet onto the terminal.""" pipe = f"{BORDER}│{RESET}" print(f"{BORDER}┌" + "───────┬" * 9 + f"───────────┐{RESET}") numbers = "".join(f"{LABEL} {n} {RESET}{pipe}" for n in range(1, 10)) print(f"{pipe}{numbers}{LABEL} 10 {RESET}{pipe}") print(f"{BORDER}├" + "───┬───┼" * 9 + f"───┬───┬───┤{RESET}") row = pipe for frame, boxes in enumerate(marks): row += "".join(roll_cell(mark) + pipe for mark in boxes) if frame == 9 and len(boxes) == 2: row += f" {pipe}" # an open tenth: blank the third box print(row) print(f"{BORDER}├" + "───┴───┼" * 9 + f"───┴───┴───┤{RESET}") totals = "".join(f"{RUNNING} {score:3d} {RESET}{pipe}" for score in running[:9]) print(f"{pipe}{totals}{FINAL} {running[9]:3d} {RESET}{pipe}") print(f"{BORDER}└" + "───────┴" * 9 + f"───────────┘{RESET}") print(f"\n {STRIKE}X{RESET}{LABEL} strike {RESET}" f"{SPARE}/{RESET}{LABEL} spare {RESET}" f"{GUTTER}-{RESET}{LABEL} gutter{RESET}") print(f" {LABEL}FINAL SCORE{RESET} {FINAL}{total}{RESET}\n")
Three details to notice.
pipe is defined once and reused everywhere. The colored vertical bar appears about thirty times in this function. Naming it once means the border color is set in exactly one place, and every row is guaranteed to match.
The rolls row is driven by len(boxes), not by a constant. That's what lets frame 10 print three boxes while everyone else prints two, without a single if frame == 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.
"".join(... for ...) is a generator expression. It's a comprehension without the square brackets, and it feeds items to join one at a time instead of building a throwaway list first. For nine cells the difference is nothing; the habit is worth having anyway. Building strings with join rather than += in a loop is the standard Python idiom.
def main(): rolls = read_rolls("scores.txt") marks, running, total = score_game(rolls) print(CLEAR, end="") print(f"\n {LABEL}BOWLING{RESET}{GUTTER} -- scores.txt{RESET}\n") draw_scoresheet(marks, running, total) if __name__ == "__main__": main()
print(CLEAR, end="") suppresses the newline print normally adds — the clear-screen code shouldn't be followed by a blank line it didn't ask for.
And if __name__ == "__main__": means "only run main() when this file is executed directly, not when something imports it." That's what lets another program do from bowling_ansi import score_game and reuse the scorer without a scoresheet suddenly painting itself across the screen.
Run it:
python3 bowling_ansi.py
The screen clears and the scoresheet appears — 167, in a grid, in color, from about 130 lines of Python with no imports at all.
"""Bowling, drawn on the terminal -- flat rolls, one loop, ANSI scoresheet.""" CLEAR = "\033[2J\033[H" BORDER = "\033[38;5;240m" LABEL = "\033[38;5;250m" STRIKE = "\033[1;31m" SPARE = "\033[1;33m" GUTTER = "\033[38;5;243m" PINS = "\033[0;37m" RUNNING = "\033[1;36m" FINAL = "\033[1;32m" RESET = "\033[0m" def mark_for(pins): """Turn one roll into the mark a scoresheet would show.""" if pins == 10: return "X" if pins == 0: return "-" return str(pins) def roll_cell(mark): """One 3-wide roll box, colored by what the mark means.""" color = PINS if mark == "X": color = STRIKE elif mark == "/": color = SPARE elif mark == "-": color = GUTTER return f" {color}{mark}{RESET} " def read_rolls(path): """Every number in the file, in order, as one flat list.""" with open(path) as f: return [int(pins) for pins in f.read().split()] def score_game(rolls): """Score ten frames. Returns (marks, running totals, final total).""" marks = [] running = [] total = 0 i = 0 tenth_start = 0 for frame in range(10): if frame == 9: tenth_start = i if rolls[i] == 10: # strike score = 10 + sum(rolls[i + 1:i + 3]) marks.append([" ", "X"]) i += 1 elif rolls[i] + rolls[i + 1] == 10: # spare score = 10 + rolls[i + 2] marks.append([mark_for(rolls[i]), "/"]) i += 2 else: # open frame score = rolls[i] + rolls[i + 1] marks.append([mark_for(rolls[i]), mark_for(rolls[i + 1])]) i += 2 total += score running.append(total) marks[9] = tenth_frame_marks(rolls[tenth_start:]) return marks, running, total def tenth_frame_marks(tenth): """The tenth frame shows its own bonus rolls, so its boxes are special.""" r1, r2 = tenth[0], tenth[1] boxes = [mark_for(r1)] if r1 != 10 and r1 + r2 == 10: boxes.append("/") else: boxes.append(mark_for(r2)) # after a strike the rack is fresh if len(tenth) == 3: r3 = tenth[2] if r1 == 10 and r2 != 10 and r2 + r3 == 10: boxes.append("/") else: boxes.append(mark_for(r3)) return boxes def draw_scoresheet(marks, running, total): """Paint the whole scoresheet onto the terminal.""" pipe = f"{BORDER}│{RESET}" print(f"{BORDER}┌" + "───────┬" * 9 + f"───────────┐{RESET}") numbers = "".join(f"{LABEL} {n} {RESET}{pipe}" for n in range(1, 10)) print(f"{pipe}{numbers}{LABEL} 10 {RESET}{pipe}") print(f"{BORDER}├" + "───┬───┼" * 9 + f"───┬───┬───┤{RESET}") row = pipe for frame, boxes in enumerate(marks): row += "".join(roll_cell(mark) + pipe for mark in boxes) if frame == 9 and len(boxes) == 2: row += f" {pipe}" # an open tenth: blank the third box print(row) print(f"{BORDER}├" + "───┴───┼" * 9 + f"───┴───┴───┤{RESET}") totals = "".join(f"{RUNNING} {score:3d} {RESET}{pipe}" for score in running[:9]) print(f"{pipe}{totals}{FINAL} {running[9]:3d} {RESET}{pipe}") print(f"{BORDER}└" + "───────┴" * 9 + f"───────────┘{RESET}") print(f"\n {STRIKE}X{RESET}{LABEL} strike {RESET}" f"{SPARE}/{RESET}{LABEL} spare {RESET}" f"{GUTTER}-{RESET}{LABEL} gutter{RESET}") print(f" {LABEL}FINAL SCORE{RESET} {FINAL}{total}{RESET}\n") def main(): rolls = read_rolls("scores.txt") marks, running, total = score_game(rolls) print(CLEAR, end="") print(f"\n {LABEL}BOWLING{RESET}{GUTTER} -- scores.txt{RESET}\n") draw_scoresheet(marks, running, total) if __name__ == "__main__": main()
Keep the first-game program as bowling.py and the new one as bowling_ansi.py. Point both at the same scores.txt:
python3 bowling.py python3 bowling_ansi.py
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.
Comparing two independent implementations on the same input is the cheapest test suite in existence.
Because the scorer is a function that takes a list and returns values, you can check all four games without touching a file at all. Save this as test_bowling.py next to the other two:
from bowling_ansi import score_game GAMES = [ ([10, 7, 3, 9, 0, 10, 0, 8, 8, 2, 0, 6, 10, 10, 10, 8, 1], 167), ([10] * 12, 300), ([9, 0] * 10, 90), ([5, 5] * 10 + [5], 150), ([0, 0] * 10, 0), ] for rolls, expected in GAMES: _marks, _running, total = score_game(rolls) status = "ok" if total == expected else "FAIL" print(f"{status:4} expected {expected:3d} got {total:3d}")
Run python3 test_bowling.py and you get five lines of pass/fail in under a second. Note [10] * 12 and [9, 0] * 10 — the same list-repetition trick as string repetition, which makes each test game a single readable expression rather than twenty numbers you have to count.
This is the real reward for splitting the program into functions in the second and third games. The first-game version, with all its logic sitting loose at the top level of the file, can't be tested this way at all — importing it would run the whole thing.
Try this:
python3 bowling_ansi.py > 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 method on the standard-output stream, isatty(), which answers "is this attached to a terminal?" The tidy way to use it is to blank the palette when the answer is no:
import sys if not sys.stdout.isatty(): CLEAR = BORDER = LABEL = STRIKE = SPARE = "" GUTTER = PINS = RUNNING = FINAL = RESET = ""
Put that right after the palette definitions and nothing else in the program changes. Every f-string still interpolates the same names — they just interpolate to nothing, and the layout stays intact because the escape codes never occupied any columns to begin with. That's the payoff for the discipline in Frame 04: since no color code was ever inside a width-counted field, removing them all can't shift a single character.
Modern Windows terminals handle these escape codes, but older consoles need them enabled first. If you're on Windows and see garbage, the colorama package fixes it in one colorama.init() call — or run everything in WSL, where it all works 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 Python-specific ones:
IndexError that names the file, the line, and the expression is a feature. Read the traceback bottom-up; the last line is what went wrong and the line above it is where."".join(...) and repetition, not += in a loop.If you want to see the same three games played in a language that hands you none of this — no bounds checking, no growable lists, no f-strings — the ANSI C version draws the identical scoresheet in about 200 lines, and the differences are the whole point.
The point wasn't to write short code. It was to see what the long code was actually saying.