Skip to content

Basic Quiz Game

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 Question 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[], options[], answers[]) are fragile and how to fix it.
  • How to load a JSON question bank.
  • How to build a non-blocking countdown timer with after.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Tkinter (ships with Python; Linux: sudo apt install python3-tk).
  • Comfort with Tkinter basics. Calculator GUI is a softer first project.
  1. Create folder quiz-game.
  2. Inside, create quizapp.py.
Quiz App pch.viewSource
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()
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

Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.

diagram Diagram mermaid
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.

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.

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

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

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 selected. Whichever one the user clicks sets selected.get() to that radio’s value=.

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) is the cleanest way to keep the parallel arrays in sync during a shuffle — pair them up, shuffle once, unzip back.

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()

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

Now each question owns its data. Shuffling is random.shuffle(BANK). Adding an explanation to display at the end is a one-line addition.

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())]

Non-programmers can now author quizzes by editing the JSON file. Multi-category support is one folder of JSON files.

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() whenever show_question runs. The user has 15 seconds before the answer locks in.

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)

Educational quizzes live or die on the feedback — a results screen that explains wrong answers turns the quiz into a teaching tool.

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

A category-picker screen. JSON folder structure:

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

Add difficulty: int (1-5) to each Question. Pick a subset of questions matching the user’s chosen level.

Each Question can have an image_path. Display it with tk.PhotoImage above the text.

Useful for language quizzes — play a .wav clip and ask “what did the voice say?”. See Basic Music Player.

Save (timestamp, category, score) rows to a JSON file. Show a “high scores” leaderboard at startup.

Pull questions from opentdb.com (free trivia API).

Award more points for faster correct answers. Penalize wrong answers under time pressure to discourage random guessing.

After two correct answers in a row, jump up a difficulty level; after two wrong, drop down. Classic “spaced repetition” lite.

Two players take turns on the same screen, or split-screen with separate scores.

A second Tkinter window where instructors author new questions and save them to the JSON bank.

Flask + Jinja (see Basic Web Server). State lives in the session.

Re-implement in Kivy (or wrap the web version as a PWA) to ship to phones.

  • 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.
  • 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.
  • Refactor to the Question 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.

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.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading