PythonMastery
intermediate 30 min read · lesson 8 of 15 in Projects

Project: Quiz Game (Your First Classes)

1 · The lesson

read

You'll build a quiz game with multiple-choice questions, a score, feedback after each answer and a summary at the end. Along the way, you'll write your first Python classes.

This project connects "Fundamentals" to "Intermediate". You've already done a lot with lists, dicts and functions. Classes let you keep data and the functions that work on that data together in one place. When you see why that is useful, OOP becomes much easier to understand.

What you'll practise: classes, __init__, methods, self, lists of objects, state management, JSON.


Step 1: The Procedural Version (No Classes Yet)

Start with what you already know.

python
import random

questions = [
    {"text": "Which keyword defines a function?", "choices": ["def", "func", "fn"], "answer": 0},
    {"text": "What does `len([1,2,3])` return?", "choices": ["2", "3", "4"], "answer": 1},
    {"text": "Which is mutable?", "choices": ["tuple", "string", "list"], "answer": 2},
]

score = 0
for i, q in enumerate(questions, start=1):
    print(f"\nQ{i}: {q['text']}")
    for j, c in enumerate(q['choices']):
        print(f"  {j}) {c}")

    user_choice = q['answer']             # in real code: int(input("Your choice: "))
    if user_choice == q['answer']:
        print("✓ Correct")
        score += 1
    else:
        print(f"✗ Correct answer: {q['choices'][q['answer']]}")

print(f"\nFinal score: {score}/{len(questions)}")

It works. But notice that questions is only data. The rules of a question (display it, check an answer, show the correct answer) are spread across the loop. Let's put them together.


Step 2: A Question Class

A class is a blueprint for an object. Each object has its own data, and it has methods that work on that data.

python
class Question:
    def __init__(self, text, choices, answer_index):
        self.text = text
        self.choices = choices
        self.answer_index = answer_index

    def ask(self):
        """Display the question and its choices."""
        print(self.text)
        for i, c in enumerate(self.choices):
            print(f"  {i}) {c}")

    def is_correct(self, choice):
        return choice == self.answer_index

    def correct_text(self):
        return self.choices[self.answer_index]


# Create some questions
q1 = Question("Which keyword defines a function?", ["def", "func", "fn"], 0)
q2 = Question("What does `len([1,2,3])` return?", ["2", "3", "4"], 1)

# Each question knows how to display itself
q1.ask()
print("Correct answer:", q1.correct_text())
print("Is 0 correct?", q1.is_correct(0))

Three things to notice:


  • __init__: a special method that Python calls when you write Question(...). It sets up the new object's data.

  • self: the usual name for "this object". Every method has self as its first parameter.

  • Methods: functions that belong to a class. q1.ask() is a shorter way to write Question.ask(q1).

These three ideas are the core of OOP in Python. Most of what comes later builds on them.


Step 3: A Quiz Class to Manage the Game

Now put the game flow in a class: the list of questions, the score and the loop.

python
class Question:
    def __init__(self, text, choices, answer_index):
        self.text = text
        self.choices = choices
        self.answer_index = answer_index

    def ask(self):
        print(self.text)
        for i, c in enumerate(self.choices):
            print(f"  {i}) {c}")

    def is_correct(self, choice):
        return choice == self.answer_index

    def correct_text(self):
        return self.choices[self.answer_index]


class Quiz:
    def __init__(self, questions):
        self.questions = questions
        self.score = 0
        self.answers_given = []   # for the final summary

    def play(self, answers):
        """Run through all questions. `answers` is a list of choice indices."""
        for i, q in enumerate(self.questions, start=1):
            print(f"\nQ{i}/{len(self.questions)}:")
            q.ask()

            chosen = answers[i - 1]
            self.answers_given.append(chosen)
            print(f"  → you chose: {chosen}")

            if q.is_correct(chosen):
                print("  ✓ correct")
                self.score += 1
            else:
                print(f"  ✗ correct answer was {q.answer_index} ({q.correct_text()})")

    def summary(self):
        pct = 100 * self.score / len(self.questions)
        print(f"\nFinal: {self.score}/{len(self.questions)}  ({pct:.0f}%)")
        if pct == 100:
            print("Perfect! 🏆")
        elif pct >= 70:
            print("Solid work.")
        else:
            print("Worth another try. Review the ones you missed.")


# Run a game
questions = [
    Question("What does `len([1,2,3])` return?", ["2", "3", "4"], 1),
    Question("Which is mutable?", ["tuple", "string", "list"], 2),
    Question("What's `2 ** 3`?", ["6", "8", "9"], 1),
]

# Simulated answers (in real code: int(input(...)) per question)
fake_answers = [1, 2, 0]   # last one wrong

quiz = Quiz(questions)
quiz.play(fake_answers)
quiz.summary()

Quiz.play() is now easy to read. The details of one question (printing it, checking an answer) are in Question. The game flow is in Quiz. This idea is called separation of concerns: each part of the code has one job.


Step 4: Load Questions from a File

A class makes saving and loading simple: you convert dicts to Question objects, and Question objects back to dicts.

python
import json
import io

class Question:
    def __init__(self, text, choices, answer_index, explanation=""):
        self.text = text
        self.choices = choices
        self.answer_index = answer_index
        self.explanation = explanation

    @classmethod
    def from_dict(cls, data):
        """Build a Question from a plain dict (for example, one loaded from JSON)."""
        return cls(
            text=data["text"],
            choices=data["choices"],
            answer_index=data["answer"],
            explanation=data.get("explanation", ""),
        )

    def to_dict(self):
        return {
            "text": self.text,
            "choices": self.choices,
            "answer": self.answer_index,
            "explanation": self.explanation,
        }


# Real version:
#   with open("questions.json", "r", encoding="utf-8") as f:
#       data = json.load(f)
# In the browser, simulate:
data_str = json.dumps([
    {"text": "How do you write a comment?", "choices": ["//", "#", "/* */"], "answer": 1,
     "explanation": "Python uses # for comments. // and /* */ are C-family."},
    {"text": "What does 'in' do for lists?", "choices": ["adds an element", "tests membership", "creates a list"], "answer": 1},
])

raw = json.loads(data_str)
questions = [Question.from_dict(d) for d in raw]
for q in questions:
    print(q.text, "→ correct:", q.correct_text() if hasattr(q, "correct_text") else q.choices[q.answer_index])

There are two new ideas here:


  • @classmethod: a method that receives cls (the class itself) instead of self. It is the usual choice for "factory" methods like from_dict, which create a new object.

  • from_dict / to_dict: a common pattern for serialising objects. With these few lines, you can save objects to JSON without writing a custom JSON encoder.


Step 5: Polished Game

This version puts everything together and adds explanations and categories.

python
import json
from collections import Counter

class Question:
    def __init__(self, text, choices, answer_index, category="general", explanation=""):
        self.text = text
        self.choices = choices
        self.answer_index = answer_index
        self.category = category
        self.explanation = explanation

    @classmethod
    def from_dict(cls, d):
        return cls(d["text"], d["choices"], d["answer"],
                   d.get("category", "general"), d.get("explanation", ""))

    def is_correct(self, choice):
        return choice == self.answer_index

    def correct_text(self):
        return self.choices[self.answer_index]

class Quiz:
    def __init__(self, questions, name="Python Quiz"):
        self.questions = questions
        self.name = name
        self.results = []   # list of (question_index, chosen, correct?)

    def play(self, answers):
        print(f"━━ {self.name} ━━")
        for i, q in enumerate(self.questions):
            print(f"\nQ{i+1}. [{q.category}] {q.text}")
            for j, c in enumerate(q.choices):
                print(f"   {j}) {c}")
            chosen = answers[i]
            correct = q.is_correct(chosen)
            self.results.append((i, chosen, correct))
            if correct:
                print("   ✓ correct")
            else:
                print(f"   ✗ correct was {q.answer_index}) {q.correct_text()}")
            if q.explanation:
                print(f"   ℹ {q.explanation}")
        self.report()

    @property
    def score(self):
        return sum(1 for *_, c in self.results if c)

    def report(self):
        n = len(self.questions)
        pct = 100 * self.score / n
        print(f"\n━━ Result ━━")
        print(f"Score: {self.score}/{n} ({pct:.0f}%)")

        # Per-category breakdown
        per_cat = Counter()
        per_cat_correct = Counter()
        for i, _chosen, correct in self.results:
            cat = self.questions[i].category
            per_cat[cat] += 1
            if correct:
                per_cat_correct[cat] += 1
        if len(per_cat) > 1:
            print("By category:")
            for cat, total in per_cat.items():
                got = per_cat_correct[cat]
                print(f"  {cat:<12} {got}/{total}")


# Use it
bank = [
    Question("def keyword?", ["def", "function", "fn"], 0,
             category="syntax", explanation="Python uses `def` to declare functions."),
    Question("Mutable?", ["tuple", "list", "string"], 1,
             category="types", explanation="Only `list` here is mutable."),
    Question("`'a' in 'apple'`?", ["True", "False"], 0,
             category="strings", explanation="The `in` operator works on strings, lists, dicts, sets."),
]

quiz = Quiz(bank, name="Python Basics Quiz")
quiz.play([0, 1, 0])   # all correct

@property is new. It lets you read quiz.score like an attribute (without parentheses), even though a method calculates it. Use it for values that are calculated from other data.


Stretch Goals

1. Persistence: load 50 questions from questions.json. Use random.sample to pick 10 for each game, so every game is different.
2. Time limit: measure the time for each question with time.time(). Give fewer points for slow answers.
3. Difficulty rating: add difficulty: 1-3 and award more points for hard correct answers.
4. Category selector: let the player choose which categories to practise.
5. Stats over time: save past scores to quiz_history.json, and show how the scores change.
6. Multi-player: a list of Player objects, each with their own score. The players answer questions in turn.


🎯 Your Turn: A TimedQuestion Subclass

Add a per-question timer. Build a TimedQuestion class that inherits from Question and adds a time limit. If the answer takes too long, it's counted wrong.

python
import time

class Question:
    def __init__(self, text, choices, answer_index):
        self.text = text
        self.choices = choices
        self.answer_index = answer_index
    def is_correct(self, choice):
        return choice == self.answer_index

class TimedQuestion(Question):
    """Same as Question, but also tracks a time limit."""
    def __init__(self, text, choices, answer_index, time_limit_seconds=10):
        # TODO 1: call the parent __init__ via super()
        # TODO 2: store the time limit
        pass

    def is_correct_in_time(self, choice, elapsed_seconds):
        # TODO 3: return True only if the answer is right AND elapsed <= time_limit
        pass

# Test
q = TimedQuestion("Capital of France?", ["Paris", "London", "Rome"], 0, time_limit_seconds=5)
print(q.is_correct_in_time(0, elapsed_seconds=3))    # True (right and in time)
print(q.is_correct_in_time(0, elapsed_seconds=8))    # False (right but too slow)
print(q.is_correct_in_time(1, elapsed_seconds=3))    # False (wrong answer)
Hint 1: super() syntax super().__init__(text, choices, answer_index) calls the parent's __init__. This way you reuse the parent class's code instead of copying it.
Hint 2: Combining the checks return self.is_correct(choice) and elapsed_seconds <= self.time_limit_seconds reuses the parent's check and adds the time limit.
Show full solution
python
class TimedQuestion(Question):
    def __init__(self, text, choices, answer_index, time_limit_seconds=10):
        super().__init__(text, choices, answer_index)
        self.time_limit_seconds = time_limit_seconds

    def is_correct_in_time(self, choice, elapsed_seconds):
        return self.is_correct(choice) and elapsed_seconds <= self.time_limit_seconds

# Real usage with timing:
import time

def ask_with_timer(question):
    start = time.time()
    answer = 0  # in real: int(input("Answer: "))
    elapsed = time.time() - start
    if question.is_correct_in_time(answer, elapsed):
        print(f"✓ Correct in {elapsed:.1f}s")
    elif question.is_correct(answer):
        print(f"✗ Right answer but too slow ({elapsed:.1f}s)")
    else:
        print(f"✗ Wrong")
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Question = _AutoMock('Question')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import json
    import io
    from collections import Counter
except Exception:
    pass
_pm_earlier = [
    'class Question:\n    def __init__(self, text, choices, answer_index):\n        self.text = text\n        self.choices = choices\n        self.answer_index = answer_index\n\n    def ask(self):\n        """Display the question and its choices."""\n        print(self.text)\n        for i, c in enumerate(self.choices):\n            print(f"  {i}) {c}")\n\n    def is_correct(self, choice):\n        return choice == self.answer_index\n\n    def correct_text(self):\n        return self.choices[self.answer_index]\n\n\n# Create some questions\nq1 = Question("Which keyword defines a function?", ["def", "func", "fn"], 0)\nq2 = Question("What does `len([1,2,3])` return?", ["2", "3", "4"], 1)\n\n# Each question knows how to display itself\nq1.ask()\nprint("Correct answer:", q1.correct_text())\nprint("Is 0 correct?", q1.is_correct(0))',
    'import json\nimport io\n\nclass Question:\n    def __init__(self, text, choices, answer_index, explanation=""):\n        self.text = text\n        self.choices = choices\n        self.answer_index = answer_index\n        self.explanation = explanation\n\n    @classmethod\n    def from_dict(cls, data):\n        """Build a Question from a plain dict (for example, one loaded from JSON)."""\n        return cls(\n            text=data["text"],\n            choices=data["choices"],\n            answer_index=data["answer"],\n            explanation=data.get("explanation", ""),\n        )\n\n    def to_dict(self):\n        return {\n            "text": self.text,\n            "choices": self.choices,\n            "answer": self.answer_index,\n            "explanation": self.explanation,\n        }\n\n\n# Real version:\n#   with open("questions.json", "r", encoding="utf-8") as f:\n#       data = json.load(f)\n# In the browser, simulate:\ndata_str = json.dumps([\n    {"text": "How do you write a comment?", "choices": ["//", "#", "/* */"], "answer": 1,\n     "explanation": "Python uses # for comments. // and /* */ are C-family."},\n    {"text": "What does \'in\' do for lists?", "choices": ["adds an element", "tests membership", "creates a list"], "answer": 1},\n])\n\nraw = json.loads(data_str)\nquestions = [Question.from_dict(d) for d in raw]\nfor q in questions:\n    print(q.text, "→ correct:", q.correct_text() if hasattr(q, "correct_text") else q.choices[q.answer_index])',
    'import json\nfrom collections import Counter\n\nclass Question:\n    def __init__(self, text, choices, answer_index, category="general", explanation=""):\n        self.text = text\n        self.choices = choices\n        self.answer_index = answer_index\n        self.category = category\n        self.explanation = explanation\n\n    @classmethod\n    def from_dict(cls, d):\n        return cls(d["text"], d["choices"], d["answer"],\n                   d.get("category", "general"), d.get("explanation", ""))\n\n    def is_correct(self, choice):\n        return choice == self.answer_index\n\n    def correct_text(self):\n        return self.choices[self.answer_index]\n\nclass Quiz:\n    def __init__(self, questions, name="Python Quiz"):\n        self.questions = questions\n        self.name = name\n        self.results = []   # list of (question_index, chosen, correct?)\n\n    def play(self, answers):\n        print(f"━━ {self.name} ━━")\n        for i, q in enumerate(self.questions):\n            print(f"\\nQ{i+1}. [{q.category}] {q.text}")\n            for j, c in enumerate(q.choices):\n                print(f"   {j}) {c}")\n            chosen = answers[i]\n            correct = q.is_correct(chosen)\n            self.results.append((i, chosen, correct))\n            if correct:\n                print("   ✓ correct")\n            else:\n                print(f"   ✗ correct was {q.answer_index}) {q.correct_text()}")\n            if q.explanation:\n                print(f"   ℹ {q.explanation}")\n        self.report()\n\n    @property\n    def score(self):\n        return sum(1 for *_, c in self.results if c)\n\n    def report(self):\n        n = len(self.questions)\n        pct = 100 * self.score / n\n        print(f"\\n━━ Result ━━")\n        print(f"Score: {self.score}/{n} ({pct:.0f}%)")\n\n        # Per-category breakdown\n        per_cat = Counter()\n        per_cat_correct = Counter()\n        for i, _chosen, correct in self.results:\n            cat = self.questions[i].category\n            per_cat[cat] += 1\n            if correct:\n                per_cat_correct[cat] += 1\n        if len(per_cat) > 1:\n            print("By category:")\n            for cat, total in per_cat.items():\n                got = per_cat_correct[cat]\n                print(f"  {cat:<12} {got}/{total}")\n\n\n# Use it\nbank = [\n    Question("def keyword?", ["def", "function", "fn"], 0,\n             category="syntax", explanation="Python uses `def` to declare functions."),\n    Question("Mutable?", ["tuple", "list", "string"], 1,\n             category="types", explanation="Only `list` here is mutable."),\n    Question("`\'a\' in \'apple\'`?", ["True", "False"], 0,\n             category="strings", explanation="The `in` operator works on strings, lists, dicts, sets."),\n]\n\nquiz = Quiz(bank, name="Python Basics Quiz")\nquiz.play([0, 1, 0])   # all correct',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

This is inheritance: a TimedQuestion is a Question, with extra data and behaviour. The full OOP lesson in Intermediate covers this in more detail.


What You Learned

  • A class keeps data and methods together. __init__ sets up an instance, and methods work on self.
  • @classmethod is for factory methods (alternate constructors).
  • @property lets you read a calculated value like an attribute.
  • from_dict / to_dict is a common pattern for saving and loading objects.
  • Separation of concerns: Question knows about questions, and Quiz knows about the flow. Each class has one job.

You can now build programs where data and behaviour are kept together in classes. That is the main idea of OOP, and you have now used it in a real program.

There's a full OOP lesson in Intermediate that covers __str__, __repr__, inheritance, properties and more.

Next: CLI Calculator, a project without classes, where you parse and calculate maths expressions.

Found this useful? Share it with a friend who’s learning Python.