Basic Quiz Game
Abstract
A quiz app touches everything: UI widgets, state machines, randomization, scoring, navigation, and (with a bit of work) persistent question banks. In this tutorial you will build a Tkinter quiz game on world capitals with 10 multiple-choice questions, then refactor it from hard-coded arrays into a clean QuestionQuestion class, load questions from JSON, add a countdown timer, support multiple categories, and finish with a sketch of a results screen that explains the wrong answers.
You will leave understanding:
- The radio-button + StringVar pattern for multiple-choice input.
- Per-question state vs. global score state.
- Why parallel arrays (
questions[]questions[],options[]options[],answers[]answers[]) are fragile and how to fix it. - How to load a JSON question bank.
- How to build a non-blocking countdown timer with
afterafter.
Prerequisites
- Python 3.6 or above.
- A text editor or IDE.
- Tkinter (ships with Python; Linux:
sudo apt install python3-tksudo apt install python3-tk). - Comfort with Tkinter basics. Calculator GUI is a softer first project.
Getting Started
Create the project
- Create folder
quiz-gamequiz-game. - Inside, create
quizapp.pyquizapp.py.
Write the code
Quiz App
Source# Basic Quiz App
# Importing Modules
from tkinter import *
from tkinter import messagebox
import random
# Creating Root Window
root = Tk()
root.title("Quiz App")
root.geometry("500x500")
root.resizable(0, 0)
root.config(bg="white")
# Creating Variables
score = 0
ques = 1
ques_num = 1
ans = 0
ans_list = []
# Creating Questions
questions = [
"What is the Capital of India?",
"What is the Capital of USA?",
"What is the Capital of UK?",
"What is the Capital of China?",
"What is the Capital of Japan?",
"What is the Capital of Russia?",
"What is the Capital of France?",
"What is the Capital of Germany?",
"What is the Capital of Brazil?",
"What is the Capital of Australia?",
]
# Creating Options
options = [
["New Delhi", "Mumbai", "Kolkata", "Chennai"],
["New York", "Washington DC", "California", "Texas"],
["London", "Manchester", "Liverpool", "Birmingham"],
["Beijing", "Shanghai", "Hong Kong", "Wuhan"],
["Tokyo", "Osaka", "Kyoto", "Yokohama"],
["Moscow", "Saint Petersburg", "Novosibirsk", "Yekaterinburg"],
["Paris", "Lyon", "Marseille", "Toulouse"],
["Berlin", "Hamburg", "Munich", "Cologne"],
["Brasilia", "Rio de Janeiro", "Sao Paulo", "Salvador"],
["Sydney", "Melbourne", "Perth", "Brisbane"],
]
# Creating Answers
answers = [0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
# Creating Functions
def selected():
global ans, radio
global ques_num, ques
global score, ans_list
x = 0
y = 50
if ques_num == 10:
submit.config(text="Finish")
if ans == answers[ques_num - 1]:
score += 1
ans_list.append(1)
else:
ans_list.append(0)
ques_num += 1
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 11:
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
def submit():
global ques_num, score, ans_list
if ques_num == 10:
submit.config(text="Finish")
if ans == answers[ques_num - 1]:
score += 1
ans_list.append(1)
else:
ans_list.append(0)
ques_num += 1
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 11:
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
def back():
global ques_num, score, ans_list
if ques_num == 1:
messagebox.showinfo("Error", "You are at the first question")
else:
ques_num -= 1
score -= ans_list[ques_num - 1]
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 10:
submit.config(text="Finish")
else:
submit.config(text="Next")
def reset():
global ques_num, score, ans_list
ques_num = 1
score = 0
ans_list = []
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
submit.config(text="Next")
def check():
global ans
ans = v.get()
def shuffle():
global questions, options, answers
x = list(zip(questions, options, answers))
random.shuffle(x)
questions, options, answers = zip(*x)
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
def show():
global ques_num, score, ans_list
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
# Creating Labels
ques = Label(root, text=questions[ques_num - 1], font=("Helvetica", 20), bg="white")
ques.place(x=50, y=50)
# Creating Radio Buttons
v = IntVar()
radio = []
for i in range(4):
radio.append(Radiobutton(root, text=options[ques_num - 1][i], font=("Helvetica", 20), bg="white", variable=v, value=i, command=check))
radio[i].place(x=50, y=100 + i * 50)
# Creating Buttons
submit = Button(root, text="Next", font=("Helvetica", 20), bg="white", command=submit)
submit.place(x=50, y=300)
back = Button(root, text="Back", font=("Helvetica", 20), bg="white", command=back)
back.place(x=150, y=300)
reset = Button(root, text="Reset", font=("Helvetica", 20), bg="white", command=reset)
reset.place(x=250, y=300)
shuffle = Button(root, text="Shuffle", font=("Helvetica", 20), bg="white", command=shuffle)
shuffle.place(x=350, y=300)
show = Button(root, text="Show Score", font=("Helvetica", 20), bg="white", command=show)
show.place(x=50, y=400)
# Running Root Window
root.mainloop()# Basic Quiz App
# Importing Modules
from tkinter import *
from tkinter import messagebox
import random
# Creating Root Window
root = Tk()
root.title("Quiz App")
root.geometry("500x500")
root.resizable(0, 0)
root.config(bg="white")
# Creating Variables
score = 0
ques = 1
ques_num = 1
ans = 0
ans_list = []
# Creating Questions
questions = [
"What is the Capital of India?",
"What is the Capital of USA?",
"What is the Capital of UK?",
"What is the Capital of China?",
"What is the Capital of Japan?",
"What is the Capital of Russia?",
"What is the Capital of France?",
"What is the Capital of Germany?",
"What is the Capital of Brazil?",
"What is the Capital of Australia?",
]
# Creating Options
options = [
["New Delhi", "Mumbai", "Kolkata", "Chennai"],
["New York", "Washington DC", "California", "Texas"],
["London", "Manchester", "Liverpool", "Birmingham"],
["Beijing", "Shanghai", "Hong Kong", "Wuhan"],
["Tokyo", "Osaka", "Kyoto", "Yokohama"],
["Moscow", "Saint Petersburg", "Novosibirsk", "Yekaterinburg"],
["Paris", "Lyon", "Marseille", "Toulouse"],
["Berlin", "Hamburg", "Munich", "Cologne"],
["Brasilia", "Rio de Janeiro", "Sao Paulo", "Salvador"],
["Sydney", "Melbourne", "Perth", "Brisbane"],
]
# Creating Answers
answers = [0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
# Creating Functions
def selected():
global ans, radio
global ques_num, ques
global score, ans_list
x = 0
y = 50
if ques_num == 10:
submit.config(text="Finish")
if ans == answers[ques_num - 1]:
score += 1
ans_list.append(1)
else:
ans_list.append(0)
ques_num += 1
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 11:
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
def submit():
global ques_num, score, ans_list
if ques_num == 10:
submit.config(text="Finish")
if ans == answers[ques_num - 1]:
score += 1
ans_list.append(1)
else:
ans_list.append(0)
ques_num += 1
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 11:
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
def back():
global ques_num, score, ans_list
if ques_num == 1:
messagebox.showinfo("Error", "You are at the first question")
else:
ques_num -= 1
score -= ans_list[ques_num - 1]
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
if ques_num == 10:
submit.config(text="Finish")
else:
submit.config(text="Next")
def reset():
global ques_num, score, ans_list
ques_num = 1
score = 0
ans_list = []
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
submit.config(text="Next")
def check():
global ans
ans = v.get()
def shuffle():
global questions, options, answers
x = list(zip(questions, options, answers))
random.shuffle(x)
questions, options, answers = zip(*x)
ques.config(text=questions[ques_num - 1])
for i in range(4):
radio[i].config(text=options[ques_num - 1][i])
def show():
global ques_num, score, ans_list
messagebox.showinfo("Score", f"Your Score is {score}")
root.destroy()
# Creating Labels
ques = Label(root, text=questions[ques_num - 1], font=("Helvetica", 20), bg="white")
ques.place(x=50, y=50)
# Creating Radio Buttons
v = IntVar()
radio = []
for i in range(4):
radio.append(Radiobutton(root, text=options[ques_num - 1][i], font=("Helvetica", 20), bg="white", variable=v, value=i, command=check))
radio[i].place(x=50, y=100 + i * 50)
# Creating Buttons
submit = Button(root, text="Next", font=("Helvetica", 20), bg="white", command=submit)
submit.place(x=50, y=300)
back = Button(root, text="Back", font=("Helvetica", 20), bg="white", command=back)
back.place(x=150, y=300)
reset = Button(root, text="Reset", font=("Helvetica", 20), bg="white", command=reset)
reset.place(x=250, y=300)
shuffle = Button(root, text="Shuffle", font=("Helvetica", 20), bg="white", command=shuffle)
shuffle.place(x=350, y=300)
show = Button(root, text="Show Score", font=("Helvetica", 20), bg="white", command=show)
show.place(x=50, y=400)
# Running Root Window
root.mainloop()Run it
C:\Users\Your Name\quiz-game> python quizapp.py
# Window opens with:
# - Question text at the top
# - 4 radio buttons for choices
# - Next / Back / Shuffle / Reset / Show Score buttonsC:\Users\Your Name\quiz-game> python quizapp.py
# Window opens with:
# - Question text at the top
# - 4 radio buttons for choices
# - Next / Back / Shuffle / Reset / Show Score buttonsStep-by-Step Explanation
1. Imports and root window
from tkinter import *
from tkinter import messagebox
import random
root = Tk()
root.title("Quiz App")
root.geometry("520x420")
root.resizable(False, False)from tkinter import *
from tkinter import messagebox
import random
root = Tk()
root.title("Quiz App")
root.geometry("520x420")
root.resizable(False, False)A fixed-size window keeps the layout predictable.
2. The question bank
questions = [
"What is the Capital of India?",
"What is the Capital of USA?",
# … 8 more
]
options = [
["New Delhi", "Mumbai", "Kolkata", "Chennai"],
["New York", "Washington DC", "California", "Texas"],
# … 8 more lists
]
answers = [0, 1, 0, 0, 4, 0, 0, 1, 0, 0] # index of the correct option per questionquestions = [
"What is the Capital of India?",
"What is the Capital of USA?",
# … 8 more
]
options = [
["New Delhi", "Mumbai", "Kolkata", "Chennai"],
["New York", "Washington DC", "California", "Texas"],
# … 8 more lists
]
answers = [0, 1, 0, 0, 4, 0, 0, 1, 0, 0] # index of the correct option per questionThis parallel-arrays layout works but is fragile — changing the order of one list without the others silently corrupts the quiz. The refactor below fixes it.
3. State variables
selected = IntVar(value=-1)
current = 0
score = 0selected = IntVar(value=-1)
current = 0
score = 0IntVarIntVar is Tkinter’s wrapper that lets the same value drive multiple widgets at once — radio buttons share it.
4. UI widgets
question_lbl = Label(root, text=questions[current],
font=("Arial", 16, "bold"), wraplength=480, pady=10)
question_lbl.pack(pady=10)
radios = []
for i in range(4):
rb = Radiobutton(root, text=options[current][i], variable=selected,
value=i, font=("Arial", 12), anchor="w", padx=20)
rb.pack(fill="x")
radios.append(rb)question_lbl = Label(root, text=questions[current],
font=("Arial", 16, "bold"), wraplength=480, pady=10)
question_lbl.pack(pady=10)
radios = []
for i in range(4):
rb = Radiobutton(root, text=options[current][i], variable=selected,
value=i, font=("Arial", 12), anchor="w", padx=20)
rb.pack(fill="x")
radios.append(rb)All four radios share selectedselected. Whichever one the user clicks sets selected.get()selected.get() to that radio’s value=value=.
5. Navigation
def show_question(idx):
question_lbl.config(text=questions[idx])
for i, rb in enumerate(radios):
rb.config(text=options[idx][i])
selected.set(-1) # clear selection
def next_q():
global current, score
if selected.get() == -1:
return messagebox.showwarning("Pick one", "Select an option first.")
if selected.get() == answers[current]:
score += 1
current += 1
if current >= len(questions):
messagebox.showinfo("Done", f"Your score: {score}/{len(questions)}")
root.destroy()
return
show_question(current)
def shuffle():
global current, score
combined = list(zip(questions, options, answers))
random.shuffle(combined)
for i, (q, o, a) in enumerate(combined):
questions[i], options[i], answers[i] = q, o, a
current = 0; score = 0
show_question(current)def show_question(idx):
question_lbl.config(text=questions[idx])
for i, rb in enumerate(radios):
rb.config(text=options[idx][i])
selected.set(-1) # clear selection
def next_q():
global current, score
if selected.get() == -1:
return messagebox.showwarning("Pick one", "Select an option first.")
if selected.get() == answers[current]:
score += 1
current += 1
if current >= len(questions):
messagebox.showinfo("Done", f"Your score: {score}/{len(questions)}")
root.destroy()
return
show_question(current)
def shuffle():
global current, score
combined = list(zip(questions, options, answers))
random.shuffle(combined)
for i, (q, o, a) in enumerate(combined):
questions[i], options[i], answers[i] = q, o, a
current = 0; score = 0
show_question(current)zip(questions, options, answers)zip(questions, options, answers) is the cleanest way to keep the parallel arrays in sync during a shuffle — pair them up, shuffle once, unzip back.
6. Wire the buttons
Button(root, text="Next", command=next_q).pack(side="left", padx=10, pady=20)
Button(root, text="Shuffle", command=shuffle).pack(side="left", padx=10, pady=20)
Button(root, text="Quit", command=root.destroy).pack(side="right", padx=10, pady=20)
root.mainloop()Button(root, text="Next", command=next_q).pack(side="left", padx=10, pady=20)
Button(root, text="Shuffle", command=shuffle).pack(side="left", padx=10, pady=20)
Button(root, text="Quit", command=root.destroy).pack(side="right", padx=10, pady=20)
root.mainloop()Refactor: One Class Per Question
Parallel arrays are an anti-pattern. The fix:
from dataclasses import dataclass
@dataclass
class Question:
text: str
options: list[str]
correct: int # index into options
explanation: str = "" # shown on the results screen
BANK = [
Question("What is the capital of India?",
["New Delhi", "Mumbai", "Kolkata", "Chennai"], 0,
"New Delhi has been India's capital since 1911."),
Question("What is the capital of USA?",
["New York", "Washington DC", "Chicago", "Los Angeles"], 1,
"Washington, D.C. was founded as the capital in 1790."),
# …
]from dataclasses import dataclass
@dataclass
class Question:
text: str
options: list[str]
correct: int # index into options
explanation: str = "" # shown on the results screen
BANK = [
Question("What is the capital of India?",
["New Delhi", "Mumbai", "Kolkata", "Chennai"], 0,
"New Delhi has been India's capital since 1911."),
Question("What is the capital of USA?",
["New York", "Washington DC", "Chicago", "Los Angeles"], 1,
"Washington, D.C. was founded as the capital in 1790."),
# …
]Now each question owns its data. Shuffling is random.shuffle(BANK)random.shuffle(BANK). Adding an explanationexplanation to display at the end is a one-line addition.
Load from JSON
[
{"text": "Capital of India?", "options": ["New Delhi","Mumbai","Kolkata","Chennai"], "correct": 0, "explanation": "..."},
…
][
{"text": "Capital of India?", "options": ["New Delhi","Mumbai","Kolkata","Chennai"], "correct": 0, "explanation": "..."},
…
]import json
from pathlib import Path
BANK = [Question(**d) for d in json.loads(Path("bank.json").read_text())]import json
from pathlib import Path
BANK = [Question(**d) for d in json.loads(Path("bank.json").read_text())]Non-programmers can now author quizzes by editing the JSON file. Multi-category support is one folder of JSON files.
Add a Per-Question Timer
remaining = 0
def start_timer(seconds=15):
global remaining
remaining = seconds
tick()
def tick():
timer_lbl.config(text=f"{remaining}s")
if remaining <= 0:
next_q() # auto-advance
return
root.after(1000, decrement)
def decrement():
global remaining
remaining -= 1
tick()remaining = 0
def start_timer(seconds=15):
global remaining
remaining = seconds
tick()
def tick():
timer_lbl.config(text=f"{remaining}s")
if remaining <= 0:
next_q() # auto-advance
return
root.after(1000, decrement)
def decrement():
global remaining
remaining -= 1
tick()Call start_timer()start_timer() whenever show_questionshow_question runs. The user has 15 seconds before the answer locks in.
Results Screen
Instead of a popup, end on a result page that shows what they got right and wrong:
def show_results():
win = Toplevel(root)
win.title("Results")
Label(win, text=f"Score: {score}/{len(BANK)}",
font=("Arial", 18, "bold")).pack(pady=10)
for i, q in enumerate(BANK):
correct = q.options[q.correct]
yours = q.options[answers_given[i]] if answers_given[i] >= 0 else "—"
mark = "✅" if answers_given[i] == q.correct else "❌"
Label(win, text=f"{mark} {q.text}\n You: {yours} Correct: {correct}\n {q.explanation}",
wraplength=600, justify="left").pack(anchor="w", padx=10)def show_results():
win = Toplevel(root)
win.title("Results")
Label(win, text=f"Score: {score}/{len(BANK)}",
font=("Arial", 18, "bold")).pack(pady=10)
for i, q in enumerate(BANK):
correct = q.options[q.correct]
yours = q.options[answers_given[i]] if answers_given[i] >= 0 else "—"
mark = "✅" if answers_given[i] == q.correct else "❌"
Label(win, text=f"{mark} {q.text}\n You: {yours} Correct: {correct}\n {q.explanation}",
wraplength=600, justify="left").pack(anchor="w", padx=10)Educational quizzes live or die on the feedback — a results screen that explains wrong answers turns the quiz into a teaching tool.
Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
| Quiz hangs at last question | Off-by-one — current > lencurrent > len after submit | Check >= len(questions)>= len(questions) |
| Same question shown twice | Manually tracked index slipped | Use enumerateenumerate + a single source list |
| Shuffle desyncs the answer | Shuffled questionsquestions but not optionsoptions and answersanswers | Use the zipzip + shuffleshuffle pattern, or QuestionQuestion objects |
IntVar.get()IntVar.get() returns 0 with nothing selected | Default is 0, indistinguishable from “chose option 0” | Initialize to -1-1 and treat as “unanswered” |
| Timer fires after window closed | Pending afterafter callback | Track and cancel with root.after_cancel(id)root.after_cancel(id) |
| Score wrong after “Back” | Decrementing not implemented | Either disable Back, or recompute score from a list |
Variations to Try
1. Multiple categories
A category-picker screen. JSON folder structure:
banks/
geography.json
science.json
history.jsonbanks/
geography.json
science.json
history.json2. Difficulty levels
Add difficulty: intdifficulty: int (1-5) to each QuestionQuestion. Pick a subset of questions matching the user’s chosen level.
3. Image questions
Each QuestionQuestion can have an image_pathimage_path. Display it with tk.PhotoImagetk.PhotoImage above the text.
4. Audio questions
Useful for language quizzes — play a .wav.wav clip and ask “what did the voice say?“. See Basic Music Player.
5. Score persistence
Save (timestamp, category, score)(timestamp, category, score) rows to a JSON file. Show a “high scores” leaderboard at startup.
6. Online quiz bank
Pull questions from opentdb.com (free trivia API).
7. Time per question
Award more points for faster correct answers. Penalize wrong answers under time pressure to discourage random guessing.
8. Adaptive difficulty
After two correct answers in a row, jump up a difficulty level; after two wrong, drop down. Classic “spaced repetition” lite.
9. Multiplayer
Two players take turns on the same screen, or split-screen with separate scores.
10. Quiz editor
A second Tkinter window where instructors author new questions and save them to the JSON bank.
11. Web version
Flask + Jinja (see Basic Web Server). State lives in the session.
12. Mobile version
Re-implement in Kivy (or wrap the web version as a PWA) to ship to phones.
Real-World Applications
- Education — flashcard apps, language learning (Duolingo-style).
- Corporate training — compliance and onboarding quizzes.
- Certification practice — AWS / Cisco / Microsoft exam prep.
- Trivia games — bar trivia apps, party games.
- Self-assessment — personality, aptitude, skill measurement.
Educational Value
- Tkinter widgets — Radiobutton + IntVar.
- State management — current question, score, selected option.
- Why objects beat parallel arrays — a textbook refactor.
- Persistence — JSON-backed question banks.
- UX of feedback — results screens vs. just a number.
Next Steps
- Refactor to the
QuestionQuestiondataclass above. - Move questions to a JSON file; support multiple categories.
- Add a per-question timer.
- Build a results screen with explanations.
- Persist high scores to disk.
- Try the Open Trivia DB integration for endless content.
Conclusion
You built a working quiz app, fixed its biggest weakness (parallel arrays), and have a path from “hard-coded ten capitals” to “multi-category, timed, leaderboard-driven trivia engine.” The exact same architecture powers commercial flashcard apps and corporate training systems. 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
