Skip to content

Lab 28: A Face With a Memory

Lab 19's menu had no memory — press A and you get the next emotion, forever, the same way no matter what happened ten seconds ago. Real creatures aren't like that. This lab gives the robot a state machine: a set of situations it can be in (Idle, Curious, Happy, Annoyed, Asleep) and a table of which state each event moves it to, from each starting point.

Sample Program Code

Two tables — POSES and TRANSITIONS — and a main loop that shrinks to "look up what happens next, then do it":

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# Lab 28: A State Machine -- Giving the Face a Memory
#
# Lab 19's menu had no memory. Press A and you get the next emotion, over
# and over, and the face reacts to your finger the same way no matter what
# happened ten seconds ago. Real creatures are not like that. Poke someone
# who is already annoyed and you get a different answer than poking
# someone who is asleep.
#
# The idea that fixes this is a STATE MACHINE, and it is one of the most
# useful abstractions in all of computing -- vending machines, traffic
# lights, game characters, and network protocols are all built on it. It
# needs only two things:
#
#   STATES       the situations the face can be in, one at a time
#   TRANSITIONS  which state each event moves you to, from each state
#
# Write both as tables and the main loop shrinks to "look up what happens
# next, then do it." Adding a whole new mood becomes two rows of data
# instead of another branch tangled into a growing pile of if-statements.
#
# Button A pokes the robot. Button B calms it. Wait long enough and it
# gets bored, then falls asleep on its own.

import config
import face
from utime import ticks_ms, ticks_diff, sleep_ms

button_a, button_b = config.init_buttons()

# What each state LOOKS like, in the column format from lab 24.
#           eye_rx  eye_ry  brow_L  brow_R  lift  mouth        x   y
POSES = {
    "Idle":    (24, 22,   0,   0,   0, face.FLAT,  30,  0),
    "Curious": (26, 29,  -7,   5,  10, face.SMIRK, 30,  0),
    "Happy":   (24, 24,   0,   0,   5, face.SMILE, 50, 24),
    "Annoyed": (24, 12,  12,  12,  -5, face.FLAT,  26,  0),
    "Asleep":  (24,  2,   0,   0,  -7, face.FLAT,  14,  0),
}

# What each state DOES, which is a different question. Read a row like a
# sentence: "from Idle, A leads to Curious, B leads to Annoyed, and after
# 8000 ms of nobody touching anything, we fall Asleep."
#
#          state         A -> ...     B -> ...     after ms -> ...
TRANSITIONS = {
    "Idle":    {"a": "Curious", "b": "Annoyed", "timeout": (8000, "Asleep")},
    "Curious": {"a": "Happy",   "b": "Idle",    "timeout": (5000, "Idle")},
    "Happy":   {"a": "Happy",   "b": "Idle",    "timeout": (4000, "Idle")},
    "Annoyed": {"a": "Asleep",  "b": "Idle",    "timeout": (6000, "Idle")},
    "Asleep":  {"a": "Curious", "b": "Curious", "timeout": None},
}


def draw_state(name):
    eye_rx, eye_ry, brow_l, brow_r, lift, style, size_x, size_y = POSES[name]
    face.clear()
    face.eyes(eye_rx, eye_ry)
    face.eyebrows(brow_l, brow_r, lift)
    face.mouth(style, size_x, size_y)
    face.label(name)
    if name == "Asleep":
        face.label("zZ", y=face.BOTTOM_LABEL_Y)


state = "Idle"
entered_at = ticks_ms()
draw_state(state)
print("state:", state)


def go_to(next_state, because):
    """The only place in the program that changes state. Funnelling every
    change through one function means there is exactly one line to watch
    when the face ends up somewhere you did not expect."""
    global state, entered_at
    print(state, "--", because, "->", next_state)
    state = next_state
    entered_at = ticks_ms()
    draw_state(state)


while True:
    rules = TRANSITIONS[state]

    if face.pressed(button_a):
        face.wait_for_release(button_a)
        go_to(rules["a"], "poke")

    elif face.pressed(button_b):
        face.wait_for_release(button_b)
        go_to(rules["b"], "calm")

    elif rules["timeout"] is not None:
        after_ms, next_state = rules["timeout"]
        if ticks_diff(ticks_ms(), entered_at) >= after_ms:
            go_to(next_state, "waited " + str(after_ms) + "ms")

    sleep_ms(10)

# Notice what the loop above does NOT contain: the word "Happy", the word
# "Asleep", or any knowledge of what a poke means. All of that lives in
# the tables. The loop just follows them.
#
# Things to try:
#
# 1. Draw the machine on paper first -- a circle for each state, an arrow
#    for each transition, labelled A, B, or the timeout. Five circles,
#    fourteen arrows. That drawing IS the two tables above, and it is how
#    engineers design this kind of code before writing any.
#
# 2. Add a "Startled" state: eyes wide, brows way up, mouth open. Give it
#    a 700 ms timeout back to Curious, and make Asleep + A go to Startled
#    instead. Two rows of data, no new logic -- waking a sleeping robot
#    should surprise it.
#
# 3. Find the trap. From Happy, button A leads back to Happy forever. Is
#    that a bug or a personality? Try changing it to "Annoyed" and see
#    whether a robot that gets tired of being poked feels more alive.
#
# 4. Watch the shell while you play. Every transition prints, so you get a
#    written history of the robot's mood -- the technique from lab 26,
#    aimed at behaviour instead of speed.
#
# 5. draw_state() calls face.clear() on every transition, and you can see
#    the wipe. Because states change at most a few times a second, that is
#    a defensible choice. Rewrite it to erase only the boxes that differ
#    between the old pose and the new one, and decide for yourself whether
#    the extra bookkeeping earned its keep. There is no single right
#    answer, and knowing that is the skill.

Here's the robot at rest, in its Idle state:

Simulated output of 28-state-machine.py

What the Loop Doesn't Know

Read the main loop and notice what it never mentions: the word "Happy," the word "Asleep," or anything at all about what a poke means. All of that lives entirely in the two tables. The loop just follows them — which is exactly why adding a whole new mood later costs two rows of data, not another branch tangled into a growing pile of if statements.

This is also the one lab in the kit that calls display.fill(BLACK) on every state change, and that's a defensible choice here — states change a few times a minute at most, nothing like the sixty-times-a-second pace an animation runs at.