Build a Better Number Guessing Game
Rules, memory, and code you can actually change
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.
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)
attempt 1: 50 -> lower attempt 2: 25 -> higher attempt 3: 37 -> higher attempt 4: 42 -> correct solved in: 4
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"))
Careful with the direction: if the guess is below the secret the player should aim "higher". For safe_int, put return int(text) inside a try and return None in except ValueError:.
def check_guess(secret, guess):
if guess < secret:
return "higher"
if guess > secret:
return "lower"
return "correct"
def safe_int(text):
try:
return int(text)
except ValueError:
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"))
higher
lower
correct
'12' -> 12
'abc' -> None
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.
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"])
same seed gives the same secret: True different seed differs: True a fresh round starts clean: 0 False
Common mistake: Calling int(input()) without protection
It works every time you test it, because you type numbers.
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
Setup naturally goes at the top of the program.
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
Copying the line is faster than writing a function.
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
It lives outside the round and nothing complains.
Return a fresh state dictionary per round rather than resetting fields by hand.
Where should the secret number be created?
Created once at the top means every replay is the same game.
Why put one round in its own function?
Everything belonging to the round is created inside it, so there is nothing to forget to reset.
What error can unvalidated input cause?
int("ten") raises ValueError and ends the program unless you catch it.
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)
Loop with for attempt, guess in enumerate(guesses, start=1) so the count starts at 1. Break out when attempt > max_attempts, return attempt as soon as check_guess says "correct", and return None after the loop if it never did.
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
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)
attempt 1: 50 -> lower
attempt 2: 25 -> higher
attempt 3: 37 -> higher
attempt 4: 42 -> correct
solved in: 4
attempt 1: 10 -> higher
attempt 2: 20 -> higher
attempt 3: 30 -> higher
solved in: None
assert play_round(5, [5], 3) == 1, "a correct first guess wins on attempt 1"
assert play_round(5, [1, 2, 3], 3) is None, "running out should return None"
assert play_round(5, [1, 5], 1) is None, "the attempt limit must be enforced"
print("✓ Looks good!")