Adding an OLED Display and Expressive Behaviors¶
Summary¶
This chapter upgrades the STEM robot to the ~$35 OLED-display tier. It introduces the I2C and SPI communication protocols used to talk to the SSD1306 OLED display, along with the pixel buffer and frame buffer model and basic drawing primitives -- pixels, lines, circles, and text. Building on those primitives, it covers facial expression design: eye shapes, emotion states, idle and blink animations, and expression transitions driven by sensor input, plus the behavior-tree and priority-based behavior patterns used to give a robot a consistent personality, and the non-blocking/cooperative-multitasking techniques needed to animate a display while still responding to sensors. Students finishing this chapter will be able to wire an OLED display over I2C and program a sensor-driven facial expression.
Concepts Covered¶
This chapter covers the following 34 concepts from the learning graph:
- OLED Display
- SSD1306 Driver
- I2C Protocol
- I2C Address
- SPI Protocol
- Display Resolution
- Pixel Buffer
- Frame Buffer
- Draw Pixel
- Draw Line
- Draw Circle
- Draw Text
- Font Rendering
- Display Refresh
- Facial Expression Design
- Eye Shape
- Emotion State
- Idle Animation
- Blink Animation
- Expression Transition
- Sensor Driven Expression
- Robot Personality
- Behavior Tree
- Priority Based Behavior
- Sound Feedback
- Buzzer Melody
- Multi Sensor Fusion
- Display Wiring
- Contrast Setting
- Screen Saver Mode
- Custom Bitmap Icon
- Animation Timing
- Non Blocking Code
- Cooperative Multitasking
Prerequisites¶
This chapter builds on concepts from:
- Chapter 1: Computational Thinking and Debugging for Physical Computing
- Chapter 3: Breadboard Wiring and Electrical Fundamentals
- Chapter 5: Programmable LED Art with NeoPixel/WS2812B
- Chapter 6: Buttons, Photoresistors, and Analog Sensor Input
- Chapter 7: Building and Programming a No-Display STEM Robot
Let's Give This Robot a Face
Your robot from the last chapter can sense, decide, and drive -- but right now it can't tell anyone how it feels about any of it. This chapter fixes that with a tiny screen and a big personality upgrade: you're wiring up an OLED display, learning to draw on it pixel by pixel, and programming a face that blinks, reacts, and shows real emotion state driven by real sensor input. Let's build something expressive!
Talking to a Display: I2C and SPI¶
Before a display can show anything, your Pico needs a way to send it a continuous stream of pixel data over just a few wires. Two communication standards handle that job throughout this book's hardware, and you'll meet both again in later chapters.
I2C protocol (Inter-Integrated Circuit) is a two-wire communication standard -- one wire for data (SDA) and one for a shared clock signal (SCL) -- that lets a Pico talk to multiple devices over the very same two wires, one at a time, by giving each device its own unique I2C address: a short numeric identifier (commonly written in hexadecimal, like 0x3C) that a device listens for before responding to any message on the shared bus. SPI protocol (Serial Peripheral Interface) is a different communication standard that uses more wires -- typically four: clock, data-out, data-in, and a dedicated chip-select line per device -- in exchange for higher data transfer speeds than I2C typically achieves. Because SPI gives each device its own chip-select wire rather than sharing an address on a common bus, it doesn't need anything equivalent to an I2C address at all.
Display wiring for an OLED module in this chapter means connecting its four pins -- power, ground, and the two I2C signal wires (SDA and SCL) -- to the matching pins on the Pico, matched exactly against the kit's wiring diagram; a display wired to the wrong GPIO pins will simply never respond, since the Pico's I2C hardware only checks the specific pins it's configured to use.
I2C(0, scl=Pin(1), sda=Pin(0), freq=400000) configures the Pico's first I2C hardware bus to use GPIO 1 as the clock line and GPIO 0 as the data line, running at 400 kilohertz -- a standard "fast mode" I2C speed. Calling i2c.scan() asks every possible address on that bus to respond, and returns a list of the I2C addresses that actually answered; a properly wired SSD1306 OLED display almost always shows up as 0x3C (60 in decimal), which is the quickest way to confirm your wiring is correct before writing a single pixel.
Berry's Tip
Run i2c.scan() as the very first thing you do after wiring a new I2C device, every time. An empty list means wiring, not code, is the problem -- and that's a much faster thing to fix than staring at working code wondering why nothing shows up on screen.
Now that I2C addresses and the shared-bus idea are defined, the diagram below lets you add multiple simulated I2C devices to one bus and watch how addressing keeps their messages separate.
Diagram: I2C Bus and Address Explorer¶
Run the I2C Bus and Address Explorer MicroSim fullscreen
I2C Bus and Address Explorer (MicroSim)
Type: microsim
sim-id: i2c-bus-address-explorer
Library: p5.js
Status: Specified
Learning objective: Students will explain (Bloom L2: Understand) how multiple I2C devices share the same two wires by responding only to messages addressed to their own unique I2C address.
Canvas: 700x420px, responsive -- recompute the bus line and device icon spacing from windowWidth in windowResized().
Layout: a horizontal pair of lines representing the shared SDA and SCL wires, running left (Pico icon) to right, with three device icons (OLED display, a generic sensor, a generic second display) tapped onto the same two wires at different points along the bus. Each device icon displays its I2C address label beneath it (e.g., "0x3C", "0x27", "0x68").
Controls: a createSelect() dropdown labeled "Send Message To" listing the three device addresses; a createButton() labeled "Send" that animates a small message packet traveling from the Pico icon along the bus to every device simultaneously; a createButton() labeled "Add Address Conflict" that changes the second display's address to also read "0x3C", demonstrating a collision.
Interaction: after clicking "Send," the animated packet reaches all three devices at once, but only the device whose address matches the dropdown selection visually lights up green and shows a small "ACK" (acknowledged) label -- the other two remain gray, showing they saw the message but ignored it since it wasn't addressed to them. If "Add Address Conflict" has been triggered, sending to the conflicting address lights up both matching devices simultaneously and displays a warning "Address collision -- both devices respond!" Clicking any device icon opens an infobox naming its address and one sentence on what a real device at that address might be.
Implementation: p5.js. Devices stored as an array of {name, address, x, y} objects. Message packet animated along a straight-line path using an interpolated lerp() position each frame. Address matching done with simple string comparison against the dropdown's selected value. Recompute all icon and wire positions from windowWidth in windowResized().
Meet the OLED¶
The specific display used throughout this tier of robot kit is an OLED display -- a screen built from organic light-emitting diode pixels that each produce their own light directly, with no separate backlight needed, which is part of why OLED modules can be so small and low-power. Most breadboard-friendly OLED modules are built around the SSD1306 driver -- a dedicated display-driver chip, mounted directly on the OLED module, that handles the low-level electrical work of lighting individual pixels so your Pico only needs to send it high-level drawing commands over I2C rather than controlling each pixel's voltage directly.
An OLED module's display resolution is the fixed number of pixels it contains, expressed as width by height -- the common module in this book's kits is 128x64, meaning 128 pixels across and 64 pixels tall, for a total of 8,192 individually controllable pixels. Unlike a NeoPixel strip's colored pixels, a monochrome OLED's contrast setting controls only how bright its "on" pixels appear against the "off" background, from barely visible to maximum brightness, and is typically set once during setup with a single driver method call.
How Pixels Become Pictures: Frame Buffers¶
Sending a display driver one pixel-lighting command at a time, for all 8,192 pixels, every single time the screen changes, would be slow and would make partially drawn frames flicker into view. Instead, the SSD1306 driver library builds the whole image in memory first. A pixel buffer is a block of memory holding the on/off state of every pixel in an image before that image is actually sent to a display, and the specific pixel buffer used to hold one complete display image, ready to be pushed to the screen all at once, is called a frame buffer.
Every drawing command you'll meet in the next section -- draw a line, draw a circle, draw text -- only edits this in-memory frame buffer; nothing changes on the physical screen yet. Display refresh is the separate step of actually transmitting the frame buffer's current contents to the OLED over I2C so the physical pixels update to match, which in the SSD1306 driver library is one explicit method call, conventionally named .show().
Berry's Key Insight
Separating "draw into the buffer" from "refresh the screen" is a pattern you'll see again and again in physical computing. It means you can draw a dozen shapes -- eyes, a mouth, a status icon -- one at a time in code, but the display only actually flickers once, when you call .show(), instead of flickering after every single shape.
Drawing Primitives¶
With the frame buffer concept in place, the SSD1306 driver library exposes a small set of drawing primitives -- basic drawing operations that every more complex image is built from. Draw pixel turns a single specific (x, y) coordinate in the frame buffer on or off. Draw line draws a straight line between two coordinates. Draw circle draws a circular outline (or, depending on the library method, a filled circle) centered on a coordinate with a given radius. Draw text renders a string of characters onto the buffer using font rendering -- the process of converting each character into its corresponding grid of pixels based on a built-in bitmap font, so that calling one text-drawing method draws dozens of individual pixels correctly shaped as letters, without your code ever touching a single pixel coordinate by hand.
from ssd1306 import SSD1306_I2C
oled = SSD1306_I2C(128, 64, i2c)
oled.fill(0) # clear the frame buffer (all pixels off)
oled.pixel(64, 32, 1) # draw pixel: turn on the center pixel
oled.line(0, 0, 127, 63, 1) # draw line: corner to corner
oled.ellipse(64, 40, 10, 10, 1) # draw circle: centered, radius 10
oled.text("Hello!", 20, 5, 1) # draw text: font-rendered string
oled.show() # display refresh: send buffer to screen
SSD1306_I2C(128, 64, i2c) creates the driver object matching the module's 128x64 display resolution and the I2C bus object configured earlier. Each drawing call's final argument, 1, means "turn these pixels on" (a 0 would turn them off, letting you erase shapes without clearing the whole buffer); everything before oled.show() only edits the in-memory frame buffer, and the screen only actually updates the instant .show() runs.
Beyond the built-in primitives, many robot-face projects define a custom bitmap icon -- a small pre-designed image, stored as a fixed pattern of pixel on/off values, that can be drawn onto the frame buffer in one call rather than being built up from individual lines and circles each frame, which is especially useful for a status icon or logo that never changes shape.
Designing a Robot Face¶
Drawing primitives are the alphabet; a robot face is what you spell with them. Facial expression design is the deliberate process of composing drawing primitives into a recognizable, expressive face on the OLED display -- deciding what eyes, a mouth, or other features should look like and how they should change to communicate something to a person watching.
The most expressive single feature on a simple robot face is usually the eyes. An eye shape is the specific geometric form -- a full circle, a narrowed oval, a simple arc -- used to draw one eye on the display, and swapping between a small library of eye shapes is often enough on its own to make a robot face look happy, sleepy, surprised, or annoyed. Each named look a robot's face can display is an emotion state: a defined, nameable condition (Happy, Curious, Alert, Sleepy) that determines exactly which eye shapes, mouth shape, and any accompanying animation should currently be drawn.
Animating Expressions¶
A face that never moves reads as broken, not calm. Two categories of animation keep a robot face feeling alive even when nothing eventful is happening. Idle animation is a small, subtle, continuously looping motion -- eyes drifting slightly, a mouth line gently curving -- played whenever the robot's current emotion state has no more specific animation of its own, purely to signal "I'm on and paying attention" rather than communicating anything about the current situation. Blink animation is a brief, deliberate animation where the eye shapes quickly close and reopen, played periodically (or triggered by an event) to make the face read as alive rather than a frozen picture, the same way a living creature's occasional blink doesn't register as meaningful on its own.
Moving between two different looks -- say, from Curious to Alert -- is an expression transition: the animated (rather than instantaneous) change from one emotion state's face to another's, which reads as far more natural to a human observer than a face that simply snaps from one static image to a completely different one with no visual bridge between them. How fast any of this happens is controlled by animation timing -- the specific durations and delays (how long a blink lasts, how many milliseconds an idle drift takes to complete one cycle) that determine whether an animation looks lifelike or either sluggish or frantic.
One more display behavior belongs in this section, even though it's less about expression and more about the display hardware itself: screen saver mode is a behavior that replaces the current face with a low-activity or blank pattern after a period of inactivity, specifically to reduce how long the exact same bright pixels stay lit in the exact same positions.
Berry's Gentle Warning
OLED pixels dim slightly with use, and a static high-contrast image -- like a face left in exactly the same pose for hours during a classroom demo table -- wears its lit pixels down faster than the pixels around them, leaving a faint permanent ghost of that shape. Build a screen saver mode into any robot that will sit powered on and idle for a long time, and treat it as standard practice, not an optional extra.
Now that eye shapes, emotion states, idle and blink animation, expression transitions, and animation timing have all been introduced, the diagram below lets you click between emotion states and watch the transition happen.
Diagram: Robot Facial Expression Designer¶
Run the Robot Facial Expression Designer MicroSim fullscreen
Robot Facial Expression Designer (MicroSim)
Type: microsim
sim-id: robot-facial-expression-designer
Library: p5.js
Status: Specified
Learning objective: Students will analyze (Bloom L4: Analyze) how eye shape, mouth shape, and animation timing combine to communicate a specific robot emotion state, and observe how an expression transition bridges two states.
Canvas: 700x420px, responsive -- recompute the simulated OLED display panel's pixel scale from windowWidth in windowResized() while keeping its aspect ratio locked to 128:64.
Layout: a simulated monochrome OLED panel (dark background, light drawn pixels, scaled up so individual "pixels" are visible as small squares) showing a robot face built from simple shapes (two eye shapes, one mouth line). Below the panel, four emotion-state buttons: Happy, Curious, Alert, Sleepy.
Controls: createButton() elements for each of the four emotion states, switching the displayed face; a createSlider() labeled "Transition Speed (ms)" (range 100-1500, default 400) controlling how long the animated morph between two states takes; a createCheckbox() labeled "Idle Animation" (default checked) that, when enabled, adds a small continuous eye-drift motion whenever no transition is in progress; a createButton() labeled "Blink" that triggers one blink animation regardless of current state.
Interaction: clicking a new emotion-state button animates eye shape and mouth shape smoothly morphing from the current state's parameters to the new state's parameters over the Transition Speed duration, rather than snapping instantly -- demonstrating expression transition directly. Hovering any emotion-state button shows a tooltip with a one-sentence description of that state's eye and mouth shape, matching the surrounding chapter text. Clicking "Blink" plays a blink animation regardless of which state is active, then returns to that state's normal look.
Implementation: p5.js, each emotion state defined as a small parameter object (eye height, eye width, mouth curve amount); transitions computed by linearly interpolating (lerp()) between the current and target parameter objects over the transition-speed duration using millis()-based timing, then redrawing the simulated pixel grid each frame from the interpolated parameters. Idle animation implemented as a small sine-wave offset added to eye position only when no transition is active.
From Sensors to Personality¶
A face that only changes when a person clicks a button is a demo, not a robot. Sensor-driven expression connects the sensors from earlier chapters -- a distance sensor, a light sensor, a sound sensor -- directly to which emotion state the face displays, so the robot appears to genuinely react to its surroundings: startled when something approaches suddenly, sleepy in a dark room, alert near an obstacle. Consistently applying the same sensor-to-emotion mappings across a robot's whole program is what gives it a robot personality -- a recognizable, repeated pattern of how a specific robot tends to react, expressed entirely through its choice of emotion states and behaviors rather than through anything programmed as literal "personality" code.
Real environments rarely offer just one relevant sensor reading at a time, though. Multi-sensor fusion is the practice of combining readings from more than one sensor to make a single, better-informed decision than any one sensor's reading could support alone -- for example, only switching to the "Alert" emotion state when both the distance sensor reports something close and the sound sensor detects a sudden noise, which is a much more confident signal than either sensor alone.
def choose_emotion(distance_cm, light_level, sound_detected):
if distance_cm < 10 and sound_detected:
return "alert"
elif light_level < 20:
return "sleepy"
elif distance_cm < 30:
return "curious"
else:
return "happy"
This function is multi-sensor fusion in miniature: it takes three separate sensor readings as parameters and combines them with ordinary if/elif logic to choose exactly one emotion state, checking the most urgent combined condition (something very close and loud) before falling back to single-sensor conditions further down.
Choosing What To Do: Behavior Trees and Priorities¶
As a robot's emotion logic, obstacle avoidance, and line following all start competing for control of the same motors and the same display at once, a plain stack of if/elif statements gets hard to manage. A behavior tree is a structured way of organizing a robot's possible behaviors into a hierarchy, where higher-level nodes decide which lower-level behavior branch should currently run, based on conditions checked in a defined order. The specific ordering rule most behavior trees use is priority-based behavior: behaviors are ranked from most to least important, and the highest-priority behavior whose trigger condition is currently true is the one that actually runs, with every lower-priority behavior automatically suspended until it doesn't.
You've Got This!
If your robot's behaviors ever seem to be "fighting" each other -- the face wants to look alert while the motors are still trying to follow a line that no longer exists -- that's not a design failure, it's a sign your project has outgrown a flat list of if statements and is ready for a real priority order. That's a good problem to have.
Robot safety stop from the previous chapter is the clearest possible example of priority-based behavior: it must always outrank every other behavior, including facial expression and line following, which is exactly why it was written as a check at the very top of the loop rather than as just one more competing condition.
Now that behavior trees and priority ordering have been defined, the diagram below lets you rearrange a robot's behavior priorities and watch which one wins under different simulated sensor conditions.
Diagram: Behavior Tree Priority Explorer¶
Run the Behavior Tree Priority Explorer MicroSim fullscreen
Behavior Tree Priority Explorer (interactive diagram)
Type: interactive-diagram
sim-id: behavior-tree-priority-explorer
Library: vis-network
Template: https://github.com/dmccreary/data-science-course/tree/main/docs/sims/duplicate-handling-decision-tree
Status: Specified
Learning objective: Students will evaluate (Bloom L5: Evaluate) which behavior a priority-based behavior tree selects when multiple sensor conditions are true simultaneously.
Canvas: 700x480px, responsive -- vis-network's built-in auto-resize handles reflow; explicitly call network.setSize() inside a window.onresize handler bound to the container's current dimensions.
Layout: a vis-network hierarchical tree with a root node "Choose Behavior" branching into four ranked child nodes in priority order top-to-bottom: "1. Safety Stop" (highest, red), "2. Obstacle Avoidance" (orange), "3. Line Following" (green), "4. Idle Face" (lowest, gray). Each behavior node has a small attached condition label (e.g., "Emergency button pressed").
Controls: four toggle switches (rendered as HTML checkboxes positioned beside the diagram, one per behavior) simulating whether that behavior's trigger condition is currently true; a "Run Selection" button that highlights, with a thick colored border, whichever enabled behavior has the highest priority.
Interaction: toggling any condition checkbox and clicking "Run Selection" highlights exactly one node -- the highest-priority node whose condition is currently enabled -- and dims the rest, with a text readout below the tree stating which behavior won and why (e.g., "Safety Stop is disabled; Obstacle Avoidance is enabled and outranks Line Following and Idle Face"). Clicking any tree node directly (via vis-network's click event) opens an infobox with a one-sentence description of that behavior, matching the surrounding chapter text. If no conditions are enabled, "Idle Face" is always highlighted as the fallback default.
Implementation: vis-network for the tree layout and click handling; priority-resolution logic implemented in plain JavaScript checking the four toggle states in fixed rank order and updating node color/border via network.body.data.nodes.update(). Keep node and edge data in a small hard-coded array so the tree structure stays easy to extend with more behaviors later.
Adding Sound¶
Expression isn't only visual. Sound feedback uses the piezo buzzer from the previous chapter alongside the OLED face, playing a short sound in sync with an emotion state or event to reinforce what the display is already showing -- a soft ascending tone alongside a "Happy" expression, a sharp beep alongside "Alert." A buzzer melody is a short, ordered sequence of tone-generation calls -- specific frequencies each played for a specific duration -- that together form a recognizable little tune rather than just one flat beep.
def play_melody(notes):
for freq, duration_ms in notes:
buzzer.freq(freq)
buzzer.duty_u16(32768)
time.sleep_ms(duration_ms)
buzzer.duty_u16(0) # silence when the melody ends
happy_chirp = [(660, 100), (880, 100), (1046, 150)]
play_melody(happy_chirp)
play_melody() takes a list of (frequency, duration) pairs -- exactly the buzzer melody structure just described -- and plays each one in order using the same freq() and duty_u16() calls from the previous chapter's tone-generation example, pausing for that note's duration before moving to the next. Setting the duty cycle to 0 after the loop finishes silences the buzzer cleanly rather than leaving the final note hanging.
Keeping It All Running: Non-Blocking Code¶
There's a serious problem hiding in play_melody() as written above: time.sleep_ms(duration_ms) completely freezes the entire program for that duration -- no sensor reading, no motor update, no display refresh can happen while the Pico is paused inside that call. A robot playing a three-note, half-second melody with sleep_ms() would be functionally blind and unresponsive for that entire half second, which is exactly the wrong time for a robot to ignore an approaching obstacle.
Non-blocking code is code written so that no single operation ever halts the entire program while it completes -- instead of pausing and waiting, the program checks "has enough time passed yet?" on each pass through the main loop and only takes the next step once it has, letting every other part of the loop keep running in the meantime. Structuring an entire program around many small operations like this, each checking its own timer independently, is called cooperative multitasking: several tasks (animate the face, watch for a wall, update the melody) all share the same single-threaded loop by each doing a small amount of work per pass and immediately yielding back to the loop, rather than any one task claiming the processor exclusively the way sleep_ms() does.
import time
next_note_time = 0
melody_index = 0
notes = [(660, 100), (880, 100), (1046, 150)]
def update_melody():
global next_note_time, melody_index
now = time.ticks_ms()
if now >= next_note_time and melody_index < len(notes):
freq, duration = notes[melody_index]
buzzer.freq(freq)
buzzer.duty_u16(32768)
next_note_time = now + duration
melody_index += 1
elif melody_index >= len(notes):
buzzer.duty_u16(0)
while True:
update_melody() # non-blocking -- returns almost instantly
read_distance_cm() # still runs every loop pass, melody or not
update_face()
time.ticks_ms() returns the current time in milliseconds without pausing anything, and next_note_time records exactly when the next note should start -- so update_melody() checks that condition, does at most a tiny bit of work, and returns immediately every single time it's called, whether or not a note is actually due. Because it never calls sleep_ms(), the surrounding while True: loop keeps calling read_distance_cm() and update_face() on every pass, melody playing or not -- exactly the cooperative-multitasking pattern that keeps a robot responsive while its face and buzzer are mid-animation.
Let's watch that difference directly before closing out the chapter: a blocking loop and a non-blocking loop given the exact same melody and the exact same incoming obstacle event.
Diagram: Cooperative Multitasking Timeline Simulator¶
Run the Cooperative Multitasking Timeline Simulator MicroSim fullscreen
Cooperative Multitasking Timeline Simulator
Type: interactive-timeline
sim-id: cooperative-multitasking-timeline
Library: vis-timeline
Template: https://github.com/dmccreary/learning-micropython/tree/main/docs/sims/blocking-vs-nonblocking
Status: Specified
Learning objective: Students will compare (Bloom L4: Analyze) how blocking sleep_ms() code versus non-blocking, ticks_ms()-based code affects a robot's ability to respond to a sensor event while a melody is playing.
Canvas: 700x420px, responsive -- vis-timeline's container is set to width: 100% with the timeline instance's redraw() called inside a debounced window.onresize handler.
Layout: two stacked vis-timeline rows sharing one time axis, labeled "Blocking Code" and "Non-Blocking Code." Each row shows the same buzzer melody as three colored timeline blocks (one per note) and a single "Obstacle appears" event marker placed at the same timestamp on both rows, roughly halfway through the second note.
Controls: a "Play Simulation" button that animates a moving playhead across both timeline rows in sync; a createSlider()-equivalent HTML range input labeled "Obstacle Timing" that lets students drag the obstacle-event marker to a different point along the melody and re-run the comparison.
Interaction: as the playhead crosses the obstacle marker, the Non-Blocking row immediately shows a green "Reacted!" flag appearing right at that timestamp, while the Blocking row's reaction flag only appears once the current note block finishes playing -- visibly later -- with a small duration label showing exactly how many milliseconds of delayed reaction that gap represents. Clicking any timeline block (via vis-timeline's click event) opens an infobox with the underlying code line it represents, drawn from the surrounding chapter's two code examples.
Implementation: vis-timeline for the dual synchronized rows; playhead animation driven by requestAnimationFrame advancing a shared simulated-time variable, with reaction-flag placement computed as "next non-blocking loop pass" (near-instant) for the bottom row versus "end of current sleep_ms call" for the top row, both derived from the melody's note-duration data.
Bringing It Together¶
Your robot can now do far more than sense and move -- it can look at you while it does it. An OLED face driven by real sensor readings, animated with proper timing, and layered into a priority-based behavior tree alongside the safety stop and obstacle avoidance from the last chapter, all held together by non-blocking, cooperative code that never freezes the whole program for one animation. The next chapter takes these same display and timekeeping ideas in a new direction -- clocks and watches -- where you'll meet dedicated display driver chips and the real-time clock hardware needed to keep accurate time even when the Pico itself is powered off.
You Unlocked a Superpower!
A robot with a face, a personality, and the non-blocking code skills to keep it all running smoothly at once -- that's a serious superpower, and a berry expressive one too. Let's build something that keeps perfect time next. See you in Chapter 9!