The math was right, the servos weren't
Aiming the CamBots is the one part of this that's a real control problem, and it's where I most enjoyed being precise and most enjoyed being wrong.
The first version fed the overhead camera's ball position into the pan-tilt aim, which is the obvious design: you have a top-down view of the whole field, so use it. It made the rigs slew wildly. The overhead track is noisy at the edges, and a noisy setpoint on a servo with real momentum is a machine that hunts. Now each CamBot aims using only its own camera's ball detection, a closed-loop visual servo, and the overhead track is dropped from aiming entirely. It's still the source of truth for game state. It's just not allowed near the servos.
For the motion profile, I sat down and derived the relationship: for a step of size d, the profile overshoots if max_accel < tracking_gain × max_speed / 2. I verified it in simulation up to a 180° step, added a 25% margin, and shipped those as the defaults: gain 30, 2400 °/s, 12000 °/s².
The config on the actual rig runs 1600 °/s and 8000 °/s². About two-thirds.
I want to be clear that the derivation isn't wrong, and I wrote its own limits into the comment before I ever hit them. It's a software analysis, not a hardware one, and it assumes the real servo can execute the commanded speed and acceleration with no extra lag or backlash. Real servos have both. The paper number was the ceiling; the real number was found by watching two cameras twitch at a ball and backing off until they stopped.
There's a related decision I like more than the control law. There are no ultrasonic sensors in the goals. I tried to spec them and couldn't find a way to make one distinguish a ball sitting in the goal mouth from a robot parked there. Goals are detected from the ball's tracked field position instead. Sometimes the sensor you don't add is the design.
What I'd fix
Two things are honestly wrong with it. The OBS scene switch happens synchronously inside the tick, while holding the engine lock, which means a hung OBS connection stalls the whole 30 Hz loop. It should be queued to its own thread. And there is no automated test suite at all: every file with "test" in the name is an interactive calibration panel or a hardware bring-up script. For a system where I can't reproduce most failures without the physical rig, the simulation mode is doing a lot of load-bearing work that a handful of actual unit tests should be doing instead.
The thing I keep coming back to, though, is that almost none of the hard-won parts of this project were the parts I expected to be hard. I budgeted my worry for the servo control loop and the OBS integration. What actually ate the time was a matte ball that YOLO couldn't see, two pixels of jitter on a corner marker, and a physically swapped sticker.