Before the pixels
Every OLED tutorial on the internet starts at the moment your board already works. This one starts two hours earlier, where people actually quit.
The circuit is four wires. The circuit is never the problem. What kills beginners is a toolchain that fails silently and lies about why. Here is the full failure set, in the order it bites, so you can recognise each one in under thirty seconds instead of an hour.
The six ways it fails
| What you see | What it actually is | Fix |
|---|---|---|
Missing FQBN | The IDE recognised the USB descriptor and printed a friendly board name, but no board definition is attached. The board list is empty. | Install esp32 by Espressif in Boards Manager, then pick ESP32S3 Dev Module. |
| Upload works. Serial is silent forever. | USB CDC On Boot is disabled, so Serial is mapped to UART0 on GPIO43/44 — physical pins, not the cable. | Tools → USB CDC On Boot → Enabled, then re-flash. Board options only apply on a fresh flash. |
Resource busy from any terminal | The IDE's serial-monitor daemon holds the port open even while the UI says "Not connected". | lsof /dev/cu.usbmodemXXXX → kill -9 <pid>. Or quit the IDE entirely. |
boot:0x2 (DOWNLOAD)"waiting for download" | GPIO0 was low at boot. Either you're holding BOOT, or a jumper is sitting on pin 0. | Unplug, wait 3s, replug without touching BOOT. You want boot:0xb. |
| Port number changes every time | Native USB is the chip. Running app and ROM bootloader enumerate as different devices. | Re-select the port after every reset. Don't memorise the number. |
Garbage characters ���� | Baud mismatch. The dropdown resets to 9600 when the port changes. | Set it to 115200 again. Check it every time the port moves. |
Wire VDD to 3V3, never 5V. The module has onboard pull-ups tied to whatever you feed it, so 5V puts 5V on SDA and SCL — straight into ESP32-S3 pins that are not 5V tolerant. It will often work for a while, then quietly kill the pin.
Pins that are already spoken for
The S3 has a GPIO matrix, so I²C can go almost anywhere. But on an N16R8, several pins aren't yours:
- 26–37 — flash and the octal PSRAM. That's what the R8 means. Touch these and the board can't boot itself.
- 19, 20 — native USB D− / D+
- 0, 3, 45, 46 — strapping pins, decided at boot
- 43, 44 — UART0, which is where Serial goes when CDC is off
Safe and free: 1–18, 21, 38–42, 47, 48. This course uses SDA = 8, SCL = 9, which is what the Arduino core defaults to on the S3 anyway.
Prove it before you draw anything
Never write display code against an unverified bus. Scan first. Note it scans in loop(), not setup() — a one-shot scan finishes before you can open the monitor, and then you spend an hour debugging silence.
#include <Wire.h>
#define SDA_PIN 8
#define SCL_PIN 9
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 4000) { delay(10); }
Wire.begin(SDA_PIN, SCL_PIN);
Serial.println("\n=== I2C scanner ready ===");
}
void loop() {
uint8_t found = 0;
Serial.println("Scanning...");
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) {
Serial.printf(" Found device at 0x%02X\n", addr);
found++;
}
}
if (!found) Serial.println(" Nothing found. Check wiring/power.");
Serial.printf("Done. %u device(s).\n\n", found);
delay(3000);
}You are looking for 0x3C. Some modules answer at 0x3D. If you get nothing at all, it's wiring or power — no amount of display code will save you, so don't write any yet.
- Deliberately break it: set
Wire.begin(7, 9)and confirm the scanner reports nothing. Learn what a wrong pin looks like. - Move the display to
SDA = 5,SCL = 6and make it work. The default pins are a convenience, not a rule. - Find your board's port with
ls /dev/cu.*(macOS/Linux) and read it withminiterminstead of the IDE. Own your serial connection. - Write down which of the six failures above you hit tonight. That list is the beginning of your own documentation.
The framebuffer
Nothing you draw touches the screen. You are painting a 1024-byte block of RAM, and once per frame you shove the whole thing down the wire.
This single idea explains almost every confusing thing about this display.
128 × 64 pixels, one bit each, is 1024 bytes living in your ESP32's memory. drawLine, print, fillCircle — all of them flip bits in that array and nothing else. The screen stays exactly as it was until you call display.display(), which transmits all 1024 bytes over I²C.
Forgetting display.display() is the single most common bug with this part, and it produces no error. Your code runs perfectly, the buffer updates perfectly, the screen shows nothing.
Coordinates
(0, 0) is the top-left. X runs right to 127. Y runs down to 63. If you've done any maths plotting, Y is upside down from what you expect, and you will get this wrong at least twice.
Off-screen coordinates are silently clipped, not errors. That's useful — it's how scrolling works — but it also means a typo that puts your text at y = 200 just shows you a blank screen with no complaint.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
void setup() {
Wire.begin(8, 9, 400000);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) for(;;);
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// corners, to prove where the edges are
display.drawPixel(0, 0, SSD1306_WHITE);
display.drawPixel(127, 0, SSD1306_WHITE);
display.drawPixel(0, 63, SSD1306_WHITE);
display.drawPixel(127, 63, SSD1306_WHITE);
display.drawRect(0, 0, 128, 64, SSD1306_WHITE);
display.setCursor(4, 4); display.print("0,0");
display.setCursor(88, 52); display.print("127,63");
display.display(); // <-- without this: nothing
}
void loop() {}Clear, draw, push
Every frame is the same three beats: clearDisplay(), then your drawing calls, then display(). If you skip the clear, last frame's pixels stay — which is a bug most of the time and a technique occasionally.
- Delete
d.display()and run. Sit with the blank screen for a second — that's the bug you'll hit for real later. - Remove
clearDisplay()and run twice with different coordinates. Watch the buffer accumulate. - Draw a pixel at
(128, 64). Explain to yourself why nothing appears and nothing breaks. - Without running it: predict what
drawRect(0,0,128,64,1)costs in bytes of buffer change vs.fillRect(0,0,128,64,1). Then reason it out.
Text
The built-in font is 5×7 in a 6×8 cell. That's not a limitation to work around — it's the entire typographic system, and it's worth knowing exactly.
At setTextSize(1) every character occupies 6 pixels across and 8 down, including its spacing. Which gives you hard numbers:
| Size | Cell | Cols × rows on 128×64 | Good for |
|---|---|---|---|
| 1 | 6 × 8 | 21 × 8 | body text, labels, data |
| 2 | 12 × 16 | 10 × 4 | headings, one value |
| 3 | 18 × 24 | 7 × 2 | a number you read across a room |
| 4 | 24 × 32 | 5 × 2 | clock digits, nothing else |
Sizes above 1 are integer-scaled — every pixel becomes a 2×2 or 3×3 block. There's no smoothing and no separate font file. That chunkiness is the aesthetic; fighting it looks worse than leaning into it.
Unlike almost every other text API you've used, setCursor(x, y) positions the top-left corner of the character cell. To vertically centre a size-1 line on a 64px screen you want y = 28, not 32.
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print("TEMPERATURE");
display.setTextSize(3);
display.setCursor(0, 14);
display.print("28.4");
display.setTextSize(1);
display.setCursor(74, 26);
display.print("degC");
// inverted text: white bg, black glyphs
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
display.setCursor(0, 48);
display.print(" LIVE vizag ");
display.display();Two colour arguments
setTextColor(fg) draws glyphs and leaves the background untouched — so new text draws on top of old text and turns into mush. setTextColor(fg, bg) paints the whole cell, which erases what was underneath. For anything that updates in place, use the two-argument form or explicitly fillRect the area black first.
- Fit a label and a value on one 128px row with no overlap, using only size 1. Count the characters before you write it.
- Centre a string of arbitrary length horizontally. You know each char is 6px wide — derive the x.
- Write text at size 2 that updates every second without ghosting. Solve it twice: once with two-arg
setTextColor, once withfillRect. - Design a readable temperature display where the number is legible from three metres and the units still fit.
Primitives
Eleven drawing functions. Everything you will ever build on this display is a composition of these, and at 1-bit there are no gradients to hide behind.
| Call | Signature |
|---|---|
drawPixel | x, y, colour |
drawLine | x0, y0, x1, y1, colour |
drawFastHLine / drawFastVLine | x, y, length, colour — cheaper than drawLine |
drawRect / fillRect | x, y, w, h, colour |
drawRoundRect / fillRoundRect | x, y, w, h, radius, colour |
drawCircle / fillCircle | cx, cy, radius, colour |
drawTriangle / fillTriangle | x0,y0, x1,y1, x2,y2, colour |
Colour is 1 (SSD1306_WHITE, pixel on), 0 (BLACK, off), or 2 (INVERSE, flip whatever's there). INVERSE is genuinely useful — it's how you highlight a menu row without redrawing it.
Mixing these up is the most common shape bug. fillRect(10, 10, 40, 20, 1) starts at the corner. fillCircle(10, 10, 20, 1) is centred on that point and extends 20px in every direction.
display.clearDisplay();
// a signal-strength meter from fast vlines
for (uint8_t i = 0; i < 5; i++) {
uint8_t h = 4 + i * 5;
display.fillRect(4 + i * 7, 28 - h, 5, h, SSD1306_WHITE);
}
// a rounded card
display.drawRoundRect(46, 4, 78, 26, 4, SSD1306_WHITE);
display.setCursor(52, 9);
display.print("ESP32-S3");
display.setCursor(52, 19);
display.print("240 MHz");
// a progress bar
display.drawRect(4, 40, 120, 10, SSD1306_WHITE);
display.fillRect(6, 42, 74, 6, SSD1306_WHITE);
// INVERSE flips whatever is underneath
display.fillRect(60, 38, 30, 14, SSD1306_INVERSE);
display.display();- Build a battery indicator — outline, terminal nub, and a fill that tracks a 0–100 variable. No text.
- Draw a clock face with 12 tick marks using trigonometry. Notice how brutal rounding is at this size.
- Make a menu with three rows where the selected row is highlighted using
INVERSEand nothing else. - Constraint: build a recognisable weather icon in under 8 primitive calls.
Motion without delay()
The moment you want two things moving at once, delay() stops being a tool and becomes the bug. This lesson is the single most transferable idea in the whole course.
delay(100) freezes the entire chip. No button reads, no WiFi servicing, no sensor polling, no second animation. It works for exactly one demo and then never again.
The replacement is a timestamp comparison. You keep a last variable, check millis() - last >= interval, and if it's time, do the thing and update last. Everything else in loop() keeps running at full speed.
// three independent timers, one loop, zero delay()
uint32_t tBounce = 0, tBlink = 0, tClock = 0;
int ballX = 0, ballDir = 2;
bool dotOn = false;
uint16_t secs = 0;
void loop() {
uint32_t now = millis();
if (now - tBounce >= 16) { // ~60 fps
tBounce = now;
ballX += ballDir;
if (ballX <= 4 || ballX >= 123) ballDir = -ballDir;
}
if (now - tBlink >= 500) { // 2 Hz
tBlink = now;
dotOn = !dotOn;
}
if (now - tClock >= 1000) { // 1 Hz
tClock = now;
secs++;
}
display.clearDisplay();
display.fillCircle(ballX, 40, 4, SSD1306_WHITE);
if (dotOn) display.fillRect(120, 2, 5, 5, SSD1306_WHITE);
display.setCursor(0, 0);
display.print(secs);
display.print("s");
display.display();
}A full display() pushes 1024 bytes. At 400kHz I²C that's roughly 25ms of wire time, so your realistic ceiling is about 30–40fps if you redraw everything. Redraw only what changed and you get much more headroom.
- Add a fourth timer at 250ms that does something visible, without touching the other three.
- Make the ball accelerate as it falls and lose energy on each bounce. Still no
delay(). - Rewrite the bounce using explicit state (
xanddirvariables) rather than deriving it fromt. Note which version is easier to reason about, and which survives a paused frame. - Measure your actual frame time with
micros()and print the fps on screen. Now optimise it.
Bitmaps
Primitives get you geometry. For anything with character — a logo, an icon, an eyebrow — you draw it in a real tool and ship it as bytes.
A monochrome bitmap is a byte array where each bit is a pixel, packed left-to-right, 8 pixels per byte, row by row. A 16×16 icon is 32 bytes. You store it in flash with PROGMEM so it doesn't eat your precious 320KB of RAM, and blit it with drawBitmap.
| Size | Bytes | Typical use |
|---|---|---|
| 8 × 8 | 8 | status glyph |
| 16 × 16 | 32 | icon |
| 32 × 32 | 128 | logo, face element |
| 128 × 64 | 1024 | full-screen splash |
Rows pad to whole bytes, so a 12px-wide bitmap still costs 2 bytes per row. Design at multiples of 8 when you can.
Draw at true size in any pixel editor, export a 1-bit PNG or BMP, then convert with image2cpp (javl.github.io/image2cpp) — set output to Arduino code, horizontal byte orientation. Do not draw at 512px and scale down; a 1-bit threshold of a downscaled image looks like static.
// 16x16 icon = 32 bytes, stored in flash not RAM
const unsigned char PROGMEM iconBolt[] = {
0x00,0x60, 0x00,0xC0, 0x01,0x80, 0x03,0x00,
0x06,0x00, 0x0F,0xF0, 0x1F,0xE0, 0x00,0xC0,
0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00,
0x18,0x00, 0x30,0x00, 0x20,0x00, 0x00,0x00
};
void setup() {
// ...init...
display.clearDisplay();
display.drawBitmap(8, 12, iconBolt, 16, 16, SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(34, 14);
display.print("4.2V");
display.display();
}- Hand-author an 8×8 icon by writing the eight bytes directly in binary (
0b00111100). Do it without a converter — once — so the packing stops being magic. - Convert a real logo through image2cpp and get it on screen at the correct size on the first try.
- Build a 4-frame animation as four bitmaps and play it at 8fps with a non-blocking timer.
- Compute the flash cost of a 60-frame full-screen animation. Decide whether it fits. This is the calculation that ends most animation ideas.
Making it feel alive
Two rounded rectangles read as eyes. What makes them read as alive is timing, not shape — and that's a design problem, not a graphics one.
This is the most-copied thing people do with these displays, and most implementations get the drawing right and the behaviour wrong. Four mechanics do almost all the work:
- Interpolation. Never snap between states. Move a fraction of the remaining distance each frame and everything reads as intentional.
- Autoblink with jitter. A blink every 3.0s reads as a machine. Every 3s ± a random 0–2s reads as a creature.
- Idle drift. Eyes that never move look dead. Small random saccades during idle do more than any expression set.
- Asymmetry. Offset one eye by a pixel, or blink them 30ms apart. Perfect symmetry is uncanny.
FluxGarage's RoboEyes library already does configurable eye width, height, corner radius and spacing, with happy/tired/angry/default moods plus autoblink, idle, laugh and confused animations, all with smooth transitions. IrisOLED ships expression bitmaps with a driver-agnostic non-blocking player. Both are on Adafruit's blog and Arduino's Library Manager.
Build your own anyway — for the understanding. But if your public library's headline feature is "cute eyes", you're shipping into a category with an incumbent. Your differentiation has to be somewhere else.
// hand-rolled, ~40 lines, no library
struct Eye { float h; float tgt; int x; };
Eye L = {26, 26, 28}, R = {26, 26, 84};
uint32_t tBlink = 0, tIdle = 0;
int gaze = 0, gazeTgt = 0;
uint16_t nextBlink = 3000;
void loop() {
uint32_t now = millis();
if (now - tBlink > nextBlink) {
tBlink = now;
nextBlink = 2200 + random(2400); // jitter
L.tgt = R.tgt = 2; // close
}
if (now - tBlink > 110) L.tgt = R.tgt = 26; // reopen
if (now - tIdle > 1400) {
tIdle = now;
gazeTgt = random(-9, 10); // saccade
}
// interpolate -- this is what sells it
L.h += (L.tgt - L.h) * 0.35;
R.h += (R.tgt - R.h) * 0.30; // slightly slower = asymmetry
gaze += (gazeTgt - gaze) * 0.18;
display.clearDisplay();
display.fillRoundRect(L.x + gaze - 17, 32 - L.h/2, 34, (int)L.h, 8, SSD1306_WHITE);
display.fillRoundRect(R.x + gaze - 17, 32 - R.h/2, 34, (int)R.h, 8, SSD1306_WHITE);
display.display();
}- Set the interpolation factor to
1.0(instant snap). Watch it die. That one number is the difference between alive and mechanical. - Add three moods — happy, tired, angry — using only eyelid geometry. No new shapes.
- Make the eyes track a value from a sensor or a serial input, so the expression means something.
- Hard one: build a face that has never been on this display before. Not eyes. Something that is yours.
Scrolling and marquee
21 characters per line is not many. Sooner or later you have text that doesn't fit, and there are three completely different ways to deal with it.
1. Hardware scroll
The SSD1306 has scrolling built into the controller — startscrollleft(), startscrollright(), and the diagonal variants. It costs zero CPU and zero I²C traffic once started, because the chip does it. But it scrolls the whole framebuffer, you can't scroll one region, and you can't draw while it runs. Use it for a splash screen, not a UI.
2. Software marquee
You redraw text at a moving x offset each frame. Full control, works per-region, composes with everything else on screen. Costs a full display() per frame. This is what you want 90% of the time.
3. Don't scroll
Page the text instead — show 21 characters, hold for 1.5s, show the next 21. Reading a marquee is genuinely harder than reading static text, and on a 128px screen paging is usually the better UX. Worth trying before you reach for motion.
const char* msg = "devsforfun studio -- hardware from first principles -- ";
int scrollX = 128;
uint32_t tScroll = 0;
// display.setTextWrap(false); in setup() -- required
void loop() {
uint32_t now = millis();
if (now - tScroll >= 40) { // 25 px/sec
tScroll = now;
scrollX -= 1;
int wpx = strlen(msg) * 6; // 6px per char at size 1
if (scrollX < -wpx) scrollX = 128;
}
display.clearDisplay();
// static header stays put while the band scrolls
display.setCursor(0, 0);
display.print("STATUS");
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
display.setCursor(scrollX, 26);
display.print(msg);
display.drawFastHLine(0, 40, 128, SSD1306_WHITE);
display.display();
}- Make the marquee loop seamlessly — the tail should meet the head with no gap. Harder than it looks.
- Scroll one region while a second region animates independently. This is where hardware scroll would have failed you.
- Run both scroll and page versions of the same 60-character message. Time yourself reading each. Pick a default and write down why.
- Add speed control that stays smooth at 10px/s and at 120px/s.
Real constraints
Everything above works on the bench. These are the four things that break it in a real build, and knowing them is most of what separates a demo from a product.
RAM
Adafruit_SSD1306 allocates the full 1024-byte framebuffer. On your S3 with 320KB that's nothing. On an ATmega328 with 2KB it's half your memory — which is why the original RoboEyes notes that Serial can misbehave on 328P boards from exhausted memory. If you ever port down, switch to U8g2's page-buffer mode, which trades CPU for a fraction of the RAM.
Bandwidth
1024 bytes per full refresh. At 100kHz that's ~100ms — 10fps ceiling. At 400kHz, ~25ms, so 30–40fps. Long jumper wires and a 400kHz clock is a classic intermittent-failure combination: it works on the bench and dies on the desk. If it's flaky, drop to 100kHz before you suspect anything else.
Burn-in is real
These are OLEDs, not LCDs. Static pixels degrade permanently. A label that sits in the same place for a week will ghost. Mitigations: shift the whole UI by a few pixels every few minutes, invert periodically, dim with display.dim(true), and blank the screen when nobody's looking.
It might not be an SSD1306
The SH1106 is pin-compatible, visually identical, and answers on the same address — but its RAM is 132 pixels wide, so an SSD1306 driver renders it with a 2px offset and garbage at the edges. If your display is almost right, that's the reason. Switch to U8g2 with the SH1106 constructor.
| Symptom | Cause |
|---|---|
| Blank, but init returned true | Missing display(), or wrong height (32 vs 64) |
| Top quarter only, garbage below | It's a 128×32 module declared as 64 |
| 2px shift, edge artefacts | SH1106 controller, not SSD1306 |
| Works, then dies after minutes | I²C too fast for the wire length. Drop to 100kHz |
| Faint permanent ghost of old UI | Burn-in. Too late for that panel |
| Upside down | setRotation(2) |
- Measure your real frame time with
micros()arounddisplay(). Compare 100kHz vs 400kHz. Get a number, not a feeling. - Redraw only a changed region instead of the whole buffer. Measure the difference.
- Implement pixel-shift burn-in protection that moves the UI ±2px every 5 minutes without the user noticing.
- Port one sketch to U8g2 page-buffer mode and compare reported free heap.
What to build
The display is an output device. On its own it's a toy. Paired with something that produces real data, it becomes a product — and that pairing is the whole point.
Projects that teach you something new
| Build | What it forces you to learn |
|---|---|
| NTP wall clock | WiFi stack, timezones, RTC drift, big-digit layout |
| Sensor readout (BME280) | Second device on the same bus, address conflicts, units and rounding |
| Serial console for a headless build | Ring buffers, text wrapping, scrollback in 8 rows |
| Rotary-encoder menu system | Interrupts, debounce, state machines, UI hierarchy |
| Live sparkline of any value | Circular buffers, autoscaling, dropping data honestly |
| Macropad with a face | Native USB HID, and expression that reacts to real input |
| Network status panel | HTTP client, JSON parsing, failure states on a 1-bit screen |
Every one of those is display + one new subsystem. That's the correct difficulty curve. The display becomes the thing you already know, which makes it the debugging surface for whatever you're learning next — which is exactly why it belongs at the start of a curriculum rather than the end.
What this course deliberately left out
- Custom fonts. GFX supports them; they cost flash and they're a rabbit hole. Learn the 5×7 grid first.
- SPI wiring. Faster, more pins, worth it above ~40fps. Your 4-pin module can't do it anyway.
- U8g2 in depth. Better fonts, lower RAM, worse documentation. Go there when Adafruit_GFX stops fitting.
- Colour. ST7735 and ST7789 at 16-bit change every constraint in Lesson 08 — the framebuffer alone becomes 100KB+. That's a different course.
- Pick one project above. Ship it end to end, including the enclosure-less ugly version. Ship beats polish.
- Write down every failure you hit and how long each cost you. That document is worth more than the project.
- Then take the weights off: build the same thing again from a blank file, no reference. Time it. That number is your actual skill level.