Simulation
Presets
Parameters
1.0
2.0
0.5x
200
Add Body
10.0

Click canvas to add body. Drag to set velocity.

Energy
Kinetic 0.00
Potential 0.00
Total 0.00
Δ Energy 0.00%
Statistics
Bodies 0
FPS 0
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// ========== Constants ==========
    const G_BASE = 1.0;

    // ========== State ==========
    const state = {
      bodies: [],
      paused: false,
      selectedBodyIndex: null,
      gravity: 1.0,
      softening: 2.0,
      timeStep: 0.5,
      trailMaxLength: 200,
      addingMass: 10,
      showCOM: false,
      initialEnergy: null,
      fps: 0,
      lastFrameTime: Date.now(),
      frameCount: 0
    };

    // ========== DOM References ==========
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    const pausePlayBtn = document.getElementById('pausePlayBtn');
    const clearBtn = document.getElementById('clearBtn');
    const gravitySlider = document.getElementById('gravitySlider');
    const softeningSlider = document.getElementById('softeningSlider');
    const timeStepSlider = document.getElementById('timeStepSlider');
    const trailSlider = document.getElementById('trailSlider');
    const massSlider = document.getElementById('massSlider');
    const comToggle = document.getElementById('comToggle');
    const comMarker = document.getElementById('comMarker');

    const presetBtns = {
      solar: document.getElementById('presetSolar'),
      binary: document.getElementById('presetBinary'),
      galaxy: document.getElementById('presetGalaxy'),
      figure8: document.getElementById('presetFigure8'),
      random: document.getElementById('presetRandom'),
      slingshot: document.getElementById('presetSlingshot')
    };

    // ========== Canvas Setup ==========
    function resizeCanvas() {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width;
      canvas.height = rect.height;
    }
    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    // ========== Body Class ==========
    class Body {
      constructor(x, y, vx, vy, mass) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.mass = mass;
        this.ax = 0;
        this.ay = 0;
        this.trail = [];
        this.color = this.getColorByMass(mass);
      }

      getColorByMass(mass) {
        const normalized = Math.min(mass / 50, 1);
        if (normalized < 0.3) return `hsl(200, 100%, ${60 + normalized * 20}%)`;
        if (normalized < 0.6) return `hsl(50, 100%, ${60 + (normalized - 0.3) * 20}%)`;
        return `hsl(20, 100%, ${50 + (normalized - 0.6) * 30}%)`;
      }

      get radius() {
        return Math.cbrt(this.mass) * 0.8;
      }

      get ke() {
        const v2 = this.vx * this.vx + this.vy * this.vy;
        return 0.5 * this.mass * v2;
      }
    }

    // ========== Event Listeners ==========
    pausePlayBtn.addEventListener('click', () => {
      state.paused = !state.paused;
      pausePlayBtn.textContent = state.paused ? '▶ Play' : '⏸ Pause';
    });

    clearBtn.addEventListener('click', () => {
      state.bodies = [];
      state.selectedBodyIndex = null;
      state.initialEnergy = null;
      updateSelectionUI();
    });

    gravitySlider.addEventListener('input', (e) => {
      state.gravity = parseFloat(e.target.value);
      document.getElementById('gravityValue').textContent = state.gravity.toFixed(1);
    });

    softeningSlider.addEventListener('input', (e) => {
      state.softening = parseFloat(e.target.value);
      document.getElementById('softeningValue').textContent = state.softening.toFixed(1);
    });

    timeStepSlider.addEventListener('input', (e) => {
      state.timeStep = parseFloat(e.target.value);
      document.getElementById('timeStepValue').textContent = state.timeStep.toFixed(1) + 'x';
    });

    trailSlider.addEventListener('input', (e) => {
      state.trailMaxLength = parseInt(e.target.value);
      document.getElementById('trailValue').textContent = state.trailMaxLength;
    });

    massSlider.addEventListener('input', (e) => {
      state.addingMass = parseFloat(e.target.value);
      document.getElementById('massValue').textContent = state.addingMass.toFixed(1);
    });

    comToggle.addEventListener('change', (e) => {
      state.showCOM = e.target.checked;
    });

    document.getElementById('deleteBodyBtn').addEventListener('click', () => {
      if (state.selectedBodyIndex !== null) {
        state.bodies.splice(state.selectedBodyIndex, 1);
        state.selectedBodyIndex = null;
        updateSelectionUI();
      }
    });

    // ========== Preset Scenarios ==========
    presetBtns.solar.addEventListener('click', createSolarSystem);
    presetBtns.binary.addEventListener('click', createBinaryStar);
    presetBtns.galaxy.addEventListener('click', createGalaxyCollision);
    presetBtns.figure8.addEventListener('click', createFigure8);
    presetBtns.random.addEventListener('click', () => createRandom(20));
    presetBtns.slingshot.addEventListener('click', createSlingshot);

    function createSolarSystem() {
      state.bodies = [];
      const sun = new Body(canvas.width / 2, canvas.height / 2, 0, 0, 200);
      state.bodies.push(sun);

      const distances = [80, 130, 160, 200, 240, 280];
      const masses = [0.5, 0.8, 1, 0.6, 100, 0.4];
      for (let i = 0; i < 6; i++) {
        const d = distances[i];
        const m = masses[i];
        const angle = Math.random() * Math.PI * 2;
        const x = canvas.width / 2 + Math.cos(angle) * d;
        const y = canvas.height / 2 + Math.sin(angle) * d;
        const v = Math.sqrt(state.gravity * sun.mass / d);
        const vx = -Math.sin(angle) * v * 0.9;
        const vy = Math.cos(angle) * v * 0.9;
        state.bodies.push(new Body(x, y, vx, vy, m));
      }
      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    function createBinaryStar() {
      state.bodies = [];
      const cx = canvas.width / 2;
      const cy = canvas.height / 2;
      const d = 100;
      const m = 80;
      const v = Math.sqrt(state.gravity * m / d) * 0.5;

      state.bodies.push(new Body(cx - d/2, cy, 0, v, m));
      state.bodies.push(new Body(cx + d/2, cy, 0, -v, m));
      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    function createGalaxyCollision() {
      state.bodies = [];

      // Disk 1
      const cx1 = canvas.width * 0.3;
      const cy1 = canvas.height / 2;
      const central1 = new Body(cx1, cy1, 4, 0, 150);
      state.bodies.push(central1);

      for (let i = 0; i < 30; i++) {
        const r = 50 + Math.random() * 60;
        const angle = Math.random() * Math.PI * 2;
        const x = cx1 + Math.cos(angle) * r;
        const y = cy1 + Math.sin(angle) * r;
        const v = Math.sqrt(state.gravity * central1.mass / r) * 0.8;
        const vx = -Math.sin(angle) * v + 4;
        const vy = Math.cos(angle) * v;
        state.bodies.push(new Body(x, y, vx, vy, 0.5));
      }

      // Disk 2
      const cx2 = canvas.width * 0.7;
      const cy2 = canvas.height / 2;
      const central2 = new Body(cx2, cy2, -4, 0, 150);
      state.bodies.push(central2);

      for (let i = 0; i < 30; i++) {
        const r = 50 + Math.random() * 60;
        const angle = Math.random() * Math.PI * 2;
        const x = cx2 + Math.cos(angle) * r;
        const y = cy2 + Math.sin(angle) * r;
        const v = Math.sqrt(state.gravity * central2.mass / r) * 0.8;
        const vx = -Math.sin(angle) * v - 4;
        const vy = Math.cos(angle) * v;
        state.bodies.push(new Body(x, y, vx, vy, 0.5));
      }

      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    function createFigure8() {
      state.bodies = [];
      const m = 1;
      const scale = 100;

      state.bodies.push(new Body(canvas.width / 2 - scale, canvas.height / 2, 0.3063 * scale, 0.3063 * scale, m));
      state.bodies.push(new Body(canvas.width / 2 + scale, canvas.height / 2, 0.3063 * scale, 0.3063 * scale, m));
      state.bodies.push(new Body(canvas.width / 2, canvas.height / 2 + scale * 0.4, -0.3063 * scale * 2, 0, m));

      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    function createRandom(count) {
      state.bodies = [];
      for (let i = 0; i < count; i++) {
        const x = Math.random() * canvas.width * 0.8 + canvas.width * 0.1;
        const y = Math.random() * canvas.height * 0.8 + canvas.height * 0.1;
        const vx = (Math.random() - 0.5) * 4;
        const vy = (Math.random() - 0.5) * 4;
        const mass = Math.random() * 15 + 1;
        state.bodies.push(new Body(x, y, vx, vy, mass));
      }
      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    function createSlingshot() {
      state.bodies = [];
      const star = new Body(canvas.width / 2, canvas.height / 2, 0, 0, 200);
      state.bodies.push(star);

      const probe = new Body(canvas.width / 2 + 300, canvas.height / 2, 0, 3, 0.1);
      state.bodies.push(probe);

      state.initialEnergy = null;
      state.selectedBodyIndex = null;
      updateSelectionUI();
    }

    // ========== Physics ==========
    function computeAccelerations() {
      for (let body of state.bodies) {
        body.ax = 0;
        body.ay = 0;
      }

      for (let i = 0; i < state.bodies.length; i++) {
        for (let j = i + 1; j < state.bodies.length; j++) {
          const b1 = state.bodies[i];
          const b2 = state.bodies[j];
          const dx = b2.x - b1.x;
          const dy = b2.y - b1.y;
          const r2 = dx * dx + dy * dy + state.softening * state.softening;
          const r = Math.sqrt(r2);
          const F = (state.gravity * G_BASE * b1.mass * b2.mass) / r2;
          const ax = (F / b1.mass) * (dx / r);
          const ay = (F / b1.mass) * (dy / r);
          b1.ax += ax;
          b1.ay += ay;
          b2.ax -= (F / b2.mass) * (dx / r);
          b2.ay -= (F / b2.mass) * (dy / r);
        }
      }
    }

    function leapfrogStep(dt) {
      computeAccelerations();
      for (let body of state.bodies) {
        body.vx += body.ax * dt / 2;
        body.vy += body.ay * dt / 2;
        body.x += body.vx * dt;
        body.y += body.vy * dt;
      }
      computeAccelerations();
      for (let body of state.bodies) {
        body.vx += body.ax * dt / 2;
        body.vy += body.ay * dt / 2;
      }
    }

    function updatePhysics() {
      if (state.paused) return;
      const dt = state.timeStep * 0.016;
      leapfrogStep(dt);

      for (let body of state.bodies) {
        body.trail.push([body.x, body.y]);
        if (body.trail.length > state.trailMaxLength) {
          body.trail.shift();
        }
      }
    }

    // ========== Energy Calculation ==========
    function calculateEnergy() {
      let ke = 0, pe = 0;

      for (let body of state.bodies) {
        ke += body.ke;
      }

      for (let i = 0; i < state.bodies.length; i++) {
        for (let j = i + 1; j < state.bodies.length; j++) {
          const b1 = state.bodies[i];
          const b2 = state.bodies[j];
          const dx = b2.x - b1.x;
          const dy = b2.y - b1.y;
          const r = Math.sqrt(dx * dx + dy * dy);
          pe -= (state.gravity * G_BASE * b1.mass * b2.mass) / r;
        }
      }

      return { ke, pe, total: ke + pe };
    }

    // ========== COM & Statistics ==========
    function calculateCOM() {
      let totalMass = 0, comX = 0, comY = 0;
      for (let body of state.bodies) {
        totalMass += body.mass;
        comX += body.x * body.mass;
        comY += body.y * body.mass;
      }
      if (totalMass === 0) return { x: canvas.width / 2, y: canvas.height / 2 };
      return { x: comX / totalMass, y: comY / totalMass };
    }

    function updateStats() {
      const energy = calculateEnergy();
      document.getElementById('statKE').textContent = energy.ke.toFixed(2);
      document.getElementById('statPE').textContent = energy.pe.toFixed(2);
      document.getElementById('statTE').textContent = energy.total.toFixed(2);

      if (state.initialEnergy === null && state.bodies.length > 0) {
        state.initialEnergy = energy.total;
      }

      if (state.initialEnergy !== null) {
        const deltaE = ((energy.total - state.initialEnergy) / Math.abs(state.initialEnergy)) * 100;
        const deltaClass = Math.abs(deltaE) > 5 ? 'error' : (Math.abs(deltaE) > 2 ? 'warning' : '');
        const elem = document.getElementById('statDeltaE');
        elem.textContent = deltaE.toFixed(2) + '%';
        elem.className = 'stat-value ' + deltaClass;
      }

      document.getElementById('statBodies').textContent = state.bodies.length;
      document.getElementById('statFPS').textContent = state.fps;

      const com = calculateCOM();
      if (state.showCOM) {
        comMarker.style.display = 'block';
        comMarker.style.left = (com.x + canvas.getBoundingClientRect().left) + 'px';
        comMarker.style.top = (com.y + canvas.getBoundingClientRect().top + 58) + 'px';
      } else {
        comMarker.style.display = 'none';
      }
    }

    // ========== Rendering ==========
    function render() {
      ctx.fillStyle = '#0a0a0a';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      // Draw trails
      for (let i = 0; i < state.bodies.length; i++) {
        const body = state.bodies[i];
        if (body.trail.length < 2) continue;
        ctx.strokeStyle = body.color;
        ctx.globalAlpha = 0.2;
        ctx.lineWidth = 0.5;
        ctx.beginPath();
        ctx.moveTo(body.trail[0][0], body.trail[0][1]);
        for (let j = 1; j < body.trail.length; j++) {
          ctx.lineTo(body.trail[j][0], body.trail[j][1]);
        }
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      // Draw bodies
      for (let i = 0; i < state.bodies.length; i++) {
        const body = state.bodies[i];
        const r = body.radius;

        // Glow
        ctx.shadowBlur = 20;
        ctx.shadowColor = body.color;
        ctx.fillStyle = body.color;
        ctx.globalAlpha = 0.3;
        ctx.beginPath();
        ctx.arc(body.x, body.y, r * 1.8, 0, Math.PI * 2);
        ctx.fill();

        ctx.shadowBlur = 0;
        ctx.globalAlpha = 1;
        ctx.fillStyle = body.color;
        ctx.beginPath();
        ctx.arc(body.x, body.y, r, 0, Math.PI * 2);
        ctx.fill();

        // Selection ring
        if (i === state.selectedBodyIndex) {
          ctx.strokeStyle = '#ff2200';
          ctx.lineWidth = 2;
          ctx.globalAlpha = 0.8;
          ctx.beginPath();
          ctx.arc(body.x, body.y, r + 6, 0, Math.PI * 2);
          ctx.stroke();
          ctx.globalAlpha = 1;
        }

        // Velocity vector
        const speed = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
        if (speed > 0.1) {
          const vlen = Math.min(speed * 5, 30);
          const vx = (body.vx / speed) * vlen;
          const vy = (body.vy / speed) * vlen;
          ctx.strokeStyle = '#555555';
          ctx.lineWidth = 1;
          ctx.globalAlpha = 0.5;
          ctx.beginPath();
          ctx.moveTo(body.x, body.y);
          ctx.lineTo(body.x + vx, body.y + vy);
          ctx.stroke();
          ctx.globalAlpha = 1;
        }
      }
    }

    // ========== Selection & Interaction ==========
    function updateSelectionUI() {
      const section = document.getElementById('selectionSection');
      if (state.selectedBodyIndex === null) {
        section.style.display = 'none';
      } else {
        const body = state.bodies[state.selectedBodyIndex];
        const info = document.getElementById('selectionInfo');
        const speed = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
        info.innerHTML = `
          <div class="selection-info-line">Mass: ${body.mass.toFixed(2)}</div>
          <div class="selection-info-line">Speed: ${speed.toFixed(2)}</div>
          <div class="selection-info-line">Vx: ${body.vx.toFixed(2)}</div>
          <div class="selection-info-line">Vy: ${body.vy.toFixed(2)}</div>
          <div class="selection-info-line">KE: ${body.ke.toFixed(2)}</div>
        `;
        section.style.display = 'block';
      }
    }

    canvas.addEventListener('click', (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      let clicked = null;
      for (let i = 0; i < state.bodies.length; i++) {
        const body = state.bodies[i];
        const dist = Math.sqrt((body.x - x) ** 2 + (body.y - y) ** 2);
        if (dist < body.radius + 10) {
          clicked = i;
          break;
        }
      }

      if (clicked !== null) {
        state.selectedBodyIndex = clicked;
        updateSelectionUI();
      } else {
        state.selectedBodyIndex = null;
        updateSelectionUI();
      }
    });

    canvas.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      for (let i = 0; i < state.bodies.length; i++) {
        const body = state.bodies[i];
        const dist = Math.sqrt((body.x - x) ** 2 + (body.y - y) ** 2);
        if (dist < body.radius + 10) {
          state.bodies.splice(i, 1);
          state.selectedBodyIndex = null;
          updateSelectionUI();
          break;
        }
      }
    });

    let dragStart = null;
    canvas.addEventListener('mousedown', (e) => {
      if (e.button === 2) return;
      const rect = canvas.getBoundingClientRect();
      dragStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
    });

    canvas.addEventListener('mousemove', (e) => {
      if (!dragStart) return;
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      // Draw velocity preview line if dragging
      if (state.dragPreview) {
        // Will be drawn in render
      }
    });

    canvas.addEventListener('mouseup', (e) => {
      if (!dragStart) return;
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      const clicked = state.selectedBodyIndex;
      if (clicked === null) {
        const vx = (x - dragStart.x) * 0.1;
        const vy = (y - dragStart.y) * 0.1;
        const body = new Body(dragStart.x, dragStart.y, vx, vy, state.addingMass);
        state.bodies.push(body);
        state.initialEnergy = null;
      }

      dragStart = null;
    });

    canvas.addEventListener('mouseleave', () => {
      dragStart = null;
    });

    // ========== FPS Counter ==========
    function updateFPS() {
      state.frameCount++;
      const now = Date.now();
      if (now - state.lastFrameTime >= 1000) {
        state.fps = state.frameCount;
        state.frameCount = 0;
        state.lastFrameTime = now;
      }
    }

    // ========== Main Loop ==========
    function animate() {
      updatePhysics();
      render();
      updateStats();
      updateFPS();
      requestAnimationFrame(animate);
    }

    animate();