3. Make a quiz game
What you'll build
You will build a quiz about the pets. The three questions are stored in a list, and a single route shows whichever question is next. Each answer choice is a button, and right and wrong answers receive different responses. A score accumulates as you play, and the results page at the end changes depending on how well you did.
The new idea is that the data drives the pages. In the story maker,
every page had its own route. Here, one ask route serves every
question, because the questions are values in a list and the app
keeps track of which one you are on.
Try the finished app
Take the quiz twice, once answering honestly and once choosing wrong answers on purpose, and watch what changes:
from drafter import *
from dataclasses import dataclass
@dataclass
class Question:
prompt: str
options: list[str]
answer: str
@dataclass
class State:
questions: list[Question]
position: int
score: int
QUESTIONS = [
Question("What kind of animal is Captain?",
["a dog", "a cat", "a hamster"], "a cat"),
Question("Which pet is a corgi?",
["Ada", "Babbage", "Domino"], "Ada"),
Question("What color is Domino the cat?",
["black", "grey", "spotted"], "black")
]
@route
def index(state: State) -> Page:
return Page(state, [
Header("The Pet Quiz"),
"Three questions. No pressure.\n",
Button("Start the quiz", "ask")
])
@route
def ask(state: State) -> Page:
question = state.questions[state.position]
content = [
Header("Question " + str(state.position + 1)),
question.prompt + "\n"
]
for option in question.options:
content.append(Button(option, "check", [Argument("chosen", option)]))
content.append("\n")
return Page(state, content)
@route
def check(state: State, chosen: str) -> Page:
question = state.questions[state.position]
state.position = state.position + 1
if chosen == question.answer:
state.score = state.score + 1
message = "Correct!"
else:
message = "Not quite. It was " + question.answer + "."
if state.position < len(state.questions):
return Page(state, [
message + "\n",
Button("Next question", "ask")
])
return Page(state, [
message + "\n",
Button("See your results", "results")
])
@route
def results(state: State) -> Page:
total = len(state.questions)
if state.score == total:
verdict = "Perfect. The pets are impressed."
elif state.score >= total / 2:
verdict = "Solid work."
else:
verdict = "The pets forgive you. Try again!"
return Page(state, [
Header("Results"),
"You scored " + str(state.score) + " out of " + str(total) + ".\n",
verdict + "\n",
Button("Play again", "restart")
])
@route
def restart(state: State) -> Page:
state.position = 0
state.score = 0
return index(state)
assert_state(check(State(QUESTIONS, 0, 0), "a cat"),
State(QUESTIONS, 1, 1))
assert_state(check(State(QUESTIONS, 0, 0), "a dog"),
State(QUESTIONS, 1, 0))
assert_has(results(State(QUESTIONS, 3, 3)), "You scored 3 out of 3.")
start_server(State(QUESTIONS, 0, 0))
What you need
You should have finished the story maker, which means you can build forms whose values arrive as route parameters. Budget one or two sittings; the quiz grows in four steps, and each step is a runnable program.
Step 1: The questions are data
A quiz question has three parts: the prompt, the options, and the
right answer. Those three parts fit naturally into a dataclass. The
whole quiz is a list of these questions, and the state carries the
list along with a position that records which question is next.
from drafter import *
from dataclasses import dataclass
@dataclass
class Question:
prompt: str
options: list[str]
answer: str
@dataclass
class State:
questions: list[Question]
position: int
QUESTIONS = [
Question("What kind of animal is Captain?",
["a dog", "a cat", "a hamster"], "a cat"),
Question("Which pet is a corgi?",
["Ada", "Babbage", "Domino"], "Ada"),
Question("What color is Domino the cat?",
["black", "grey", "spotted"], "black")
]
@route
def index(state: State) -> Page:
question = state.questions[state.position]
return Page(state, [
Header("The Pet Quiz"),
"This quiz has " + str(len(state.questions)) + " questions.\n",
"First up: " + question.prompt
])
start_server(State(QUESTIONS, 0))
What this means: state.questions[state.position] is the central
idea of this project. Nothing in the route is specific to pets; the
page shows whatever question the position points at.
Predict first: change the last line to
start_server(State(QUESTIONS, 2)). What will the page show? Run it
and check. Then predict what State(QUESTIONS, 3) will do before you
try it. (It breaks: there is no question 3. Read the error, then put
the 0 back. Step 3 will make running out of questions mean something.)
Step 2: One route, every question
Move the question display into its own ask route, show the options
with a BulletedList, and add a Skip button that moves the position
forward. One route now serves every question.
from drafter import *
from dataclasses import dataclass
@dataclass
class Question:
prompt: str
options: list[str]
answer: str
@dataclass
class State:
questions: list[Question]
position: int
QUESTIONS = [
Question("What kind of animal is Captain?",
["a dog", "a cat", "a hamster"], "a cat"),
Question("Which pet is a corgi?",
["Ada", "Babbage", "Domino"], "Ada"),
Question("What color is Domino the cat?",
["black", "grey", "spotted"], "black")
]
@route
def index(state: State) -> Page:
return Page(state, [
Header("The Pet Quiz"),
"Three questions. No pressure.\n",
Button("Start the quiz", "ask")
])
@route
def ask(state: State) -> Page:
if state.position >= len(state.questions):
return Page(state, [
"That was the last question!\n",
Button("Back to the start", "restart")
])
question = state.questions[state.position]
return Page(state, [
Header("Question " + str(state.position + 1)),
question.prompt + "\n",
BulletedList(question.options),
Button("Skip", "skip")
])
@route
def skip(state: State) -> Page:
state.position = state.position + 1
return ask(state)
@route
def restart(state: State) -> Page:
state.position = 0
return index(state)
start_server(State(QUESTIONS, 0))
What this means: ask now has a branch. If the position has moved
past the end of the list, the route returns an ending page instead of
a question. One route can now produce several completely different
pages, and which one you get depends on state. Drafter calls pages
like this dynamic, and you will meet the idea again and again.
Predict first: add a fourth Question(...) to the list. How many
skips until the ending page? Check.
Step 3: Answers are buttons
The Skip button was a placeholder; a real quiz lets the player answer.
Replace the BulletedList with one Button per option, built in a
loop, and give every button the same target: a new check route. An
Argument on each button records which option it represents.
Replace ask and skip with:
@route
def ask(state: State) -> Page:
question = state.questions[state.position]
content = [
Header("Question " + str(state.position + 1)),
question.prompt + "\n"
]
for option in question.options:
content.append(Button(option, "check", [Argument("chosen", option)]))
content.append("\n")
return Page(state, content)
@route
def check(state: State, chosen: str) -> Page:
question = state.questions[state.position]
state.position = state.position + 1
if chosen == question.answer:
message = "Correct!"
else:
message = "Not quite. It was " + question.answer + "."
if state.position < len(state.questions):
return Page(state, [
message + "\n",
Button("Next question", "ask")
])
return Page(state, [
message + "\n",
Button("Back to the start", "restart")
])
Here is the whole program at this stage, runnable:
from drafter import *
from dataclasses import dataclass
@dataclass
class Question:
prompt: str
options: list[str]
answer: str
@dataclass
class State:
questions: list[Question]
position: int
QUESTIONS = [
Question("What kind of animal is Captain?",
["a dog", "a cat", "a hamster"], "a cat"),
Question("Which pet is a corgi?",
["Ada", "Babbage", "Domino"], "Ada"),
Question("What color is Domino the cat?",
["black", "grey", "spotted"], "black")
]
@route
def index(state: State) -> Page:
return Page(state, [
Header("The Pet Quiz"),
"Three questions. No pressure.\n",
Button("Start the quiz", "ask")
])
@route
def ask(state: State) -> Page:
question = state.questions[state.position]
content = [
Header("Question " + str(state.position + 1)),
question.prompt + "\n"
]
for option in question.options:
content.append(Button(option, "check", [Argument("chosen", option)]))
content.append("\n")
return Page(state, content)
@route
def check(state: State, chosen: str) -> Page:
question = state.questions[state.position]
state.position = state.position + 1
if chosen == question.answer:
message = "Correct!"
else:
message = "Not quite. It was " + question.answer + "."
if state.position < len(state.questions):
return Page(state, [
message + "\n",
Button("Next question", "ask")
])
return Page(state, [
message + "\n",
Button("Back to the start", "restart")
])
@route
def restart(state: State) -> Page:
state.position = 0
return index(state)
start_server(State(QUESTIONS, 0))
What this means: every answer button points at the same check
route, and the Argument("chosen", option) is what distinguishes them.
When a button is clicked, its argument fills check's chosen
parameter, exactly the way a text box's contents filled a parameter in
the story maker. Then check compares chosen against the right
answer and branches.
Predict first: in check, what happens to the position when the
answer is wrong? Is that what a quiz should do? (The position moves
forward either way; each question is asked exactly once, whether the
answer was right or wrong.)
Step 4: Keep score, vary the ending
Add a score field, count correct answers in check, and replace the
plain ending with a results route whose verdict depends on the
score. The finished app at the top of the page is exactly this step;
its last lines before start_server are three tests:
assert_state(check(State(QUESTIONS, 0, 0), "a cat"),
State(QUESTIONS, 1, 1))
assert_state(check(State(QUESTIONS, 0, 0), "a dog"),
State(QUESTIONS, 1, 0))
assert_has(results(State(QUESTIONS, 3, 3)), "You scored 3 out of 3.")
Add them to your copy, above start_server(...). The first two pin
down the scoring rule from both sides: a right answer advances both
the position and the score, while a wrong answer advances only the
position. The third checks the results page for a perfect run.
Break it on purpose: change check to add 2 points per correct
answer, run the app, and open the debug panel's Tests tab. The first
test fails and shows the state it expected against the state it got.
Fix it and watch the tests go green.
Common problems
IndexError(list position out of range):state.positionran past the end ofstate.questions. It usually means a route indexed the list without theposition < len(...)guard, or moved the position twice for one answer.- The same question repeats forever: nothing moves
state.positionforward. It should advance exactly once, insidecheck. - Every answer says correct: make sure
chosen == question.answeruses the question fetched before the position moves, and check that each option string matches itsanswerexactly, including case. - A
missing parametererror oncheck: the answer buttons must each carry[Argument("chosen", option)]; without it, nothing fills thechosenparameter. Play againshows question 4 of 3:restartmust reset bothpositionandscoreto 0.
Name it
Two ideas got names while you built this:
- Dynamic pages: a dynamic page comes from one route that renders
differently depending on state and arguments. Here,
askshows whichever question is next,resultschanges its verdict with the score, andcheckbranches on right and wrong answers. The full idea is explained in Dynamic pages. - Arguments: an argument is an extra value that a button carries to its route, filling a parameter by name in the same way a form field does. Details are on the Argument reference page.
One older idea also grew: your state now contains a list of
dataclasses. Everything specific to this quiz lives in QUESTIONS;
if you change the data, the same code runs a different quiz.
Make it yours
- Write three more questions about anything you like. Notice you never touch the routes.
- Show the running score on every question page.
- Add a
wrong: list[str]field collecting the prompts of missed questions, and show them on the results page with aBulletedList. - Harder: a branching story has the same structure as this program.
Each "question" becomes a scene, each "option" leads to another
scene, and instead of a score you track which scene comes next.
Sketch the
Scenedataclass and try it.
Next steps
You unlocked two task pages: Show a collection of items for displaying lists like your questions, and Show different content for dynamic pages beyond quizzes.
-
Next project: Finish and test an app
Take the quiz (or any app) from working to finished by adding frozen regression tests, a theme, and production settings.