If you’re learning Python GUI development, a number guessing game is one of the best “small-but-real” projects to practice. It looks simple from the outside—guess a number and get feedback—but it naturally teaches you the most important GUI concepts: event-driven programming, state management, input validation, widget updates, and clean class-based structure.
In this tutorial, you will build a Tkinter Number Guessing Game that feels like an actual mini-app. The game has a level system that expands the guessing range as you progress, a score system that rewards correct guesses, a limited number of attempts per level, and buttons for Submit, New Game, and Quit. The explanation is written for students, professionals, and developers, so it keeps the language approachable while still explaining the design clearly.
You will create a desktop GUI game where the player guesses a randomly generated target number. The game starts at Level 1 with a range of 1 to 10. Every time the user guesses correctly, they earn points and move to the next level. With each new level, the range increases using a simple formula:
number_range = 10 + (level – 1) * 5
That means Level 1 is 1–10, Level 2 is 1–15, Level 3 is 1–20, and so on. The player gets 5 attempts to guess the number in each level. If attempts run out, the game resets.
This project demonstrates how to:
To follow along comfortably, you should have Python 3 installed and be able to run a .py file from an editor or terminal. Tkinter is included with most Python installations by default (especially on Windows and macOS), so you usually don’t need to install anything extra.
A few tools you can use:
You should also be familiar with basic Python concepts like variables, functions, conditionals (if/else), and exception handling (try/except). If you are new to GUI programming, don’t worry—this tutorial explains the core GUI workflow as you build.
Tkinter applications work differently than command-line programs. In CLI code, your program typically runs from top to bottom. In a GUI app, the program starts, builds the interface, and then waits for events like button clicks and key presses. This is called the event loop.
Because GUI apps are event-driven, it’s important to design your code so your game logic and UI updates remain predictable. The cleanest approach is to create a class that holds both:
That’s exactly what your NumberGuessingGame class does. The structure is simple but scalable:
__init__: Initializes state and creates widgetscreate_widgets: Builds the entire UIcheck_guess: Core logic for validating input and comparing guesseslevel_up: Moves the game forward when the user guesses correctlyupdate_attempts: Updates the attempts labelnew_game: Resets everythingquit_game: Closes the applicationThis architecture is professional enough to extend later with extra features without rewriting everything.
Create a new file named something like:
number_guessing_game.py
Then start with the required imports:
import tkinter as tk
import random
from tkinter import messagebox
Code language: JavaScript (javascript)
Here’s why each import is necessary:
tkinter as tk is the GUI framework. Importing it as tk is a common Python convention that makes widget names shorter and easier to read.
random is used to generate the number the player must guess. Every level should feel different, so you’ll generate a random integer inside the current range.
messagebox is a Tkinter utility for pop-up dialogs. This is a very user-friendly way to show important feedback like “Invalid input,” “Game over,” or “Congratulations.”
A class-based design keeps the game clean. Instead of spreading variables everywhere, the class stores everything as self.* attributes.
class NumberGuessingGame:
def __init__(self, root):
self.root = root
self.root.title("Number Guessing Game")
self.root.geometry("400x500")
self.level = 1
self.score = 0
self.max_attempts = 5
self.target_number = random.randint(1, 10)
self.create_widgets()
Let’s unpack this piece by piece.
root is the main Tkinter window created outside the class. We store it in self.root because we will attach all widgets to this window.
title() sets the top window title, which helps the app look like a proper desktop program.
geometry("400x500") sets the initial size. You can change it later, but this is a good size for a simple game.
Now the game state begins.
self.level = 1 means the player starts at Level 1.
self.score = 0 initializes score.
self.max_attempts = 5 means each level gives the player 5 tries.
self.target_number = random.randint(1, 10) sets the initial secret number. At Level 1, the range is 1–10, so this random number matches the hint you display.
Finally, self.create_widgets() builds the full UI.
create_widgetsTkinter widgets are the visible parts of your GUI—labels, buttons, entry fields, and so on. In this game, you want the UI to continuously show:
Here’s the full widget creation method:
def create_widgets(self):
self.title_label = tk.Label(self.root, text="Number Guessing Game", font=("Arial", 16, "bold"))
self.title_label.pack(pady=10)
self.level_label = tk.Label(self.root, text=f"Level: {self.level}", font=("Arial", 12))
self.level_label.pack()
self.score_label = tk.Label(self.root, text=f"Score: {self.score}", font=("Arial", 12))
self.score_label.pack()
self.hint_label = tk.Label(self.root, text=f"Guess a number between 1 and 10", font=("Arial", 12))
self.hint_label.pack(pady=10)
self.entry = tk.Entry(self.root, font=("Arial", 12))
self.entry.pack()
self.submit_button = tk.Button(self.root, text="Submit", command=self.check_guess, font=("Arial", 12))
self.submit_button.pack(pady=10)
self.new_game_button = tk.Button(self.root, text="New Game", command=self.new_game, font=("Arial", 12))
self.new_game_button.pack(pady=5)
self.quit_button = tk.Button(self.root, text="Quit", command=self.quit_game, font=("Arial", 12))
self.quit_button.pack(pady=5)
self.message_label = tk.Label(self.root, text="", font=("Arial", 12))
self.message_label.pack()
self.attempts_label = tk.Label(self.root, text=f"Attempts Left: {self.max_attempts}", font=("Arial", 12))
self.attempts_label.pack()
self.attempts_left = self.max_attempts
Code language: PHP (php)
This is where the UI becomes interactive. The key idea is that the Submit button connects directly to the check_guess method through command=self.check_guess. When the user clicks Submit, Tkinter calls that method automatically.
The labels such as level_label, score_label, and attempts_label are created once, but their text will be updated later. That’s a common GUI pattern: create widgets once, then change their content using .config().
attempts_left is a state variable. You display it in a label and update it every time the user guesses.
The most important method is check_guess(). This is the game engine. It reads user input, validates it, compares it to the target number, and then updates state and UI.
def check_guess(self):
try:
guess = int(self.entry.get())
except ValueError:
messagebox.showerror("Invalid Input", "Please enter a valid number!")
return
number_range = 10 + (self.level - 1) * 5
if guess < 1 or guess > number_range:
messagebox.showerror("Invalid Input", f"Please guess a number between 1 and {number_range}.")
return
self.attempts_left -= 1
if guess == self.target_number:
self.score += 10
messagebox.showinfo("Congratulations!", f"You guessed it right! Moving to Level {self.level + 1}.")
self.level_up()
elif self.attempts_left == 0:
messagebox.showinfo("Game Over", "You've used all attempts! Try again.")
self.new_game()
elif guess < self.target_number:
self.message_label.config(text="Too low! Try again.", fg="red")
else:
self.message_label.config(text="Too high! Try again.", fg="red")
self.update_attempts()
self.entry.delete(0, tk.END)
Code language: PHP (php)
This method contains multiple professional practices.
First, it uses try/except to handle cases where the user enters text like “abc” or leaves input empty. Instead of crashing, the app shows a friendly error pop-up and returns early. This is what makes a GUI app feel polished.
Next, it calculates the current level’s range using:
number_range = 10 + (self.level - 1) * 5
This formula keeps the difficulty increasing at a predictable pace. It also ensures the UI hint stays consistent with the actual random number generation.
Then the method checks if the guess is inside the allowed range. If it is not, the player is alerted immediately.
After validation, the game decreases attempts:
self.attempts_left -= 1
This is important: the attempt is counted only after the input is valid.
Now the logic splits into scenarios.
If the guess equals the target number, the player earns points, sees a success message, and moves to the next level.
If attempts become zero, it’s game over. Instead of exiting, the game resets using new_game(), which is a friendly user experience.
If the guess is wrong and attempts remain, the app displays a “Too low” or “Too high” message in the window itself. This is good because users don’t want pop-ups on every wrong attempt. Pop-ups are used only for major milestones.
Finally, update_attempts() refreshes the attempts label, and the entry box is cleared to make the next guess faster.
When the player guesses correctly, the game moves forward. The level_up() method is responsible for updating state and UI.
def level_up(self):
self.level += 1
self.attempts_left = self.max_attempts
number_range = 10 + (self.level - 1) * 5
self.target_number = random.randint(1, number_range)
self.level_label.config(text=f"Level: {self.level}")
self.score_label.config(text=f"Score: {self.score}")
self.hint_label.config(text=f"Guess a number between 1 and {number_range}")
self.update_attempts()
self.message_label.config(text="")
Code language: PHP (php)
This method increases the level, resets attempts for the new level, calculates the new range, and generates a new target number.
The order matters. You want self.level updated first so that the range calculation uses the correct level. Then you generate self.target_number using the new range.
After that, labels are updated using .config(). This is the standard Tkinter way to update an existing widget’s text instead of creating a new widget.
Finally, you clear the message label so the player doesn’t see “Too high” from the previous round when they reach a new level.
A small helper method makes the UI updates consistent.
def update_attempts(self):
self.attempts_label.config(text=f"Attempts Left: {self.attempts_left}")
Code language: PHP (php)
Even though this method is tiny, it improves code quality. Instead of repeating the label update code in multiple places, you put it in one method and call it whenever attempts change. This is the kind of small discipline that makes larger applications maintainable.
Quitting in Tkinter is simply destroying the root window.
def quit_game(self):
self.root.destroy()
Code language: CSS (css)
This immediately closes the app and ends the event loop.
The New Game button resets all state to the default setup.
def new_game(self):
self.level = 1
self.score = 0
self.attempts_left = self.max_attempts
self.target_number = random.randint(1, 10)
self.level_label.config(text=f"Level: {self.level}")
self.score_label.config(text=f"Score: {self.score}")
self.hint_label.config(text="Guess a number between 1 and 10")
self.update_attempts()
self.message_label.config(text="")
Code language: PHP (php)
The key is that resetting isn’t just about variables. In a GUI app, your UI must match your state. That’s why the method updates all visible labels immediately after resetting variables.
This approach is also safer than creating a brand-new window. You reuse the same UI widgets and simply change their text, which keeps the app responsive and avoids memory leaks.
At the bottom of the file, you need the standard Python entry point:
if __name__ == "__main__":
root = tk.Tk()
game = NumberGuessingGame(root)
root.mainloop()
Code language: JavaScript (javascript)
tk.Tk() creates the main window.
NumberGuessingGame(root) builds your class instance, creates widgets, and prepares the game.
root.mainloop() starts the Tkinter event loop. Without this line, the window would appear and immediately close.
Once you understand this foundation, you can evolve it into a more advanced project that still uses the same architecture. You could add a difficulty mode selector that changes max_attempts or how fast the range increases. You could add a “best score” system stored in a JSON file. You could also add a timer per level, a progress bar for attempts, or keyboard support so pressing Enter triggers Submit.
From a software engineering standpoint, you can also separate UI from logic by creating a “GameEngine” class and a “GameUI” class, which makes testing easier. But for most learners, the current single-class approach is ideal because it is short, readable, and still professional.
import tkinter as tk
import random
from tkinter import messagebox
class NumberGuessingGame:
def __init__(self, root):
self.root = root
self.root.title("Number Guessing Game")
self.root.geometry("400x500")
self.level = 1
self.score = 0
self.max_attempts = 5
self.target_number = random.randint(1, 10)
self.create_widgets()
def create_widgets(self):
self.title_label = tk.Label(self.root, text="Number Guessing Game", font=("Arial", 16, "bold"))
self.title_label.pack(pady=10)
self.level_label = tk.Label(self.root, text=f"Level: {self.level}", font=("Arial", 12))
self.level_label.pack()
self.score_label = tk.Label(self.root, text=f"Score: {self.score}", font=("Arial", 12))
self.score_label.pack()
self.hint_label = tk.Label(self.root, text=f"Guess a number between 1 and 10", font=("Arial", 12))
self.hint_label.pack(pady=10)
self.entry = tk.Entry(self.root, font=("Arial", 12))
self.entry.pack()
self.submit_button = tk.Button(self.root, text="Submit", command=self.check_guess, font=("Arial", 12))
self.submit_button.pack(pady=10)
self.new_game_button = tk.Button(self.root, text="New Game", command=self.new_game, font=("Arial", 12))
self.new_game_button.pack(pady=5)
self.quit_button = tk.Button(self.root, text="Quit", command=self.quit_game, font=("Arial", 12))
self.quit_button.pack(pady=5)
self.message_label = tk.Label(self.root, text="", font=("Arial", 12))
self.message_label.pack()
self.attempts_label = tk.Label(self.root, text=f"Attempts Left: {self.max_attempts}", font=("Arial", 12))
self.attempts_label.pack()
self.attempts_left = self.max_attempts
def check_guess(self):
try:
guess = int(self.entry.get())
except ValueError:
messagebox.showerror("Invalid Input", "Please enter a valid number!")
return
number_range = 10 + (self.level - 1) * 5
if guess < 1 or guess > number_range:
messagebox.showerror("Invalid Input", f"Please guess a number between 1 and {number_range}.")
return
self.attempts_left -= 1
if guess == self.target_number:
self.score += 10
messagebox.showinfo("Congratulations!", f"You guessed it right! Moving to Level {self.level + 1}.")
self.level_up()
elif self.attempts_left == 0:
messagebox.showinfo("Game Over", "You've used all attempts! Try again.")
self.new_game()
elif guess < self.target_number:
self.message_label.config(text="Too low! Try again.", fg="red")
else:
self.message_label.config(text="Too high! Try again.", fg="red")
self.update_attempts()
self.entry.delete(0, tk.END)
def level_up(self):
self.level += 1
self.attempts_left = self.max_attempts
number_range = 10 + (self.level - 1) * 5
self.target_number = random.randint(1, number_range)
self.level_label.config(text=f"Level: {self.level}")
self.score_label.config(text=f"Score: {self.score}")
self.hint_label.config(text=f"Guess a number between 1 and {number_range}")
self.update_attempts()
self.message_label.config(text="")
def update_attempts(self):
self.attempts_label.config(text=f"Attempts Left: {self.attempts_left}")
def quit_game(self):
self.root.destroy()
def new_game(self):
self.level = 1
self.score = 0
self.attempts_left = self.max_attempts
self.target_number = random.randint(1, 10)
self.level_label.config(text=f"Level: {self.level}")
self.score_label.config(text=f"Score: {self.score}")
self.hint_label.config(text="Guess a number between 1 and 10")
self.update_attempts()
self.message_label.config(text="")
if __name__ == "__main__":
root = tk.Tk()
game = NumberGuessingGame(root)
root.mainloop()
Code language: HTML, XML (xml)