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 — questions with multiple-choice answers, scoring, immediate feedback, end-of-game summary. And you'll meet your first Python classes along the way.

This is the bridge from "Fundamentals" to "Intermediate". You've done lots with lists, dicts, and functions. Classes let you bundle data + behaviour together — and once you see why that's useful, OOP stops feeling abstract.

What you'll practice: 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 — questions is just data. The "rules" of a question (display it, check an answer, format the explanation) are scattered across the loop. Let's bundle them.


Step 2 — A Question Class

A class is a blueprint for an object. Each object has its own data AND knows how to operate 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__ — special method called when you write Question(...). It sets up the new object's data.

  • self — the convention for "this object". Every method has self as the first parameter.

  • Methods — functions that belong to a class. q1.ask() is just Question.ask(q1) in disguise.

That's 90% of OOP. The rest is variations on this theme.


Step 3 — A Quiz Class to Manage the Game

Now bundle the game flow — the questions list, the score, 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 misses.")


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

Notice how clean the orchestration is now: Quiz.play() reads almost like a sentence. The complicated parts (printing a question, checking an answer) live in Question. The "game flow" parts live in Quiz. Separation of concerns.


Step 4 — Load Questions from a File

A class makes saving/loading clean: you map dicts ↔ Question objects.

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 (e.g. 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])

Two new ideas worth highlighting:


  • @classmethod — a method that takes cls (the class itself) instead of self. Conventional for "factory" methods like from_dict.

  • from_dict / to_dict — the standard pattern for serializing your objects. A few lines that let you persist instances without writing custom JSON encoders.


Step 5 — Polished Game

Putting it together with explanations, categories, and a clean play loop.

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 a small new piece — it makes quiz.score look like an attribute (no parentheses) even though it's computed by a method. Use it for "derived" values.


Stretch Goals

1. Persistence: load 50 questions from questions.json. random.sample 10 for each session so it's different every play.
2. Time limit: track time per question with time.time(). Penalize slow answers.
3. Difficulty rating: add difficulty: 1-3 and award more points for hard correct answers.
4. Categories selector: let the player pick which categories to drill on.
5. Stats over time: persist past scores to quiz_history.json. Show improvement.
6. Multi-player: a list of Player objects, each with their own score. Round-robin questions.


🎯 Your Turn — A Timer Mixin

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
Hint 1 — super() syntax super().__init__(text, choices, answer_index) calls the parent's __init__. This is how you build on existing class behaviour instead of duplicating it.
Hint 2 — Composing the check return self.is_correct(choice) and elapsed_seconds <= self.time_limit_seconds — reuses the parent's logic AND adds the time constraint.
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 · defines Question
# 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')

This is classic inheritance: TimedQuestion IS-A Question, plus extra state and behaviour. The full OOP lesson in Intermediate goes deeper.


What You Learned

  • A class is data + methods, bundled. __init__ builds an instance; methods operate on self.
  • @classmethod is for factory methods (alternate constructors).
  • @property turns a method into a virtual attribute.
  • from_dict / to_dict is the canonical serialization pattern.
  • Separation of concerns: Question knows about questions; Quiz knows about the flow. Each class has one job.

You can now build real programs that have state and behaviour packaged together. That's the whole point of OOP — and now you've felt why, not just read about it.

There's a full OOP lesson in Intermediate that covers __str__, __repr__, inheritance, properties, and the rest of the iceberg. But you've already crossed the bridge.

Next: CLI Calculator — back to procedural for a real-world parsing exercise.