Skip to content
Buy 10+ on select items — save 10% auto-applied
Free US shipping on orders $35+
Order by 3pm ET — ships same-day from the US
Worldwide shipping via DHL
Skip to main content

Arduino Ball and Beam: PID Control With a VL53L0X Laser Sensor, a Servo and a Slide-Pot Setpoint

October 10, 2026 4 views

Project

Project Overview Arduino Ball and Beam — the Classic Control-Theory Rig: a VL53L0X Laser Sensor Finds the Ball, a PID Loop on a Nano Tilts the Beam With a Servo, and a Slide Potentiometer Moves the Target: A ball on a tilting beam is the textbook unstable system.

3 hr Intermediate8 parts

Project Overview

Arduino Ball and Beam — the Classic Control-Theory Rig: a VL53L0X Laser Sensor Finds the Ball, a PID Loop on a Nano Tilts the Beam With a Servo, and a Slide Potentiometer Moves the Target: A ball on a tilting beam is the textbook unstable system. Tilt the beam even slightly and the ball accelerates; wait too long to correct and it's gone. That's exactly why every control course uses it — and why getting one to hold a ball still, then glide it to a new spot on command, is so satisfying. This build makes the frame from card or foam board in an afternoon, measures the ball's position with a laser time-of-flight sensor about 45 times a second, and runs a hand-written PID controller on an Arduino Nano that you can tune live from the Serial Monitor while the Serial Plotter draws the response.

  • Time: ~3 hours including the frame
  • Skill level: Intermediate
  • What you will build: A 35 cm ball-and-beam that holds a ball wherever a slide potentiometer points, with PID gains, level trim and direction adjustable over USB.
Ping pong ball held mid-beam on a card ball and beam rig with a laptop running the Arduino IDE behind it
A card-built ball and beam holding a ball mid-beam under feedback control.

Parts List

From ShillehTek

External

  • Corrugated card, foam board or thin plywood, and a hot glue gun
  • A ping-pong ball, or any light, pale plastic ball 40–60 mm across
  • An M3 bolt and nut for the pivot, and a stiff wire or straightened paperclip for the servo link
  • A 9–12 V DC adapter for the LM2596

Note: the VL53L0X sees a cone about 25° wide, so a small ball far away fills less and less of its view. Keep the beam to about 35 cm, use a pale ball, and leave the far end of the beam open (or line it with something matte black) so the sensor's strongest echo always comes from the ball.

Step-by-Step Guide

Step 1 — Build the Frame

Corrugated card strips, supports and base cut out for the ball and beam frame
Every structural part can be cut from corrugated card: beam strips, supports and a base.
Maker hot gluing the card supports of the ball and beam frame
Hot glue is all it takes to hold the frame together.

Goal: A light, stiff beam that pivots freely at one end.

What to do: Cut a base of about 40 × 20 cm, a tall support for the pivot end and a beam of about 34 cm. Glue two beam strips side by side with a slight V between them (or add low side walls) so the ball rolls straight instead of falling off. Push the M3 bolt through the support and the end of the beam as the pivot — it should rotate without wobble or friction. The other end of the beam stays free; the servo will lift and lower it.

Expected result: A beam that swings up and down smoothly about its pivot and is stiff along its length.

Step 2 — Add the Servo Link

Side view of the card ball and beam with the servo and lever arm under the free end of the beam
The servo sits under the free end and pushes it up and down through a lever.

Goal: Servo angle turned into beam tilt with no slop.

What to do: Glue the servo to a block on the base under the free end of the beam. Join the servo horn to the beam with a stiff link (a straightened paperclip bent into hooks works well) so that with the servo at 90° the beam sits roughly level. Any play in the link shows up later as a ball that wanders, so make the holes snug. You only need a few degrees of beam tilt either way — a long beam and a short horn give finer control.

Expected result: Turning the servo by hand moves the beam end smoothly up and down, with no free movement in between.

Step 3 — Mount the Sensor and Wire Everything

Front view of the finished ball and beam with the Arduino Nano on a breadboard and the servo lever on the left
The finished rig: pivot on the right, servo lever on the left and the Nano on a small breadboard.

Goal: The sensor looking straight down the beam, and clean power for the servo.

What to do: Fix the VL53L0X at the pivot end, centred on the beam at ball height and looking along it. Mounted on the beam, it tilts with it, so it always looks straight down the track. Wiring to the Nano: VL53L0X VIN → 5V, GND → GND, SDA → A4, SCL → A5. Slide pot: VCC → 5V, GND → GND, output (OTA on most modules) → A0. Servo signal → D9. Set the LM2596 output to 5.0 V before connecting the servo, then LM2596 OUT+ → servo red, OUT− → servo brown and Nano GND, with a 470 µF capacitor across the servo supply.

Expected result: Three sensors and actuators on one Nano, the servo on its own supply, and every ground joined.

Step 4 — The Sketch

#include <Wire.h>
#include <VL53L0X.h>          // "VL53L0X" by Pololu
#include <Servo.h>

VL53L0X tof;
Servo beam;

const int SERVO_PIN = 9, POT_PIN = A0;
const int MIN_MM = 50, MAX_MM = 300;     // usable stretch of beam, measured from the sensor
const int TILT = 25;                     // max servo swing either side of level, degrees
int   LEVEL = 90;                        // servo angle that holds the beam level (Step 5)
int   DIR = 1;                           // -1 if the beam tilts the wrong way (Step 5)
float Kp = 0.12, Ki = 0.03, Kd = 0.07;   // starting gains, degrees per mm (Step 6)

float pos, vel, integral, setpoint = 175;
bool  tracking = false;
unsigned long lastUs;

void readCommands() {                    // type p0.15  i0.02  d0.08  l92  r-1  in the Serial Monitor
  static char buf[12];
  static byte n = 0;
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') {
      if (n > 1) {
        buf[n] = 0;
        float v = atof(buf + 1);
        switch (buf[0]) {
          case 'p': Kp = v; break;
          case 'i': Ki = v; integral = 0; break;
          case 'd': Kd = v; break;
          case 'l': LEVEL = (int)v; break;
          case 'r': DIR = (v < 0) ? -1 : 1; break;
        }
        Serial.print(F("# Kp=")); Serial.print(Kp, 3);
        Serial.print(F(" Ki="));  Serial.print(Ki, 3);
        Serial.print(F(" Kd="));  Serial.print(Kd, 3);
        Serial.print(F(" level=")); Serial.print(LEVEL);
        Serial.print(F(" dir="));   Serial.println(DIR);
      }
      n = 0;
    } else if (n < sizeof(buf) - 1) {
      buf[n++] = c;
    }
  }
}

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000);
  beam.attach(SERVO_PIN);
  beam.write(LEVEL);
  tof.setTimeout(100);
  if (!tof.init()) {
    Serial.println(F("VL53L0X not found - check SDA A4 / SCL A5"));
    while (1) {}
  }
  tof.setMeasurementTimingBudget(20000);  // 20 ms per reading: fast enough for a rolling ball
  tof.startContinuous();
  lastUs = micros();
}

void loop() {
  readCommands();
  uint16_t mm = tof.readRangeContinuousMillimeters();   // waits for the next reading, ~45 per second
  unsigned long now = micros();
  float dt = (now - lastUs) / 1000000.0;
  lastUs = now;

  float target = map(analogRead(POT_PIN), 0, 1023, MIN_MM + 20, MAX_MM - 20);
  setpoint += 0.1 * (target - setpoint);                 // glide to a new target instead of jumping

  if (tof.timeoutOccurred() || mm > MAX_MM + 60) {      // lost the ball: past the far end or off the beam
    tracking = false;
    integral = 0;
    beam.write(LEVEL - DIR * TILT / 2);                  // tip gently toward the sensor to bring it back
    return;
  }
  if (!tracking) {                                       // ball just came into view: start clean
    pos = mm;
    vel = 0;
    tracking = true;
  }

  float prev = pos;
  pos += 0.5 * (mm - pos);                               // light smoothing of the range reading
  vel += 0.3 * ((pos - prev) / dt - vel);                // filtered ball speed in mm/s

  float err = setpoint - pos;                            // positive = ball too close to the sensor
  integral = constrain(integral + err * dt, -150, 150);  // limit wind-up
  float u = Kp * err + Ki * integral - Kd * vel;         // PID with derivative on measurement
  int tilt = constrain((int)u, -TILT, TILT);
  beam.write(LEVEL + DIR * tilt);

  Serial.print(F("pos:"));   Serial.print(pos, 0);       // Tools > Serial Plotter draws these
  Serial.print(F(",set:"));  Serial.print(setpoint, 0);
  Serial.print(F(",tilt:")); Serial.println(tilt);
}

What to do: Install VL53L0X by Pololu from the Library Manager, select "Arduino Nano" (try "ATmega328P (Old Bootloader)" if the upload times out) and upload. The derivative term uses the ball's measured speed rather than the change in error, so moving the slide pot never produces a sudden kick.

Expected result: The Serial Monitor (115200 baud) shows a steady stream of pos, set and tilt values, and pos follows the ball when you move it along the beam by hand.

Step 5 — Find Level and Direction

Goal: The controller knows what "level" is and which way is "away from the sensor".

What to do: In the Serial Monitor (line ending set to Newline) send p0, i0 and d0 — with all gains zero the servo simply holds the level angle. Place the ball mid-beam and send l88, l92 and so on until the ball barely drifts; note that number and put it in LEVEL in the sketch for next time. Now send p0.12 and place the ball near the sensor: the beam should tip so the ball rolls away from it, toward the target. If it tips the other way, send r-1 (and set DIR = -1 in the sketch).

Expected result: With only Kp active, the ball rolls toward the target and oscillates back and forth around it.

Step 6 — Tune the PID

Close view of a ping pong ball resting on the card beam
Tuned well, the ball settles in one or two swings and stays put.

Goal: A ball that settles quickly at the target and follows the slide pot.

What to do: Open Tools → Serial Plotter to watch pos against set. Start with i0 and set p so the ball swings steadily around the target without running off the end. Add damping with d: raise it in small steps until each swing is smaller than the last and the ball stops within one or two. If the beam starts buzzing or twitching, Kd is too high for the sensor noise — back off a little. Finally add a small i (0.01–0.05) to remove any steady offset between the ball and the target. Every rig is different — horn length, beam length and ball weight all change the numbers — so treat the starting gains as just that. Once it's tuned, write your values into the sketch.

Expected result: Move the slide pot and the ball rolls to the new spot and stops; nudge it with a finger and it comes straight back.

Step 7 — Take It Further

Goal: Better control and new experiments.

What to do: Mount an MPU6050 on the beam and run a fast inner loop on beam angle with this ball loop on top — cascaded control is how real balancing systems are built. Add an OLED to show the gains and position without a computer. Record step responses from the plotter and compare them with what the textbook model predicts. Or go two-dimensional: a flat plate on two servos with a camera or touch panel to sense the ball is the next classic, the ball-and-plate.

Expected result: A desk-sized control lab you can keep learning on.

Conclusion

With an Arduino Nano, a VL53L0X time-of-flight sensor, one MG90S servo and a slide potentiometer, you built the classic ball-and-beam and closed the loop with a PID controller you can tune while it runs. The ideas here — measure, compare with a setpoint, correct proportionally, damp with speed, trim with the integral — are the same ones that hold drones level and keep 3D printer hotends on temperature.

Want the exact parts used in this build? Grab them from ShillehTek.com. If you want help customizing this project, check out our IoT consulting services.

Credits

All photos and images in this tutorial are credited to Karem BenChikha on Hackster.io. The original guide by Karem BenChikha served as the reference for this ShillehTek version. We thank them for their excellent work in the maker community.

Parts for this build

Everything used in this tutorial. Uncheck what you already have.

All 8 in stock
0 parts selected $0.00