Project: Quiz Game (Your First Classes)
1 · The lesson
readYou'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.
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.
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 writeQuestion(...). It sets up the new object's data.self— the convention for "this object". Every method hasselfas the first parameter.- Methods — functions that belong to a class.
q1.ask()is justQuestion.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.
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.
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 takescls(the class itself) instead ofself. Conventional for "factory" methods likefrom_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.
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.
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
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 onself. @classmethodis for factory methods (alternate constructors).@propertyturns a method into a virtual attribute.from_dict/to_dictis 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.