Self-balancing cube simulator
A reaction-wheel inverted pendulum, solved in your browser. It builds the linearised plant from your physical dimensions, computes an optimal LQR controller by Riccati recursion, then integrates the full nonlinear dynamics against a real motor torque-speed curve. Change a number and the controller is redesigned on the spot.
projects · lab · cube simulator
The simulator [ live ]
Presets
Mechanics
Motor
Electronics
LQR weights
The number that decides your build is "max recoverable lean." It is found by bisection — the page runs the full nonlinear simulation repeatedly from progressively larger starting angles until the cube stops catching itself. Drag the stall-torque slider down and watch it collapse. For scale, ETH Zurich reported roughly 7° on the original Cubli's 1‑DOF prototype, torque-limited because they ran gearless.
Four experiments worth running [ before you buy anything ]
- Set "penalise wheel speed" to zero, then press kick two or three times. The wheel ratchets up in one direction, hits its no-load speed, runs out of torque authority and the cube drops. This is the single most common way a first build dies.
- Drag the control rate down from 500 Hz. Nothing happens for a while, then somewhere near 80 Hz the recoverable angle falls off a cliff. The gains are recomputed for each rate, so this is not detuning — it is the best possible controller at that rate.
- Push cube edge to 200 mm. Required torque rises far faster than size. The baseline motor that caught 10° on a 120 mm cube barely holds a couple of degrees on a 200 mm one.
- Add IMU noise and latency together. Either alone is survivable; together they are what makes a real cube jitter and eventually fall, which is why loop jitter matters as much as loop rate.
What the model actually is [ the maths ]
Two rigid bodies: the cube rotating about a bottom edge, and a flywheel spinning on a motor mounted inside it. The motor pushes the wheel one way and shoves the cube the other. Writing th for tilt from vertical and w for the wheel's speed relative to the cube:
J_p · tḧ = M g l sin(th) − (tau − b·w) J_w · (tḧ + ẇ) = tau − b·w
Gravity destabilises the first equation; the motor torque tau appears in both with opposite sign, which is the whole trick. Linearising about upright gives three states — tilt, tilt rate, wheel speed — and the controller is three numbers:
tau = Kθ·th + Kθ̇·tḣ + Kω·w
That third term is the one people leave out, and it is why their cube dies after four seconds. Without a wheel-speed penalty the flywheel winds up, saturates, and the cube drops. With it, the controller deliberately leans a little to bleed the wheel back toward zero. ETH solved the same problem a second way as well: a constant sensor bias also drives the wheel to a non-zero steady speed, so they folded a low-pass-filtered integrator on the tilt estimate into the loop to absorb the bias. In their published trace an uncorrected 0.058 rad offset parked the wheel at 37 rad/s forever.
Why the gains move when you move the loop-rate slider
The controller is designed in discrete time against the exact zero-order-hold discretisation of the plant, so the gains shown are the ones for your sample rate, not a continuous-time approximation sampled. Sampling slower doesn't merely degrade performance — it changes what the optimal gains are. ETH sampled their 1‑DOF prototype at 20 ms (50 Hz), choosing it to be roughly 30× faster than the open-loop unstable time constant. Apply that same rule of thumb to this cube's 107 ms time constant and you land near 300 Hz.
How this page solves the LQR
The textbook route — integrating the continuous Riccati differential equation — diverges here, because 1/J_wheel is around 10,000 and the equation is stiff. Instead the page discretises with a matrix exponential (scaling and squaring) and runs the Riccati difference equation, the dynamic-programming recursion. It is unconditionally stable, and because there is a single input the only matrix inverse is a scalar divide. It converges in 878 iterations at 500 Hz, in well under a millisecond — which is why the gains can be redesigned live while you drag a slider.
Verification [ 44 / 44 ]
The engine was written twice — once in numpy, once in dependency-free JavaScript — and the JavaScript is checked against the numpy reference on every quantity that matters. Spot values at the 120 mm baseline:
| Quantity | numpy reference | this page | status |
|---|---|---|---|
| Open-loop unstable pole | 9.3106 rad/s | 9.3106 | match |
| Kθ at 500 Hz | −49.3738 | −49.3738 | match |
| Kθ̇ at 500 Hz | −5.3500 | −5.3500 | match |
| Kω at 500 Hz | −0.026801 | −0.026801 | match |
| Closed-loop spectral radius | 0.9822 | 0.9822 | match |
| Peak wheel speed catching 8° | 2740 rpm | 2740 | match |
| Peak torque catching 8° | 0.209 N·m | 0.209 | match |
| Max recoverable lean, 0.18 N·m | 10.09° | 10.09° | match |
Both implementations integrate the same nonlinear equations with the same explicit scheme at 4 kHz, so this checks the linear algebra, the discretisation, the Riccati recursion and the bisection — not the physics itself. The physics is checked the old way: the closed loop settles to 0.000°, the wheel returns to 0 rpm, and momentum dumping shows up as a 6.76° lean when the wheel is pre-spun to 2865 rpm and asked to shed it.
Where this model is deliberately simple. The pendulum inertia assumes a uniform solid cube, so a real build with motors bolted to the faces will differ — measure yours. Bearing drag is a single viscous term. The motor is a straight torque-speed line, which is right for a brushed DC motor and only approximately right for a BLDC under field-oriented control. And this is the edge-balancing problem in one plane; corner balancing couples three of these together and is genuinely harder.