Digital Spirit Level
Intermediate45 minA project that turns the breadboard itself into a spirit level: the MPU6050's accelerometer measures which way gravity pulls, and the Arduino works out the tilt on two axes in degrees. On the left of the OLED a bubble runs to the high side like in a real spirit level, and on the right the X and Y tilt are written; once both are under half a degree the screen says "LEVEL" and the buzzer gives two short beeps. It teaches using a sensor without a library by reading its registers directly, working out an angle from gravity (atan2), smoothing a jittery reading, and putting two I2C modules on one line. Install "Adafruit SSD1306" and "Adafruit GFX" from the Library Manager.
Build it step by step in the appWhat you need
1 × Arduino Uno R3
1 × Breadboard 830 Points (MB-102)
1 × MPU-6050 Gyroscope + Accelerometer (GY-521)
1 × 0.96" OLED Display (SSD1306, I2C)
1 × Passive Buzzer (Piezo)
14 × Jumper Wire - Male/Male
Wiring steps
- 1
Connect and join the power rails

Plug a red male-male jumper wire into the 5V pin on the Arduino's power header and connect its other end to the breadboard's bottom red (+) rail. Plug a black wire into the GND pin on the same header and connect it to the bottom blue (−) rail. The breadboard's top and bottom rails are not connected to each other: at the right end of the breadboard, join the bottom (+) rail to the top (+) rail with a red wire, and the bottom (−) rail to the top (−) rail with a black wire. Now all four rails carry 5V and GND.
- Arduino GND → the bottom blue (−) rail (black wire)
- Arduino 5V → the bottom red (+) rail (red wire)
- the bottom red (+) rail → the top red (+) rail (red wire)
- the bottom blue (−) rail → the top blue (−) rail (black wire)
- 2
Plug in and wire the MPU6050

Plug the MPU6050 (GY-521) module in with its eight pins in row E of the bottom half: VCC in column 3, GND in 4, SCL in 5 and SDA in 6; XDA, XCL, AD0 and INT sit in columns 7–10 but are not used. The module's body reaches over the gap in the middle. Connect column 3 to the bottom (+) rail with a short red wire and column 4 to the bottom (−) rail with a short black wire. Connect the Arduino's A5 pin to column 5 with a yellow wire and A4 to column 6 with a blue wire. Press the module down so it lies flat on the breadboard: the breadboard itself is the level, and a module that sits crooked measures crooked.
- MPU6050 VCC → column 3, bottom half
- MPU6050 GND → column 4, bottom half
- MPU6050 SCL → column 5, bottom half
- MPU6050 SDA → column 6, bottom half
- MPU6050 XDA → column 7, bottom half
- MPU6050 XCL → column 8, bottom half
- MPU6050 AD0 → column 9, bottom half
- MPU6050 INT → column 10, bottom half
- column 3, bottom half → the bottom red (+) rail (red wire)
- column 4, bottom half → the bottom blue (−) rail (black wire)
- Arduino A5 → column 5, bottom half (yellow wire)
- Arduino A4 → column 6, bottom half (blue wire)
- 3
Plug in and wire the OLED

Plug the OLED in with its four pins in row F of the top half: GND in column 27, VCC in 28, SCL in 29 and SDA in 30; the screen reaches over the gap in the middle. Some OLEDs have VCC and GND the other way round, so go by the names printed on the pins. Connect GND's column to the top (−) rail with a short black wire and VCC's column to the top (+) rail with a short red wire. Connect the SCL pin next to AREF on the Arduino's top row to column 29 with a yellow wire, and the SDA pin beside it to column 30 with a blue wire. On an Uno R3 these two pins are the same lines as A4 and A5: the MPU6050 and the OLED share one I2C line, and the code tells them apart by their addresses (MPU6050 0x68, OLED 0x3C).
- OLED GND → column 27, top half
- OLED VCC → column 28, top half
- OLED SCL → column 29, top half
- OLED SDA → column 30, top half
- column 27, top half → the top blue (−) rail (black wire)
- column 28, top half → the top red (+) rail (red wire)
- Arduino SCL (next to AREF) → column 29, top half (yellow wire)
- Arduino SDA (next to AREF) → column 30, top half (blue wire)
- 4
Add and wire the buzzer

Plug the passive buzzer's + leg into column 15 in the top half and its other leg into column 16; the + leg is usually the longer one and the body has a + mark. Connect the Arduino's pin 8 to column 15 with an orange wire (BUZZER_PIN 8). Connect column 16 to the top (−) rail with a short black wire.
- buzzer + leg → column 15, top half
- buzzer − leg → column 16, top half
- Arduino pin 8 → column 15, top half (orange wire)
- column 16, top half → the top blue (−) rail (black wire)
Arduino sketch
// Install from the Library Manager:
// "Adafruit SSD1306" and "Adafruit GFX"
// (the MPU6050 is read with Wire alone)
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
const int BUZZER_PIN = 8;
// The MPU6050's address (AD0 free: 0x68)
const byte MPU_ADDR = 0x68;
// If X or Y is not zero on a surface you
// know is level, put the angle shown here
const float OFFSET_X = 0.0;
const float OFFSET_Y = 0.0;
// Bubble going the wrong way? FLIP turns
// an axis round, SWAP trades X and Y
const bool FLIP_X = false;
const bool FLIP_Y = false;
const bool SWAP_AXES = false;
// This close (degrees) counts as level;
// it stops once tilted by OUT
const float LEVEL_IN = 0.5;
const float LEVEL_OUT = 1.0;
// The level's sizes (pixels)
const int CX = 31;
const int CY = 31;
const int RING_R = 30;
const int TARGET_R = 7;
const int BUBBLE_R = 5;
const int TRAVEL = 23;
// How many pixels one degree moves it
const float SCALE = 2.0;
// 128x64 screen; I2C address mostly 0x3C
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
// Smoothed tilts (degrees)
float tiltX = 0;
float tiltY = 0;
bool level = false;
bool mpuOk = false;
bool firstReading = true;
int beepsLeft = 0;
unsigned long beepAt = 0;
unsigned long lastRead = 0;
unsigned long lastDraw = 0;
unsigned long lastPrint = 0;
void writeRegister(byte reg, byte value) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
// Wakes it; true if the MPU6050 answers
bool startMpu() {
Wire.beginTransmission(MPU_ADDR);
// Power register 0: out of sleep mode
Wire.write(0x6B);
Wire.write(0);
if (Wire.endTransmission() != 0) return false;
// A filter against shaking (~44 Hz)
writeRegister(0x1A, 3);
return true;
}
// Two bytes into one number (high first)
int read16() {
byte high = Wire.read();
byte low = Wire.read();
return (int16_t)((high << 8) | low);
}
// Reads the accelerometer's three axes;
// false on an error
bool readAccel(float &ax, float &ay, float &az) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x3B);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)MPU_ADDR, 6) != 6) return false;
ax = read16();
ay = read16();
az = read16();
return true;
}
void readTilt() {
float ax, ay, az;
if (!readAccel(ax, ay, az)) {
mpuOk = false;
firstReading = true;
return;
}
// Angle from gravity's share on each
// axis: the higher the axis, the bigger
float x = atan2(ax, sqrt(ay * ay + az * az)) * RAD_TO_DEG;
float y = atan2(ay, sqrt(ax * ax + az * az)) * RAD_TO_DEG;
if (SWAP_AXES) {
float t = x;
x = y;
y = t;
}
if (FLIP_X) x = -x;
if (FLIP_Y) y = -y;
x -= OFFSET_X;
y -= OFFSET_Y;
// The first reading is taken as it is
// (from 0 it would pass for level)
if (firstReading) {
tiltX = x;
tiltY = y;
firstReading = false;
}
// Smoothing: no jittery numbers
tiltX += (x - tiltX) * 0.15;
tiltY += (y - tiltY) * 0.15;
}
void checkLevel() {
float worst = max(abs(tiltX), abs(tiltY));
if (!level && worst <= LEVEL_IN) {
level = true;
// Two short beeps when it is level
beepsLeft = 2;
beepAt = millis();
Serial.println("Level!");
} else if (level && worst > LEVEL_OUT) {
level = false;
}
}
void playBeeps() {
if (beepsLeft > 0 && millis() >= beepAt) {
tone(BUZZER_PIN, 1800, 60);
beepsLeft--;
beepAt = millis() + 140;
}
}
// The angle in big print, a small degree
// sign beside it
void printAngle(int x, int y, float angle) {
oled.setTextSize(2);
oled.setCursor(x, y);
oled.print(angle, 1);
int end = oled.getCursorX();
if (end + 6 <= 128) {
oled.setTextSize(1);
oled.setCursor(end + 1, y);
// 247 is the screen's degree sign
oled.write(247);
}
}
void drawScreen() {
oled.clearDisplay();
if (!mpuOk) {
oled.setTextSize(1);
oled.setCursor(0, 20);
oled.println("No MPU6050");
oled.println("Check the wires");
oled.display();
return;
}
// Left: ring, target and cross lines
oled.drawCircle(CX, CY, RING_R, SSD1306_WHITE);
oled.drawCircle(CX, CY, TARGET_R, SSD1306_WHITE);
oled.drawFastHLine(CX - RING_R, CY, RING_R - TARGET_R, SSD1306_WHITE);
oled.drawFastHLine(CX + TARGET_R + 1, CY, RING_R - TARGET_R, SSD1306_WHITE);
oled.drawFastVLine(CX, CY - RING_R, RING_R - TARGET_R, SSD1306_WHITE);
oled.drawFastVLine(CX, CY + TARGET_R + 1, RING_R - TARGET_R, SSD1306_WHITE);
// The bubble runs to the high side; cut
// short so it stays inside the ring
float dx = tiltX * SCALE;
float dy = -tiltY * SCALE;
float far = sqrt(dx * dx + dy * dy);
if (far > TRAVEL) {
dx = dx * TRAVEL / far;
dy = dy * TRAVEL / far;
}
oled.fillCircle(CX + round(dx), CY + round(dy), BUBBLE_R, SSD1306_WHITE);
// Right: both axes' angles
oled.setTextSize(1);
oled.setCursor(66, 0);
oled.print("X");
printAngle(66, 9, tiltX);
oled.setTextSize(1);
oled.setCursor(66, 29);
oled.print("Y");
printAngle(66, 38, tiltY);
if (level) {
oled.setTextSize(1);
oled.setCursor(66, 56);
// Shown while it is level
oled.print("LEVEL");
}
oled.display();
}
void setup() {
Serial.begin(9600);
Wire.begin();
// Fast I2C: the screen redraws sooner
Wire.setClock(400000);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED not found");
// No screen: stop here
while (true) {}
}
oled.setTextColor(SSD1306_WHITE);
// For the degree sign
oled.cp437(true);
mpuOk = startMpu();
if (!mpuOk) Serial.println("MPU6050 not found: check the wires.");
}
void loop() {
unsigned long now = millis();
if (now - lastRead >= 20) {
lastRead = now;
// Tries a lost connection again
if (!mpuOk) mpuOk = startMpu();
if (mpuOk) readTilt();
if (mpuOk) checkLevel();
}
playBeeps();
if (now - lastDraw >= 100) {
lastDraw = now;
drawScreen();
}
if (mpuOk && now - lastPrint >= 500) {
lastPrint = now;
Serial.print("X: ");
Serial.print(tiltX, 1);
Serial.print("° Y: ");
Serial.print(tiltY, 1);
Serial.println("°");
}
}
What the Serial Monitor shows
X: 3.2° Y: -1.4° X: 1.1° Y: -0.6° Level! X: 0.3° Y: -0.2° X: 0.2° Y: -0.1°
What you will see
Once the code is uploaded the OLED shows a ring with a bubble in it on the left, and the X and Y tilt in degrees on the right. Put the breadboard with the Arduino on a table and lift one edge a little: the bubble runs to the side you lifted, the high side, and that axis's angle grows. When you bring the bubble into the small ring in the middle, so both axes are under half a degree, "LEVEL" appears at the bottom right and the buzzer gives two short beeps; tilt it more than a degree and it is no longer level. The angles are also printed in the Serial Monitor every half second. If the bubble goes away from the side you lift, set FLIP_X or FLIP_Y in the code to true; if tilting left and right moves it up and down, set SWAP_AXES to true. If X or Y is not zero on a surface you know is level, put the value it shows into OFFSET_X or OFFSET_Y: every sensor is a little off. If the screen says "No MPU6050", check the MPU6050's wires; if the screen stays dark, check the OLED's wires and its address (0x3C, 0x3D on some).
In the app, tick off every wire, match the parts to your inventory and pick up where you left off.
Build it step by step in the app