Lesson 9: Mini-Project — Quiz Game
🎧 Listen to this lesson
Time to build something real. This lesson assembles everything so far — dictionaries for data, functions for organisation, loops for gameplay, conditionals for scoring, random shuffling, and defensive input handling — into a complete quiz game. Just as important as the code is the architecture: professional programs separate data (the questions), logic (asking and checking) and presentation (messages and scores), because each part can then be changed without breaking the others.
Step 1 — the core loop, no frills
Start with the smallest version that works: questions as dictionaries of question and answer text, asked in order, typed answers compared case-insensitively.
questions = [
{"q": "What is 7 * 8?", "answer": "56"},
{"q": "Which planet is known as the Red Planet?", "answer": "Mars"},
{"q": "What is the capital of Japan?", "answer": "Tokyo"},
]
score = 0
for item in questions:
reply = input(item["q"] + " ")
if reply.strip().lower() == item["answer"].lower():
print("Correct!")
score += 1
else:
print("No - it was", item["answer"])
print(f"Score: {score}/{len(questions)}")
Answering 56, mars and Kyoto scores 2/3: the first two match (the second only because both sides are lowercased — "mars" versus "Mars" would fail a raw comparison), and the third prints No - it was Tokyo. This version already teaches the shape of every quiz, survey and flashcard app: a list of structured items, a loop, a check, a tally. Everything after this is refinement.
Step 2 — multiple choice with shuffled options
Real quizzes offer options, and options must be shuffled — otherwise players memorise "the answer is always C". Shuffling creates the lesson's central problem: once the options move, a displayed number no longer equals the stored answer index. The professional solution is to shuffle a list of indices and translate through it. This tiny demonstration isolates the idea with a fixed order (one possible shuffle outcome):
options = ["Venus", "Jupiter", "Mars", "Saturn"]
answer = 2
order = [2, 0, 3, 1] # one possible shuffle result
for display_no, opt_idx in enumerate(order, 1):
print(f" {display_no}. {options[opt_idx]}")
choice = 1 # the player picks displayed option 1
print("Correct?", order[choice - 1] == answer)
Output:
1. Mars
2. Venus
3. Saturn
4. Jupiter
Correct? True
Read it slowly: order says "display Mars first, then Venus, then Saturn, then Jupiter". The player picks displayed option 1; order[1 - 1] is order[0] = 2, the original index of Mars — which equals answer, so the pick is correct. Every shuffled-quiz bug in history is a failure to make exactly this translation.
Step 3 — the complete game
Now the full program, assembled from the pieces. ask_question owns one question (display, shuffled options, input defence, the index translation, the verdict); run_quiz owns the game (welcome, shuffled question order, tally, percentage, grade); the data sits untouched at the top where a non-programmer could edit it.
import random
QUESTIONS = [
{"q": "What is 7 * 8?", "options": ["54", "56", "63", "48"], "answer": 1},
{"q": "Which planet is known as the Red Planet?",
"options": ["Venus", "Jupiter", "Mars", "Saturn"], "answer": 2},
{"q": "What does 'CPU' stand for?",
"options": ["Central Processing Unit", "Computer Personal Unit",
"Central Program Utility", "Core Processing Unit"], "answer": 0},
{"q": "Which language is this course about?",
"options": ["Java", "Python", "C++", "Ruby"], "answer": 1},
{"q": "What is the capital of Japan?",
"options": ["Seoul", "Beijing", "Osaka", "Tokyo"], "answer": 3},
]
def grade_letter(pct):
"""Return the letter grade for a percentage score."""
if pct >= 90:
return "A"
if pct >= 80:
return "B"
if pct >= 70:
return "C"
if pct >= 60:
return "D"
return "F"
def ask_question(item):
"""Ask one question; return True if answered correctly."""
print("\n" + item["q"])
order = list(range(len(item["options"])))
random.shuffle(order)
for display_no, opt_idx in enumerate(order, 1):
print(f" {display_no}. {item['options'][opt_idx]}")
try:
choice = int(input("Your answer (number): ")) - 1
except ValueError:
print("Not a number - counted as wrong.")
return False
if choice < 0 or choice >= len(order):
print("No such option - counted as wrong.")
return False
if order[choice] == item["answer"]:
print("Correct!")
return True
print("Wrong - it was", item["options"][item["answer"]])
return False
def run_quiz():
print("=== Welcome to the Quiz Game! ===")
name = input("Enter your name: ")
questions = QUESTIONS[:]
random.shuffle(questions)
score = 0
for item in questions:
if ask_question(item):
score += 1
pct = 100 * score / len(questions)
print(f"\n{name}, you scored {score}/{len(questions)} ({pct:.0f}%) - Grade: {grade_letter(pct)}")
if pct == 100:
print("Perfect score! Outstanding.")
if __name__ == "__main__":
run_quiz()
Sample run:
=== Welcome to the Quiz Game! ===
Enter your name: Ada
What is the capital of Japan?
1. Beijing
2. Tokyo
3. Osaka
4. Seoul
Your answer (number): 2
Correct!
What is 7 * 8?
1. 56
2. 54
3. 48
4. 63
Your answer (number): 1
Correct!
Which language is this course about?
1. Ruby
2. C++
3. Java
4. Python
Your answer (number): 4
Correct!
Which planet is known as the Red Planet?
1. Mars
2. Venus
3. Jupiter
4. Saturn
Your answer (number): 1
Correct!
What does 'CPU' stand for?
1. Central Program Utility
2. Core Processing Unit
3. Central Processing Unit
4. Computer Personal Unit
Your answer (number): 3
Correct!
Ada, you scored 5/5 (100%) - Grade: A
Perfect score! Outstanding.
Your option order will differ — that is the shuffle doing its job — but the flow is identical. Note three professional touches in the code: questions = QUESTIONS[:] copies the list before shuffling, so the master data is never mutated; the try/except around int(input(...)) means typing "three" loses a point instead of crashing the game; and the if __name__ == "__main__": guard runs the game only when the file is executed directly, not when another file imports it (Lesson 12 explains why that matters, and Lesson 17 exploits it for testing).
order list) between what the user sees and what the program stored — and translate through it at checking time.Common mistakes
- Comparing the player's number to the stored answer directly. After shuffling, displayed option 2 might be original index 0.
if choice == item["answer"]silently grades almost everything wrong — the program runs, smiles, and lies (Lesson 7's hardest bug class). Always translate:order[choice]. - Shuffling the master list.
random.shuffle(QUESTIONS)permanently reorders the original data; shuffle a copy (QUESTIONS[:]) so the source stays pristine for the next game. - Letting bad input crash the game.
int("banana")raises ValueError mid-game. Anyint(input(...))in a real program deserves atry/except. - Forgetting the -1. Players count from 1; lists count from 0.
choice = int(input(...)) - 1is the bridge; skip it and every grade is off by one option.
Real-World Challenge
The task. Extend the game with two features real games have: a streak bonus (celebrate three correct answers in a row) and a persistent best score saved to a file with the Lesson 6 pattern, so the game remembers you between runs.
Worked solution. This code extends the game above — it uses ask_question and QUESTIONS from Step 3:
import json
def play_with_streak(questions):
"""Ask each question; track score and best streak of correct answers."""
score = 0
streak = 0
best_streak = 0
for item in questions:
if ask_question(item):
score += 1
streak += 1
best_streak = max(best_streak, streak)
if streak == 3:
print("Three in a row - on fire!")
else:
streak = 0
return score, best_streak
def save_best(score, path="quiz_best.json"):
"""Save score only if it beats the stored best; return the best."""
try:
with open(path, encoding="utf-8") as f:
best = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
best = -1
if score > best:
with open(path, "w", encoding="utf-8") as f:
json.dump(score, f)
print(f"New personal best: {score}!")
return score
print(f"Best score stays {best}.")
return best
How it works. The streak logic is a tiny state machine: a correct answer increments both score and streak (and best_streak remembers the peak via max), while a wrong answer resets streak to 0 — the celebration fires exactly when the streak *reaches* 3, not on every subsequent answer. save_best is the load-or-start-fresh pattern from Lesson 6 with a twist: the "empty" default is -1, a score no real game can produce, so the first ever score always counts as a new best; afterwards the file is rewritten only when beaten. Wire it into run_quiz by replacing the scoring loop with score, best_streak = play_with_streak(questions), printing the best streak beside the score, and calling save_best(score) at the end.
Extension. Add a timer: record time.time() before each question and warn "Too slow!" if answering takes over 15 seconds (import the time module; the elapsed time is the difference of two time.time() calls). Then make the limit shrink by one second per question, so the game accelerates — and playtest whether 15 seconds is even the right starting number.
Going further — ordering questions by difficulty
A quiz that adapts — easier questions after a miss, harder after a streak — starts with being able to *order* questions by their difficulty field. Sorting dictionaries by a key is the same sorted machinery from Lesson 8 with a lambda reaching into each record:
questions = [
{"q": "2 + 2?", "difficulty": 1},
{"q": "Capital of Iceland?", "difficulty": 3},
{"q": "Water's chemical symbol?", "difficulty": 2},
]
for item in sorted(questions, key=lambda x: x["difficulty"]):
print(item["difficulty"], item["q"])
Output:
1 2 + 2?
2 Water's chemical symbol?
3 Capital of Iceland?
With ordering available, adaptive rules become small changes rather than redesigns: present questions sorted and step forward on correct answers and backward on wrong ones, or filter to a band with a comprehension ([q for q in questions if q["difficulty"] == level]) and choose randomly inside it. Notice how little of the main lesson's game had to change — the data structure carried a difficulty field from the start in this extension, and every operation (shuffle, sort, filter) is a standard transformation over it. That is the recurring payoff of modelling data as lists of dictionaries: each new feature tends to be one familiar operation away, and the code you write for a school project is the same shape as the code behind real quiz platforms.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Build a Python quiz game — beginner project tutorial (Tech With Tim style) — pick a recent, well-reviewed video
- freeCodeCamp: Python mini project — build a game — pick a recent, well-reviewed video
- Python project structure tips for beginners — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Add a sixth question — Add one more question/answer pair to the quiz game's question data and confirm it appears in the game.
Hint
Find where the questions list (or dict) is defined — just append another entry in the same format. - Shuffle the questions — Make the questions appear in a random order each run.
Hint
import random, thenrandom.shuffle(questions)before the game loop. - Beat-the-clock mode — Track how many seconds the player takes for the whole quiz and show it with the final score.
Hint
Savestart = time.time()before the loop and computetime.time() - startafter;import time.
🌍 Real World
Quiz apps like Kahoot run the same loop you built: show question, collect answer, update score. Interview-prep platforms, driving-test practice sites, and classroom tools are all quiz games with nicer graphics — you now know their core engine.