Introduction
In Part 1, we covered the vector math behind shooting on the fly. The feedback I got was “cool, but how do I actually tune this thing without a physics degree?” Fair point. Let’s talk about the practical side: building and using lookup tables (LUTs) that make SOTF work in the real world.
The core insight is this: you don’t need to model physics perfectly. You just need a table that maps distance to “what makes the ball go in.” The vector math handles the motion compensation; the LUT handles the “shooting while stationary” baseline.
The Only Map You Will Ever Need
Forget complicated multi-dimensional tables. For most shooters, you need exactly one lookup table:
Distance (meters) → Horizontal Exit Velocity (m/s)
That’s it. If you have a variable-speed shooter with fixed hood, this becomes:
Distance (meters) → RPM
If you have a fixed-speed shooter with variable hood, you technically don’t even need a distance LUT for the speed, you need one for the hood angle, and you derive the horizontal component from that.
Why Horizontal Velocity?
The SOTF vector math operates in the horizontal plane. Gravity doesn’t care about your robot’s sideways motion, it only pulls down. So when we do the vector subtraction:
V_shot = V_target - V_robot
We’re working with horizontal vectors. Your LUT needs to output the horizontal component of the exit velocity, not the total exit velocity (RPM).
For a flat shooter (exit angle near 0°), these are basically the same. For a lobbed shooter (60°+ exit angle), total velocity and horizontal velocity are very different:
v_horizontal = v_total * cos(theta_pitch) If your pitch is 60° and your total velocity is 12 m/s, your horizontal velocity is only 6 m/s. Get this wrong and your motion compensation will be 2x too aggressive.
Building the LUT: The Lazy Way
Here’s the reality: you don’t need fancy equipment. You need a tape measure, some tape on the floor, and patience.
Step 1: Mark Your Distances
Put tape on the floor at regular intervals. For FRC-scale games, every 0.5m from 1.5m to 6m is usually sufficient. For FTC, every 0.25m from 0.5m to 3m.
Step 2: Tune Each Distance
Park your robot at each mark. Adjust RPM (or hood angle) until shots consistently go in. Write down the value. That’s it. No math required.
// Example LUT (distance in meters, RPM)
private static final InterpolatingTreeMap<Double, Double> SHOOTER_MAP = new InterpolatingTreeMap<>();
static {
SHOOTER_MAP.put(1.5, 2800.0);
SHOOTER_MAP.put(2.0, 3100.0);
SHOOTER_MAP.put(2.5, 3400.0);
SHOOTER_MAP.put(3.0, 3650.0);
SHOOTER_MAP.put(3.5, 3900.0);
SHOOTER_MAP.put(4.0, 4100.0);
SHOOTER_MAP.put(4.5, 4350.0);
SHOOTER_MAP.put(5.0, 4550.0);
} Step 3: Measure Time of Flight
Here’s the trick that makes everything work without modeling shooter physics: measure the time of flight.
At each distance, when you find the RPM that works, also record how long the ball takes to reach the goal. You can do this with:
- A phone recording at 60fps (count frames, divide by 60)
- A stopwatch and a friend (less accurate, but works)
- An optitrack if you’re fancy
// Example LUT: distance (m) → {RPM, time of flight (s)}
private static final InterpolatingTreeMap<Double, ShooterParams> SHOOTER_MAP = new InterpolatingTreeMap<>();
static {
SHOOTER_MAP.put(1.5, new ShooterParams(2800.0, 0.42));
SHOOTER_MAP.put(2.0, new ShooterParams(3100.0, 0.51));
SHOOTER_MAP.put(2.5, new ShooterParams(3400.0, 0.58));
SHOOTER_MAP.put(3.0, new ShooterParams(3650.0, 0.65));
SHOOTER_MAP.put(3.5, new ShooterParams(3900.0, 0.71));
SHOOTER_MAP.put(4.0, new ShooterParams(4100.0, 0.78));
SHOOTER_MAP.put(4.5, new ShooterParams(4350.0, 0.84));
SHOOTER_MAP.put(5.0, new ShooterParams(4550.0, 0.91));
} Step 4: Let Linear Interpolation Do the Work
Use an InterpolatingTreeMap (WPILib) or equivalent. It handles in-between distances automatically. You don’t need to characterize every centimeter.
Getting Horizontal Velocity from ToF
This is where it gets elegant. The horizontal velocity is just:
v_horizontal = distance / time_of_flight
That’s it. Pure kinematics. If the ball travels 3 meters in 0.65 seconds, it was moving at 4.6 m/s horizontally. No model required.
public double getHorizontalVelocity(double distance) {
ShooterParams params = SHOOTER_MAP.get(distance);
return distance / params.timeOfFlight;
} Why does this work? Because when you tuned the shot to go in, you implicitly found the correct horizontal velocity. The ToF measurement just extracts that information without needing to model the shooter.
Converting Back to RPM
After the SOTF vector math gives you a new required horizontal velocity, you need to convert back to RPM. Here’s the trick: use the same table in reverse.
Your table maps distance → velocity (via ToF). So if you need a certain velocity, find the distance that would produce it, then look up the RPM for that distance.
public double velocityToEffectiveDistance(double velocity) {
// Binary search or iterate through table to find distance
// where (distance / ToF) = velocity
// Most InterpolatingTreeMap implementations support inverse lookup
// or you can build a reverse map: velocity → distance
for (Map.Entry<Double, ShooterParams> entry : SHOOTER_MAP.entrySet()) {
double dist = entry.getKey();
double vel = dist / entry.getValue().timeOfFlight;
if (vel >= velocity) {
return dist; // Interpolate for better accuracy
}
}
return SHOOTER_MAP.lastKey(); // Clamp to max
}
public double calculateAdjustedRpm(double requiredVelocity) {
double effectiveDistance = velocityToEffectiveDistance(requiredVelocity);
return SHOOTER_MAP.get(effectiveDistance).rpm;
} This way you’re always using empirically-tuned RPM values from the table, no assumptions about linearity required.
The “I Have a Variable Hood” Variant
If your shooter has a constant flywheel speed and a moving hood, the same ToF approach works. You just adjust the hood instead of RPM.
Your LUT becomes:
Distance (meters) → Hood Angle (degrees), Time of Flight (s)
// LUT for stationary shots with variable hood
private static final InterpolatingTreeMap<Double, HoodParams> HOOD_MAP = new InterpolatingTreeMap<>();
static {
HOOD_MAP.put(1.5, new HoodParams(35.0, 0.38)); // degrees, seconds
HOOD_MAP.put(2.0, new HoodParams(40.0, 0.45));
HOOD_MAP.put(2.5, new HoodParams(45.0, 0.52));
HOOD_MAP.put(3.0, new HoodParams(50.0, 0.60));
HOOD_MAP.put(3.5, new HoodParams(55.0, 0.68));
HOOD_MAP.put(4.0, new HoodParams(58.0, 0.76));
HOOD_MAP.put(4.5, new HoodParams(61.0, 0.85));
HOOD_MAP.put(5.0, new HoodParams(64.0, 0.94));
} Get horizontal velocity the same way:
public double getHorizontalVelocity(double distance) {
HoodParams params = HOOD_MAP.get(distance);
return distance / params.timeOfFlight;
} When you apply the SOTF correction, you get a new required horizontal velocity. To convert back to hood angle, you need to know the total exit velocity. Since the flywheel speed is constant, you can calculate this once from your baseline data:
// Calculate total velocity from baseline measurement
// v_total = v_horizontal / cos(hood_angle)
public double getTotalVelocity(double distance) {
HoodParams params = HOOD_MAP.get(distance);
double vHoriz = distance / params.timeOfFlight;
return vHoriz / Math.cos(Math.toRadians(params.hoodAngle));
}
public double calculateAdjustedHood(double distance, double requiredHorizontalVelocity) {
double totalVelocity = getTotalVelocity(distance);
// Clamp to physical limits
double ratio = MathUtil.clamp(
requiredHorizontalVelocity / totalVelocity,
0.0,
1.0
);
return Math.toDegrees(Math.acos(ratio));
} Note: For a truly constant-speed shooter, getTotalVelocity should return roughly the same value regardless of distance. If it varies a lot, your flywheel speed isn’t as constant as you think, or your hood angle affects exit velocity.
The “I Have Both” Variant
If you have both a variable flywheel and a variable hood, congratulations. You have two degrees of freedom. This is both a blessing and a curse.
The blessing: you have more flexibility to hit the required horizontal velocity.
The curse: you have to decide how to use that flexibility.
The LUT
Your table now has three outputs:
Distance (meters) → RPM, Hood Angle (degrees), Time of Flight (s)
private static final InterpolatingTreeMap<Double, FullShooterParams> SHOOTER_MAP = new InterpolatingTreeMap<>();
static {
SHOOTER_MAP.put(1.5, new FullShooterParams(2800.0, 35.0, 0.38));
SHOOTER_MAP.put(2.0, new FullShooterParams(3100.0, 38.0, 0.45));
SHOOTER_MAP.put(2.5, new FullShooterParams(3400.0, 42.0, 0.52));
SHOOTER_MAP.put(3.0, new FullShooterParams(3650.0, 46.0, 0.60));
SHOOTER_MAP.put(3.5, new FullShooterParams(3900.0, 50.0, 0.68));
SHOOTER_MAP.put(4.0, new FullShooterParams(4100.0, 54.0, 0.76));
SHOOTER_MAP.put(4.5, new FullShooterParams(4350.0, 58.0, 0.85));
SHOOTER_MAP.put(5.0, new FullShooterParams(4550.0, 62.0, 0.94));
}
public record FullShooterParams(double rpm, double hoodAngle, double timeOfFlight) {} The Strategy Question
When SOTF demands a different horizontal velocity, you can achieve it by:
- Changing RPM (keep hood fixed)
- Changing hood angle (keep RPM fixed)
- Changing both
There’s no single “right” answer.
Honestly, option 1 works well for most smaller robots, and option 2 works best for longer range or heavier flywheels. The added complexity of coordinating two actuators rarely pays off unless you’re hitting the limits of one system frequently.
Full Integration Example
Here’s what the complete SOTF loop looks like with the ToF-based LUT:
public class ShooterController {
private final InterpolatingTreeMap<Double, ShooterParams> shooterTable;
public ShooterCommand calculate(
Translation2d robotPosition,
Translation2d robotVelocity,
Translation2d goalPosition,
double latencyCompensation
) {
// 1. Project future position
Translation2d futurePos = robotPosition.plus(
robotVelocity.times(latencyCompensation)
);
// 2. Get target vector
Translation2d toGoal = goalPosition.minus(futurePos);
double distance = toGoal.getNorm();
Translation2d targetDirection = toGoal.div(distance);
// 3. Look up baseline velocity from table
ShooterParams baseline = shooterTable.get(distance);
double baselineVelocity = distance / baseline.timeOfFlight;
// 4. Build target velocity vector
Translation2d targetVelocity = targetDirection.times(baselineVelocity);
// 5. THE MAGIC: subtract robot velocity
Translation2d shotVelocity = targetVelocity.minus(robotVelocity);
// 6. Extract results
Rotation2d turretAngle = shotVelocity.getAngle();
double requiredVelocity = shotVelocity.getNorm();
// 7. Use table in reverse: velocity → effective distance → RPM
double effectiveDistance = velocityToEffectiveDistance(requiredVelocity);
double requiredRpm = shooterTable.get(effectiveDistance).rpm;
return new ShooterCommand(turretAngle, requiredRpm);
}
}
// Simple data class for the LUT
public record ShooterParams(double rpm, double timeOfFlight) {} Handling Edge Cases
Impossible Shots
If requiredVelocity exceeds your shooter’s capability, the shot is physically impossible at your current speed. You can signal to the drive system to reduce speed, don’t shoot until velocity drops into range, or just clamp to max and hope.
Turret Limits
If the required turret angle exceeds your mechanical limits (cable wrap, etc.), you have similar options. The smart play is often to use chassis rotation to assist.
Tuning Tips
The Latency Constant
The latencyCompensation parameter (in seconds) is the single most important tuning knob. It accounts for:
- Camera processing time
- Network/CAN latency
- Motor response time
- Ball flight time through the shooter mechanism
Start at 0.1 seconds. Increase if shots land behind where you’re aiming (you moved past the target). Decrease if shots land ahead (you overcorrected).
A good test: drive in a straight line orthogonal the goal at constant speed. If shots consistently land left or right, your latency compensation is off.
Table Resolution
More points in your LUT isn’t always better. If you have too many points, a single bad datapoint can create weird interpolation artifacts.
Rule of thumb from my experience, FRC needs 8-12 points across your operating range, FTC can usually get away with 6-10 (shooters are a lot more linear).
If your interpolated values seem wrong, check nearby table entries for typos.