Height: -- m
Time: -- s
Every time a grenade arcs perfectly through a doorway in Halo, every time an arrow drops gracefully in Zelda, every time a mortar shell crests its apex and plunges toward a target in Hell Let Loose, all of it follows the same physics: projectile trajectories and ballistics.
Ballistic motion describes an object that has been given an initial velocity and then moves under gravity alone. It's fundamental to physics simulation. Master it, and you unlock everything from simple thrown grenades to sophisticated artillery aiming systems, predictive enemy AI, and arc-preview UI. The underlying math is beautiful and surprisingly compact.
The Core Insight: Independent Components
The key insight in projectile motion is that horizontal and vertical motion are completely independent of each other. A bullet fired horizontally from a gun hits the ground at exactly the same time as a bullet dropped from the same height. Gravity pulls them down identically regardless of any horizontal velocity.
This means you can break any launch into two separate 1D problems, solve each one, and combine the results. Given a launch angle $\theta$ (measured from the horizontal) and initial speed $v_0$, the initial velocity components are:
$$v_x = v_0 \cos(\theta)$$
$$v_y = v_0 \sin(\theta)$$
Horizontal velocity $v_x$ stays constant throughout the flight (ignoring air resistance). Vertical velocity $v_y$ decreases at a rate of $g$ (gravitational acceleration, roughly $9.8\,m/s^2$ on Earth, though most games use a higher value for snappier feel).
The Kinematic Equations
With those components in hand, position at any time $t$ is given by the standard kinematic equations:
$$x(t) = x_0 + v_x \cdot t$$
$$y(t) = y_0 + v_y \cdot t - \frac{1}{2} g t^2$$
The horizontal equation is linear—constant-speed motion. The vertical equation is quadratic—constant-acceleration motion. Together they trace out a parabola.
These two equations are your entire ballistics engine. Everything else—time of flight, range, peak height, inverse aiming—is derived from them.
Time of Flight
When does the projectile return to launch height? Set $y(t) = y_0$ and solve:
$$0 = v_y \cdot t - \frac{1}{2} g t^2 = t\left(v_y - \frac{1}{2} g t\right)$$
The non-trivial solution (excluding $t = 0$) is:
$$T = \frac{2 v_y}{g} = \frac{2 v_0 \sin(\theta)}{g}$$
Maximum Height
The peak occurs when vertical velocity reaches zero: $v_y - g \cdot t = 0$, giving $t_{peak} = v_0 \sin(\theta) / g$. Substituting back:
$$H_{max} = \frac{(v_0 \sin\theta)^2}{2g}$$
Horizontal Range
Range is how far the projectile travels before returning to launch height:
$$R = v_x \cdot T = v_0 \cos(\theta) \cdot \frac{2 v_0 \sin(\theta)}{g} = \frac{v_0^2 \sin(2\theta)}{g}$$
Range is maximized when $\sin(2\theta) = 1$, which means $\theta = 45°$. A 45-degree launch always achieves maximum range for a given speed on flat terrain. Complementary angles (e.g., 30° and 60°) produce identical ranges. The low arc and high arc land in the same spot.
Implementing Ballistics in Code
The simplest runtime implementation uses a velocity-integration loop. Each frame, apply gravity to the vertical velocity, then move the projectile:
class Projectile {
constructor(x, y, angleDeg, speed) {
const a = angleDeg * Math.PI / 180;
this.x = x;
this.y = y;
this.vx = speed * Math.cos(a);
this.vy = speed * Math.sin(a);
this.alive = true;
}
update(dt, gravity = 9.8) {
this.vy -= gravity * dt; // gravity reduces upward velocity
this.x += this.vx * dt;
this.y += this.vy * dt;
if (this.y <= 0) {
this.y = 0;
this.alive = false; // hit ground
}
}
}This is Euler integration. It's cheap and accurate enough for short-lived game projectiles. For very long flights or high precision (long-range sniper simulation, space games), consider Verlet or RK4 integration.
Pre-computing the Arc Analytically
Rather than stepping through the simulation frame by frame, you can use the kinematic equations to compute any point on the arc instantly. This is essential for drawing arc-preview lines, running AI aiming calculations every frame, and checking whether a target is reachable.
function computeArc(x0, y0, angleDeg, speed, gravity, numPoints) {
const a = angleDeg * Math.PI / 180;
const vx = speed * Math.cos(a);
const vy = speed * Math.sin(a);
// Time until y returns to y0 (quadratic formula)
const T = (vy + Math.sqrt(vy * vy + 2 * gravity * y0)) / gravity;
const dt = T / numPoints;
const points = [];
for (let i = 0; i <= numPoints; i++) {
const t = i * dt;
points.push({
x: x0 + vx * t,
y: y0 + vy * t - 0.5 * gravity * t * t
});
}
return points;
}This runs in $O(n)$ time with no integration error. Every point is computed exactly from the closed-form equations.
Inverse Ballistics: Solving for the Launch Angle
Here's a common problem: given a target at position $(dx, dy)$ relative to the launcher, what angle do I need to fire at?
Substituting the kinematic equations and rearranging produces the general inverse formula:
$$\tan(\theta) = \frac{v_0^2 \pm \sqrt{v_0^4 - g\left(g \cdot dx^2 + 2 \cdot dy \cdot v_0^2\right)}}{g \cdot dx}$$
The $\pm$ gives two solutions: a low arc (shallow, fast, direct) and a high arc (steep, slow, lobbed). Both land at the same target. The discriminant under the square root determines whether the target is reachable. If it goes negative, the target is outside maximum range.
function solveAimAngle(dx, dy, speed, gravity) {
const g = gravity;
const v2 = speed * speed;
const disc = v2 * v2 - g * (g * dx * dx + 2 * dy * v2);
if (disc < 0) return null; // out of range
const sqrtDisc = Math.sqrt(disc);
const lowAngle = Math.atan2(v2 - sqrtDisc, g * dx);
const highAngle = Math.atan2(v2 + sqrtDisc, g * dx);
return { lowAngle, highAngle };
}In gameplay terms, a sniper or direct-fire weapon uses the lowAngle for speed. A mortar or grenade launcher deliberately uses the highAngle to arc over walls. Checking disc < 0 lets you show an "Out of Range" indicator before the player fires.
Adding Air Resistance
Real projectiles experience drag. The physical drag force is:
$$\vec{F}_{drag} = -\frac{1}{2} \rho C_d A |\vec{v}|^2 \hat{v}$$
where $\rho$ is air density, $C_d$ the drag coefficient, and $A$ the cross-sectional area. In games this becomes a simple damping multiplier applied each frame:
update(dt) {
const drag = 0.98; // retain 98% of velocity each second
this.vx *= Math.pow(drag, dt);
this.vy *= Math.pow(drag, dt);
this.vy -= gravity * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
}Once drag is added, closed-form solutions no longer work. Arc previews must be computed by running a fast simulation loop. AI aiming must use iterative search (binary search on angle, or Newton's method). The trade-off in feel is worth it for many games—dragged projectiles feel more weighty and realistic.
3D Ballistics
Extending to 3D is straightforward. Gravity only affects one axis (typically Y in a Y-up coordinate system). The other two axes behave like the horizontal component—constant velocity.
class Projectile3D {
constructor(origin, directionNormalized, speed) {
this.pos = origin.clone();
this.vel = directionNormalized.clone().multiplyScalar(speed);
}
update(dt, gravity = 14.0) {
this.vel.y -= gravity * dt; // only Y is affected
this.pos.addScaledVector(this.vel, dt);
}
}
// Launching toward a 3D target:
const dir = new THREE.Vector3(dx, 0, dz).normalize();
const launchDir = new THREE.Vector3(
dir.x * Math.cos(angle),
Math.sin(angle),
dir.z * Math.cos(angle)
);
const proj = new Projectile3D(launcher.position, launchDir, speed);The horizontal aim direction is a yaw angle on the XZ plane. The vertical launch angle is computed separately from the inverse ballistics formula using the 2D horizontal distance and vertical offset to the target.
Arc Visualization
Angry Birds made the arc preview famous, but it's used across genres: artillery games, golf simulators, tactical RPGs, physics puzzlers. Showing the predicted trajectory gives the player clear feedback and makes aiming feel like a skill rather than guesswork.
The implementation is straightforward. Compute 40–80 arc points analytically, update a line geometry each frame as the player adjusts aim, and render it as a dotted or dashed line. In Three.js:
function refreshArcLine(arcLine, angle, speed, gravity) {
const pts = computeArc(0, 0, angle, speed, gravity, 60);
const attr = arcLine.geometry.attributes.position;
pts.forEach((p, i) => {
attr.setXYZ(i, p.x, p.y, 0);
});
attr.needsUpdate = true;
arcLine.geometry.setDrawRange(0, pts.length);
}This runs every frame while the player aims. The analytic computation is fast enough to be effectively free, even on mobile hardware.
Predictive Aiming for AI
AI that fires at moving players must predict where the player will be at impact time, not where they are now. The algorithm iterates to convergence:
function predictiveAim(shooter, targetPos, targetVel, projSpeed, gravity) {
let predicted = targetPos.clone();
// Iterate 3 times — converges quickly
for (let i = 0; i < 3; i++) {
const dist = predicted.distanceTo(shooter);
const timeEstimate = dist / projSpeed; // rough flight time
predicted = targetPos.clone().addScaledVector(targetVel, timeEstimate);
}
const dx = predicted.x - shooter.x;
const dy = predicted.y - shooter.y;
return solveAimAngle(dx, dy, projSpeed, gravity);
}Three iterations are almost always sufficient. The first pass dramatically improves on firing at the current position, and subsequent passes refine the estimate to near-perfect accuracy.
Bounce and Ricochet
For bouncing projectiles—grenades, rubber bullets, pinballs—handle surface collisions with a velocity reflection. When hitting a surface with outward normal $\hat{n}$:
$$\vec{v}_{reflected} = \vec{v} - 2(\vec{v} \cdot \hat{n})\hat{n}$$
Multiply by a restitution coefficient $e \in [0, 1]$ to model energy loss per bounce:
$$\vec{v}_{after} = e \cdot \vec{v}_{reflected}$$
A coefficient of 1.0 is a perfect elastic bounce (no energy loss). A coefficient of 0.0 means the projectile stops dead on impact. Grenades in most games use $e \approx 0.3$ to 0.5—bouncy enough to feel right without rolling forever.
Games That Use This
Angry Birds: The entire game is ballistics. The arc preview teaches players the relationship between angle and range experientially.
World of Tanks / World of Warships: Both use full 3D ballistics with shell velocity, shell drop, and armor penetration. Experienced players learn to lead fast targets and elevate aim at long range to compensate for drop.
Halo (grenades): Requires precise arc throws in 3D space. Bouncing a plasma grenade around a corner or sticking it through a narrow gap is a high-skill move entirely enabled by ballistic physics.
Golf games (Mario Golf, Hot Shots Golf): Ballistics extended with significant spin, wind, and lift (Magnus effect). The launch angle and power meter map directly to the kinematic equations.
Spelunky / Hollow Knight: Arrow and dart traps use simple ballistic models. Once fired, they follow a clean parabola until impact, creating predictable hazards the player can learn to read and dodge.
Scorched Earth / Worms: Pure ballistics puzzle games. Players mentally solve inverse ballistics to land shots over terrain features and compensate for wind.
Performance Notes
When simulating hundreds or thousands of projectiles (bullet-hell games, large artillery battles), per-object simulation stays cheap. Ballistic projectiles require only a velocity update and a position update—two vector additions per frame. The bottleneck is usually collision detection, not the physics math itself.
For deterministic networked gameplay, ballistic projectiles are ideal. Position is fully determined by initial state and elapsed time, so any client can reproduce the exact trajectory from the launch parameters alone. This makes synchronization trivial compared to contact-physics objects.
The parabola is everywhere. In grenades arcing around corners, in basketballs sailing through air, in artillery shells curving across a battlefield. Once you see it, you can't unsee it.
Comments