Build a Zooming Pixel-Art Earth With an ESP32 & a Twisty Knob
Turn one little knob and watch a glowing pixel-art Earth spin, then zoom all the way down — Earth → Continent → Country → City → Street — right on a tiny OLED screen. No soldering, no prior coding experience, just wires, a screen, and a knob. Let's build it together!
Live preview: Earth
One Knob. Five Worlds.
This project is basically a tiny, physical version of "zooming in on a map" — like flying a spaceship down from outer space until you land on your own street. Every click of the rotary encoder tells the ESP32 to blend one pixel-art picture into the next, so the zoom feels smooth instead of jumpy.
Whole spinning planet
A big landmass
Borders & a capital star
A skyline of buildings
Your own block!
What You'll Need
Tap each box as you collect it. Everything here plugs into a breadboard — no soldering iron required.
Grown-up check: Always plug in USB cables before you start clicking buttons, and ask an adult to help the first time you install software on a computer. Unplug the USB cable whenever you're changing wires.
Wire the Circuit
Here's the whole circuit at a glance. The OLED talks to the ESP32 over two wires (I2C), and the rotary encoder uses three more wires to tell the ESP32 which way it's turning.
| Wire Color | From (Part) | To (ESP32 Pin) | What It Does |
|---|---|---|---|
| Red | OLED VCC | 3V3 | Powers the screen |
| Black | OLED GND | GND | Common ground |
| Blue | OLED SDA | GPIO 21 | I2C data line |
| Yellow | OLED SCL | GPIO 22 | I2C clock line |
| Red | Encoder + | 3V3 | Powers the encoder |
| Black | Encoder GND | GND | Common ground |
| Blue | Encoder CLK | GPIO 34 | Turn signal A |
| Yellow | Encoder DT | GPIO 35 | Turn signal B (direction) |
| Purple | Encoder SW | GPIO 32 | Push-button (toggle labels) |
Build It, Step by Step
Follow these in order — the wiring depends on earlier steps being done first.
Push the ESP32 onto the breadboard
Place it so pins straddle the center gap, leaving room on both sides for wires.
Seat the OLED display
Most 1.3" OLED modules have 4 pins: GND, VCC, SCL, SDA. Plug it into its own row on the breadboard.
Wire power first
Connect OLED VCC → ESP32 3V3, and OLED GND → ESP32 GND. Do the same for the encoder's + and GND pins.
Wire the I2C data lines
OLED SDA → GPIO 21, OLED SCL → GPIO 22.
Wire the encoder's signal pins
CLK → GPIO 34, DT → GPIO 35, SW → GPIO 32.
Double-check before power-up
Trace every wire with your finger. Look for any bare wires touching each other. This is the most important step!
Install the Arduino IDE & ESP32 board files
Download the free Arduino IDE, then add the ESP32 board manager URL in Preferences and install "esp32" from the Boards Manager.
Install two libraries
Open Library Manager and install U8g2 (for the OLED) — that's the only external library this project needs!
Plug in USB and upload
Paste the code from the next section, select your ESP32 board and COM port, then click Upload.
Turn the knob!
Watch Earth spin, then slowly zoom through Continent, Country, City, all the way to Street.
The Full Arduino Code
This sketch reads the rotary encoder, figures out which two zoom levels you're between, and pixel-dissolves one into the other using a classic dithering trick (the same idea old newspapers used to fake shades of gray with only black dots!). Copy it all into a new Arduino sketch.
// ============================================================ // PIXEL PLANET ZOOM — ESP32 + 1.3" OLED (SH1106) + Rotary Encoder // Turn the knob to zoom: Earth -> Continent -> Country -> City -> Street // Library needed: U8g2 (search "U8g2" in Library Manager) // ============================================================ #include <U8g2lib.h> #include <Wire.h> // ---- Screen setup (SH1106 128x64, I2C) ---- U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); // ---- Pins ---- const int PIN_CLK = 34; // encoder signal A const int PIN_DT = 35; // encoder signal B const int PIN_SW = 32; // encoder push-button // ---- Zoom map ---- const int GRID = 16; // each pixel-art frame is 16x16 const int SCALE = 4; // drawn 4x bigger = 64x64 on screen const int LEVELS = 5; // Earth, Continent, Country, City, Street const int STEPS_PER_LEVEL = 10; // encoder detents needed per zoom stage const int TICKS_PER_LEVEL = STEPS_PER_LEVEL * 4; // most encoders fire 4 ticks/detent const char* levelNames[LEVELS] = {"EARTH", "CONTINENT", "COUNTRY", "CITY", "STREET"}; // ---- Pixel-art frames (1 = lit pixel). Swap these for your own art! ---- // Tip: draw 16x16 art at pixelartmaker.com, then convert rows to binary. const uint16_t gridEarth[GRID] = { 0b0000000000000000, 0b0000111100000000, 0b0001111110000000, 0b0011111111000110, 0b0111111111100111, 0b0111100111111111, 0b0011000011111111, 0b0001100001111110, 0b0011110000111000, 0b0111111000011000, 0b0111111100001100, 0b0011111110000000, 0b0001111100000000, 0b0000011000000000, 0b0000000000000000, 0b0000000000000000 }; const uint16_t gridContinent[GRID] = { 0b0000000000000000, 0b0000011111100000, 0b0001111111111000, 0b0011111111111100, 0b0111111111111110, 0b0111110111111110, 0b1111100011111111, 0b1111110001111111, 0b1111111000111111, 0b0111111100011110, 0b0111111110001100, 0b0011111111100000, 0b0001111111000000, 0b0000011110000000, 0b0000000000000000, 0b0000000000000000 }; const uint16_t gridCountry[GRID] = { 0b0000000000000000, 0b0000111111100000, 0b0011000000011100, 0b0110001100001110, 0b1100011110000111, 0b1000111111000011, 0b1001111111100011, 0b1000111111000011, 0b1100011110000111, 0b0110001100001110, 0b0011000000011100, 0b0000111111100000, 0b0000000000000000, 0b0000000000000000, 0b0000000000000000, 0b0000000000000000 }; const uint16_t gridCity[GRID] = { 0b0100010001000100, 0b0100010001000100, 0b0100011101000100, 0b0111011101110100, 0b0111011101110111, 0b0111111101110111, 0b0111111111110111, 0b0111111111111111, 0b0111111111111111, 0b0111111111111111, 0b0111111111111111, 0b0111111111111111, 0b0111111111111111, 0b1111111111111111, 0b1111111111111111, 0b1111111111111111 }; const uint16_t gridStreet[GRID] = { 0b1111001111001111, 0b1111001111001111, 0b1111001111001111, 0b1111000000001111, 0b1111001111001111, 0b1111001111001111, 0b1111000000001111, 0b1111001111001111, 0b1111001111001111, 0b1111000000001111, 0b1111001111001111, 0b1111001111001111, 0b1111000000001111, 0b1111001111001111, 0b1111001111001111, 0b1111001111001111 }; const uint16_t* levelGrids[LEVELS] = { gridEarth, gridContinent, gridCountry, gridCity, gridStreet }; // ---- 4x4 Bayer dither matrix (creates the pixel "dissolve" transition) ---- const uint8_t bayer4x4[4][4] = { {0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5} }; // ---- Encoder state (updated inside an interrupt) ---- volatile long encoderTicks = 0; volatile bool lastCLK = HIGH; bool showLabel = true; unsigned long lastButtonMs = 0; unsigned long lastSpinMs = 0; int spinOffset = 0; // Reads which way the knob turned and updates encoderTicks void IRAM_ATTR onEncoderChange() { bool clkNow = digitalRead(PIN_CLK); if (clkNow != lastCLK) { if (digitalRead(PIN_DT) != clkNow) encoderTicks++; // clockwise else encoderTicks--; // counter-clockwise } lastCLK = clkNow; } // Rotates a 16-bit row left by `n` bits (makes the Earth "spin") uint16_t rotl16(uint16_t v, int n) { n = n % 16; return (v << n) | (v >> (16 - n)); } void setup() { pinMode(PIN_CLK, INPUT); pinMode(PIN_DT, INPUT); pinMode(PIN_SW, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIN_CLK), onEncoderChange, CHANGE); u8g2.begin(); u8g2.setFont(u8g2_font_6x10_tf); } void loop() { // ---- read button (toggle the little name label) ---- if (digitalRead(PIN_SW) == LOW && millis() - lastButtonMs > 250) { showLabel = !showLabel; lastButtonMs = millis(); } // ---- turn ticks into a smooth zoom position 0.0 -> 4.0 ---- long ticks = constrain(encoderTicks, 0, TICKS_PER_LEVEL * (LEVELS - 1)); float zoomPos = (float)ticks / TICKS_PER_LEVEL; int level = (int)zoomPos; if (level > LEVELS - 2) level = LEVELS - 2; float blend = zoomPos - level; // 0.0 = fully at `level`, 1.0 = fully at `level+1` const uint16_t* gridA = levelGrids[level]; const uint16_t* gridB = levelGrids[level + 1]; // ---- idle spin: only when resting exactly on EARTH ---- if (level == 0 && blend < 0.02 && millis() - lastSpinMs > 150) { spinOffset = (spinOffset + 1) % 16; lastSpinMs = millis(); } u8g2.clearBuffer(); int offsetX = (128 - GRID * SCALE) / 2; // center the 64x64 art on the 128x64 screen for (int y = 0; y < GRID; y++) { uint16_t rowA = (level == 0 && blend < 0.02) ? rotl16(gridA[y], spinOffset) : gridA[y]; uint16_t rowB = gridB[y]; for (int x = 0; x < GRID; x++) { bool bitA = (rowA >> (15 - x)) & 1; bool bitB = (rowB >> (15 - x)) & 1; uint8_t threshold = bayer4x4[y % 4][x % 4]; bool lit = (blend * 16.0 > threshold) ? bitB : bitA; // dissolve A -> B if (lit) { u8g2.drawBox(offsetX + x * SCALE, y * SCALE, SCALE, SCALE); } } } if (showLabel) { u8g2.setDrawColor(1); u8g2.drawStr(2, 62, levelNames[level]); } u8g2.sendBuffer(); delay(15); }
How the Zoom Magic Actually Works
No fancy graphics chip here — just clever math on a tiny, cheap microcontroller. Here's the trick behind each part of the illusion.
🎛️ Reading the knob
Rotary encoders send two overlapping electrical pulses. By comparing which pulse arrives first, the ESP32 can tell "clockwise" from "counter-clockwise" and count every tiny click.
🌀 The spinning Earth
Instead of storing dozens of animation frames, the code shifts all 16 bits of each row sideways using rotl16() — like spinning a barcode. It's cheap on memory and looks just like rotation.
🧩 The dissolve transition
A 4×4 checkerboard of numbers (the Bayer matrix) decides, pixel by pixel, when to switch from the old picture to the new one — so the change spreads across the screen like a sprinkle instead of a hard cut.
📐 One knob, five worlds
The knob's tick-count is just divided by how many ticks make one "zoom stage." That single number decides both which two pictures to blend and by how much.
Draw Your Own Real Street
The pixel art in this guide is a simple starting point. For the last zoom stage, try recreating your actual street!
Find a map view
Open a map of your neighborhood and take a screenshot from directly above.
Pixelate it
Use a free online pixel-art editor to shrink the screenshot down to a 16×16 or 32×32 grid.
Convert to code
Read each row left-to-right as 1s (lit) and 0s (dark), and replace gridStreet with your new rows.
Re-upload & zoom home
Save, upload, and zoom all the way down to your very own front door!
Quick Troubleshooting
The screen stays blank
Double-check SDA and SCL aren't swapped, and confirm your OLED is really a 1.3" SH1106 (not the 0.96" SSD1306, which needs a different driver line in code).
Turning the knob does nothing
Make sure CLK and DT are on GPIO 34 and 35, and that the encoder's + pin actually reaches 3V3 — test with a multimeter if you have one.
It zooms the wrong direction
Swap the CLK and DT wires at the encoder — that flips which way is "forward."
The zoom feels jumpy, not smooth
Increase STEPS_PER_LEVEL in the code so more knob clicks are needed per zoom stage — this stretches the dissolve out longer.
Kids Ask, We Answer
Do I need to know how to code already?
Nope! You just copy the code exactly as shown. As you get more comfortable, try changing small things like the spin speed or the pixel art.
Can I use a different OLED size?
Yes — a 0.96" SSD1306 128×64 works too, but swap the U8g2 constructor line to an SSD1306 one and keep the same wiring.
Is this project safe for kids to build?
Yes, with adult supervision. It uses low-voltage (3.3V) parts, a breadboard, and no soldering — the main safety rule is unplugging USB before changing any wires.
What is a rotary encoder, exactly?
It's a knob that reports how far and which direction it's been turned, instead of a fixed position like a volume dial — perfect for endless zooming!

Comments
Post a Comment