OLED Animation Maker OLED Maker

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
PartArduino Uno / NanoESP32 (common)
OLED VCC / GND5V / GND3.3V / GND
OLED SDA / SCLA4 / A5GPIO21 / GPIO22
BTN_BLINK (optional)D2 → GND when pressedGPIO0 or any free GPIO
Second OLED (optional)Same bus, address 0x3DSame 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:

  1. Clear the buffer
  2. Draw each eye outline / filled sclera white
  3. Punch blacks for lids if partially closed
  4. Draw pupils at (cx + lookX, cy + lookY)
  5. Optional: tiny highlight pixel offset from the pupil
  6. 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:

  1. Open — full sclera + pupil
  2. Half — black bars (lids) cover the top and bottom thirds
  3. Closed — thin white horizontal line where the eye was
  4. Half again on the way up
  5. 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_SSD1306 objects inside loop().
  • 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_RX or move centers farther apart.
  • Pupils clip outside the white: tighten constrain limits or shrink PUPIL_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

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 →