I made the wonderful ProtoBot self-balance as a learning experience to prepare work on a quadruped robot for my niece.
Out of the box, it balanced for just a few seconds on the stock PD controller.
One thing that made it hard was that the motors used (N20s) have no encoders, so the robot can't measure its wheel speed and overshoots corrections.
Over 2-3 days with AI, I built a dashboard with telemetry over bluetooth, gave the robot a model of its own motors and wheels based on observations, and trained a tiny neural network to self-balance forever.
From the 1.2kB neural network, I distilled a small linear model from the traces, which can also maintain balance for 60 seconds.
A couple days later, self-balance no longer worked because the battery level had changed.
I added interpolation on the motor and wheel models to account for battery voltage and the robot is now pretty stable regardless of battery charge.
The final linear controller
One linear formula, run 100 times per second. It's a linear regression of what the trained network does near upright, and it matches 99% of the network's behavior.
| Term | Weight | What it reads, and why it's there |
|---|---|---|
| lean | 5.36 duty/° | degrees from the true balance point (0.55° off vertical on my unit) - the main push |
| tip_rate | −0.45 | the gyro, in °/s - damping that arrives before the lean grows |
| wheel_speed | +1.38 | the imaginary wheel (below) - pushes with the motion to get back under the fall; my hand tuning had the sign backwards |
| position | +0.23 | drifted distance - pulls the robot back home |
| last_push | +0.35 | its own previous output - memory that rides out the 20-25 ms sensor-to-motor delay |
| constant | +0.5 | a small standing bias the regression found |
The 1.2 KB network that taught it (7 inputs, two layers of 32, 8-bit weights, 2.7 ms per decision) also works onboard as a switchable mode.
Seeing the robot
You can't debug a robot you can't see, so telemetry came before any control work. The firmware streams 50 measurements per second over Bluetooth Low Energy into a browser dashboard: live charts, a pose view, and 22 settings adjustable while the robot runs. Every run archives itself with its exact settings, about 300 in total.

What the robot measured about itself
Every constant we guessed was wrong, so the firmware grew a 25-second self-test: duty staircases, step trials, battery sag, and an open-loop fall.
| Quantity | Value | Note |
|---|---|---|
| Sensor-to-motor delay | 20-25 ms | the wall every gain runs into; past ~8 duty/° the loop rings at 5 Hz |
| Friction threshold | duty 48 ± 4 flat & cold | wanders 36-56 with heat and battery |
| Warm motors vs cold | −20-25 % torque | confirmed two independent ways |
| Balance point | ≈ −0.55° | this unit's crookedness; every build has its own |
| Top speed | 0.26-0.28 m/s | a 2° standing error falls in ~0.75 s |
| Robot weight | 94 g | lighter than a deck of cards |
| Brain size & speed | 1.2 KB · 2.7 ms | 100 decisions/s on a chip with no floating-point unit |
| Longest run | 65.5 s | stopped by hand; runs now end at 60 s with a rainbow |
| Straightest run | 1.47° error rms | by the five-multiply formula |
| Battery life | 170 mAh → 15-20 min | of continuous balancing |
The techniques, in the order we needed them
Tuned PD control
The stock controller is a PD controller, and tuning it well is the honest first step. It reached 15 to 18 second sessions and stopped there: past a gain of about 8 duty per degree, the 20-25 ms delay turns every correction into a new disturbance and the robot rocks at 5 Hz. That wall is physical. Everything after this section exists to go around it.
Pulse-density modulation (PDM)
Below the friction threshold the wheels don't turn at all, so nearly half the command range did nothing. PDM makes the dead zone invisible: small commands become full-strength 10 ms pulses, spaced so their average equals what was asked, like pushing a swing less often instead of more softly. The standing duty floor is 33-35, well below the flat threshold of 48. The pulses do the breaking-away.
A small ask (top) becomes spaced full-strength pulses (bottom). Each pulse clears the friction threshold; the dashed line is the average the robot actually feels.
The imaginary wheel
The motors have no encoders, so the robot can't feel its own wheels. It keeps an estimate instead: speed += (command − speed) · dt / lag, with a 0.05 s lag when coasting because friction stops wheels faster than motors spin them up. Speed integrates into a position estimate with a 10-second leak. Without this, any small bias carries the robot to its 26 cm/s top speed, where nothing is left for balancing.
The balance point
No build is perfectly symmetric, so "straight up" is not where the robot balances. We ran two candidate setpoints, watched which way it drifted under each, and interpolated to the angle with zero drift: 0.55° off vertical on my unit. Guessing this costs you a slow walk across the desk.
Training in simulation
To get past the classical ceiling we rebuilt the robot inside a physics simulator and let it fall ten thousand times a minute until a habit of balancing emerged. About 30 minutes per brain on a desktop CPU.
- The calibration gate refuses to train unless the sim reproduces the measured torque curve and falling speed. Our first two attempts skipped this and learned violence from dishonest worlds.
- A sim is only trusted once the policy that already works on hardware behaves the same inside it. Judge the world by whether reality's champion survives it.
- The network that passed everything balances 60+ seconds, recovers from driving over a cable, and works upside-down, a pose it saw in only 20% of training.
Distillation
Near its balance point, any good controller behaves almost linearly. So we fit a plain regression to the network's own decisions across its good runs, and got the five-multiply formula at the top of this page. It matches 99% of the network's behavior, and dropping the accelerometer input costs almost nothing (0.989 to 0.987), which is why the formula needs only parts every balancer already has.
Battery and temperature tracking
The last technique, and the one the first four days didn't know they needed. Gearbox friction is a fixed torque, but duty buys torque in proportion to battery volts, so the dead-zone threshold moves opposite to the charge, and warm grease lets go earlier. End to end, a full battery makes the drivetrain 1.5 to 2 times stronger for the same command.
| Symptom | Cause | Fix in firmware |
|---|---|---|
| Overreacts on a full charge | more torque per duty unit | scale all duty by 3820 mV / V, clamped 0.85-1.1 |
| Drifts off at full charge, looking calm | real wheels outrun the imaginary one | wheel lag slides 0.30→0.20 s across 3.75-4.05 V |
| First cold runs starve and fall early | cold grease raises the threshold | floor slides 35→33 by voltage, and an adaptive trim (±8) compares delivered acceleration to commanded effort |