DIY ESP32 Pixel-Art Zooming Earth

DIY ESP32 Pixel-Art Zooming Earth | Fun OLED Robotics Project for Kids
🤖 Beginner Robotics Project

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!

1–2 hrsBuild Time
$12–18Total Cost
Age 10+With a grown-up
BeginnerDifficulty

Live preview: Earth

The Big Idea

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.

🌍
Earth
Whole spinning planet
🗺️
Continent
A big landmass
🏳️
Country
Borders & a capital star
🏙️
City
A skyline of buildings
🏠
Street
Your own block!
Step 1

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.

Step 2

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.

Wiring diagram: ESP32 connected to a 1.3 inch OLED display and a rotary encoder ESP32 DevKit 3V3 GND D21 (SDA) D22 (SCL) D34 D35 D32 1.3" OLED (SH1106) pixel zoom here VCC GND SCL SDA Rotary Encoder + GND SW DT CLK
Pin Connection Cheat Sheet
Wire ColorFrom (Part)To (ESP32 Pin)What It Does
RedOLED VCC3V3Powers the screen
BlackOLED GNDGNDCommon ground
BlueOLED SDAGPIO 21I2C data line
YellowOLED SCLGPIO 22I2C clock line
RedEncoder +3V3Powers the encoder
BlackEncoder GNDGNDCommon ground
BlueEncoder CLKGPIO 34Turn signal A
YellowEncoder DTGPIO 35Turn signal B (direction)
PurpleEncoder SWGPIO 32Push-button (toggle labels)
Step 3

Build It, Step by Step

Follow these in order — the wiring depends on earlier steps being done first.

1

Push the ESP32 onto the breadboard

Place it so pins straddle the center gap, leaving room on both sides for wires.

2

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.

3

Wire power first

Connect OLED VCC → ESP32 3V3, and OLED GND → ESP32 GND. Do the same for the encoder's + and GND pins.

4

Wire the I2C data lines

OLED SDAGPIO 21, OLED SCLGPIO 22.

5

Wire the encoder's signal pins

CLKGPIO 34, DTGPIO 35, SWGPIO 32.

6

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!

7

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.

8

Install two libraries

Open Library Manager and install U8g2 (for the OLED) — that's the only external library this project needs!

9

Plug in USB and upload

Paste the code from the next section, select your ESP32 board and COM port, then click Upload.

10

Turn the knob!

Watch Earth spin, then slowly zoom through Continent, Country, City, all the way to Street.

Step 4

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_earth_zoom.ino
// ============================================================
//  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);
}
The Science Bit

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.

Make It Yours

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!

1

Find a map view

Open a map of your neighborhood and take a screenshot from directly above.

2

Pixelate it

Use a free online pixel-art editor to shrink the screenshot down to a 16×16 or 32×32 grid.

3

Convert to code

Read each row left-to-right as 1s (lit) and 0s (dark), and replace gridStreet with your new rows.

4

Re-upload & zoom home

Save, upload, and zoom all the way down to your very own front door!

If Something's Stuck

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.

Frequently Asked Questions

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!

🌍 Pixel Planet Lab

A beginner-friendly ESP32 robotics project. Build it, break it, remix it — that's what making is for.

Comments

Product Cards
Buddy Bot eBook
⭐ New 2026 Release
Build Your
Own Robot!
3D design, wiring &
Arduino coding.
Young inventors love it!
🖨️
3D Print
All parts
Wire it
Circuit guide
💻
Code it
Arduino IDE
🤖
Watch it
Walk & react
📋 Your Details
Enter your name
Valid 10-digit no.
Enter a valid email
Special Website Offer
₹499 300
🌍 International: $5 USD
One-time · Instant digital delivery
🔒 Secured by Razorpay · Your data is safe
📄 Download Free Sample Copy
🔒 Secured by Razorpay · Your data is safe
🍓
Raspberry Pi Pico Mastery
21 Projects
⚡ Launch Price — 80% OFF
Learn Pico
Build 21 Projects!
MicroPython · Wokwi
IoT · Certificate
Perfect for beginners!
🖥️
Wokwi
No hardware
🐍
MicroPy
From zero
🔨
21 Projects
IoT + sensors
📄
Certificate
Verified cert
📋 Your Details
Enter your name
Valid 10-digit no.
Enter a valid email
Special Launch Offer
₹999 200 80% OFF
🌍 International: $5 USD
One-time · Lifetime access · No subscription
🔒 Secured by Razorpay · UPI · Cards · NetBanking
🎉

You're in!

Payment successful! Your Buddy Bot eBook is ready. Time to build!

📖 Access Your eBook Now
🎉

Enrolled!

Payment successful! Lifetime access to all 21 Pico Projects is yours!

🍓 Go to My Course