Skip to content

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

  1. Create folder quiz-gamequiz-game.
  2. Inside, create quizapp.pyquizapp.py.

Write the code

Quiz AppSource
Quiz App
# 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()
Quiz App
# 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

command
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 buttons
command
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 buttons

Step-by-Step Explanation

1. Imports and root window

quizapp.py
from tkinter import *
from tkinter import messagebox
import random
 
root = Tk()
root.title("Quiz App")
root.geometry("520x420")
root.resizable(False, False)
quizapp.py
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

quizapp.py
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 question
quizapp.py
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 question

This 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

quizapp.py
selected = IntVar(value=-1)
current = 0
score = 0
quizapp.py
selected = IntVar(value=-1)
current = 0
score = 0

IntVarIntVar is Tkinter’s wrapper that lets the same value drive multiple widgets at once — radio buttons share it.

4. UI widgets

quizapp.py
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)
quizapp.py
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

quizapp.py
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)
quizapp.py
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

quizapp.py
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()
quizapp.py
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:

oop.py
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."),
    # …
]
oop.py
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

bank.json
[
  {"text": "Capital of India?", "options": ["New Delhi","Mumbai","Kolkata","Chennai"], "correct": 0, "explanation": "..."},

]
bank.json
[
  {"text": "Capital of India?", "options": ["New Delhi","Mumbai","Kolkata","Chennai"], "correct": 0, "explanation": "..."},

]
load.py
import json
from pathlib import Path
BANK = [Question(**d) for d in json.loads(Path("bank.json").read_text())]
load.py
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

timer.py
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()
timer.py
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:

results.py
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)
results.py
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

ProblemCauseFix
Quiz hangs at last questionOff-by-one — current > lencurrent > len after submitCheck >= len(questions)>= len(questions)
Same question shown twiceManually tracked index slippedUse enumerateenumerate + a single source list
Shuffle desyncs the answerShuffled questionsquestions but not optionsoptions and answersanswersUse the zipzip + shuffleshuffle pattern, or QuestionQuestion objects
IntVar.get()IntVar.get() returns 0 with nothing selectedDefault is 0, indistinguishable from “chose option 0”Initialize to -1-1 and treat as “unanswered”
Timer fires after window closedPending afterafter callbackTrack and cancel with root.after_cancel(id)root.after_cancel(id)
Score wrong after “Back”Decrementing not implementedEither disable Back, or recompute score from a list

Variations to Try

1. Multiple categories

A category-picker screen. JSON folder structure:

text
banks/
  geography.json
  science.json
  history.json
text
banks/
  geography.json
  science.json
  history.json

2. 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 QuestionQuestion dataclass 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 coffee

Was this page helpful?

Let us know how we did