1. Care for a virtual pet
What you'll build
You will build a virtual pet. The pet has a hunger level, an energy level, and a mood that depends on both. Three buttons let you feed the pet, play with it, and let it rest. Every click changes the pet's numbers, and those numbers determine its mood.
Try the finished app
Play with the finished version before building it. Feed the pet, play with it until it gets tired, and figure out what makes its mood change:
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hunger: int
energy: int
def pet_mood(state: State) -> str:
if state.hunger > 6:
return "hungry"
if state.energy < 3:
return "sleepy"
return "happy"
@route
def index(state: State) -> Page:
return Page(state, [
"Hunger: " + str(state.hunger) + "\n",
"Energy: " + str(state.energy) + "\n",
"Mood: " + pet_mood(state) + "\n",
Button("Feed", "feed"),
Button("Play", "play"),
Button("Rest", "rest")
])
@route
def feed(state: State) -> Page:
state.hunger = state.hunger - 2
if state.hunger < 0:
state.hunger = 0
return index(state)
@route
def play(state: State) -> Page:
state.energy = state.energy - 3
state.hunger = state.hunger + 2
if state.energy < 0:
state.energy = 0
return index(state)
@route
def rest(state: State) -> Page:
state.energy = state.energy + 3
if state.energy > 10:
state.energy = 10
return index(state)
start_server(State(5, 5))
Before building it, answer from playing: what makes the mood say "sleepy"? What fixes it?
What you need
- The skills from Start: building a page, adding a button, running tests.
- One sitting.
Step 1: The pet's data
Start with what the pet remembers: its hunger and its energy. There are no buttons yet, only a page that shows the two values.
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hunger: int
energy: int
@route
def index(state: State) -> Page:
return Page(state, [
"Hunger: " + str(state.hunger) + "\n",
"Energy: " + str(state.energy)
])
start_server(State(5, 5))
Step 2: Feed the pet
Feeding reduces hunger by 2, but hunger should never go below zero. A negative hunger value would not mean anything.
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hunger: int
energy: int
@route
def index(state: State) -> Page:
return Page(state, [
"Hunger: " + str(state.hunger) + "\n",
"Energy: " + str(state.energy) + "\n",
Button("Feed", "feed")
])
@route
def feed(state: State) -> Page:
state.hunger = state.hunger - 2
if state.hunger < 0:
state.hunger = 0
return index(state)
start_server(State(5, 5))
Predict first: starting from hunger 5, how many clicks of Feed until hunger reaches 0? Click and check.
Step 3: Play and rest
Next come two more actions. Playing costs energy and makes the pet hungrier, while resting restores energy. Actions that push the numbers in different directions are what make the pet feel alive.
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hunger: int
energy: int
@route
def index(state: State) -> Page:
return Page(state, [
"Hunger: " + str(state.hunger) + "\n",
"Energy: " + str(state.energy) + "\n",
Button("Feed", "feed"),
Button("Play", "play"),
Button("Rest", "rest")
])
@route
def feed(state: State) -> Page:
state.hunger = state.hunger - 2
if state.hunger < 0:
state.hunger = 0
return index(state)
@route
def play(state: State) -> Page:
state.energy = state.energy - 3
state.hunger = state.hunger + 2
if state.energy < 0:
state.energy = 0
return index(state)
@route
def rest(state: State) -> Page:
state.energy = state.energy + 3
if state.energy > 10:
state.energy = 10
return index(state)
start_server(State(5, 5))
Each button's function has one job. It changes the numbers that its
action affects, keeps them in range, and then calls index to redraw
the page.
Step 4: Give the pet a mood
The mood is not stored anywhere. It is computed from hunger and energy
each time the page is drawn. Computing the mood deserves its own
function, because index should not be crowded with mood rules. Add
tests while you are at it; the mood rules are exactly the kind of logic
that quietly breaks later.
from drafter import *
from dataclasses import dataclass
@dataclass
class State:
hunger: int
energy: int
def pet_mood(state: State) -> str:
if state.hunger > 6:
return "hungry"
if state.energy < 3:
return "sleepy"
return "happy"
@route
def index(state: State) -> Page:
return Page(state, [
"Hunger: " + str(state.hunger) + "\n",
"Energy: " + str(state.energy) + "\n",
"Mood: " + pet_mood(state) + "\n",
Button("Feed", "feed"),
Button("Play", "play"),
Button("Rest", "rest")
])
@route
def feed(state: State) -> Page:
state.hunger = state.hunger - 2
if state.hunger < 0:
state.hunger = 0
return index(state)
@route
def play(state: State) -> Page:
state.energy = state.energy - 3
state.hunger = state.hunger + 2
if state.energy < 0:
state.energy = 0
return index(state)
@route
def rest(state: State) -> Page:
state.energy = state.energy + 3
if state.energy > 10:
state.energy = 10
return index(state)
assert_equal(pet_mood(State(8, 5)), "hungry")
assert_equal(pet_mood(State(2, 1)), "sleepy")
assert_state(feed(State(1, 5)), State(0, 5))
assert_has(index(State(5, 5)), "Mood: happy")
start_server(State(5, 5))
Notice that pet_mood has no @route. It is a plain helper function:
routes return pages, while helper functions return everything else. Read
the third test carefully; it checks the "never below zero" rule from
Step 2 by feeding a pet whose hunger is already 1.
Step 5: Break it, watch a test catch it
On your own copy, change the first line of feed to add 2 instead of
subtracting. Run the app and open the debug panel's Tests tab. The
assert_state test fails and shows the difference: it expected hunger 0
and got hunger 3. Fix feed and watch the test pass again.
This is why the tests exist. You did not have to click anything to find the bug; the tests checked the rules the moment the program started.
Common problems
payload.verification_failedonfeed,play, orrest: the buttons inindexrefer to routes defined further down the file. Every name in aButton(...)must match a route function that exists. Check spelling first. Also make sure that the route function has@routeabove it, so that Drafter knows it is a route.- Hunger or energy goes negative or past 10: one of your range checks is missing or checks the wrong direction. Compare with Step 3.
- The mood never changes: make sure the page shows
pet_mood(state), freshly computed, and that you did not store a mood value inState. A stored mood can go stale, but a computed mood cannot. State(...)complains about arguments: the order of the values inState(5, 5)must match the dataclass fields exactly.
Name it
You have now used every idea from Start on a real app, plus two new ones:
- State is your app's memory: the pet's hunger and energy. Buttons change it, the page displays it, and reloading resets it. The full story is in State.
- Routes are the functions that return your pages and respond to
your buttons:
index,feed,play, andrest. How they connect is explained in Routes and pages. - Helper functions like
pet_moodkeep routes short. Rules that compute something from state belong in helpers, where tests can reach them easily.
Make it yours
- Add a
happinessfield toState, changed by playing, and work it intopet_mood. - Add a "Treat" button: hunger down 1, happiness up, but energy down 1.
- Make the pet's name part of
Stateand show it in the page text. - Harder: if energy is 0, make
playdo nothing. Decide: should the page say why? Write a test for the new rule.
Next steps
You unlocked two task pages: Remember a score or choice generalizes what you did with state, and Add and connect pages is next when one page stops being enough.
-
Next project: Build a story maker
The next project adds more pages, text boxes, and values that travel from one page to another.