You make animated robot eyes by driving a small monochrome OLED display from an ESP32 microcontroller and redrawing simple eye shapes — rounded rectangles or ellipses with moving pupils — many times per second. In practice that means wiring the display to the ESP32's I2C pins, installing a graphics library such as Adafruit SSD1306 or U8g2, and writing a loop that nudges eye position, size, and eyelid height a few pixels per frame so blinks and glances look continuous. The ESP32 is a Wi-Fi-capable microcontroller with far more RAM and clock speed than an Arduino Uno, which matters here because smooth animation depends on buffering a whole frame in memory and pushing it out quickly. The common pairing is a 128×64 pixel SSD1306 OLED, often two of them for a left and right eye, or a single display showing both eyes side by side.
Table of Contents
- Choosing the display and wiring it
- What actually makes an eye look animated
- Keeping motion smooth instead of jerky
- Libraries and ready-made eye code
- Limits worth knowing before you build
- Frequently Asked Questions
Choosing the display and wiring it
Most robot-eye builds use one of two OLED controllers. The SSD1306 is the cheap, widely available 0.96-inch or 1.3-inch monochrome panel, typically I2C, and is the usual starting point. The SH1106 looks identical but has a slightly different memory layout, so a sketch written for one can show a shifted or wrapped image on the other — if your eyes appear offset by two pixels, you likely picked the wrong driver class. I2C wiring is four wires: VCC to 3.3V, GND to ground, SDA and SCL to the ESP32's I2C pins (GPIO 21 and 22 on the classic ESP32 DevKit, though the ESP32 lets you remap them in software).
SPI versions of the same panels exist and refresh faster, at the cost of more wires. Two eyes on one I2C bus is the first real snag. SSD1306 modules usually ship with a fixed address of 0x3C, and some expose a solder jumper to switch to 0x3D. If both boards are stuck at the same address you have three options:.
- Bridge the address jumper on one board, if it has one
- Use the ESP32's second I2C peripheral, giving each display its own bus and pin pair
- Add an I2C multiplexer such as a TCA9548A and select one channel at a time
What actually makes an eye look animated
An eye is not a bitmap in most of these projects. It is two or three primitives redrawn each frame: a filled rounded rectangle or ellipse for the eye white, a smaller filled shape for the pupil, and a black rectangle sliding down from the top to act as an eyelid. Animation is just changing those numbers over time. The core loop is clear-buffer, draw shapes at their current coordinates, send the buffer to the display, then advance the coordinates.
Libraries like Adafruit SSD1306 keep a full 1 KB framebuffer in the ESP32's RAM and transmit it on `display()`, which is why you never see partial draws tearing across the screen. Three behaviours do most of the expressive work. A blink is the eyelid rectangle growing to cover the eye and shrinking back over roughly 100 to 200 milliseconds. A saccade is the pupil jumping to a new offset, held for a random interval. A squint or widen is the eye shape's own height changing, which reads as mood far more strongly than pupil position alone.
Keeping motion smooth instead of jerky
The usual mistake is animating with `delay()`. A blocking delay freezes everything — sensors, Wi-Fi, serial — and produces motion that stutters whenever any other code runs long. Track time instead: store a `millis()` timestamp, and on each pass through `loop()` check whether enough milliseconds have passed to advance the animation one step. Frame rate is bounded by how fast you can push the framebuffer.
I2C at the default 100 kHz is slow for 1 KB of pixel data every frame; raising the bus to 400 kHz, which the ESP32 and most SSD1306 modules support, is the single easiest improvement. If you still need more headroom, an SPI display or the U8g2 library's partial-buffer modes reduce the data sent per update. Interpolate rather than teleport. Moving a pupil from one position to the next across several frames, with easing so it starts and stops gradually, is what separates an eye that looks alive from one that looks like it is being switched between two states. Adding small random jitter and irregular blink timing helps, because perfectly periodic blinking reads as mechanical.
Libraries and ready-made eye code
You have a choice between writing the shapes yourself and adopting an existing eye engine. The lightweight route is Adafruit GFX plus Adafruit SSD1306: you get `fillRoundRect`, `fillEllipse`-style primitives and a framebuffer, and you build the eye logic. U8g2 is the alternative, supporting a wider set of OLED controllers and offering buffer modes that trade RAM for speed. There is also a well-known category of prebuilt robot-eye code.
The RoboEyes library (written for Arduino-family boards including the ESP32) implements blinking, winking, mood states and directional glances as function calls, so you set a mood or trigger a blink rather than managing coordinates. At the much more complex end, Adafruit's "Uncanny Eyes" project renders photorealistic eyes with iris textures, but it targets colour TFT displays and more capable boards, not a monochrome 128×64 OLED. Choosing between them is mostly about where you want to spend effort. A prebuilt library gets convincing eyes running in an evening; hand-rolled primitives let you design a distinctive look and keep the code small enough to sit alongside your robot's other tasks.
Limits worth knowing before you build
OLED burn-in is real, and robot eyes are close to a worst case: bright shapes in fixed positions, on for hours. Mitigations are the same as for any OLED — dim the display, shift the whole eye region by a few pixels periodically, and blank the screen when the robot is idle. Monochrome panels give you one bit per pixel, so there is no antialiasing.
Curved edges will look stepped, especially on a 0.96-inch panel where pixels are visibly large. Larger 1.3-inch modules and careful use of rounded rectangles instead of true circles make this less obvious. Power and mounting cause more failures than code. Drive the panels from a supply that can handle the ESP32's Wi-Fi current spikes, keep I2C runs short, and remember that jumper wires to a head that moves will eventually fail at the connector — strain relief on those four wires is worth the trouble.
Frequently Asked Questions
Can I run two OLED eyes from one ESP32?
Yes. Use the address jumper if your modules have one, or put each display on one of the ESP32's two hardware I2C buses with separate SDA/SCL pins.
Do I need an ESP32, or will a smaller board work?
An Arduino Uno can drive an SSD1306, but its 2 KB of RAM leaves almost nothing after a 1 KB framebuffer. The ESP32's extra RAM and speed give you room for animation logic plus sensors or networking.
Why do my eyes look shifted or wrapped around the screen?
That is the classic SSD1306-versus-SH1106 mismatch. The two controllers address memory differently; switch to the library class matching your actual panel.



