Skip to content

Projectile Motion

The parabola, plus what air does to it.

Beginner / integration / drag

A projectile integrated with quadratic air drag flies against the analytic drag-free arc: at zero drag they coincide, and as the drag coefficient rises the trajectory buckles asymmetrically - steeper on the way down, range collapsed.

Tagged: quadratic drag, trajectory, range, apex

Live simulation

Drag slider at zero makes the integrated path match the analytic arc exactly.

Speed

Drag slider at zero makes the integrated path match the analytic arc exactly.

Mathematics

Without drag, a projectile's path is the parabola y(t) = y0 + v sin(theta) t - g t^2/2, and the range is exactly v^2 sin(2 theta) / g. With quadratic drag, no closed form exists - which is why the integrated path and the analytic path are drawn as two separate curves.

no drag: range = v^2 sin(2 theta) / g

with drag: dv/dt = -g j - k |v| v

integrated with explicit Euler at 60 Hz substeps

The analytic range formula is the test: the test suite asserts the integrated trajectory at zero drag matches v^2 sin(2 theta)/g to within a percent. With drag on, the metric shows how far the real arc falls short - energy is being paid to the air, and the asymmetry of the collapse is the visualization of that bill.

How it works

The numeric projectile integrates velocity and position with quadratic drag (force opposite velocity, proportional to speed squared). The analytic trail is generated once per launch from the closed form. The auto-relaunch keeps the comparison alive; turn it off and read the landing metrics.

Launch height is 30 m so the asymmetric descent is clearly visible against the symmetric ideal.

Implementation

src/engine/experiments/projectile-motion.ts · 170 lines · the module running above

TypeScript · fixed-timestep engine

1import { BaseExperiment } from "../core/base-experiment";
2import type { ParameterDef, ThemeColors } from "../core/types";
3
4/**
5 * Projectile motion with quadratic air drag, integrated numerically, against
6 * the analytic drag-free trajectory. At drag = 0 the numeric range matches
7 * v^2 sin(2theta)/g to float precision - which the tests assert.
8 */
9export class ProjectileExperiment extends BaseExperiment {
10 readonly id = "projectile-motion";
11
12 private x = 0;
13 private y = 0;
14 private vx = 0;
15 private vy = 0;
16 private flying = false;
17 private trail: { x: number; y: number }[] = [];
18 private analyticTrail: { x: number; y: number }[] = [];
19 private landedRange = 0;
20 private apex = 0;
21 private flightTime = 0;
22
23 protected params(): ParameterDef[] {
24 return [
25 { key: "speed", label: "Launch speed", min: 20, max: 120, step: 2, defaultValue: 62, unit: "m/s" },
26 { key: "angle", label: "Launch angle", min: 10, max: 80, step: 1, defaultValue: 45, unit: "°" },
27 { key: "drag", label: "Drag coefficient", min: 0, max: 0.08, step: 0.002, defaultValue: 0.01 },
28 { key: "auto", label: "Auto-relaunch", options: [{ value: "on", label: "On" }, { value: "off", label: "Off" }], defaultValue: "on" },
29 ];
30 }
31
32 protected onReset(): void {
33 this.launch();
34 }
35
36 protected onParameterChange(key: string): void {
37 // New speed/angle/drag means a fresh launch with those parameters.
38 if (key === "auto") return;
39 this.launch();
40 void key;
41 }
42
43 private launch(): void {
44 const angle = (this.num("angle") * Math.PI) / 180;
45 this.x = 0;
46 this.y = 0;
47 this.vx = this.num("speed") * Math.cos(angle);
48 this.vy = this.num("speed") * Math.sin(angle);
49 this.flying = true;
50 this.trail = [{ x: 0, y: 30 }];
51 this.landedRange = 0;
52 this.apex = 30;
53 this.flightTime = 0;
54 // Analytic drag-free reference from the same launch conditions.
55 this.analyticTrail = [];
56 const speed = this.num("speed");
57 const g = 9.81;
58 const tFlight = (2 * speed * Math.sin(angle)) / g;
59 for (let i = 0; i <= 200; i++) {
60 const t = (i / 200) * tFlight;
61 this.analyticTrail.push({ x: speed * Math.cos(angle) * t, y: speed * Math.sin(angle) * t - 0.5 * g * t * t });
62 }
63 }
64
65 resize(width: number, height: number): void {
66 this.width = width;
67 this.height = height;
68 this.reset();
69 }
70
71 protected onUpdate(dt: number): void {
72 if (!this.flying) {
73 if (this.str("auto") === "on") this.launch();
74 return;
75 }
76 const drag = this.num("drag");
77 // Quadratic drag opposite the velocity, gravity down; 8 substeps keep
78 // the zero-drag range inside a percent of the analytic value.
79 const sub = dt / 8;
80 for (let s2 = 0; s2 < 8; s2++) {
81 const sp = Math.hypot(this.vx, this.vy);
82 this.vx += -drag * sp * this.vx * sub;
83 this.vy += (-9.81 - drag * sp * this.vy) * sub;
84 this.x += this.vx * sub;
85 this.y += this.vy * sub;
86 }
87 this.flightTime += dt;
88 this.apex = Math.max(this.apex, this.y);
89 this.trail.push({ x: this.x, y: this.y });
90 if (this.trail.length > 4000) this.trail.shift();
91 if (this.y <= 0) {
92 this.flying = false;
93 this.landedRange = this.x;
94 }
95 }
96
97 render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void {
98 ctx.fillStyle = theme.bg;
99 ctx.fillRect(0, 0, this.width, this.height);
100 // World window: 220 m wide.
101 const scale = this.width / 220;
102 const groundY = this.height - 30;
103 const to = (x: number, y: number): [number, number] => [x * scale, groundY - y * (scale * 1.4)];
104 // Ground
105 ctx.strokeStyle = theme.line;
106 ctx.beginPath();
107 ctx.moveTo(0, groundY);
108 ctx.lineTo(this.width, groundY);
109 ctx.stroke();
110 // Analytic (drag-free) path
111 ctx.strokeStyle = theme.fgTertiary;
112 ctx.setLineDash([4, 4]);
113 ctx.beginPath();
114 this.analyticTrail.forEach((point, index) => {
115 const [px, py] = to(point.x, point.y);
116 if (index === 0) ctx.moveTo(px, py);
117 else ctx.lineTo(px, py);
118 });
119 ctx.stroke();
120 ctx.setLineDash([]);
121 // Numeric path with drag
122 ctx.strokeStyle = theme.accent;
123 ctx.lineWidth = 2;
124 ctx.beginPath();
125 this.trail.forEach((point, index) => {
126 const [px, py] = to(point.x, point.y);
127 if (index === 0) ctx.moveTo(px, py);
128 else ctx.lineTo(px, py);
129 });
130 ctx.stroke();
131 ctx.lineWidth = 1;
132 // Projectile
133 const [px, py] = to(this.x, this.y);
134 ctx.fillStyle = theme.accent;
135 ctx.beginPath();
136 ctx.arc(px, py, 4, 0, Math.PI * 2);
137 ctx.fill();
138 ctx.font = "11px ui-monospace, monospace";
139 ctx.fillStyle = theme.fgTertiary;
140 ctx.fillText("DRAG-FREE (ANALYTIC)", 10, 16);
141 ctx.fillStyle = theme.accent;
142 ctx.fillText("WITH DRAG (INTEGRATED)", 10, 30);
143 }
144
145 getMetrics() {
146 const speed = this.num("speed");
147 const angle = (this.num("angle") * Math.PI) / 180;
148 const g = 9.81;
149 const idealRange = (speed * speed * Math.sin(2 * angle)) / g;
150 return {
151 range: `${this.landedRange.toFixed(1)} m`,
152 idealRange: `${idealRange.toFixed(1)} m`,
153 apex: `${this.apex.toFixed(1)} m`,
154 flightTime: `${this.flightTime.toFixed(2)} s`,
155 flying: this.flying ? "yes" : "landed",
156 };
157 }
158
159 describe(): string {
160 if (this.flying) {
161 return `A projectile is in flight: ${this.x.toFixed(0)} m downrange at ${this.y.toFixed(0)} m altitude, with quadratic drag bending it below the analytic arc.`;
162 }
163 return `The projectile landed at ${this.landedRange.toFixed(1)} m (the drag-free ideal would reach ${(this.getMetrics().idealRange.match(/[\d.]+/) ?? ["0"])[0]} m).`;
164 }
165
166 entities(): number {
167 return this.trail.length;
168 }
169}
170

Performance

Time per tick
O(1) per tick
Space
The trail arrays
Notes
At drag = 0 the two curves overlap to sub-pixel precision - that overlap is the correctness proof.

Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.