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|
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();