Unit 14 Project: Build a Tiny Quiz App

One set of rules, two different faces

Advanced 18 min

In this lesson

The project that proves the point this unit has been making. You will build a quiz engine that knows nothing about screens, then attach two completely different interfaces to it without changing a line of the rules.

That is not an academic exercise. It is why the logic in every lesson here could be tested in your browser while the windows had to run on your machine.

Explain it like I’m 5

Write the rules of the quiz once, then put whatever face you like on the front — a terminal, a window, or a web page.

Questions are data, not code

The first decision decides everything else: the questions are a list of dictionaries, not a series of if statements.

Data means adding a question is one entry, the quiz can be loaded from a JSON file with what you learned in Unit 7, and the grading code never changes however many questions there are. Questions as code means editing the program every time.

Example
QUESTIONS = [
    {"prompt": "What does len() return?", "options": ["length", "type"], "answer": 0},
    {"prompt": "Which starts a loop?",     "options": ["for", "def"],    "answer": 0},
    {"prompt": "What is 2 ** 3?",          "options": ["6", "8"],        "answer": 1},
]

def grade(answers):
    """How many of the given answers are right?"""
    score = 0
    for question, given in zip(QUESTIONS, answers):
        if given == question["answer"]:
            score += 1
    return score

def report(score, total):
    """Turn a score into a sentence."""
    percent = round(100 * score / total)
    verdict = "pass" if percent >= 60 else "try again"
    return f"{score}/{total} ({percent}%) - {verdict}"

print(report(grade([0, 0, 1]), len(QUESTIONS)))
print(report(grade([0, 1, 0]), len(QUESTIONS)))
print(report(grade([1, 1, 0]), len(QUESTIONS)))
Output
3/3 (100%) - pass
1/3 (33%) - try again
0/3 (0%) - try again
The entire quiz engine. No screen, no input, no toolkit.

Face one: the terminal

Now the first interface. Every line of it is presentation, and none of it is quiz rules.

Example · quiz_terminal.py
def ask(question):
    """Show one question and return the chosen option number."""
    print()
    print(question["prompt"])
    for number, option in enumerate(question["options"]):
        print(f"  {number}) {option}")

    while True:
        raw = input("your answer: ")
        try:
            choice = int(raw)
        except ValueError:
            print("please type a number")
            continue
        if 0 <= choice < len(question["options"]):
            return choice
        print("that is not one of the options")


def run_quiz():
    answers = [ask(question) for question in QUESTIONS]
    print()
    print(report(grade(answers), len(QUESTIONS)))


if __name__ == "__main__":
    run_quiz()
Output
# What does len() return?
#   0) length
#   1) type
# your answer: 0
# ... and so on, ending with:
# 3/3 (100%) - pass
The terminal front end. Run locally with: python quiz_terminal.py

Face two: a window

Now the same engine behind Tkinter. Compare it to the terminal version: the widgets and the callback are entirely different, and grade and report are called in exactly the same way.

Example · quiz_window.py
import tkinter as tk

state = {"index": 0, "answers": []}


def choose(option_number):
    """Record an answer and move on, or finish the quiz."""
    state["answers"].append(option_number)
    state["index"] += 1

    if state["index"] >= len(QUESTIONS):
        score = grade(state["answers"])
        prompt_label.config(text=report(score, len(QUESTIONS)))
        for button in option_buttons:
            button.grid_remove()
        return

    show_question()


def show_question():
    question = QUESTIONS[state["index"]]
    prompt_label.config(text=question["prompt"])
    for button, option in zip(option_buttons, question["options"]):
        button.config(text=option)


root = tk.Tk()
root.title("Quiz")

prompt_label = tk.Label(root, text="", wraplength=300, pady=12)
prompt_label.grid(row=0, column=0, columnspan=2, padx=12)

option_buttons = []
for position in range(2):
    button = tk.Button(root, text="", width=14,
                       command=lambda n=position: choose(n))
    button.grid(row=1, column=position, padx=8, pady=12)
    option_buttons.append(button)

show_question()
root.mainloop()
Output
# A window shows one question with two option buttons.
# Clicking an option records it and shows the next question.
# After the last one the buttons disappear and the label reads:
# 3/3 (100%) - pass
The same quiz, as a desktop app. Run locally with: python quiz_window.py

Write the engine both interfaces depend on. grade(answers) counts how many chosen option numbers match each question's answer, and report(score, total) formats the result as "2/2 (100%)" with the percentage rounded to a whole number.

QUESTIONS = [
    {"prompt": "Capital of France?", "options": ["Paris", "Rome"], "answer": 0},
    {"prompt": "2 + 2?",             "options": ["3", "4"],        "answer": 1},
]

def grade(answers):
    # TODO: count how many answers match their question's "answer"
    return 0

def report(score, total):
    # TODO: return "score/total (percent%)" with percent rounded
    return ""

print(report(grade([0, 1]), len(QUESTIONS)))
print(report(grade([1, 1]), len(QUESTIONS)))
print("questions:", len(QUESTIONS))

Take it further

The structure you now have makes each of these a small change rather than a rewrite:

  • Load the questions from a file. Move QUESTIONS into a JSON file and read it with json.load() from Unit 7. Anyone can then write a quiz without touching Python.
  • Save the scores. Append each result to the SQLite database from Unit 8 and show a personal best.
  • Put it on the web. Add a Flask route from Unit 6 that serves the questions and grades the answers, a third face on the same unchanged engine.
  • Test the engine. grade and report are pure functions, so the Unit 5 testing habits apply directly. Try an empty answer list and a perfect score.

That last one is the quiet argument for this whole structure. A quiz written as one long loop of input() and print() cannot be tested at all without a person sitting there typing.

Common mistake: Writing the questions as if/elif branches

Why it happens:

The first question works fine that way, and so does the second.

How to fix it:

Keep them as data. Then adding a question is one entry and the grading code never changes.

Common mistake: Mixing grading into the display code

Why it happens:

Checking the answer right where it is entered feels direct.

How to fix it:

Keep the rules in functions that return values. Otherwise a second interface means rewriting the rules.

Common mistake: Storing the correct answer as text

Why it happens:

Comparing the words seems clearer than comparing positions.

How to fix it:

Store the option's index. Text comparisons break on a rewording, a stray space, or different capitalization.

Common mistake: Building callbacks in a loop without capturing the variable

Why it happens:

command=lambda: choose(position) looks obviously right.

How to fix it:

Every button ends up using the loop's final value. Capture it with a default: lambda n=position: choose(n).

Why store the questions as a list of dictionaries?

Why keep grade() and report() separate from the interface?

Why does the correct answer store a position rather than the option text?

Mini exercise (hard)

Add per-question feedback to the engine, the thing every good quiz shows at the end. Write review(answers) returning a list of (prompt, correct) pairs saying which questions were right, and wrong_questions(answers) returning just the prompts that were missed, so a learner can see what to study.

Have a go. Finish the code and press Run to see the result immediately, right on this page.

QUESTIONS = [
    {"prompt": "Capital of France?", "options": ["Paris", "Rome"], "answer": 0},
    {"prompt": "2 + 2?",             "options": ["3", "4"],        "answer": 1},
    {"prompt": "Which is a loop?",   "options": ["for", "if"],     "answer": 0},
]

def review(answers):
    """Return (prompt, correct) for each answered question."""
    return []   # TODO

def wrong_questions(answers):
    """Return just the prompts that were answered incorrectly."""
    return []   # TODO: build this on top of review()

given = [0, 0, 1]
for prompt, correct in review(given):
    print(f"{'ok  ' if correct else 'wrong'} {prompt}")

print("to study:", wrong_questions(given))

What to learn next

You built a quiz engine that knows nothing about screens: questions as data, grade and report as pure functions, and then drove it from both a terminal loop and a Tkinter window without changing a line of the rules. You also met the loop-and-lambda trap that makes every button do the same thing.

That completes Unit 14, and with it the building. You can now write programs that fetch, store, analyze, predict and respond. What is left is everything around the code: Unit 15 covers reviewing what you know, debugging, testing, style, packaging, CI and shipping, turning a script into something other people can install and trust. When you’re ready, keep building.