RizTech Academy logo
RizTech Academy
FunctionsLesson 7 of 730 min

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

  1. Refactor the game yourself, step by step, running it after each extraction. Do not paste the finished version.
  2. Add the assert checks for describe_guess and run them.
  3. 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.
  4. Add a best_score that tracks the fewest attempts across a session.
  5. Write ask_yes_no(prompt: str) -> bool and use it for "play again". Then notice it is reusable in anything else you write.
  6. Add docstrings and type hints to every function.
  7. Take the receipt program from module 2 and refactor it the same way. It is shorter and the same process applies.
  8. 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