physics simulation
Projectile Motion
Interactive simulator exploring launch angles, gravity, and air resistance on projectile trajectories.
How to use: Adjust initial velocity, launch angle, gravity, and air drag. Watch the trajectory update in real-time. Press LAUNCH to animate the projectile motion.
Initial Velocity (m/s)
45
Launch Angle (°)
45
◆ Optimal range: 45°
Gravity Preset
Air Drag (k/m)
0.00
Launch Height (m)
0
Simulation Speed
1.0×
Range (no drag):
88.7 m
Max Height:
51.6 m
Time of Flight:
6.48 s
Launch Velocity:
45.0 m/s
| Planet | Range (m) | Height (m) |
|---|---|---|
| Earth | 88.7 | 51.6 |
| Moon | 533.0 | 309.6 |
| Mars | 239.8 | 139.2 |
| Jupiter | 33.1 | 19.2 |
Equations & Formulas
Without Air Drag:
x(t) = v₀cos(θ) · t
y(t) = v₀sin(θ) · t − ½g · t²
Range: R = v₀²sin(2θ)/g
Max Height: H = v₀²sin²(θ)/(2g)
Time of Flight: T = 2v₀sin(θ)/g
With Air Drag:
F_drag = ½ρCdA · v²
aₓ = −(k/m) · vₓ · |v|
aᵧ = −g − (k/m) · vᵧ · |v|
x(t) = v₀cos(θ) · t
y(t) = v₀sin(θ) · t − ½g · t²
Range: R = v₀²sin(2θ)/g
Max Height: H = v₀²sin²(θ)/(2g)
Time of Flight: T = 2v₀sin(θ)/g
With Air Drag:
F_drag = ½ρCdA · v²
aₓ = −(k/m) · vₓ · |v|
aᵧ = −g − (k/m) · vᵧ · |v|
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Resize canvas to fit container
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = Math.max(600, rect.width - 20);
canvas.height = 500;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// State
let state = {
v0: 45,
angle: 45,
gravity: 9.81,
drag: 0,
height: 0,
isAnimating: false,
trajectoryPoints: [],
dragTrajectoryPoints: [],
optimalTrajectoryPoints: [],
currentPosition: null,
currentVelocity: null,
landingPoint: null,
time: 0,
launchTime: 0,
simTime: 0,
simSpeed: 1,
lastAnimTime: 0
};
// DOM elements
const v0Slider = document.getElementById('v0Slider');
const v0Input = document.getElementById('v0Input');
const v0Display = document.getElementById('v0Display');
const angleSlider = document.getElementById('angleSlider');
const angleInput = document.getElementById('angleInput');
const angleDisplay = document.getElementById('angleDisplay');
const dragSlider = document.getElementById('dragSlider');
const dragDisplay = document.getElementById('dragDisplay');
const heightSlider = document.getElementById('heightSlider');
const heightDisplay = document.getElementById('heightDisplay');
const gravityInput = document.getElementById('gravityInput');
const launchBtn = document.getElementById('launchBtn');
const resetBtn = document.getElementById('resetBtn');
const compareCheckbox = document.getElementById('compareCheckbox');
const gravityButtons = document.querySelectorAll('.gravity-btn');
// Event listeners for sliders and inputs
function syncV0() {
state.v0 = parseFloat(v0Slider.value);
v0Slider.value = state.v0;
v0Input.value = state.v0;
v0Display.textContent = state.v0.toFixed(1);
updateStats();
updateCanvas();
}
function syncAngle() {
state.angle = parseFloat(angleSlider.value);
angleSlider.value = state.angle;
angleInput.value = state.angle;
angleDisplay.textContent = state.angle.toFixed(1);
updateStats();
updateCanvas();
}
function syncGravity() {
state.gravity = parseFloat(gravityInput.value);
gravityInput.value = state.gravity;
updateGravityButtons();
updateStats();
updateCanvas();
}
function syncDrag() {
state.drag = parseFloat(dragSlider.value);
dragDisplay.textContent = state.drag.toFixed(2);
updateStats();
updateCanvas();
}
function syncHeight() {
state.height = parseFloat(heightSlider.value);
heightDisplay.textContent = state.height.toFixed(1);
updateStats();
updateCanvas();
}
v0Slider.addEventListener('input', syncV0);
v0Input.addEventListener('change', syncV0);
angleSlider.addEventListener('input', syncAngle);
angleInput.addEventListener('change', syncAngle);
dragSlider.addEventListener('input', syncDrag);
heightSlider.addEventListener('input', syncHeight);
gravityInput.addEventListener('change', syncGravity);
gravityButtons.forEach(btn => {
btn.addEventListener('click', () => {
state.gravity = parseFloat(btn.dataset.gravity);
gravityInput.value = state.gravity;
updateGravityButtons();
updateStats();
updateCanvas();
});
});
function updateGravityButtons() {
gravityButtons.forEach(btn => {
if (Math.abs(parseFloat(btn.dataset.gravity) - state.gravity) < 0.01) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Physics calculations
function calculateTrajectory() {
const v0 = state.v0;
const angle = state.angle * Math.PI / 180;
const g = state.gravity;
const k = state.drag;
const h0 = state.height;
const vx0 = v0 * Math.cos(angle);
const vy0 = v0 * Math.sin(angle);
// Calculate time of flight (with initial height)
let tof = (vy0 + Math.sqrt(vy0 * vy0 + 2 * g * h0)) / g;
state.trajectoryPoints = [];
state.dragTrajectoryPoints = [];
// No-drag trajectory
for (let t = 0; t <= tof; t += tof / 100) {
const x = vx0 * t;
const y = h0 + vy0 * t - 0.5 * g * t * t;
state.trajectoryPoints.push({ x, y, t });
}
// With-drag trajectory
if (k > 0) {
let px = 0, py = h0;
let vx = vx0, vy = vy0;
let dt = 0.01;
state.dragTrajectoryPoints.push({ x: px, y: py });
for (let i = 0; i < 5000; i++) {
let v = Math.sqrt(vx * vx + vy * vy);
let ax = -k * vx * v;
let ay = -g - k * vy * v;
vx += ax * dt;
vy += ay * dt;
px += vx * dt;
py += vy * dt;
state.dragTrajectoryPoints.push({ x: px, y: py });
if (py < 0) break;
}
}
// Optimal 45-degree trajectory
if (compareCheckbox.checked) {
const optimalAngle = 45 * Math.PI / 180;
const optVx0 = v0 * Math.cos(optimalAngle);
const optVy0 = v0 * Math.sin(optimalAngle);
const optTof = (optVy0 + Math.sqrt(optVy0 * optVy0 + 2 * g * h0)) / g;
state.optimalTrajectoryPoints = [];
for (let t = 0; t <= optTof; t += optTof / 100) {
const x = optVx0 * t;
const y = h0 + optVy0 * t - 0.5 * g * t * t;
state.optimalTrajectoryPoints.push({ x, y, t });
}
}
}
function updateStats() {
const v0 = state.v0;
const angle = state.angle * Math.PI / 180;
const g = state.gravity;
const h0 = state.height;
// Calculate theoretical values (no drag)
const range = (v0 * v0 * Math.sin(2 * angle) / g) + (v0 * Math.cos(angle) * Math.sqrt(2 * h0 / g) * Math.sqrt(1 + (Math.tan(angle) * Math.tan(angle))));
const maxHeight = h0 + (v0 * v0 * Math.sin(angle) * Math.sin(angle)) / (2 * g);
const tof = (v0 * Math.sin(angle) + Math.sqrt(v0 * v0 * Math.sin(angle) * Math.sin(angle) + 2 * g * h0)) / g;
// Simplified range calculation
const simpleRange = (v0 * v0 * Math.sin(2 * angle)) / g;
document.getElementById('rangeStat').textContent = simpleRange.toFixed(1) + ' m';
document.getElementById('heightStat').textContent = maxHeight.toFixed(1) + ' m';
document.getElementById('timeStat').textContent = tof.toFixed(2) + ' s';
document.getElementById('launchVelStat').textContent = v0.toFixed(1) + ' m/s';
// Update comparison table
const planets = [
{ name: 'earthRange', g: 9.81 },
{ name: 'moonRange', g: 1.6 },
{ name: 'marsRange', g: 3.7 },
{ name: 'jupiterRange', g: 24.8 }
];
planets.forEach(p => {
const r = (v0 * v0 * Math.sin(2 * angle)) / p.g;
document.getElementById(p.name).textContent = r.toFixed(1);
});
const heightPlanets = [
{ name: 'earthHeight', g: 9.81 },
{ name: 'moonHeight', g: 1.6 },
{ name: 'marsHeight', g: 3.7 },
{ name: 'jupiterHeight', g: 24.8 }
];
heightPlanets.forEach(p => {
const h = (v0 * v0 * Math.sin(angle) * Math.sin(angle)) / (2 * p.g);
document.getElementById(p.name).textContent = h.toFixed(1);
});
calculateTrajectory();
}
function updateCanvas() {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
for (let i = 0; i < canvas.width; i += 50) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
ctx.stroke();
}
for (let i = 0; i < canvas.height; i += 50) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(canvas.width, i);
ctx.stroke();
}
// Draw ground
const groundY = canvas.height - 20;
ctx.strokeStyle = '#333333';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(canvas.width, groundY);
ctx.stroke();
ctx.fillStyle = 'rgba(51, 51, 51, 0.2)';
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
// Scale: max range should fit in canvas
const maxRange = (state.v0 * state.v0 * Math.sin(2 * state.angle * Math.PI / 180)) / state.gravity;
const scale = (canvas.width - 40) / Math.max(maxRange, 100);
// Helper to convert physics coords to canvas coords
function toCanvasX(x) {
return 20 + x * scale;
}
function toCanvasY(y) {
return groundY - y * scale;
}
// Draw theoretical trajectory (no drag)
if (state.trajectoryPoints.length > 0) {
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(toCanvasX(state.trajectoryPoints[0].x), toCanvasY(state.trajectoryPoints[0].y));
for (let i = 1; i < state.trajectoryPoints.length; i++) {
const p = state.trajectoryPoints[i];
ctx.lineTo(toCanvasX(p.x), toCanvasY(p.y));
}
ctx.stroke();
ctx.setLineDash([]);
// Draw initial velocity vector
const angle = state.angle * Math.PI / 180;
const vScale = 30;
const vx = Math.cos(angle) * vScale;
const vy = -Math.sin(angle) * vScale;
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(toCanvasX(0), toCanvasY(state.height));
ctx.lineTo(toCanvasX(vx * 0.01), toCanvasY(state.height + vy * 0.01));
ctx.stroke();
// Arrowhead
ctx.fillStyle = '#00c896';
const headlen = 10;
const angle2 = Math.atan2(vy, vx);
ctx.beginPath();
ctx.moveTo(toCanvasX(vx * 0.01) + headlen * Math.cos(angle2 + Math.PI / 6), toCanvasY(state.height + vy * 0.01) + headlen * Math.sin(angle2 + Math.PI / 6));
ctx.lineTo(toCanvasX(vx * 0.01), toCanvasY(state.height + vy * 0.01));
ctx.lineTo(toCanvasX(vx * 0.01) + headlen * Math.cos(angle2 - Math.PI / 6), toCanvasY(state.height + vy * 0.01) + headlen * Math.sin(angle2 - Math.PI / 6));
ctx.fill();
}
// Draw drag trajectory
if (state.drag > 0 && state.dragTrajectoryPoints.length > 0) {
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(toCanvasX(state.dragTrajectoryPoints[0].x), toCanvasY(state.dragTrajectoryPoints[0].y));
for (let i = 1; i < state.dragTrajectoryPoints.length; i++) {
const p = state.dragTrajectoryPoints[i];
ctx.lineTo(toCanvasX(p.x), toCanvasY(p.y));
}
ctx.stroke();
}
// Draw optimal 45-degree trajectory
if (compareCheckbox.checked && state.optimalTrajectoryPoints.length > 0) {
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.setLineDash([6, 3]);
ctx.beginPath();
ctx.moveTo(toCanvasX(state.optimalTrajectoryPoints[0].x), toCanvasY(state.optimalTrajectoryPoints[0].y));
for (let i = 1; i < state.optimalTrajectoryPoints.length; i++) {
const p = state.optimalTrajectoryPoints[i];
ctx.lineTo(toCanvasX(p.x), toCanvasY(p.y));
}
ctx.stroke();
ctx.setLineDash([]);
}
// Draw projectile if animating
if (state.isAnimating && state.currentPosition) {
const px = toCanvasX(state.currentPosition.x);
const py = toCanvasY(state.currentPosition.y);
// Motion blur tail
ctx.strokeStyle = 'rgba(255, 34, 0, 0.3)';
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(px, py);
if (state.currentVelocity) {
const tailX = px - state.currentVelocity.x * scale * 0.1;
const tailY = py + state.currentVelocity.y * scale * 0.1;
ctx.lineTo(tailX, tailY);
}
ctx.stroke();
// Glow effect
ctx.fillStyle = 'rgba(255, 34, 0, 0.2)';
ctx.beginPath();
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fill();
// Projectile circle
ctx.fillStyle = '#ff2200';
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
// Info overlay
if (state.currentVelocity) {
const speed = Math.sqrt(state.currentVelocity.x ** 2 + state.currentVelocity.y ** 2);
ctx.fillStyle = '#e8e0d5';
ctx.font = '12px "DM Mono"';
ctx.fillText(`v: ${speed.toFixed(1)} m/s`, px + 15, py - 5);
ctx.fillText(`h: ${state.currentPosition.y.toFixed(1)} m`, px + 15, py + 10);
}
}
// Draw landing point
if (state.landingPoint) {
const lx = toCanvasX(state.landingPoint.x);
const ly = toCanvasY(0);
// Explosion effect
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
for (let i = 0; i < 4; i++) {
const size = 8 + i * 4;
ctx.beginPath();
ctx.arc(lx, ly, size, 0, Math.PI * 2);
ctx.stroke();
}
// Landing marker
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lx - 8, ly - 8);
ctx.lineTo(lx + 8, ly + 8);
ctx.moveTo(lx + 8, ly - 8);
ctx.lineTo(lx - 8, ly + 8);
ctx.stroke();
// Distance label
ctx.fillStyle = '#e8e0d5';
ctx.font = 'bold 14px "Bebas Neue"';
ctx.fillText(`Range: ${state.landingPoint.x.toFixed(1)} m`, lx - 50, ly + 30);
}
// Draw max height marker
if (state.trajectoryPoints.length > 0) {
let maxH = 0;
let maxX = 0;
for (let p of state.trajectoryPoints) {
if (p.y > maxH) {
maxH = p.y;
maxX = p.x;
}
}
const mx = toCanvasX(maxX);
const my = toCanvasY(maxH);
// Vertical line
ctx.strokeStyle = 'rgba(0, 200, 150, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx, my);
ctx.lineTo(mx, groundY);
ctx.stroke();
// Arrow and label
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(mx - 10, my);
ctx.lineTo(mx + 10, my);
ctx.stroke();
ctx.fillStyle = '#00c896';
ctx.font = '11px "DM Mono"';
ctx.fillText(`Max H: ${maxH.toFixed(1)} m`, mx - 40, my - 5);
}
// Draw range marker
if (state.trajectoryPoints.length > 0) {
const lastPoint = state.trajectoryPoints[state.trajectoryPoints.length - 1];
const rx = toCanvasX(lastPoint.x);
const ry = groundY;
// Horizontal line
ctx.strokeStyle = 'rgba(255, 34, 0, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(20, ry);
ctx.lineTo(rx, ry);
ctx.stroke();
// Markers
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(20, ry - 5);
ctx.lineTo(20, ry + 5);
ctx.moveTo(rx, ry - 5);
ctx.lineTo(rx, ry + 5);
ctx.stroke();
}
}
function animate() {
if (!state.isAnimating) return;
const now = performance.now();
const realDelta = (now - state.lastAnimTime) / 1000;
state.lastAnimTime = now;
state.simTime += realDelta * state.simSpeed;
const t = state.simTime;
const v0 = state.v0;
const angle = state.angle * Math.PI / 180;
const g = state.gravity;
const k = state.drag;
const h0 = state.height;
const vx0 = v0 * Math.cos(angle);
const vy0 = v0 * Math.sin(angle);
let x, y, vx, vy;
if (k > 0) {
// With drag - numerical integration
let px = 0, py = h0;
let cvx = vx0, cvy = vy0;
let dt = 0.01;
let steps = Math.round(t / dt);
for (let i = 0; i < steps; i++) {
let v = Math.sqrt(cvx * cvx + cvy * cvy);
let ax = -k * cvx * v;
let ay = -g - k * cvy * v;
cvx += ax * dt;
cvy += ay * dt;
px += cvx * dt;
py += cvy * dt;
if (py < 0) {
state.isAnimating = false;
state.landingPoint = { x: px, y: 0 };
break;
}
}
x = px;
y = py;
vx = cvx;
vy = cvy;
} else {
// No drag - analytical solution
x = vx0 * t;
y = h0 + vy0 * t - 0.5 * g * t * t;
vx = vx0;
vy = vy0 - g * t;
if (y < 0) {
state.isAnimating = false;
state.landingPoint = { x, y: 0 };
}
}
state.currentPosition = { x, y, t };
state.currentVelocity = { x: vx, y: vy };
updateCanvas();
if (state.isAnimating) {
requestAnimationFrame(animate);
}
}
launchBtn.addEventListener('click', () => {
if (state.isAnimating) return;
state.isAnimating = true;
state.launchTime = performance.now();
state.simTime = 0;
state.lastAnimTime = performance.now();
state.landingPoint = null;
animate();
});
document.getElementById('simSpeedSlider').addEventListener('input', (e) => {
state.simSpeed = parseFloat(e.target.value);
document.getElementById('simSpeedDisplay').textContent = state.simSpeed.toFixed(1) + '×';
});
resetBtn.addEventListener('click', () => {
state.isAnimating = false;
state.currentPosition = null;
state.currentVelocity = null;
state.landingPoint = null;
state.time = 0;
state.simTime = 0;
updateCanvas();
});
compareCheckbox.addEventListener('change', () => {
updateStats();
updateCanvas();
});
// Initial setup
updateStats();
updateCanvas();