Practice: refactoring a messy script into functions
Back in module 3 you built a guessing game, and the lesson ended by saying the nesting was its weak point and that functions would fix it. Time to do that.
Refactoring means changing how code is organised without changing what it does. It is a large part of real work — most code you are paid to write will be changes to code that already exists — and doing it deliberately is a skill worth practising on something you already understand.
The starting point
import random
MAX_ATTEMPTS = 7
LOWEST = 1
HIGHEST = 100
print("Guess the number!")
print(f"I am thinking of a number between {LOWEST} and {HIGHEST}.")
playing = True
while playing:
secret = random.randint(LOWEST, HIGHEST)
attempts = 0
while attempts < MAX_ATTEMPTS:
remaining = MAX_ATTEMPTS - attempts
raw = input(f"\nGuess ({remaining} left): ").strip()
if not raw.isdigit():
print("Please enter a whole number.")
continue
guess = int(raw)
if not LOWEST <= guess <= HIGHEST:
print(f"Out of range. Pick between {LOWEST} and {HIGHEST}.")
continue
attempts += 1
if guess == secret:
print(f"Correct! You got it in {attempts} attempts.")
break
elif guess < secret:
print("Too low.")
else:
print("Too high.")
else:
print(f"\nOut of attempts. The number was {secret}.")
again = input("\nPlay again? (y/n): ").strip().lower()
if again != "y":
print("Thanks for playing.")
playing = False
It works. Every criticism below is about structure, not correctness — and that distinction matters, because refactoring working code is how you improve it without risk.
What is actually wrong with it
Everything is at one level of importance. "Print a welcome" and "run a complete game" sit side by side, and a reader has to hold all of it at once.
You cannot test any of it. There is no piece you could check without playing the game by hand. Module 10 is about tests; this shape makes them impossible.
You cannot reuse anything. The input validation is good and is trapped.
Two loops deep and thirty lines in, you have to scroll to find which loop a
break belongs to.
It is hard to change. Adding difficulty levels means editing the middle of a long block and hoping.
Step 1: find the smallest thing with a name
Look for a few lines doing one identifiable job. The welcome message:
def show_welcome() -> None:
"""Print the game's opening message."""
print("Guess the number!")
print(f"I am thinking of a number between {LOWEST} and {HIGHEST}.")
Small, and worth it. The main flow now says show_welcome() — one line, in
English.
Step 2: extract the validation
This is the reusable part:
def ask_for_guess(remaining: int) -> int:
"""Ask for a guess, repeating until a valid one is given.
Rejects non-numeric and out-of-range input without costing an attempt.
"""
while True:
raw = input(f"\nGuess ({remaining} left): ").strip()
if not raw.isdigit():
print("Please enter a whole number.")
continue
guess = int(raw)
if not LOWEST <= guess <= HIGHEST:
print(f"Out of range. Pick between {LOWEST} and {HIGHEST}.")
continue
return guess
Note what changed. The original used continue to skip the rest of the game
loop; this uses continue inside its own small loop and return to hand back a
valid number. The "does not cost an attempt" rule is now structural — the
function cannot return until the input is good, so no attempt is consumed.
The docstring records that decision, which is exactly the kind of thing that gets accidentally removed later.
Step 3: extract the feedback
def describe_guess(guess: int, secret: int) -> str:
"""Return 'correct', 'low' or 'high' for a guess."""
if guess == secret:
return "correct"
if guess < secret:
return "low"
return "high"
Tiny, and the most valuable function here — because it is the only piece with real logic and it is now testable without any input at all:
assert describe_guess(5, 10) == "low"
assert describe_guess(10, 10) == "correct"
Two lines proving the core of the game works. That was impossible before.
Step 4: one round
def play_round() -> bool:
"""Play one complete game. Return True if the player won."""
secret = random.randint(LOWEST, HIGHEST)
for attempt in range(1, MAX_ATTEMPTS + 1):
guess = ask_for_guess(MAX_ATTEMPTS - attempt + 1)
result = describe_guess(guess, secret)
if result == "correct":
print(f"Correct! You got it in {attempt} attempts.")
return True
print("Too low." if result == "low" else "Too high.")
print(f"\nOut of attempts. The number was {secret}.")
return False
The while became a for, because with validation moved out, the number of
attempts is now known in advance — exactly the case for is for. The manual
counter is gone.
The while/else is gone too. return True on a win means reaching the bottom
can only mean running out, so the message needs no special construct. Guard
clauses removed the need for the cleverness.
Step 5: the main flow
def main() -> None:
"""Run the game until the player stops."""
show_welcome()
wins = 0
while True:
if play_round():
wins += 1
if input("\nPlay again? (y/n): ").strip().lower() != "y":
break
print(f"Thanks for playing. You won {wins} time(s).")
main()
Read main() on its own: welcome, play rounds, keep score, say goodbye. That is
the entire program, and it fits in your head.
The win counter is a new feature. It took one line, because play_round already
returns whether the player won — a sign the structure is right.
What improved
Each function does one thing, and its name says which.
The deepest nesting is now two levels, inside play_round, instead of four.
The logic is testable. describe_guess can be checked with assert
statements, and that is module 10's starting point.
Adding a feature is local. Difficulty levels mean changing
random.randint(LOWEST, HIGHEST) and one parameter — not surgery on a
forty-line block.
The code reads top-down. Small helpers, then play_round, then main. A
reader can stop at whatever depth answers their question.
What did not improve: it is slightly longer, and there is more to read at a glance. That is a real cost, and it is worth paying once a program stops being trivial. For a ten-line script, functions would be ceremony.
The if __name__ line
Real scripts end like this rather than with a bare main():
if __name__ == "__main__":
main()
That means "only run this if the file was executed directly, not if it was
imported by something else". Without it, importing your game to reuse
describe_guess would start a game.
It becomes properly clear in the modules lesson. Write it now; it is the convention.
Practice
- Refactor the game yourself, step by step, running it after each extraction. Do not paste the finished version.
- Add the
assertchecks fordescribe_guessand run them. - Add a difficulty choice — easy is 1 to 50 with 8 attempts, hard is 1 to 100 with 5. Notice how much less of the code you had to touch than you would have before.
- Add a
best_scorethat tracks the fewest attempts across a session. - Write
ask_yes_no(prompt: str) -> booland use it for "play again". Then notice it is reusable in anything else you write. - Add docstrings and type hints to every function.
- Take the receipt program from module 2 and refactor it the same way. It is shorter and the same process applies.
- Find the longest stretch of code you have written on this course and extract one function from it. One is enough — this is a habit, not an event.
That is module five. Your programs can now be organised rather than merely long, and the parts that matter can be tested.
Next module: errors and debugging — reading a traceback properly, handling
failure on purpose, and finding bugs with something better than scattered
print calls.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship