If you've ever coded a turret that "leads" a moving target, you've used the vacuum formula: predict where the target will be, aim there. It's clean, it's closed-form, and it falls apart the moment the projectile feels air drag.
There's no closed-form trajectory for quadratic drag, so the nice "solve for the lead point" algebra has nowhere to stand. Most projects respond by either ignoring drag (and missing), or hand-tuning fudge factors. I wanted something that just… hits.
So I built ballistic-solver: a small native solver that computes the launch angles to intercept a moving target under gravity, quadratic drag, and wind. MIT, v1.0, with Python, C++, C# and Godot bindings.
Here it is leading a noisy, diving target in real time (Kalman tracker + range rings + tracers + hit-rate HUD):
The idea: don't guess the formula, solve it
Instead of a closed form, the solver simulates the projectile (RK4 with drag and wind) and solves the intercept numerically.
The trick is choosing the right residual. For a candidate (elevation, azimuth), integrate the trajectory, find the time of closest approach t*, and take the 3D miss vector at t* as the residual:
r = projectile(t*) − target(t*) ∈ ℝ³
That miss vector is well-conditioned and varies smoothly with the angles, so the two launch angles fall out of a Gauss-Newton iteration on r. (Contrast with reconstructing an angle-space residual through an inverse — that's the older, fiddlier path; it's still in the library as solve_aux for reproducibility.)
Making it fast and robust
Two unknowns, a 3D residual, sub-millisecond budget. What makes it quick:
- Vacuum warm start. The closed-form vacuum lead is a great initial guess — exact when drag is zero, close when it's small.
- Analytic Jacobian seed. The initial inverse-Jacobian is finite-differenced from the closed-form vacuum-arc map — not from the simulated trajectory. So seeding the Jacobian costs no extra RK4 integrations. This is the part I'mmost happy with.
- Broyden refinement. Rank-1 updates keep the Jacobian honest as it converges; full steps, no damping, no line search.
- Multistart fallback. If the warm start stalls, retry from a small arc-appropriate elevation grid. This is what makes the hard cases reliable.
Using it
Python (pip install ballistic-solver):
import ballistic_solver as bs
r = bs.solve(relPos0=(120, 30, 5), relVel=(2, -1, 0), v0=90, kDrag=0.002)
print(r["theta"], r["phi"], r["miss"], r["success"])
C++ (modern surface; C++20 designated-init shown):
#include <ballistic_solver.hpp>
auto r = bs::solve({.rel_pos0 = {120, 30, 5},
.rel_vel = {2, -1, 0},
.v0 = 90,
.k_drag = 0.002});
if (r) aim(r.theta, r.phi);
Godot (GDExtension):
var solver := BallisticSolver.new()
var r := solver.solve(rel_pos0, rel_vel, v0, k_drag, 0) # 0 low / 1 high
if r.success:
aim_at(r.theta, r.phi)
There's a stable C ABI underneath for FFI from anywhere.
Being honest about it
Two things I tried hard to do straight:
Benchmarks by difficulty, not one flattering average. The grid spans easy → hard (low/high arc × drag strength). The expensive corner — high-arc plunging shots with strong drag — has a real long tail (P99 in the tens of milliseconds) where the multistart fallback does the most work. I show that tail instead of averaging it away. Everything else is sub-millisecond at 100% success on the reachable cases.
A limitations page. It's a point-mass solver: single drag coefficient (no Mach-dependent table), no spin drift, no terrain collision, and it's not a certified fire-control system. It's for games, simulation, robotics, and research.
Try it
- Repo: https://github.com/ujinf74/ballistic-solver
- pip install ballistic-solver
- Numerical method and limitations are documented in docs/.
Feedback very welcome — especially on the numerical method and where the failure modes bite in real use.

Top comments (0)