ESP32 desk robot with OLED eyes: wiring, code and touch moods
An ESP32 desk robot is a small box on your desk with two animated eyes on an OLED screen. It blinks, looks around, smiles when you tap its head and falls asleep when you ignore it. This guide shows you how to build one with an ESP32-C3 board, a 0.96 inch SSD1306 OLED, a TTP223 touch sensor and a buzzer. You get the wiring, one complete Arduino sketch with four moods, and fixes for the problems people hit most often.
Quick answer. Wire the OLED to 3.3V, GND, SDA (GPIO 8) and SCL (GPIO 9) on an ESP32-C3 SuperMini. Wire the TTP223 touch sensor OUT pin to GPIO 3 and a passive buzzer to GPIO 4. Install Adafruit SSD1306 and Adafruit GFX, upload the sketch below, and the robot will blink, glance around, smile on a tap and sleep after one minute alone.
What the ESP32 desk robot does
Desk companion robots are one of the most popular maker builds this autumn. Most of them share the same simple idea. The "face" is a tiny OLED, and the "personality" is a few lines of code that decide when to blink and where to look. Our version has four moods:
- Normal: the eyes blink every 2 to 6 seconds and glance in random directions.
- Happy: a tap on the head turns the eyes into smiling arcs and plays a short chirp. Keep your finger on the sensor and the robot stays happy, like a pet being stroked.
- Sleepy: after 60 seconds with no touch, the upper lids drop, the eyes look down, the screen dims and a small "z" appears.
- Surprised: tap a sleeping robot and its eyes grow wide for a moment before it goes back to normal.
The eyes are drawn with shapes, not stored pictures. That keeps the sketch small and lets the eyes move smoothly to any position. If you want to understand the drawing part in more depth, read the Arduino OLED robot eyes tutorial first. This post focuses on turning those eyes into a finished robot.
What you need
| Part | Notes |
|---|---|
| ESP32-C3 SuperMini | Tiny board with USB-C. A classic ESP32 DevKit also works with the pins in the next table. |
| 0.96 inch SSD1306 OLED, I2C | 128x64 pixels, 4 pins: GND, VCC, SCL, SDA. White or blue both look good. |
| TTP223 touch module | A small board with a touch pad. Its output goes HIGH while you touch it. |
| Passive buzzer or piezo disc | Optional. Passive means it needs a changing signal to make sound, so tone() can play notes. |
| Jumper wires and USB-C cable | The cable must carry data, not only charge. |
| A small box or 3D printed head | Anything with a window for the screen and a thin top for the touch pad. |
Most 0.96 inch modules use the SSD1306 chip. Many 1.3 inch modules use the SH1106 chip instead, which needs a different library. If you are not sure which one you have, see SH1106 vs SSD1306: how to tell them apart.
ESP32 desk robot wiring
I2C (a two-wire bus for data and clock) carries everything the OLED needs. Power every module from the 3.3V pin, not 5V. The ESP32 pins are 3.3V only, and the TTP223 output follows its own supply voltage.
| Module pin | ESP32-C3 SuperMini | Classic ESP32 DevKit |
|---|---|---|
| OLED VCC | 3.3V | 3V3 |
| OLED GND | GND | GND |
| OLED SDA | GPIO 8 | GPIO 21 |
| OLED SCL | GPIO 9 | GPIO 22 |
| TTP223 VCC | 3.3V | 3V3 |
| TTP223 GND | GND | GND |
| TTP223 OUT (I/O) | GPIO 3 | GPIO 27 |
| Buzzer + | GPIO 4 | GPIO 25 |
| Buzzer - | GND | GND |
A few things to know about these pins:
- GPIO 8 and 9 are the default I2C pins in the Arduino setup for the SuperMini. They are also "strapping" pins, which the chip reads at power-on to choose how to boot. The pull-up resistors on the OLED module keep them HIGH, which is the normal boot state, so this is safe.
- The blue LED on the SuperMini sits on GPIO 8. It will flicker while the screen updates. That is normal and harmless.
- Leave the jumpers on the TTP223 open. With both open the module gives a HIGH output only while touched, which is what the code expects.
- A small magnetic buzzer can draw 20 to 30 mA. Put a 100 ohm resistor in series with it to protect the pin. A piezo disc draws very little and can connect directly.
Install the board package and libraries
- In the Arduino IDE, open Boards Manager and install esp32 by Espressif Systems (version 3.x).
- Open Library Manager and install Adafruit SSD1306. Accept the prompt to install Adafruit GFX and Adafruit BusIO too.
- Select the board ESP32C3 Dev Module for a SuperMini, or ESP32 Dev Module for a classic board.
- For the SuperMini, set Tools > USB CDC On Boot to Enabled. Without it, the Serial Monitor stays empty.
- Pick the right port under Tools > Port.
The full list of drawing functions is in the Adafruit SSD1306 library on GitHub. Board setup details for every ESP32 chip are in the Espressif Arduino-ESP32 documentation.
The complete desk robot sketch
Copy the whole sketch below. The only lines you may need to change are the five pin and address lines at the top. Each one has a comment that shows the classic ESP32 value.
// ESP32 desk robot: animated OLED eyes with touch and sleep moods
// Board: ESP32-C3 SuperMini (or any ESP32). Screen: 128x64 SSD1306 I2C.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_ADDR 0x3C // change to 0x3D if your module uses that address
#define SDA_PIN 8 // classic ESP32: 21
#define SCL_PIN 9 // classic ESP32: 22
#define TOUCH_PIN 3 // TTP223 OUT. Classic ESP32: 27
#define BUZZER_PIN 4 // passive buzzer. Classic ESP32: 25. Use -1 for no buzzer
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);
enum Mood { NORMAL, HAPPY, SLEEPY, SURPRISED };
Mood mood = NORMAL;
const int EYE_W = 36; // eye width in pixels
const int EYE_H = 36; // eye height in pixels
const int EYE_R = 8; // corner radius
const int EYE_GAP = 16; // space between the eyes
const int EYE_Y = 14; // top of the eyes when looking straight ahead
const unsigned long SLEEP_AFTER = 60000; // ms without a touch before sleeping
float lookX = 0, lookY = 0; // where the eyes are now
int targetX = 0, targetY = 0; // where the eyes are moving to
float openAmt = 1.0; // 1.0 = fully open, 0.0 = closed
unsigned long nextBlink = 0, nextLook = 0, moodUntil = 0, lastTouch = 0;
unsigned long blinkStart = 0;
bool blinking = false;
bool wasTouched = false;
void chirp(int freq, int ms) {
if (BUZZER_PIN >= 0) tone(BUZZER_PIN, freq, ms);
}
void setMood(Mood m) {
if (m == mood) return;
display.dim(m == SLEEPY); // lower brightness while asleep
mood = m;
}
void drawEye(int cx, int cy, int w, int h) {
int hh = max(2, (int)(h * openAmt)); // squash the eye while blinking
int x = cx - w / 2;
int y = cy - hh / 2;
int r = min(EYE_R, hh / 2);
display.fillRoundRect(x, y, w, hh, r, SSD1306_WHITE);
if (mood == HAPPY && hh > 8) {
// a black block over the lower half leaves a smiling arc
display.fillRoundRect(x - 2, y + hh / 2, w + 4, hh, EYE_R, SSD1306_BLACK);
}
if (mood == SLEEPY) {
display.fillRect(x, y, w, hh / 2, SSD1306_BLACK); // heavy upper lid
}
}
void drawFace() {
display.clearDisplay();
int w = EYE_W, h = EYE_H;
if (mood == SURPRISED) { w += 6; h += 6; } // wide awake
int cy = EYE_Y + EYE_H / 2 + (int)lookY;
int leftCx = SCREEN_W / 2 - EYE_GAP / 2 - EYE_W / 2 + (int)lookX;
int rightCx = SCREEN_W / 2 + EYE_GAP / 2 + EYE_W / 2 + (int)lookX;
drawEye(leftCx, cy, w, h);
drawEye(rightCx, cy, w, h);
if (mood == SLEEPY) {
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(114, 4);
display.print("z");
}
display.display(); // send the finished frame to the OLED
}
void updateBlink(unsigned long now) {
unsigned long len = (mood == SLEEPY) ? 600 : 160; // slow blink when sleepy
if (!blinking && now >= nextBlink) {
blinking = true;
blinkStart = now;
}
if (blinking) {
float t = (now - blinkStart) / (float)len;
if (t >= 1.0) {
blinking = false;
openAmt = 1.0;
nextBlink = now + (mood == SLEEPY ? random(1500, 3000) : random(2000, 6000));
} else {
openAmt = fabs(1.0 - 2.0 * t); // close, then open again
}
}
}
void updateLook(unsigned long now) {
if (mood == SLEEPY) {
targetX = 0;
targetY = 5; // droop downwards
} else if (now >= nextLook) {
targetX = random(-12, 13);
targetY = random(-6, 7);
if (random(3) == 0) { targetX = 0; targetY = 0; } // often look back at you
nextLook = now + random(1200, 4000);
}
lookX += (targetX - lookX) * 0.25; // ease towards the target
lookY += (targetY - lookY) * 0.25;
}
void updateTouch(unsigned long now) {
bool touched = digitalRead(TOUCH_PIN) == HIGH;
if (touched && !wasTouched) { // a new tap
if (mood == SLEEPY) {
setMood(SURPRISED);
moodUntil = now + 1500;
chirp(1400, 80);
} else {
setMood(HAPPY);
moodUntil = now + 2000;
chirp(2200, 60);
}
}
if (touched) {
lastTouch = now;
if (mood == HAPPY) moodUntil = now + 2000; // holding = petting
}
wasTouched = touched;
}
void updateMood(unsigned long now) {
if ((mood == HAPPY || mood == SURPRISED) && now > moodUntil) setMood(NORMAL);
if (mood == NORMAL && now - lastTouch > SLEEP_AFTER) setMood(SLEEPY);
}
void setup() {
Serial.begin(115200);
pinMode(TOUCH_PIN, INPUT);
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(400000); // fast I2C for smoother animation
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println("SSD1306 not found. Check wiring and address.");
while (true) delay(1000);
}
display.clearDisplay();
display.display();
unsigned long now = millis();
nextBlink = now + 1000;
nextLook = now + 1500;
lastTouch = now;
}
void loop() {
static unsigned long lastFrame = 0;
unsigned long now = millis();
updateTouch(now); // check the sensor every pass
if (now - lastFrame < 40) return; // about 25 frames per second
lastFrame = now;
updateMood(now);
updateBlink(now);
updateLook(now);
drawFace();
}
What you should see: the screen stays dark for a moment, then two rounded white eyes appear. Within a second they blink, and every few seconds they glance somewhere new. Tap the sensor and the eyes curve into a smile with a short chirp. Leave it alone for a minute and the lids drop and the screen dims.
How the code works
No delay() anywhere
The loop never waits. It checks the touch sensor on every pass and only redraws the face every 40 ms. This style uses
millis(), the number of milliseconds since the board started. Because nothing blocks, a tap is noticed
at once, even in the middle of a blink.
Blinking and looking
A blink is just the eye height shrinking to 2 pixels and growing back. The variable openAmt goes from 1
to 0 and back to 1 over 160 ms. Looking around works by moving both eyes towards a random target a quarter of the way
each frame. That easing makes the glance start fast and settle gently, which feels alive.
Moods are small changes on top
Every mood starts from the same rounded rectangle. Happy paints a black block over the lower half, which leaves a curved top edge. Sleepy paints over the top half. Surprised simply draws the eyes 6 pixels bigger. The ESP32 picks random numbers with its hardware random generator, so the robot never repeats the same pattern.
Speed
A full 128x64 frame is 1,024 bytes. At 400 kHz I2C it takes a little over 20 ms to send. That is why 25 frames per second is a sensible limit. If you need more speed, the SPI vs I2C OLED guide shows how the faster SPI version is wired.
Customise your robot's face
Change the constants at the top to give your robot a different character. Taller, narrower eyes look curious. Wide,
short eyes look bored. A bigger corner radius makes round, friendly eyes. Change SLEEP_AFTER to make
the robot doze off sooner or later.
If you would rather design the face by eye, try the free OLED Robot Eyes Generator. You set the eye size, corner radius, gap, pupils and blink speed with sliders and watch a live preview. It has nine moods, including angry, sad and love. Type your touch pin into the Touch sensor pin box and the code it writes will make the eyes happy for two seconds on each tap.
Another popular option is the open source
FluxGarage RoboEyes library. It
gives you ready-made calls such as setMood(), setAutoblinker() and
anim_laugh(). It is quicker to start with, but writing the eyes yourself, as above, teaches you how the
animation works.
Build the body
The case is where your robot gets its look. A typical 0.96 inch module board is about 27 x 27 mm, and the glowing area is about 22 x 11 mm. Measure your own module before you design anything, because sizes vary between sellers.
- Cut or print a front window slightly larger than the glowing area, and add a ledge behind it so the module cannot fall forward.
- Mount the TTP223 under the top of the head. It senses a finger through 1 to 2 mm of plastic, so the wall there must be thin.
- Leave a slot for the USB-C cable at the back.
- Print in PLA. A dark colour around the screen makes the white eyes stand out.
For a personal touch, add a name plate on the front. You can make the 3D model of any name with our Text to STL generator and glue it below the face.
Troubleshooting the ESP32 robot eyes
| Problem | Why | Fix |
|---|---|---|
| Screen stays black, Serial says "SSD1306 not found" | Wrong pins, loose wire or wrong address | Check SDA and SCL are not swapped. Try 0x3D instead of 0x3C. |
| Serial Monitor shows nothing on the SuperMini | USB CDC On Boot is disabled | Set it to Enabled in the Tools menu and upload again. |
| Upload fails with "No serial data received" | The board did not enter download mode | Hold BOOT, tap RESET, release BOOT, then upload. |
| Eyes look shifted with a line of noise at one edge | The module is an SH1106, not an SSD1306 | Use the Adafruit SH110X library and the Adafruit_SH1106G class. |
| Robot is happy all the time | Touch output stuck HIGH, or toggle mode jumper closed | Open the jumpers on the TTP223 and check OUT goes to the right pin. |
| Touch is ignored | Sensor powered from the wrong pin, or wall too thick | Check its VCC, and test the bare pad with your finger first. |
| Buzzer is silent or only clicks | It is an active buzzer, or wired backwards | Use a passive buzzer and swap its leads if needed. |
| Board resets when the buzzer sounds | A weak USB cable or too much current | Add the 100 ohm resistor and use a better cable. |
For more screen problems, work through the SSD1306 blank screen fix guide. It covers address scanning, power and reset pins step by step.
Next steps
- Add a second OLED for one eye per screen. The two SSD1306 displays guide shows how to set two addresses on one bus.
- Use WiFi to show the time or weather between naps. The ESP32 OLED animation guide covers more ESP32 screen tricks.
- Run it from a battery. The OLED power consumption guide has real current numbers.
- Add a short boot animation, such as your robot's name assembling letter by letter, made with the OLED Animation Maker.
Frequently asked questions
Which ESP32 board is best for a desk robot?
An ESP32-C3 SuperMini is the most popular choice because it is tiny, cheap and has USB-C. A classic ESP32 DevKit works the same way with different pins, and an ESP32-S3 is worth it only if you want a camera or a lot of memory.
Can I use the ESP32 built-in touch pins instead of a TTP223?
On a classic ESP32 or ESP32-S3, yes: use touchRead() on a touch pin with a wire or copper pad. The ESP32-C3 has no capacitive touch pins, so it needs an external module such as the TTP223.
Does this code work with a 1.3 inch SH1106 OLED?
Not unchanged. A 1.3 inch module usually has an SH1106 controller, so switch to the Adafruit SH110X library and the Adafruit_SH1106G class. The eye drawing code stays the same.
Will the OLED burn in if the robot runs all day?
Moving, blinking eyes spread the wear across the screen, which helps. The sketch also dims the display while the robot sleeps. For a robot that runs for months, turn the screen off at night as well.
How much power does an ESP32 desk robot use?
With WiFi off, an ESP32-C3 plus a 0.96 inch OLED showing two eyes draws roughly 30 to 50 mA from USB, so any phone charger or power bank can run it.
Related guides
- Arduino OLED robot eyes: animated eyes code
- ESP32 OLED animation: SSD1306 wiring and code
- SH1106 vs SSD1306: differences and how to tell
- Fix an SSD1306 OLED blank screen
- DIY OLED name badge desk toy
Free tools: OLED Robot Eyes Generator for animated eyes with moods, the OLED Animation Maker for frame animations and name effects. Try Image to Byte Array for logos and icons, and Text to STL for name plates and keychains. See all tools.