Unit 14 Project: Build a Tiny Quiz App
One set of rules, two different faces
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.
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)))
3/3 (100%) - pass 1/3 (33%) - try again 0/3 (0%) - try again
Face one: the terminal
Now the first interface. Every line of it is presentation, and none of it is quiz rules.
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()
# What does len() return? # 0) length # 1) type # your answer: 0 # ... and so on, ending with: # 3/3 (100%) - pass
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.
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()
# 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
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))
Pair them with zip(QUESTIONS, answers) and count matches: sum(1 for q, a in zip(...) if a == q["answer"]). For the report, percent = round(100 * score / total) then an f-string. Remember to escape nothing, just write % after the number.
QUESTIONS = [
{"prompt": "Capital of France?", "options": ["Paris", "Rome"], "answer": 0},
{"prompt": "2 + 2?", "options": ["3", "4"], "answer": 1},
]
def grade(answers):
return sum(1 for q, a in zip(QUESTIONS, answers) if a == q["answer"])
def report(score, total):
percent = round(100 * score / total)
return f"{score}/{total} ({percent}%)"
print(report(grade([0, 1]), len(QUESTIONS)))
print(report(grade([1, 1]), len(QUESTIONS)))
print("questions:", len(QUESTIONS))
2/2 (100%)
1/2 (50%)
questions: 2
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
QUESTIONSinto a JSON file and read it withjson.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.
gradeandreportare 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
The first question works fine that way, and so does the second.
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
Checking the answer right where it is entered feels direct.
Keep the rules in functions that return values. Otherwise a second interface means rewriting the rules.
Common mistake: Storing the correct answer as text
Comparing the words seems clearer than comparing positions.
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
command=lambda: choose(position) looks obviously right.
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?
The grading code stays identical however many questions there are, and the quiz can be loaded from a file.
Why keep grade() and report() separate from the interface?
The project attaches two completely different front ends to one unchanged engine.
Why does the correct answer store a position rather than the option text?
Comparing text breaks on a rename, a stray space, or a capitalization change.
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))
Both walk zip(QUESTIONS, answers). review builds (q["prompt"], a == q["answer"]) for each pair; wrong_questions can then filter the result of review rather than repeating the comparison.
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 [(q["prompt"], a == q["answer"])
for q, a in zip(QUESTIONS, answers)]
def wrong_questions(answers):
"""Return just the prompts that were answered incorrectly."""
return [prompt for prompt, correct in review(answers) if not correct]
given = [0, 0, 1]
for prompt, correct in review(given):
print(f"{'ok ' if correct else 'wrong'} {prompt}")
print("to study:", wrong_questions(given))
ok Capital of France?
wrong 2 + 2?
wrong Which is a loop?
to study: ['2 + 2?', 'Which is a loop?']
assert review([]) == [], "no answers means nothing to review"
assert review([0])[0] == ("Capital of France?", True), "the first answer is correct"
assert wrong_questions([0, 1, 0]) == [], "a perfect score leaves nothing to study"
assert len(wrong_questions([1, 0, 1])) == 3, "all three wrong should list all three"
print("✓ Looks good!")