Quiz Game with Timer
Abstract
Adding a timer to a quiz sounds trivial — until you discover that time.sleeptime.sleep freezes the entire window, and that updating widgets from a background thread is officially unsafe in Tkinter. This project is the perfect vehicle for learning non-blocking timing and GUI thread safety. You’ll build a multiple-choice quiz where each question has a 10-second countdown that auto-advances when it expires, then refactor the timing to the idiomatic, bug-free approach and grow the game with a JSON question bank, a progress bar, and a proper results screen.
You will leave understanding:
- Why
time.sleeptime.sleepon the main thread freezes a GUI, and two correct alternatives. - The thread-safety rule for Tkinter and how
root.afterroot.aftersidesteps it. - How to manage per-question state (index, score, time left).
- How to cancel a pending timer when the player answers early.
Prerequisites
- Python 3.6 or above.
- A text editor or IDE.
- Tkinter (bundled with Python).
- Basic understanding of functions, classes, and (helpful) threads.
Getting Started
Create the project
- Create a folder named
quiz-timerquiz-timer. - Inside it, create
quiz_game_with_timer.pyquiz_game_with_timer.py.
Write the code
quiz_game_with_timer.py
Source"""
Quiz Game with Timer
A Python-based quiz game where players must answer questions within a time limit. The game includes:
- A set of multiple-choice questions
- A timer for each question
- Score calculation based on correct answers
"""
import time
import threading
from tkinter import Tk, Label, Button, messagebox
class QuizGameWithTimer:
def __init__(self, root):
self.root = root
self.root.title("Quiz Game with Timer")
self.questions = [
{"question": "What is the capital of France?", "options": ["Paris", "London", "Berlin", "Madrid"], "answer": "Paris"},
{"question": "What is 5 + 7?", "options": ["10", "12", "14", "16"], "answer": "12"},
{"question": "Which planet is known as the Red Planet?", "options": ["Earth", "Mars", "Jupiter", "Venus"], "answer": "Mars"},
]
self.current_question_index = 0
self.score = 0
self.time_left = 10
self.timer_thread = None
self.timer_running = False
self.setup_ui()
self.display_question()
def setup_ui(self):
"""Set up the user interface."""
self.question_label = Label(self.root, text="", wraplength=400, font=("Arial", 14))
self.question_label.pack(pady=20)
self.option_buttons = []
for i in range(4):
button = Button(self.root, text="", command=lambda i=i: self.check_answer(i), width=20)
button.pack(pady=5)
self.option_buttons.append(button)
self.timer_label = Label(self.root, text="Time left: 10 seconds", font=("Arial", 12))
self.timer_label.pack(pady=10)
def display_question(self):
"""Display the current question and options."""
if self.current_question_index >= len(self.questions):
self.end_game()
return
question_data = self.questions[self.current_question_index]
self.question_label.config(text=question_data["question"])
for i, option in enumerate(question_data["options"]):
self.option_buttons[i].config(text=option)
self.time_left = 10
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
self.start_timer()
def start_timer(self):
"""Start the countdown timer."""
self.timer_running = True
self.timer_thread = threading.Thread(target=self.countdown)
self.timer_thread.start()
def countdown(self):
"""Countdown timer logic."""
while self.time_left > 0 and self.timer_running:
time.sleep(1)
self.time_left -= 1
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left == 0:
self.timer_running = False
self.root.after(0, self.time_up)
def time_up(self):
"""Handle the event when time is up."""
messagebox.showinfo("Time's Up!", "You ran out of time for this question.")
self.next_question()
def check_answer(self, index):
"""Check if the selected answer is correct."""
if not self.timer_running:
return
self.timer_running = False
question_data = self.questions[self.current_question_index]
selected_option = self.option_buttons[index].cget("text")
if selected_option == question_data["answer"]:
self.score += 1
self.next_question()
def next_question(self):
"""Move to the next question."""
self.current_question_index += 1
self.display_question()
def end_game(self):
"""End the game and display the score."""
messagebox.showinfo("Game Over", f"Your score: {self.score}/{len(self.questions)}")
self.root.destroy()
def main():
root = Tk()
app = QuizGameWithTimer(root)
root.mainloop()
if __name__ == "__main__":
main()
"""
Quiz Game with Timer
A Python-based quiz game where players must answer questions within a time limit. The game includes:
- A set of multiple-choice questions
- A timer for each question
- Score calculation based on correct answers
"""
import time
import threading
from tkinter import Tk, Label, Button, messagebox
class QuizGameWithTimer:
def __init__(self, root):
self.root = root
self.root.title("Quiz Game with Timer")
self.questions = [
{"question": "What is the capital of France?", "options": ["Paris", "London", "Berlin", "Madrid"], "answer": "Paris"},
{"question": "What is 5 + 7?", "options": ["10", "12", "14", "16"], "answer": "12"},
{"question": "Which planet is known as the Red Planet?", "options": ["Earth", "Mars", "Jupiter", "Venus"], "answer": "Mars"},
]
self.current_question_index = 0
self.score = 0
self.time_left = 10
self.timer_thread = None
self.timer_running = False
self.setup_ui()
self.display_question()
def setup_ui(self):
"""Set up the user interface."""
self.question_label = Label(self.root, text="", wraplength=400, font=("Arial", 14))
self.question_label.pack(pady=20)
self.option_buttons = []
for i in range(4):
button = Button(self.root, text="", command=lambda i=i: self.check_answer(i), width=20)
button.pack(pady=5)
self.option_buttons.append(button)
self.timer_label = Label(self.root, text="Time left: 10 seconds", font=("Arial", 12))
self.timer_label.pack(pady=10)
def display_question(self):
"""Display the current question and options."""
if self.current_question_index >= len(self.questions):
self.end_game()
return
question_data = self.questions[self.current_question_index]
self.question_label.config(text=question_data["question"])
for i, option in enumerate(question_data["options"]):
self.option_buttons[i].config(text=option)
self.time_left = 10
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
self.start_timer()
def start_timer(self):
"""Start the countdown timer."""
self.timer_running = True
self.timer_thread = threading.Thread(target=self.countdown)
self.timer_thread.start()
def countdown(self):
"""Countdown timer logic."""
while self.time_left > 0 and self.timer_running:
time.sleep(1)
self.time_left -= 1
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left == 0:
self.timer_running = False
self.root.after(0, self.time_up)
def time_up(self):
"""Handle the event when time is up."""
messagebox.showinfo("Time's Up!", "You ran out of time for this question.")
self.next_question()
def check_answer(self, index):
"""Check if the selected answer is correct."""
if not self.timer_running:
return
self.timer_running = False
question_data = self.questions[self.current_question_index]
selected_option = self.option_buttons[index].cget("text")
if selected_option == question_data["answer"]:
self.score += 1
self.next_question()
def next_question(self):
"""Move to the next question."""
self.current_question_index += 1
self.display_question()
def end_game(self):
"""End the game and display the score."""
messagebox.showinfo("Game Over", f"Your score: {self.score}/{len(self.questions)}")
self.root.destroy()
def main():
root = Tk()
app = QuizGameWithTimer(root)
root.mainloop()
if __name__ == "__main__":
main()
Run it
C:\Users\Your Name\quiz-timer> python quiz_game_with_timer.py
# Each question shows four options and a countdown. Answer before time runs out.C:\Users\Your Name\quiz-timer> python quiz_game_with_timer.py
# Each question shows four options and a countdown. Answer before time runs out.Step-by-Step Explanation
1. Questions as a list of dicts
self.questions = [
{"question": "What is the capital of France?",
"options": ["Paris", "London", "Berlin", "Madrid"], "answer": "Paris"},
...
]self.questions = [
{"question": "What is the capital of France?",
"options": ["Paris", "London", "Berlin", "Madrid"], "answer": "Paris"},
...
]Each question is a self-contained dict — far cleaner than parallel lists. Later you’ll load this same shape from a JSON file.
2. Per-question state
self.current_question_index = 0
self.score = 0
self.time_left = 10self.current_question_index = 0
self.score = 0
self.time_left = 10Three variables track the whole game: which question, the running score, and the countdown.
3. The timer (as written — note the caveat)
def start_timer(self):
self.timer_running = True
self.timer_thread = threading.Thread(target=self.countdown)
self.timer_thread.start()
def countdown(self):
while self.time_left > 0 and self.timer_running:
time.sleep(1)
self.time_left -= 1
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left == 0:
self.timer_running = False
self.root.after(0, self.time_up)def start_timer(self):
self.timer_running = True
self.timer_thread = threading.Thread(target=self.countdown)
self.timer_thread.start()
def countdown(self):
while self.time_left > 0 and self.timer_running:
time.sleep(1)
self.time_left -= 1
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left == 0:
self.timer_running = False
self.root.after(0, self.time_up)This runs the countdown on a background thread so time.sleeptime.sleep doesn’t freeze the window. It works, but it breaks a Tkinter rule: self.timer_label.config(...)self.timer_label.config(...) is called from the background thread. Tkinter widgets should only be touched from the main thread. It usually works on CPython but can crash or corrupt the display. The fix is below — and it’s simpler.
4. Checking the answer cancels the timer
def check_answer(self, index):
if not self.timer_running:
return
self.timer_running = False # stop the countdown loop
...def check_answer(self, index):
if not self.timer_running:
return
self.timer_running = False # stop the countdown loop
...Setting timer_running = Falsetimer_running = False is how the answer button tells the countdown loop to stop. Without it, an expired timer could advance the question a second time.
The Idiomatic Fix: root.afterroot.after Instead of Threads
Tkinter has a built-in scheduler. root.after(ms, fn)root.after(ms, fn) runs fnfn later, on the main thread — no threads, no sleep, no safety violation:
def start_timer(self, seconds=10):
self.time_left = seconds
self.tick()
def tick(self):
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left <= 0:
self.time_up()
return
self.time_left -= 1
self._after_id = self.root.after(1000, self.tick) # schedule the next tick
def check_answer(self, index):
self.root.after_cancel(self._after_id) # stop the pending tick
...def start_timer(self, seconds=10):
self.time_left = seconds
self.tick()
def tick(self):
self.timer_label.config(text=f"Time left: {self.time_left} seconds")
if self.time_left <= 0:
self.time_up()
return
self.time_left -= 1
self._after_id = self.root.after(1000, self.tick) # schedule the next tick
def check_answer(self, index):
self.root.after_cancel(self._after_id) # stop the pending tick
...This is shorter, thread-safe, and trivially cancelable. Rule: for periodic UI work in Tkinter, reach for afterafter, not threadingthreading + sleepsleep.
Load Questions from JSON
Hard-coded questions don’t scale. Move them to a file:
[
{"question": "Capital of France?", "options": ["Paris","London","Berlin","Madrid"], "answer": "Paris"},
{"question": "5 + 7?", "options": ["10","12","14","16"], "answer": "12"}
][
{"question": "Capital of France?", "options": ["Paris","London","Berlin","Madrid"], "answer": "Paris"},
{"question": "5 + 7?", "options": ["10","12","14","16"], "answer": "12"}
]import json
from pathlib import Path
self.questions = json.loads(Path("questions.json").read_text())import json
from pathlib import Path
self.questions = json.loads(Path("questions.json").read_text())Now non-programmers can author quizzes, and you can ship multiple categories as separate files.
Add a Progress Bar
Show how much time remains visually:
from tkinter import ttk
self.bar = ttk.Progressbar(self.root, maximum=10, length=300)
self.bar.pack(pady=5)
# in tick():
self.bar["value"] = self.time_leftfrom tkinter import ttk
self.bar = ttk.Progressbar(self.root, maximum=10, length=300)
self.bar.pack(pady=5)
# in tick():
self.bar["value"] = self.time_leftBuild a Results Screen
Instead of a popup, end on a summary with per-question feedback — which you got right, which timed out, and the correct answers. Educational quizzes live on feedback, not just a final number.
Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
| Window freezes during countdown | time.sleeptime.sleep on the main thread | Use root.after(1000, ...)root.after(1000, ...) |
| Random crashes / display glitches | Updating widgets from a thread | Do all UI work on the main thread |
| Question advances twice | Expired timer + manual answer both fire | Cancel the timer on answer (after_cancelafter_cancel) |
| Timer keeps running after game over | Loop/afterafter not stopped | Cancel pending callbacks in end_gameend_game |
| Score off by one | Checked answer after incrementing index | Compare before advancing |
| Same answer position every time | Options not shuffled | random.shufflerandom.shuffle the options per question |
Variations to Try
- Difficulty-based timing — less time for harder questions.
- Streak bonus — extra points for fast consecutive correct answers.
- Categories — load different JSON banks for science, history, etc.
- Lifelines — 50/50 (remove two wrong options), skip, or extra time.
- Sound effects — a tick near the deadline, a chime for correct answers.
- Leaderboard — persist scores to JSON and show top players.
- Online questions — pull from the Open Trivia DB API for endless content.
Real-World Applications
- E-learning — timed assessments and certification practice.
- Corporate training — compliance quizzes with time pressure.
- Game shows / trivia apps — the timer is the format.
- Cognitive testing — reaction-time and recall measurement.
Educational Value
- Non-blocking timing —
afteraftervs.sleepsleep, a lesson that scales to all async UI. - GUI thread safety — the single-thread rule and why it matters.
- State machines — advancing through questions cleanly.
- Data-driven design — questions as data, loaded from JSON.
Next Steps
- Refactor the timer to
root.afterroot.afterand delete the thread. - Move questions to a JSON bank with multiple categories.
- Add a progress bar and a results screen.
- Persist a leaderboard to disk.
Conclusion
You built a timed quiz, then learned the most important Tkinter lesson hiding inside it: never block or touch widgets off the main thread — root.afterroot.after does timing the right way. With a JSON question bank and a results screen, the same code becomes a real assessment engine. Full source on GitHub. Find more games on Python Central Hub.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
