Arduino Robot Eyes OLED Animation — Blink, Look, Emotion Frames
Every classroom robot looks “alive” the moment it has eyes. Not a photo of eyes pasted as a full-screen bitmap — just two white outlines, pupils that glance sideways, and a blink that collapses the lids. That alone makes kids lean in and adults start smiling at a plastic box.
On a 128×64 SSD1306 you have more room than you think. I have shippped this same pattern on scrap
Nano boards and on ESP32 heads with dual panels. The trick is geometry plus a tiny state machine — not
dozens of full-frame PNGs eating flash. This tutorial walks through how I draw the eyes with Adafruit
GFX, animate look and blink with millis(), add two emotion modes (happy and sleepy), and
optionally mirror the look to a second OLED. You get a complete sketch at the end.
What you will build
- Left + right eye outlines and white “sclera” ellipses (or circles) on one 128×64 panel
- Pupils that look center / left / right on a schedule
- A multi-step blink that squashes the open eye into a thin line and opens again
- Emotion states: normal, happy (curved lower lid), sleepy (half lids + slow blink)
- Non-blocking timing so you can still read buttons or sensors in
loop() - Notes for a dual-OLED robot head (one eye per display)
Parts and wiring
- Arduino Uno or Nano (ESP32 works the same with different I2C pins)
- One I2C SSD1306 128×64 (or two if you want separate eye panels)
- Optional: one free digital pin for a demo button that forces a blink
| Part | Arduino Uno / Nano | ESP32 (common) |
|---|---|---|
| OLED VCC / GND | 5V / GND | 3.3V / GND |
| OLED SDA / SCL | A4 / A5 | GPIO21 / GPIO22 |
| BTN_BLINK (optional) | D2 → GND when pressed | GPIO0 or any free GPIO |
| Second OLED (optional) | Same bus, address 0x3D | Same idea |
Confirm the panel answers on 0x3C (or 0x3D) before arguing with eye math. If the
screen stays black, use the blank-screen checklist first — eyes will not fix a dead bus.
Why vector eyes beat giant bitmaps (on Uno)
A full 128×64 frame is 1024 bytes in flash when stored as a PROGMEM bitmap. Eight carefully drawn “emotion frames” as images eats 8 KB before you write a line of behavior. Circles and filled boxes, meanwhile, cost almost no flash — only the few constants that describe width, height, and pupil offset. On a Uno that budget difference is the project.
Bitmaps are still the right tool when you want lashes, comic pupils, or brand-specific shapes. I often start with vectors for the workshop, then refine one or two hero expressions as bitmaps in the maker tool once the timing feels good. Mix both: vectors for idle life, bitmaps for a special “wake up” animation.
Eye geometry on a 128×64 canvas
Mentally split the screen into two halves. Each eye sits on a center point. I use roughly:
// screen 128x64 — centers for left and right eyes
const int LEFT_CX = 40;
const int RIGHT_CX = 88;
const int EYE_CY = 32;
const int EYE_RX = 22; // horizontal radius of sclera
const int EYE_RY = 18; // vertical radius (slightly flatter)
const int PUPIL_R = 6;
Adafruit GFX draws circles with equal radius, so a true ellipse needs either a helper that plots the outline or a filled circle plus clipping. For classroom clarity I often draw a rounded rectangle (or circle) and accept slightly cartoon proportions. For prettier ovals, mask with upper/lower lids — which you need for blink anyway.
Drawing order that stays readable:
- Clear the buffer
- Draw each eye outline / filled sclera white
- Punch blacks for lids if partially closed
- Draw pupils at
(cx + lookX, cy + lookY) - Optional: tiny highlight pixel offset from the pupil
display.display()once
void drawOpenEye(int cx, int cy, int lookX, int lookY) {
// sclera
display.fillCircle(cx, cy, EYE_RX, SSD1306_WHITE);
// keep pupil inside — clamp look offsets
int px = constrain(lookX, -8, 8);
int py = constrain(lookY, -4, 4);
display.fillCircle(cx + px, cy + py, PUPIL_R, SSD1306_BLACK);
// highlight
display.fillCircle(cx + px - 2, cy + py - 2, 2, SSD1306_WHITE);
}
If your module looks “fat” because of a circular fill overlapping lids, switch to
drawRoundRect + fillRoundRect for a more anime “stadium” eye. Same pupil math.
Look left / right without blocking
Humans do not stare forever. Idle life is: hold center a bit, glance, hold, glance the other way,
blink occasionally. Store intent in variables, not in nested delay() sandwiches.
enum LookDir : int8_t { LOOK_LEFT = -1, LOOK_CENTER = 0, LOOK_RIGHT = 1 };
LookDir look = LOOK_CENTER;
int8_t lookX = 0; // animated pixel offset
unsigned long nextLookAt = 0;
void updateLook(unsigned long now) {
if (now < nextLookAt) return;
// pick a new target every 1.2–2.8 s
uint8_t r = random(0, 3);
look = (r == 0) ? LOOK_LEFT : (r == 1) ? LOOK_RIGHT : LOOK_CENTER;
nextLookAt = now + 1200UL + random(0, 1600);
}
// ease pupil toward target each frame
void easePupil() {
int target = (int)look * 7;
if (lookX < target) lookX++;
else if (lookX > target) lookX--;
}
Keep easePupil() light: change by one pixel per redraw. Calling redraw at 20–30 Hz already
looks smooth on OLED because black-to-white edges are sharp. Do not chase 60 fps — I2C bandwidth and
display() time will fight you, especially on long Dupont wires.
Blink frames (the bit that sells the illusion)
A convincing blink is three to five discrete shapes, not one “line then open.” My workshop set:
- Open — full sclera + pupil
- Half — black bars (lids) cover the top and bottom thirds
- Closed — thin white horizontal line where the eye was
- Half again on the way up
- Open
enum BlinkPhase : uint8_t {
BLINK_IDLE = 0,
BLINK_CLOSING,
BLINK_CLOSED,
BLINK_OPENING
};
BlinkPhase blink = BLINK_IDLE;
uint8_t blinkStep = 0; // 0 open .. 4 closed-ish scale
unsigned long nextBlinkAt = 0;
unsigned long blinkStepAt = 0;
void startBlink(unsigned long now) {
if (blink != BLINK_IDLE) return;
blink = BLINK_CLOSING;
blinkStep = 0;
blinkStepAt = now;
}
void updateBlink(unsigned long now) {
if (blink == BLINK_IDLE) {
if (now >= nextBlinkAt) {
startBlink(now);
nextBlinkAt = now + 2800UL + random(0, 4000);
}
return;
}
// advance lid every ~35 ms
if (now - blinkStepAt < 35) return;
blinkStepAt = now;
if (blink == BLINK_CLOSING) {
if (blinkStep < 4) blinkStep++;
else { blink = BLINK_CLOSED; }
} else if (blink == BLINK_CLOSED) {
blink = BLINK_OPENING;
} else if (blink == BLINK_OPENING) {
if (blinkStep > 0) blinkStep--;
else blink = BLINK_IDLE;
}
}
// 0 = fully open, 4 = fully closed
void drawEyeWithBlink(int cx, int cy, int lookX, int lookY, uint8_t step) {
if (step >= 4) {
display.drawFastHLine(cx - EYE_RX, cy, EYE_RX * 2, SSD1306_WHITE);
return;
}
display.fillCircle(cx, cy, EYE_RX, SSD1306_WHITE);
// lid cover from top and bottom based on step
int cover = step * 5; // pixels of black lid
display.fillRect(cx - EYE_RX, cy - EYE_RX, EYE_RX * 2, cover, SSD1306_BLACK);
display.fillRect(cx - EYE_RX, cy + EYE_RX - cover, EYE_RX * 2, cover, SSD1306_BLACK);
if (step < 3) {
int px = constrain(lookX, -8, 8);
int py = constrain(lookY, -4, 4);
display.fillCircle(cx + px, cy + py, PUPIL_R, SSD1306_BLACK);
}
}
Randomize the idle blink interval so the face never feels like a metronome. Double blinks (two closures
a few hundred ms apart) are an easy next step if you want more personality — just schedule a second
startBlink() during BLINK_IDLE shortly after the first finishes.
Emotion states: happy and sleepy
I keep emotions as an enum and let draw code branch lightly. Happy lowers the lower lid into a soft curve — you can fake a curve with a black filled arc (triangle wedges) or simply raise a thick black band under the pupil so the eye looks squinting with joy. Sleepy drops both lids permanently (start from step 2) and slows the blink + look timers.
enum Mood : uint8_t { MOOD_NORMAL, MOOD_HAPPY, MOOD_SLEEPY };
Mood mood = MOOD_NORMAL;
void applyMoodOverlay(int cx, int cy) {
if (mood == MOOD_HAPPY) {
// soft smile lids: cover lower portion
display.fillRect(cx - EYE_RX, cy + 4, EYE_RX * 2, EYE_RX, SSD1306_BLACK);
// little upward curve using two lines
display.drawLine(cx - 10, cy + 6, cx, cy + 2, SSD1306_WHITE);
display.drawLine(cx, cy + 2, cx + 10, cy + 6, SSD1306_WHITE);
} else if (mood == MOOD_SLEEPY) {
// heavy upper lid
display.fillRect(cx - EYE_RX, cy - EYE_RX, EYE_RX * 2, 10, SSD1306_BLACK);
}
}
Drive mood from real inputs when you can: light sensor → sleepy in the dark, Ultrasonic “someone close” → happy look toward the object, battery low → half lids. The eyes become UI for the robot’s state, which is more useful than a seven-segment percent.
Frame rate and redraw strategy
Redraw only when something visible changed (look offset, blink step, mood) or on a slow idle tick so you still clear noise. A fixed 40 ms redraw (~25 Hz) is plenty:
unsigned long lastDraw = 0;
const unsigned long FRAME_MS = 40;
void loop() {
unsigned long now = millis();
updateLook(now);
updateBlink(now);
easePupil();
// optional: read sensors / buttons here
if (now - lastDraw >= FRAME_MS) {
lastDraw = now;
display.clearDisplay();
uint8_t step = (blink == BLINK_IDLE) ? 0 : blinkStep;
if (mood == MOOD_SLEEPY && step < 2) step = 2;
drawEyeWithBlink(LEFT_CX, EYE_CY, lookX, 0, step);
applyMoodOverlay(LEFT_CX, EYE_CY);
drawEyeWithBlink(RIGHT_CX, EYE_CY, lookX, 0, step);
applyMoodOverlay(RIGHT_CX, EYE_CY);
display.display();
}
}
Notice both eyes share lookX. That is intentional — human eyes conjugate. For a lazy comedy
robot you can offset the right eye by a couple of pixels so they never quite agree.
Optional second OLED for dual eyes
Chassis builders love one display per eye. You need two addresses: change the solder jumper on one
module to 0x3D, then instantiate two displays. Full dual wiring notes live in the dual OLED
article; the eye logic stays the same — call the same drawEyeWithBlink once per panel with
center coordinates local to that 128×64 (for example cx = 64 on each).
Adafruit_SSD1306 leftEye(128, 64, &Wire, -1);
Adafruit_SSD1306 rightEye(128, 64, &Wire, -1);
void setup() {
if (!leftEye.begin(SSD1306_SWITCHCAPVCC, 0x3C)) for (;;);
if (!rightEye.begin(SSD1306_SWITCHCAPVCC, 0x3D)) for (;;);
}
Warning for Uno: two full Adafruit buffers are ~2 KB of SRAM before your sketch variables. That often
still fits a lean eyes sketch, but any extra sensors + String use will push you over. Prefer
ESP32, or one panel with both eyes drawn, when students start adding Wi-Fi libraries.
Memory tips from the lab
- Keep strings in
F()if you print debug text on the OLED. - Avoid stacking many full-screen PROGMEM frames “just in case.”
- If you export blink bitmaps from a tool, crop to the eye bounding box (e.g. 48×40), not 128×64.
- Do not create temporary
Adafruit_SSD1306objects insideloop(). - Read the Uno frame budget guide before packing a 20-frame welcome animation onto the same board.
Refining frames in OLED Animation Maker
Once the timing state machine works, polish the shapes visually. Open
oledanimationmaker.com, draw a closed lid and a half
lid as small sprite frames, export Adafruit PROGMEM arrays, and swap the “closed” / “half” branches to
drawBitmap. Leave look offsets as live math — combining bitmap lids with a moving pupil is
how store-shelf toys feel custom without burning the entire flash chip.
For brand eyes (hexagonal lenses, LED-ring pupils), draw once in the maker, export, then keep your
millis() scheduler unchanged. The scheduler is the hard part; pixels can iterate.
Full sketch (copy-paste)
Single 128×64 panel, both eyes, look + blink + moods cycling for demo. Optional blink button on D2 (INPUT_PULLUP). Libraries: Adafruit GFX + Adafruit SSD1306.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
const int LEFT_CX = 40;
const int RIGHT_CX = 88;
const int EYE_CY = 32;
const int EYE_RX = 22;
const int PUPIL_R = 6;
const uint8_t PIN_BLINK = 2;
enum LookDir : int8_t { LOOK_LEFT = -1, LOOK_CENTER = 0, LOOK_RIGHT = 1 };
enum BlinkPhase : uint8_t { BLINK_IDLE, BLINK_CLOSING, BLINK_CLOSED, BLINK_OPENING };
enum Mood : uint8_t { MOOD_NORMAL, MOOD_HAPPY, MOOD_SLEEPY };
LookDir look = LOOK_CENTER;
int8_t lookX = 0;
BlinkPhase blink = BLINK_IDLE;
uint8_t blinkStep = 0;
Mood mood = MOOD_NORMAL;
unsigned long nextLookAt = 0;
unsigned long nextBlinkAt = 0;
unsigned long blinkStepAt = 0;
unsigned long nextMoodAt = 0;
unsigned long lastDraw = 0;
bool btnLast = true;
void startBlink(unsigned long now) {
if (blink != BLINK_IDLE) return;
blink = BLINK_CLOSING;
blinkStep = 0;
blinkStepAt = now;
}
void updateLook(unsigned long now) {
if (now < nextLookAt) return;
uint8_t r = random(0, 3);
look = (r == 0) ? LOOK_LEFT : (r == 1) ? LOOK_RIGHT : LOOK_CENTER;
unsigned long span = (mood == MOOD_SLEEPY) ? 2200UL : 1200UL;
nextLookAt = now + span + random(0, 1600);
}
void easePupil() {
int target = (int)look * 7;
if (lookX < target) lookX++;
else if (lookX > target) lookX--;
}
void updateBlink(unsigned long now) {
if (blink == BLINK_IDLE) {
if (now >= nextBlinkAt) {
startBlink(now);
unsigned long gap = (mood == MOOD_SLEEPY) ? 4500UL : 2800UL;
nextBlinkAt = now + gap + random(0, 4000);
}
return;
}
unsigned long pace = (mood == MOOD_SLEEPY) ? 55UL : 35UL;
if (now - blinkStepAt < pace) return;
blinkStepAt = now;
if (blink == BLINK_CLOSING) {
if (blinkStep < 4) blinkStep++;
else blink = BLINK_CLOSED;
} else if (blink == BLINK_CLOSED) {
blink = BLINK_OPENING;
} else if (blink == BLINK_OPENING) {
if (blinkStep > 0) blinkStep--;
else blink = BLINK_IDLE;
}
}
void applyMoodOverlay(int cx, int cy) {
if (mood == MOOD_HAPPY) {
display.fillRect(cx - EYE_RX, cy + 4, EYE_RX * 2, EYE_RX, SSD1306_BLACK);
display.drawLine(cx - 10, cy + 6, cx, cy + 2, SSD1306_WHITE);
display.drawLine(cx, cy + 2, cx + 10, cy + 6, SSD1306_WHITE);
} else if (mood == MOOD_SLEEPY) {
display.fillRect(cx - EYE_RX, cy - EYE_RX, EYE_RX * 2, 10, SSD1306_BLACK);
}
}
void drawEyeWithBlink(int cx, int cy, int lx, uint8_t step) {
if (step >= 4) {
display.drawFastHLine(cx - EYE_RX, cy, EYE_RX * 2, SSD1306_WHITE);
return;
}
display.fillCircle(cx, cy, EYE_RX, SSD1306_WHITE);
int cover = step * 5;
display.fillRect(cx - EYE_RX, cy - EYE_RX, EYE_RX * 2, cover, SSD1306_BLACK);
display.fillRect(cx - EYE_RX, cy + EYE_RX - cover, EYE_RX * 2, cover, SSD1306_BLACK);
if (step < 3) {
int px = constrain(lx, -8, 8);
display.fillCircle(cx + px, cy, PUPIL_R, SSD1306_BLACK);
display.fillCircle(cx + px - 2, cy - 2, 2, SSD1306_WHITE);
}
}
void pollBlinkButton(unsigned long now) {
bool raw = digitalRead(PIN_BLINK); // HIGH = not pressed
if (btnLast == HIGH && raw == LOW) startBlink(now);
btnLast = raw;
}
void setup() {
pinMode(PIN_BLINK, INPUT_PULLUP);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) for (;;);
randomSeed(analogRead(A0));
unsigned long now = millis();
nextLookAt = now + 800;
nextBlinkAt = now + 1500;
nextMoodAt = now + 8000;
display.clearDisplay();
display.display();
}
void loop() {
unsigned long now = millis();
pollBlinkButton(now);
updateLook(now);
updateBlink(now);
easePupil();
if (now >= nextMoodAt) {
mood = (Mood)((mood + 1) % 3);
nextMoodAt = now + 10000UL;
}
if (now - lastDraw >= 40) {
lastDraw = now;
display.clearDisplay();
uint8_t step = (blink == BLINK_IDLE) ? 0 : blinkStep;
if (mood == MOOD_SLEEPY && blink == BLINK_IDLE && step < 2) step = 2;
drawEyeWithBlink(LEFT_CX, EYE_CY, lookX, step);
applyMoodOverlay(LEFT_CX, EYE_CY);
drawEyeWithBlink(RIGHT_CX, EYE_CY, lookX, step);
applyMoodOverlay(RIGHT_CX, EYE_CY);
display.display();
}
}
Tuning on the robot
- Eyes too big / overlapping: lower
EYE_RXor move centers farther apart. - Pupils clip outside the white: tighten
constrainlimits or shrinkPUPIL_R. - Blink looks harsh: more steps (0..6) and longer
pace. - Face feels nervous: lengthen look hold times; reduce how often look is non-center.
- Want sound sync: call
startBlink()when a servo moves or a speaker beep starts.
Making look direction mean something
Random glances are fine for a desk mascot. On a mobile robot I wire look to the same sensors that already
drive motion. Example: if an HC-SR04 reading on the left is closer than the right by more than 8 cm, force
look = LOOK_LEFT for at least half a second before returning to idle wander. Students instantly
understand “the robot noticed the obstacle” without a text HUD.
For a pan-tilt camera head, map servo angles to pupil offsets with a linear scale — keep the range small (±6 to ±8 px) or the pupil escapes the sclera and the character looks broken. When the head is moving quickly, suppress random look changes; competing motions make the face feel drunk rather than curious.
Button or capacitive touch near the chin can trigger happy mood for a few seconds. That single affordance turns the OLED into a social interface, which is exactly why people bolt screens onto otherwise boring chassis.
Power, flicker, and motors on the same rail
OLED eyes look great until a motor driver browns out the 5V rail. Symptoms people mislabel as “animation bugs”: sudden full blank, stuck frames, or pupils that freeze mid-glance. I separate motor power, keep a fat electrolytic near the OLED VCC, and shorten I2C wires before I touch the blink timing again.
If you must share USB power for a demo, throttle servo motion while display() is in flight —
or simply accept 15 FPS and fewer simultaneous actuators. Classroom robots that “worked on the bench”
often fail on the competition carpet for this reason alone.
From demo to reusable module
Once you like the behavior, lift eyes into a small C++ class or a pair of .h/.cpp files:
begin(), setMood(), nudgeLook(), update(now),
draw(display). Keep Arduino loop() thin. That structure survives when you later
bolt on a menu system or a battery meter on the same panel — eyes become one layer you redraw after the
HUD, or you swap to a dedicated eye OLED and leave telemetry on the other.
Troubleshooting FAQ
Eyes draw but never blink
Check that updateBlink runs every loop and that nextBlinkAt is not stuck in the
past incorrectly. Also confirm you are not blocking with a long delay() elsewhere.
One eye lags the other on dual OLEDs
You are probably calling display() on left, doing heavy work, then right. Update both
buffers, then flush both back-to-back. Keep I2C wires short and share a solid GND.
Allocation failed when adding a second display
SRAM is gone. Stay with one panel drawing two eyes, move to ESP32, or switch to a page-buffer library for advanced cases — see the library comparison post.
Can I use bitmaps instead of fillCircle?
Yes. Export open / half / closed frames from the maker and select by blinkStep. Keep pupil as
a live fill so look direction stays free.
SH1106 module — eyes look shifted
Classic controller mismatch. Use an SH1106-capable init or add a small X offset. Details in SH1106 vs SSD1306.
Related
- GFX shapes: circle, rect, line
- PROGMEM animation frames
- Two OLEDs on one I2C bus
- Uno memory limits
- U8g2 vs Adafruit for animations
- Blank screen checklist
Polish lids as bitmap frames
Sketch custom closed and half-open lids in the free tool, export PROGMEM arrays, and drop them into this blink state machine.
Open oledanimationmaker.com →