A self-contained, interactive demo of PID, LQR and MPPI path tracking for
lecturing. No installs, no server — just open index.html in any browser:
open index.htmlThe plant is a kinematic bicycle at constant forward speed; the control input is the steering angle, clamped to a limit. Where the Planning Playground asks "how do we find a path?", this one asks "how do we follow it?" — same canvas, same controls, same one-chart-per-theorem philosophy.
- Pick the mode at the top of the sidebar: PID, LQR, MPPI, or Race: all three (three cars overlaid on the identical path).
- PID acts on the signed cross-track error (P), its integral (I) and a filtered derivative (D), with conditional anti-windup at the steering limit. The Result panel shows the live P/I/D contributions in degrees of steering.
- LQR linearizes the error dynamics (cross-track + heading error) about
the reference, solves the discrete Riccati equation live (gains retune
instantly when you move the Q/R sliders mid-run), and adds a curvature
feedforward
δff = atan(L·κ). The breakdown line shows feedforward vs feedback and the current gain vector. - MPPI is honest sampling-based MPC: every control tick it perturbs the
previous steering sequence into K candidate sequences, rolls each through
the nonlinear bicycle model over the horizon, scores tracking + effort,
and blends them with weights
exp(-cost/λ). You see the whole thing: the faint magenta fan is the sampled futures (opacity = weight), the bold dashed line is the committed plan. Receding horizon means it re-imagines the world ~17 times a second. - Space plays/pauses, S advances one sim step (0.02 s), R resets, P toggles projector mode (bigger text, thicker strokes).
- At slow sim speeds the cross-track error vector is drawn from the path to each car (yellow, dashed) with its live value.
- Drag the green start ring to change the initial offset — every run opens with a step-response transient, so the start position is the experiment. Any world edit (start, speed, wind, bias, noise, latency) resets the run and clears the chart; controller-gain changes don't.
- Preset paths: Step (straight line, car starts 90 px off — a textbook step response), Slalom (sinusoid), Corners (two filleted 90° turns), Circle (constant curvature), Planned path (a jagged RRT-style polyline — the bridge back to the planning demo).
- Sliders: sim speed, vehicle speed, steering limit, crosswind, steering bias, sensor noise, actuator latency, and per-controller parameters. Changing controller gains mid-run is allowed and instructive.
The centerpiece: all three cars drive the identical path with the same physics, the same wind, and the same seeded sensor-noise stream — the only difference is the control law. The Tracking error chart draws each controller's signed cross-track error over time; the end banner reports the RMS scoreboard. Same path, same physics, different brains.
Curves persist across runs until the world changes, so running PID, LQR and MPPI one after another on the same preset overlays all three step responses on one chart — the whole controller landscape in one picture.
- Step + PID, kP only (set kD = 0): pure oscillation about the path — proportional control on a double-integrator-like plant is undamped. Raise kD and watch the oscillation die; that's derivative action, live.
- kD amplifies noise: put sensor noise at 3–4 px and watch PID's steering (and effort stat) chatter while the path tracking barely improves. The derivative of a noisy signal is the classic PID pathology.
- Steering bias — the integral-action story: set steering bias to ~8° (crooked wheels). PD leaves a visible steady-state offset; a bit of kI removes it exactly. Now try LQR: it has no integral action, so the offset stays — as does MPPI's, since the bias isn't in its model. Only integral action rejects what your model doesn't know about.
- Curvature: on Circle, PID rides visibly outside the reference all lap (steady-state error against constant curvature; kI fixes it). LQR is clean from the start — that's the curvature feedforward, not better feedback.
- Too much integral: crosswind 30, kI at 0.6 — the integrator's lag during the big initial transient swings the heading past 90° and the car spirals off. Raise kD to ~0.9 and it's rescued. Integral action trades steady-state accuracy for phase lag; saturation makes the trade violent.
- Strong wind — heading-blindness: at crosswind 50, cross-track PID can spin outright (it knows nothing about heading; once the car points backwards the feedback sign flips). LQR and MPPI, which both reason about heading, crab into the wind and keep tracking — with a steady offset, because the wind isn't in their models either.
- Latency — the MPC punchline: set actuator latency to 160 ms. PID's damping degrades into oscillation; MPPI barely notices, because its rollouts simulate the still-pending steering commands before optimizing what comes next. Prediction is the cure for delay.
- Horizon slider: shrink MPPI's horizon to 0.4 s and it degenerates toward reactive control (watch the Corners preset — it stops anticipating the turn). Stretch it to 2.4 s and the fan reaches around the corner before the car gets there. That anticipation is the visible difference between optimizing the future and reacting to the present.
- Temperature λ: small λ ≈ pick-the-best-sample (greedy, jittery); large λ ≈ average-everything (smooth, sluggish). The soft-max knob, on a slider.
- Planned path: planner output is jagged; tracking it is where control meets planning. MPPI's RMS beats PID's by ~40% here — smoothing-by- optimization — and the segue back to the planning lecture writes itself.
There is no QP solver in this file, and that's the point. MPPI minimizes the same finite-horizon cost an industrial linear-MPC QP would, but by sampling — which handles the nonlinear model and the steering-limit constraint for free, never diverges mid-lecture, and (unlike a QP) lets students literally watch the optimizer think: the rollout fan is the search. It's also a real algorithm run on real robots, not a classroom simplification.
The code exposes a controller-plugin interface in index.html:
const CONTROLLERS = { pid: PID, lqr: LQR, mppi: MPPI };A controller implements init() → state and
control(state, meas, view) → {delta, info}, where meas is the (possibly
noisy) pose plus its projection onto the path (cross-track error, heading
error, curvature, arc length), and info drives the canvas annotations and
the Result breakdown line. Controllers declare a period (control interval
in sim steps — MPPI runs at 17 Hz, PID/LQR at 50 Hz) and everything else
(latency queue, noise streams, metrics, chart, race mode) is shared
machinery. Pure Pursuit or Stanley would each be ~15 lines and slot straight
into the race.
harness.js is a headless smoke test (DOM stubs + the window.__test hook
at the bottom of index.html): it drives every controller over every preset
and checks that the teaching stories (integral action, windup, latency,
noise) still hold. Run it with node harness.js, or on a stock Mac with
osascript -l JavaScript harness.js. Re-run it after touching a controller.