2. Build a story maker
What you'll build
You will build a fill-in-the-blank story maker. One page asks for a hero, a place, and an object, and the next page weaves them into a story. Changing one word changes the whole story, and the same three words can tell more than one story.
Try the finished app
Fill in the blanks, tell the story, then tell it differently without retyping anything:
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hero: str
place: str
thing: str
@route
def index(state: State) -> Page:
return Page(state, [
"Fill in the blanks, then tell the story.\n",
"A hero:",
TextBox("hero", state.hero),
"\nA place:",
TextBox("place", state.place),
"\nAn object:",
TextBox("thing", state.thing),
"\n",
Button("Tell the story", "story")
])
@route
def story(state: State, hero: str, place: str, thing: str) -> Page:
state.hero = hero
state.place = place
state.thing = thing
return Page(state, [
"Long ago, " + state.hero + " traveled to " + state.place + ".",
"Nobody there had ever seen " + state.thing + " before.",
"By sunset, " + state.hero + " was famous.\n",
Button("Tell it differently", "another_story"),
Button("Change the words", "index")
])
@route
def another_story(state: State) -> Page:
return Page(state, [
"Deep in " + state.place + ", something glittered.",
"It was " + state.thing + ", lost for a hundred years.",
"Only " + state.hero + " knew what it could do.\n",
Button("Tell the first story", "story_again"),
Button("Change the words", "index")
])
@route
def story_again(state: State) -> Page:
return story(state, state.hero, state.place, state.thing)
start_server(State("Ada", "the library", "a tiny robot"))
Before building it, answer from playing: when you click "Change the words", the boxes are already filled in with your words. Where must those words be stored for that to work?
What you need
- The virtual pet finished, or comfort with state, routes, and buttons.
- One sitting.
Step 1: Ask, then answer
Start with the smallest version of the contract you met in
Forms and input: one page asks for a
value, and one route receives it. Watch the name hero travel from the
TextBox, through the button click, into the parameter:
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hero: str
@route
def index(state: State) -> Page:
return Page(state, [
"Who is the hero of the story?",
TextBox("hero"),
"\n",
Button("Tell the story", "story")
])
@route
def story(state: State, hero: str) -> Page:
state.hero = hero
return Page(state, [
"Once upon a time, there was " + state.hero + ".\n",
Button("Start over", "index")
])
start_server(State(""))
The route saves hero into state right away. A parameter only exists
during the call that receives it, but state lasts from page to page.
Step 2: Three blanks, three parameters
A page can hold several inputs. When the button is clicked, all of them are delivered to the target route, each matched to the parameter with the same name:
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hero: str
place: str
thing: str
@route
def index(state: State) -> Page:
return Page(state, [
"A hero:",
TextBox("hero"),
"\nA place:",
TextBox("place"),
"\nAn object:",
TextBox("thing"),
"\n",
Button("Tell the story", "story")
])
@route
def story(state: State, hero: str, place: str, thing: str) -> Page:
state.hero = hero
state.place = place
state.thing = thing
return Page(state, [
"Long ago, " + state.hero + " traveled to " + state.place + ".",
"Nobody there had ever seen " + state.thing + " before.",
"By sunset, " + state.hero + " was famous.\n",
Button("Change the words", "index")
])
start_server(State("", "", ""))
Predict first: rename the first TextBox to "heroine" but leave the
parameter called hero. What will happen when you click the button? Try
it, read the error, then change both names to match.
Step 3: Remember the words
Try the Step 2 app again and notice a small annoyance: when you come back
to change the words, the boxes are empty. The fix uses an idea you
already know from the pet. The words are stored in state, so pass each
one to its TextBox as the starting value.
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hero: str
place: str
thing: str
@route
def index(state: State) -> Page:
return Page(state, [
"A hero:",
TextBox("hero", state.hero),
"\nA place:",
TextBox("place", state.place),
"\nAn object:",
TextBox("thing", state.thing),
"\n",
Button("Tell the story", "story")
])
@route
def story(state: State, hero: str, place: str, thing: str) -> Page:
state.hero = hero
state.place = place
state.thing = thing
return Page(state, [
"Long ago, " + state.hero + " traveled to " + state.place + ".",
"Nobody there had ever seen " + state.thing + " before.",
"By sunset, " + state.hero + " was famous.\n",
Button("Change the words", "index")
])
start_server(State("Ada", "the library", "a tiny robot"))
Now the app has a pleasant loop: you tell the story, tweak one word, and
tell it again. Giving State real starting values also means the first story
works before the visitor types anything.
Step 4: A second story from the same words
The words live in state, so another route can tell a different story
without any new typing. another_story takes no extra parameters because
it needs nothing from the page; everything it uses comes from state. Add
tests for both story routes while you are here:
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hero: str
place: str
thing: str
@route
def index(state: State) -> Page:
return Page(state, [
"A hero:",
TextBox("hero", state.hero),
"\nA place:",
TextBox("place", state.place),
"\nAn object:",
TextBox("thing", state.thing),
"\n",
Button("Tell the story", "story")
])
@route
def story(state: State, hero: str, place: str, thing: str) -> Page:
state.hero = hero
state.place = place
state.thing = thing
return Page(state, [
"Long ago, " + state.hero + " traveled to " + state.place + ".",
"Nobody there had ever seen " + state.thing + " before.",
"By sunset, " + state.hero + " was famous.\n",
Button("Tell it differently", "another_story"),
Button("Change the words", "index")
])
@route
def another_story(state: State) -> Page:
return Page(state, [
"Deep in " + state.place + ", something glittered.",
"It was " + state.thing + ", lost for a hundred years.",
"Only " + state.hero + " knew what it could do.\n",
Button("Change the words", "index")
])
assert_has(story(State("", "", ""), "Ada", "the moon", "a spoon"), "Ada traveled to the moon")
assert_state(story(State("", "", ""), "Ada", "the moon", "a spoon"), State("Ada", "the moon", "a spoon"))
assert_has(another_story(State("Ada", "the moon", "a spoon")), "Deep in the moon")
start_server(State("Ada", "the library", "a tiny robot"))
Read the tests as sentences. The first says that telling the story with
these three words should produce a page containing "Ada traveled to the
moon". The second says that the same call should leave those words saved
in state. The third says that another_story should build its page from
state alone.
Common problems
- An error about a missing parameter: an input's name and the route's
parameter name do not match. The error names the parameter it expected;
compare it with your
TextBox(...)names letter by letter. - A "points to non-existent page" error: a button names a route that
does not exist yet. Check spelling, and make sure the route has
@routeabove it. - The boxes come back empty: each
TextBoxneeds its starting value, likeTextBox("hero", state.hero), and the story route must save its parameters into state first. - Your story shows blank spots: the visitor left a box empty. That is allowed; decide whether your starting values make that impossible or whether a blank hero is funny enough to keep.
Name it
- The pages that collect input are forms, and the values they carry are delivered as parameters. The full contract, including how types are converted, is described in Forms and input.
- Moving between
index,story, andanother_storyis navigation: buttons name routes, and routes return pages, as Routes and pages explains.
Make it yours
- Add a fourth blank (an animal, a food, a feeling) and thread it through both stories.
- Write a third story template, and a button on each story page leading to it.
- Make the story change based on the words: if the hero's name is short, use a nickname sentence. Write a test for it.
- Harder: add a
TextBox("years", 100)and anintparameter, and put the number in a sentence. What happens if the visitor typesabc?
Next steps
You unlocked two task pages: Ask the user for information covers every input component, and Add and connect pages covers linking pages.
-
Next project: Make a quiz game
A list of questions drives the pages: one route serves every question and keeps a running score.