Build a Better Number Guessing Game

Rules, memory, and code you can actually change

Advanced 14 min

In this lesson

The number guessing game is everyone's first project, and everyone's first version is one long block that works exactly once.

This lesson rebuilds it properly: difficulty levels, a limit on attempts, input that does not crash on a typo, and a replay loop. The game is an excuse; the real subject is turning a working script into something you can keep changing.

Explain it like I’m 5

A game is just rules plus memory: the program remembers the secret number and follows the same rules each turn.

One rule, one function

Start with the smallest piece: comparing a guess to the secret. Pulling it out into its own function looks almost pointless at three lines, and it is the change that makes everything else possible.

check_guess returns a word rather than printing one. That is the separation from the last lesson: the rule knows nothing about the screen, so it can be reused behind a terminal, a window, or a test.

Example
DIFFICULTY = {"easy": 10, "normal": 6, "hard": 3}

def check_guess(secret, guess):
    if guess < secret:
        return "higher"
    if guess > secret:
        return "lower"
    return "correct"

def play_round(secret, guesses, max_attempts):
    for attempt, guess in enumerate(guesses, start=1):
        if attempt > max_attempts:
            break
        result = check_guess(secret, guess)
        print(f"attempt {attempt}: {guess} -> {result}")
        if result == "correct":
            return attempt
    return None

attempts = play_round(42, [50, 25, 37, 42], DIFFICULTY["normal"])
print("solved in:", attempts)
Output
attempt 1: 50 -> lower
attempt 2: 25 -> higher
attempt 3: 37 -> higher
attempt 4: 42 -> correct
solved in: 4
One round of the game, with the guesses supplied instead of typed.

Input that survives bad typing

int(input(...)) is the single most common crash in beginner code. Type “ten”, or press Enter by mistake, and the whole program dies with a ValueError.

The fix is the try/except from Unit 3, wrapped in a small function that returns None instead of exploding. The caller then decides what to do about it, which is the right place for that decision, since only the caller knows whether to re-prompt, use a default, or give up.

Write the two small functions the game depends on. check_guess returns "higher", "lower" or "correct" by comparing a guess to the secret. safe_int converts text to a whole number, returning None rather than crashing when the text is not a number.

def check_guess(secret, guess):
    # TODO: "higher" if the guess is too low, "lower" if too high,
    #       "correct" if it matches
    return ""

def safe_int(text):
    # TODO: return the number, or None if the text is not a number
    return None

print(check_guess(50, 20))
print(check_guess(50, 80))
print(check_guess(50, 50))
print("'12' ->", safe_int("12"))
print("'abc' ->", safe_int("abc"))

Replaying without leftovers

Adding “play again?” is where the game usually breaks, and always in the same way: something from the last round survives into the next one. The score keeps climbing, the attempt counter never resets, or — the classic — the secret number stays the same because it was generated once, outside the loop.

The reliable fix is structural. Everything belonging to a single round gets created inside the function that plays a round. Then a replay is just calling that function again, and there is nothing left over to reset because nothing survived.

Where the secret number is created is the whole question. Inside play_round, every round is new; outside it, every round is the same game.

Example
import random

def new_round(low=1, high=100, seed=None):
    """Everything a single round needs, created fresh."""
    rng = random.Random(seed)          # seeded so this example is repeatable
    return {"secret": rng.randint(low, high), "attempts": 0, "won": False}

round_one = new_round(seed=7)
round_two = new_round(seed=7)
round_three = new_round(seed=99)

print("same seed gives the same secret:", round_one["secret"] == round_two["secret"])
print("different seed differs:", round_one["secret"] != round_three["secret"])
print("a fresh round starts clean:", round_one["attempts"], round_one["won"])
Output
same seed gives the same secret: True
different seed differs: True
a fresh round starts clean: 0 False
A round is a fresh dictionary, so there is nothing to reset.

Common mistake: Calling int(input()) without protection

Why it happens:

It works every time you test it, because you type numbers.

How to fix it:

Wrap the conversion in try/except ValueError and decide what a bad entry should do.

Common mistake: Generating the secret number outside the replay loop

Why it happens:

Setup naturally goes at the top of the program.

How to fix it:

Anything belonging to one round must be created inside the round. Otherwise every replay is the same game.

Common mistake: Duplicating the same prompt in several branches

Why it happens:

Copying the line is faster than writing a function.

How to fix it:

One function for asking, called from everywhere. Otherwise a wording change means finding every copy.

Common mistake: Forgetting to reset the attempt counter on replay

Why it happens:

It lives outside the round and nothing complains.

How to fix it:

Return a fresh state dictionary per round rather than resetting fields by hand.

Where should the secret number be created?

Why put one round in its own function?

What error can unvalidated input cause?

Mini exercise (medium)

Play a full round with an attempt limit. Write play_round(secret, guesses, max_attempts) so it checks each guess in turn, stops when the limit is reached, and returns the attempt number on which the player won, or None if they ran out. Report the result for a winning run and a losing one.

Take the wheel. Complete the code, hit Run, and check your output right here.

def check_guess(secret, guess):
    if guess < secret:
        return "higher"
    if guess > secret:
        return "lower"
    return "correct"

def play_round(secret, guesses, max_attempts):
    # TODO: check each guess in turn, printing "attempt N: G -> RESULT"
    # TODO: stop once max_attempts is reached
    # TODO: return the attempt number that won, or None if they ran out
    return None

won = play_round(42, [50, 25, 37, 42], 6)
print("solved in:", won)

lost = play_round(42, [10, 20, 30], 3)
print("solved in:", lost)

What to learn next

You rebuilt the guessing game as functions that return values rather than print them: check_guess holding the rule, play_round handling one round and reporting how it went, safe_int surviving a typo, and difficulty as data instead of branches. Creating a fresh round is what makes replay work without leftovers.

Now put a window around it. Pygame Basics gives Python a canvas and a clock — the first thing in this course you run on your own machine and watch move.