Shooting on the Fly, Part 2: The LUT Approach

A deep dive into shooting on the fly algorithms for competitive robotics

frcftcshootingvector-mathprogramming

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.

LUT Playground
Distance → RPM + Time of Flight → Horizontal Velocity
Table
Distance (m)
RPM
ToF (s)
v = d/tof (m/s)
3.57
3.92
4.31
4.62
4.93
5.13
5.36
5.49
Pick a distance
Distance
3.00 m
1.50m 5.00m
Linear interpolation between two nearest points
Interpolated RPM
3650
t=1.00 between 2.5m and 3m
Interpolated ToF
0.65s
(measured, not modeled)
Derived horizontal velocity
4.62 m/s
v = d / tof
Shape preview (not to scale)
RPMv horiz
The point: you can tune shots by distance, measure time-of-flight with a phone, and extract the only number SOTF needs: horizontal velocity.

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:

  1. Changing RPM (keep hood fixed)
  2. Changing hood angle (keep RPM fixed)
  3. 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.