Project: Quiz Game (Your First Classes)
1 · The lesson
readYou'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.
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.
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 writeQuestion(...). It sets up the new object's data.self: the usual name for "this object". Every method hasselfas its first parameter.- Methods: functions that belong to a class.
q1.ask()is a shorter way to writeQuestion.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.
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.
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 receivescls(the class itself) instead ofself. It is the usual choice for "factory" methods likefrom_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.
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.
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
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 onself. @classmethodis for factory methods (alternate constructors).@propertylets you read a calculated value like an attribute.from_dict/to_dictis 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.