Learning Goals
3 minBy the end of this lesson you can:
- Pick a simple text-line file format for tabular data — "name,score" per line.
- Parse the file into a list of dicts (or tuples) at game start.
- Update the in-memory list after each game, sort, trim, then write the whole thing back.
- Display a top-N leaderboard with f-string width specifiers.
Warm-Up · Designing the File
5 minBefore writing any code, decide what scores.txt should look like. The simplest format that works for tabular data is one record per line, with a fixed separator:
Aisyah,95 Wei Jie,87 Priya,92 Iman,70 Aizat,81
One line, one record. A comma between name and score. No header line. Easy to read with for line in f: and .split(","); easy to write back with f.write.
A high-score table is the same shape as PY-L2-11's list-of-dicts — except now it lives on disk. Load on start, save on change. That round-trip is the whole game save system.
New Concept · Three Functions That Do Everything
14 min1 · load_scores()
Open the file in default read mode, walk it line by line, split each line, build a list of dicts. Handle "no file yet" with try/except.
def load_scores(): scores = [] try: with open("scores.txt", encoding="utf-8") as f: for line in f: line = line.strip() if line == "": continue # skip blank lines name, raw = line.split(",") scores.append({"name": name.strip(), "score": int(raw.strip())}) except FileNotFoundError: pass # no scores yet — empty list return scores
The try/except turns "file doesn't exist" from a crash into "empty list". The shape is one dict per record — matches PY-L2-11 perfectly.
2 · save_scores(scores)
Open in "w" mode (we're replacing the whole file). Loop the list, write one line per record. Easy and reliable.
def save_scores(scores): with open("scores.txt", "w", encoding="utf-8") as f: for s in scores: f.write(f"{s['name']},{s['score']}\n")
Use "w" not "a". Replacing the whole file every save means we don't accumulate duplicates as the table changes — no bookkeeping needed.
3 · add_score(scores, name, score)
Append, sort by score descending, trim to top-N, save.
def add_score(scores, name, score, top_n=5): scores.append({"name": name, "score": score}) scores.sort(key=lambda s: s["score"], reverse=True) del scores[top_n:] # keep only the first N save_scores(scores) return scores
Four lines do the whole bookkeeping. scores.sort(key=...) sorts in place by the score (PY-L2-04 and -11 lambdas). del scores[top_n:] trims everything past the top N — list slicing on the left side of del.
4 · show_top(scores, n=5)
Print the top-N as a banner using PY-L2-16 width specifiers.
def show_top(scores, n=5): print("=== HIGH SCORES ===") print(f"{'Rank':<6}{'Name':<14}{'Score':>6}") print("-" * 26) for rank, s in enumerate(scores[:n], start=1): print(f"{rank:<6}{s['name']:<14}{s['score']:>6}") print("-" * 26)
The full life-cycle
At start scores = load_scores() After each game scores = add_score(scores, name, score) On demand show_top(scores)
That's the whole save system. Three function calls — load, add, show.
Worked Example · Full High-Score Keeper
12 minSave the whole thing as hi_score.py:
Code
# hi_score.py — file-backed high-score table FILE = "scores.txt" def load_scores(): scores = [] try: with open(FILE, encoding="utf-8") as f: for line in f: line = line.strip() if line == "": continue name, raw = line.split(",") scores.append({"name": name.strip(), "score": int(raw.strip())}) except FileNotFoundError: pass return scores def save_scores(scores): with open(FILE, "w", encoding="utf-8") as f: for s in scores: f.write(f"{s['name']},{s['score']}\n") def add_score(scores, name, score, top_n=5): scores.append({"name": name, "score": score}) scores.sort(key=lambda s: s["score"], reverse=True) del scores[top_n:] save_scores(scores) return scores def show_top(scores, n=5): if len(scores) == 0: print("(no scores yet — be the first!)") return print() print("=== HIGH SCORES ===") print(f"{'Rank':<6}{'Name':<14}{'Score':>6}") print("-" * 26) for rank, s in enumerate(scores[:n], start=1): print(f"{rank:<6}{s['name']:<14}{s['score']:>6}") print("-" * 26) def made_top_5(scores, score): if len(scores) < 5: return True return score > scores[-1]["score"] # --- demo: pretend we just played 3 games --- scores = load_scores() show_top(scores) import random for game in range(3): name = input("Player name: ") score = random.randint(50, 100) print(f"You scored: {score}") if made_top_5(scores, score): print("🏆 Top-5!") scores = add_score(scores, name, score) show_top(scores)
Sample run (your scores will vary)
(no scores yet — be the first!) Player name: Aisyah You scored: 87 🏆 Top-5! Player name: Wei Jie You scored: 73 🏆 Top-5! Player name: Priya You scored: 95 🏆 Top-5! === HIGH SCORES === Rank Name Score -------------------------- 1 Priya 95 2 Aisyah 87 3 Wei Jie 73 --------------------------
Quit the program now. Run it again. The three scores are still there — and any new ones get sorted into the table:
=== HIGH SCORES === Rank Name Score -------------------------- 1 Priya 95 2 Aisyah 87 3 Wei Jie 73 -------------------------- Player name: Iman You scored: 100 🏆 Top-5! ...
Three things to notice. (1) The load_scores/save_scores pair uses the same file format both ways — written by us, read by us, round-trippable. (2) add_score does the sort and trim every time, so the file is always at most 5 lines long. (3) made_top_5 compares the new score against the weakest score currently in the table (scores[-1]) — the only test you need to know if it'll make the cut.
Try It Yourself
13 minUsing your load_scores(), print the single best score on file. If there are no scores, print (no scores yet).
Hint
scores = load_scores() if len(scores) == 0: print("(no scores yet)") else: scores.sort(key=lambda s: s["score"], reverse=True) top = scores[0] print(f"Top: {top['name']} — {top['score']}")
Tweak show_top so it accepts an n argument and you can print a top-3 instead of a top-5.
Hint
show_top(scores, n=3) # inside the function: # for rank, s in enumerate(scores[:n], start=1):
One change — the default argument and the slicing. The rest of the function doesn't care.
Add a function scores_for(scores, name) that returns the list of scores belonging to that player. Useful for "Your personal best" reports.
Hint
def scores_for(scores, name): return [s["score"] for s in scores if s["name"].lower() == name.lower()] mine = scores_for(scores, "Aisyah") if mine: print(f"Aisyah: best {max(mine)}, average {sum(mine) // len(mine)}") else: print("Aisyah has no scores yet.")
A list comprehension filters by name (case-insensitively). max and sum turn the list into the player's personal stats.
Mini-Challenge · Wire It Into Number Guesser
8 minYou've still got a working Number Guesser from PY-L1-48. Today you give it a real high-score table.
Re-open your guesser file (or copy the skeleton below) and:
- At the top: import the four high-score functions, or paste them in.
- At the start of each game: call
show_top(load_scores()). - Score the player based on how few guesses they used:
score = max(0, 100 - (guesses - 1) * 10). - At the end: ask for the player name, call
add_score, print the updated table.
Show one possible skeleton
# guesser_with_scores.py import random from hi_score import load_scores, save_scores, add_score, show_top, made_top_5 # (or paste the four functions at the top of this file directly) scores = load_scores() show_top(scores) while True: target = random.randint(1, 100) guesses = 0 while True: try: g = int(input("Guess 1-100: ")) except ValueError: print("Numbers only please.") continue guesses += 1 if g == target: print(f"Got it in {guesses}!") break elif g < target: print("Higher.") else: print("Lower.") score = max(0, 100 - (guesses - 1) * 10) print(f"Score: {score}") if made_top_5(scores, score): print("🏆 New top-5!") name = input("Player name: ") scores = add_score(scores, name, score) show_top(scores) if input("Play again? (y/n) ") != "y": break
Non-negotiables: load the table at the start, score the player, save the updated table at the end of every game. Quit the program. Run it again. The leaderboard you walked away from should be the first thing you see — that's the whole point.
Recap
3 minA file-backed high-score table is three little functions. load_scores opens the file (or returns an empty list if missing) and parses lines into a list of dicts. save_scores writes the list back in "w" mode — fresh every save. add_score appends, sorts in place by score, trims to top-N, and saves. show_top prints the top-N as a tidy banner with width specifiers. Tomorrow we'll use this same shape for the Wordle word bank — but with a much bigger file.
Vocabulary Card
- load on start
- Read the persistent file once when the program begins, then work with the in-memory copy.
- save after change
- Write the whole list back to disk after every mutation. Avoids accumulation bugs.
- top-N trim
del scores[n:]drops everything past indexn. Keeps the table bounded.- made-the-cut test
- If the table isn't full, you make it. Otherwise compare against the weakest score —
scores[-1]when sorted descending.
Homework
4 minAdd three polish features to your hi_score.py:
- Per-game tables. Take a
gameargument in every function and use a file likescores_{game}.txt. Now one program can keep separate tables for Number Guesser, Hangman and Higher-or-Lower. - Date stamping. Each line in the file becomes
name,score,date. Usedatetime.now().date().isoformat()to get today as2026-05-27. Show the date as a fourth column in the banner. - Clean-up. If the file has more than 100 lines, trim the oldest entries — keep your high-score history bounded.
Sample · hi_score.py (key snippets)
from datetime import date def file_for(game): return f"scores_{game}.txt" def load_scores(game): scores = [] try: with open(file_for(game), encoding="utf-8") as f: for line in f: parts = line.strip().split(",") if len(parts) != 3: continue name, raw, day = parts scores.append({"name": name, "score": int(raw), "date": day}) except FileNotFoundError: pass return scores def save_scores(game, scores): with open(file_for(game), "w", encoding="utf-8") as f: for s in scores: f.write(f"{s['name']},{s['score']},{s['date']}\n") def add_score(game, name, score, top_n=100): scores = load_scores(game) scores.append({"name": name, "score": score, "date": date.today().isoformat()}) scores.sort(key=lambda s: s["score"], reverse=True) del scores[top_n:] # bounded history save_scores(game, scores) return scores def show_top(scores, n=5): print() print("=== HIGH SCORES ===") print(f"{'Rank':<6}{'Name':<14}{'Score':>6}{'Date':>12}") print("-" * 38) for rank, s in enumerate(scores[:n], start=1): print(f"{rank:<6}{s['name']:<14}{s['score']:>6}{s['date']:>12}") print("-" * 38)
Non-negotiables: per-game filename, date column, and a bounded history (100). The whole shape now scales — one set of functions, many games.