128×64

A working course on the monochrome OLED — SSD1306 · ESP32-S3 · Adafruit GFX. Every page runs in the browser and on the bench.

Lesson 00 — the part nobody documents

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 seeWhat it actually isFix
Missing FQBNThe 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 terminalThe IDE's serial-monitor daemon holds the port open even while the UI says "Not connected".lsof /dev/cu.usbmodemXXXXkill -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 timeNative 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.
Power — the one that costs money

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);
}
SSD1306128 × 64
live simulator — edit the code, hit Run

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.

Weights
Drills under constraint. Do them before moving on.
  1. Deliberately break it: set Wire.begin(7, 9) and confirm the scanner reports nothing. Learn what a wrong pin looks like.
  2. Move the display to SDA = 5, SCL = 6 and make it work. The default pins are a convenience, not a rule.
  3. Find your board's port with ls /dev/cu.* (macOS/Linux) and read it with miniterm instead of the IDE. Own your serial connection.
  4. Write down which of the six failures above you hit tonight. That list is the beginning of your own documentation.
Lesson 01 — the mental model

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.

The consequence

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() {}
SSD1306128 × 64
live simulator — edit the code, hit Run

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.

Weights
Drills under constraint. Do them before moving on.
  1. Delete d.display() and run. Sit with the blank screen for a second — that's the bug you'll hit for real later.
  2. Remove clearDisplay() and run twice with different coordinates. Watch the buffer accumulate.
  3. Draw a pixel at (128, 64). Explain to yourself why nothing appears and nothing breaks.
  4. 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.
Lesson 02 — type on a 1-bit grid

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:

SizeCellCols × rows on 128×64Good for
16 × 821 × 8body text, labels, data
212 × 1610 × 4headings, one value
318 × 247 × 2a number you read across a room
424 × 325 × 2clock 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.

setCursor is the top-left, not the baseline

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();
SSD1306128 × 64
live simulator — edit the code, hit Run

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.

Weights
Drills under constraint. Do them before moving on.
  1. Fit a label and a value on one 128px row with no overlap, using only size 1. Count the characters before you write it.
  2. Centre a string of arbitrary length horizontally. You know each char is 6px wide — derive the x.
  3. Write text at size 2 that updates every second without ghosting. Solve it twice: once with two-arg setTextColor, once with fillRect.
  4. Design a readable temperature display where the number is legible from three metres and the units still fit.
Lesson 03 — shapes and structure

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.

CallSignature
drawPixelx, y, colour
drawLinex0, y0, x1, y1, colour
drawFastHLine / drawFastVLinex, y, length, colour — cheaper than drawLine
drawRect / fillRectx, y, w, h, colour
drawRoundRect / fillRoundRectx, y, w, h, radius, colour
drawCircle / fillCirclecx, cy, radius, colour
drawTriangle / fillTrianglex0,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.

Rect coordinates are position + size, circles are centre + radius

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();
SSD1306128 × 64
live simulator — edit the code, hit Run
Weights
Drills under constraint. Do them before moving on.
  1. Build a battery indicator — outline, terminal nub, and a fill that tracks a 0–100 variable. No text.
  2. Draw a clock face with 12 tick marks using trigonometry. Notice how brutal rounding is at this size.
  3. Make a menu with three rows where the selected row is highlighted using INVERSE and nothing else.
  4. Constraint: build a recognisable weather icon in under 8 primitive calls.
Lesson 04 — the animation rule

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();
}
SSD1306128 × 64
live simulator — edit the code, hit Run
Frame budget

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.

Weights
Drills under constraint. Do them before moving on.
  1. Add a fourth timer at 250ms that does something visible, without touching the other three.
  2. Make the ball accelerate as it falls and lose energy on each bounce. Still no delay().
  3. Rewrite the bounce using explicit state (x and dir variables) rather than deriving it from t. Note which version is easier to reason about, and which survives a paused frame.
  4. Measure your actual frame time with micros() and print the fps on screen. Now optimise it.
Lesson 05 — pixels you author elsewhere

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.

SizeBytesTypical use
8 × 88status glyph
16 × 1632icon
32 × 32128logo, face element
128 × 641024full-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.

Getting from image to bytes

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();
}
SSD1306128 × 64
live simulator — edit the code, hit Run
Weights
Drills under constraint. Do them before moving on.
  1. 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.
  2. Convert a real logo through image2cpp and get it on screen at the correct size on the first try.
  3. Build a 4-frame animation as four bitmaps and play it at 8fps with a non-blocking timer.
  4. Compute the flash cost of a 60-frame full-screen animation. Decide whether it fits. This is the calculation that ends most animation ideas.
Lesson 06 — expression

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.
Before you build this: it's a solved problem

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();
}
SSD1306128 × 64
live simulator — edit the code, hit Run
Weights
Drills under constraint. Do them before moving on.
  1. Set the interpolation factor to 1.0 (instant snap). Watch it die. That one number is the difference between alive and mechanical.
  2. Add three moods — happy, tired, angry — using only eyelid geometry. No new shapes.
  3. Make the eyes track a value from a sensor or a serial input, so the expression means something.
  4. Hard one: build a face that has never been on this display before. Not eyes. Something that is yours.
Lesson 07 — more text than screen

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();
}
SSD1306128 × 64
live simulator — edit the code, hit Run
Weights
Drills under constraint. Do them before moving on.
  1. Make the marquee loop seamlessly — the tail should meet the head with no gap. Harder than it looks.
  2. Scroll one region while a second region animates independently. This is where hardware scroll would have failed you.
  3. Run both scroll and page versions of the same 60-character message. Time yourself reading each. Pick a default and write down why.
  4. Add speed control that stays smooth at 10px/s and at 120px/s.
Lesson 08 — where it actually breaks

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.

SymptomCause
Blank, but init returned trueMissing display(), or wrong height (32 vs 64)
Top quarter only, garbage belowIt's a 128×32 module declared as 64
2px shift, edge artefactsSH1106 controller, not SSD1306
Works, then dies after minutesI²C too fast for the wire length. Drop to 100kHz
Faint permanent ghost of old UIBurn-in. Too late for that panel
Upside downsetRotation(2)
Weights
Drills under constraint. Do them before moving on.
  1. Measure your real frame time with micros() around display(). Compare 100kHz vs 400kHz. Get a number, not a feeling.
  2. Redraw only a changed region instead of the whole buffer. Measure the difference.
  3. Implement pixel-shift burn-in protection that moves the UI ±2px every 5 minutes without the user noticing.
  4. Port one sketch to U8g2 page-buffer mode and compare reported free heap.
Lesson 09 — the exit

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

BuildWhat it forces you to learn
NTP wall clockWiFi 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 buildRing buffers, text wrapping, scrollback in 8 rows
Rotary-encoder menu systemInterrupts, debounce, state machines, UI hierarchy
Live sparkline of any valueCircular buffers, autoscaling, dropping data honestly
Macropad with a faceNative USB HID, and expression that reacts to real input
Network status panelHTTP client, JSON parsing, failure states on a 1-bit screen
The pattern worth noticing

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.
Final weight
The one that matters.
  1. Pick one project above. Ship it end to end, including the enclosure-less ugly version. Ship beats polish.
  2. Write down every failure you hit and how long each cost you. That document is worth more than the project.
  3. 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.