LearnAI ToolsCareerPractice BuildsPlayContact
Python ProgrammingBeginner~1.5 hours

Quiz Game

Build a multiple-choice quiz app with scoring using OOP principles.

OOPDictionariesConditionals

Overview

A multiple-choice quiz is a small, self-contained way to see object-oriented programming solve a real problem instead of feeling like ceremony around a function. A question is not just a string — it is a prompt, a set of labeled choices, and a correct answer, all of which belong together, and asking one is behavior that belongs on the same object as that data. That is the whole case for a `Question` class: bundling `text`, `choices`, and `correct_answer` together, plus an `ask()` method that knows how to present itself, is more honest about what a question actually is than four separate parallel lists ever could be.

By the end of this tutorial you will have a console quiz built around two classes: `Question`, which represents one multiple-choice question and knows how to ask itself and check an answer, and `Quiz`, which owns a list of `Question` objects, runs them in order, and keeps score. Neither class needs to know anything about how the other is implemented internally — `Quiz` only ever calls `question.ask()` and `question.is_correct(answer)`, which is exactly the kind of clean boundary encapsulation is meant to produce.

What You'll Build
  • A `Question` class holding question text, a dict of lettered choices, and the correct answer.
  • An `ask()` method that prints a question and its choices and returns the user's answer.
  • An `is_correct()` method that checks an answer against the stored correct answer.
  • A `Quiz` class that owns a list of `Question` objects and tracks a running score.
  • A `run()` method that asks every question in order and a `show_results()` method that prints the final score.

Prerequisites

  • OOP basics — defining a `class` with `__init__`, instance attributes (`self.x`), and methods.
  • Dictionaries — modeling a question's lettered choices as `{"A": "...", "B": "...", ...}`.
  • Conditionals — comparing the user's answer to the correct one and branching on the result.
  • Loops — `for` with `enumerate()` to number questions as they are asked.
  • String formatting — f-strings for printing questions, choices, and the final score.

Project Structure

The whole program lives in one file, `quiz.py`, with two classes doing all the work. `Question` is deliberately self-contained — it never touches `Quiz`, never knows how many other questions exist, and never tracks score. `Quiz` is the layer above it: it owns a `list` of `Question` objects passed in at construction time, loops over them one at a time in `run()`, and is the only place `score` is ever read or changed.

Each question's `choices` are stored as a `dict` mapping a letter key (`"A"`, `"B"`, `"C"`, `"D"`) to the choice text, rather than as a plain list. That choice matters: a dict preserves insertion order in modern Python, so choices always print in the order they were defined, and the same letter the user types back (`"A"`) is exactly the key used to check correctness — there is no separate step converting a list index into a letter or back.

Step 1: Define the Question Class

`ask()` prints the question and every choice, reads the user's answer, and normalizes it with `.strip().upper()` so that `"a"`, `"A"`, and `" a "` are all treated identically. `is_correct()` is intentionally a separate method from `ask()` rather than folded into it — `Quiz.run()` in Step 2 needs the raw answer for both checking correctness and, on a wrong answer, telling the user what the right one was.

class Question:
"""Represents one multiple-choice question: its text, its choices, and the correct answer."""
def __init__(self, text, choices, correct_answer):
self.text = text # the question prompt shown to the user
self.choices = choices # dict like {"A": "Paris", "B": "Rome", "C": "Berlin", "D": "Madrid"}
self.correct_answer = correct_answer # the key (e.g. "A") that is correct, not the full answer text
def ask(self):
"""Print the question and its choices, then return the user's chosen letter."""
print(f"\n{self.text}")
for letter, choice_text in self.choices.items(): # dict preserves insertion order, so choices print A, B, C, D in order
print(f" {letter}. {choice_text}")
answer = input("Your answer: ").strip().upper() # normalize so "a" and " a " both count as "A"
return answer
def is_correct(self, answer):
"""Return True if the given answer letter matches this question's correct answer."""
return answer == self.correct_answer

Step 2: Define the Quiz Class

`Quiz.__init__` stores the list of questions it was given and starts `score` at zero — `Quiz` never builds its own questions, it is only ever handed a ready-made list, which keeps question content and quiz-running logic cleanly separated. `run()` is where the two classes actually meet: it calls `question.ask()` to get an answer and `question.is_correct(answer)` to grade it, without ever looking inside `Question` at its `choices` dict directly.

class Quiz:
"""Owns a list of Question objects, runs them in order, and tracks the score."""
def __init__(self, questions):
self.questions = questions # list of Question objects, built by the caller in Step 4
self.score = 0 # Quiz is the only class that ever reads or changes this
def run(self):
"""Ask every question in order, updating self.score as the user answers."""
for i, question in enumerate(self.questions, start=1): # start=1 so questions are announced "1 of 4", not "0 of 4"
print(f"\nQuestion {i} of {len(self.questions)}")
answer = question.ask()
if question.is_correct(answer):
print("Correct!")
self.score += 1
else:
print(f"Incorrect. The correct answer was {question.correct_answer}.")
Example Usage

Click Run to see what this code prints.

Step 3: Show Final Results

`show_results()` is added as another method on `Quiz`, called once after `run()` finishes. The `if total > 0 else 0` guard exists so an empty quiz (an empty `questions` list) prints a score of 0% instead of crashing on a division by zero — a small edge case, but one that costs nothing to handle correctly.

def show_results(self):
"""Print the final score as a raw count and a percentage."""
total = len(self.questions)
percentage = (self.score / total) * 100 if total > 0 else 0 # guards against dividing by zero on an empty quiz
print(f"\nYou scored {self.score} out of {total} ({percentage:.1f}%)")

This method is indented at the same level as `__init__` and `run()` above it, meaning it belongs inside the `Quiz` class body — the Complete Code section below shows the full class with all three methods assembled together in one place.

Step 4: Build the Question Bank

`build_sample_questions()` is a plain function, not a method on either class, because building the specific set of questions for this quiz is a separate concern from how questions or quizzes behave in general — swapping in an entirely different question bank later means replacing this one function, and nothing else in the program needs to change.

def build_sample_questions():
"""Return the list of Question objects that make up this quiz's question bank."""
return [
Question(
"What does OOP stand for?",
{"A": "Object-Oriented Programming", "B": "Only One Program",
"C": "Output Of Process", "D": "Ordered Object Pattern"},
"A",
),
Question(
"Which keyword defines a class in Python?",
{"A": "function", "B": "class", "C": "def", "D": "object"},
"B",
),
Question(
"What data type is {'A': 'Paris'}?",
{"A": "list", "B": "tuple", "C": "dict", "D": "set"},
"C",
),
Question(
"Which special method is automatically called when you create a new object?",
{"A": "__init__", "B": "__main__", "C": "__new__", "D": "__create__"},
"A",
),
]

Step 5: Build the Entry Point

`main()` is short by design: build the questions, construct a `Quiz` around them, run it, and show the results. Every interesting decision — how a question asks itself, how an answer is graded, how the score is tallied — already lives inside `Question` and `Quiz`, which is the payoff of building those classes carefully in the earlier steps.

def main():
quiz = Quiz(build_sample_questions())
quiz.run()
quiz.show_results()
if __name__ == "__main__":
main()

Complete Code

Here is the full program with both classes assembled, ready to run with `python quiz.py`.

class Question:
"""Represents one multiple-choice question: its text, its choices, and the correct answer."""
def __init__(self, text, choices, correct_answer):
self.text = text
self.choices = choices
self.correct_answer = correct_answer
def ask(self):
"""Print the question and its choices, then return the user's chosen letter."""
print(f"\n{self.text}")
for letter, choice_text in self.choices.items():
print(f" {letter}. {choice_text}")
answer = input("Your answer: ").strip().upper()
return answer
def is_correct(self, answer):
"""Return True if the given answer letter matches this question's correct answer."""
return answer == self.correct_answer
class Quiz:
"""Owns a list of Question objects, runs them in order, and tracks the score."""
def __init__(self, questions):
self.questions = questions
self.score = 0
def run(self):
"""Ask every question in order, updating self.score as the user answers."""
for i, question in enumerate(self.questions, start=1):
print(f"\nQuestion {i} of {len(self.questions)}")
answer = question.ask()
if question.is_correct(answer):
print("Correct!")
self.score += 1
else:
print(f"Incorrect. The correct answer was {question.correct_answer}.")
def show_results(self):
"""Print the final score as a raw count and a percentage."""
total = len(self.questions)
percentage = (self.score / total) * 100 if total > 0 else 0
print(f"\nYou scored {self.score} out of {total} ({percentage:.1f}%)")
def build_sample_questions():
"""Return the list of Question objects that make up this quiz's question bank."""
return [
Question(
"What does OOP stand for?",
{"A": "Object-Oriented Programming", "B": "Only One Program",
"C": "Output Of Process", "D": "Ordered Object Pattern"},
"A",
),
Question(
"Which keyword defines a class in Python?",
{"A": "function", "B": "class", "C": "def", "D": "object"},
"B",
),
Question(
"What data type is {'A': 'Paris'}?",
{"A": "list", "B": "tuple", "C": "dict", "D": "set"},
"C",
),
Question(
"Which special method is automatically called when you create a new object?",
{"A": "__init__", "B": "__main__", "C": "__new__", "D": "__create__"},
"A",
),
]
def main():
quiz = Quiz(build_sample_questions())
quiz.run()
quiz.show_results()
if __name__ == "__main__":
main()

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Load questions from a JSON file instead of hardcoding them, reusing the load/save pattern from the Todo List project.
  • Add a `Timer` that limits each question to a set number of seconds using the `time` module.
  • Add difficulty levels by giving `Question` a `points` attribute so harder questions are worth more.
  • Shuffle both the question order and each question's choices with `random.shuffle()` before each run.
  • Track high scores across multiple runs by saving each `show_results()` outcome to a small history file.

Summary

You built a quiz game where `Question` and `Quiz` each own exactly one responsibility: a `Question` knows how to present itself and grade one answer, and a `Quiz` knows how to run a list of them and keep score, without either class reaching into the other's internal data. That division of responsibility is the real lesson OOP is trying to teach beyond syntax — once a class's data and the behavior that uses it live together, the rest of the program only ever needs to know what a class can do, never how it does it.