Physics Lab
COLLISION SIMULATOR
Explore elastic and inelastic collisions. Watch momentum and kinetic energy conservation in action.
Simulation
1D
2D
Parameters
Inelastic (0)
1.00
Elastic (1)
Presets
Collision Analysis
Momentum Before
0.00
kg·m/s
Momentum After
0.00
kg·m/s
Kinetic Energy Before
0.00
J
Kinetic Energy After
0.00
J
Collision Details
Collision Type: —
Relative Velocity: 0.00 m/s
Energy Lost: 0 J (Elastic)
Center of Mass Velocity: 0.00 m/s
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// State
const state = {
dimension: '1d',
running: false,
slowmotion: false,
paused: false,
collided: false,
collisionTime: 0,
// Simulation parameters
m1: 1,
m2: 1,
v1: 5,
v2: 0,
e: 1,
// Current velocities
currentV1: 5,
currentV2: 0,
// 2D state
x1: 100,
y1: 150,
x2: 400,
y2: 150,
vx1: 5,
vy1: 0,
vx2: 0,
vy2: 0,
// Canvas
canvas: null,
ctx: null,
// Trail
trail1: [],
trail2: [],
maxTrailLength: 15
};
const presets = {
'equal-elastic': { m1: 1, m2: 1, v1: 5, v2: 0, e: 1 },
'heavy-light': { m1: 2, m2: 1, v1: 3, v2: 0, e: 0.9 },
'light-heavy': { m1: 1, m2: 2, v1: 5, v2: 0, e: 0.9 },
'inelastic': { m1: 1, m2: 1, v1: 5, v2: -3, e: 0 },
'cradle': { m1: 1, m2: 1, v1: 4, v2: 0, e: 1 }
};
// DOM Elements
const canvas = document.getElementById('simulationCanvas');
const ctx = canvas.getContext('2d');
const mass1Input = document.getElementById('mass1');
const mass2Input = document.getElementById('mass2');
const vel1Input = document.getElementById('vel1');
const vel2Input = document.getElementById('vel2');
const restitutionInput = document.getElementById('restitution');
const collisionTypeSelect = document.getElementById('collisionType');
const launchBtn = document.getElementById('launchBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resetBtn = document.getElementById('resetBtn');
const slowmoBtn = document.getElementById('slowmoBtn');
const dimensionToggles = document.querySelectorAll('[data-dimension]');
const presetButtons = document.querySelectorAll('[data-preset]');
// Initialize canvas
state.canvas = canvas;
state.ctx = ctx;
// Set canvas resolution
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// Event listeners
mass1Input.addEventListener('change', () => {
state.m1 = parseFloat(mass1Input.value);
updateDisplay();
});
mass2Input.addEventListener('change', () => {
state.m2 = parseFloat(mass2Input.value);
updateDisplay();
});
vel1Input.addEventListener('change', () => {
state.v1 = parseFloat(vel1Input.value);
state.currentV1 = state.v1;
updateDisplay();
});
vel2Input.addEventListener('change', () => {
state.v2 = parseFloat(vel2Input.value);
state.currentV2 = state.v2;
updateDisplay();
});
restitutionInput.addEventListener('input', (e) => {
state.e = parseFloat(restitutionInput.value);
document.getElementById('restitutionValue').textContent = state.e.toFixed(2);
collisionTypeSelect.value = 'custom';
updateDisplay();
});
collisionTypeSelect.addEventListener('change', (e) => {
if (e.target.value !== 'custom') {
const e_map = { elastic: 1, inelastic: 0.5, stick: 0 };
state.e = e_map[e.target.value];
restitutionInput.value = state.e;
document.getElementById('restitutionValue').textContent = state.e.toFixed(2);
}
});
launchBtn.addEventListener('click', () => {
state.running = true;
state.paused = false;
state.collided = false;
state.trail1 = [];
state.trail2 = [];
state.currentV1 = state.v1;
state.currentV2 = state.v2;
if (state.dimension === '2d') {
state.x1 = 80;
state.y1 = canvas.height / 2;
state.x2 = canvas.width - 80;
state.y2 = canvas.height / 2;
state.vx1 = state.v1;
state.vy1 = 0;
state.vx2 = state.v2;
state.vy2 = 0;
}
launchBtn.textContent = 'Running...';
launchBtn.disabled = true;
updateDisplay();
});
pauseBtn.addEventListener('click', () => {
state.paused = !state.paused;
pauseBtn.textContent = state.paused ? 'Resume' : 'Pause';
});
resetBtn.addEventListener('click', () => {
state.running = false;
state.paused = false;
state.collided = false;
state.collisionTime = 0;
state.trail1 = [];
state.trail2 = [];
state.currentV1 = state.v1;
state.currentV2 = state.v2;
launchBtn.textContent = 'Launch';
launchBtn.disabled = false;
updateDisplay();
draw();
});
slowmoBtn.addEventListener('click', () => {
state.slowmotion = !state.slowmotion;
slowmoBtn.classList.toggle('btn-primary', state.slowmotion);
slowmoBtn.classList.toggle('btn-secondary', !state.slowmotion);
});
dimensionToggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
dimensionToggles.forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
state.dimension = e.target.dataset.dimension;
state.running = false;
state.paused = false;
state.trail1 = [];
state.trail2 = [];
launchBtn.textContent = 'Launch';
launchBtn.disabled = false;
updateDisplay();
draw();
});
});
presetButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = presets[e.target.dataset.preset];
mass1Input.value = preset.m1;
mass2Input.value = preset.m2;
vel1Input.value = preset.v1;
vel2Input.value = preset.v2;
restitutionInput.value = preset.e;
collisionTypeSelect.value = 'custom';
state.m1 = preset.m1;
state.m2 = preset.m2;
state.v1 = preset.v1;
state.v2 = preset.v2;
state.e = preset.e;
state.currentV1 = state.v1;
state.currentV2 = state.v2;
document.getElementById('restitutionValue').textContent = state.e.toFixed(2);
updateDisplay();
});
});
function calculatePostCollisionVelocities() {
const m1 = state.m1;
const m2 = state.m2;
const v1 = state.currentV1;
const v2 = state.currentV2;
const e = state.e;
const denom = m1 + m2;
const v1_new = ((m1 - e * m2) * v1 + (1 + e) * m2 * v2) / denom;
const v2_new = ((m2 - e * m1) * v2 + (1 + e) * m1 * v1) / denom;
return { v1_new, v2_new };
}
function calculateMomentum(v1, v2) {
return state.m1 * v1 + state.m2 * v2;
}
function calculateKineticEnergy(v1, v2) {
return 0.5 * state.m1 * v1 * v1 + 0.5 * state.m2 * v2 * v2;
}
function getCollisionType(e) {
if (e > 0.99) return 'Perfectly Elastic';
if (e < 0.01) return 'Perfectly Inelastic (Stick)';
return `Partially Elastic (e=${e.toFixed(2)})`;
}
function updateDisplay() {
const m1 = state.m1;
const m2 = state.m2;
const v1 = state.currentV1;
const v2 = state.currentV2;
const momentumBefore = calculateMomentum(state.v1, state.v2);
const momentumAfter = state.collided ? calculateMomentum(state.currentV1, state.currentV2) : momentumBefore;
const energyBefore = calculateKineticEnergy(state.v1, state.v2);
const energyAfter = state.collided ? calculateKineticEnergy(state.currentV1, state.currentV2) : energyBefore;
const energyLost = Math.max(0, energyBefore - energyAfter);
const energyLostPercent = energyBefore > 0 ? (energyLost / energyBefore * 100) : 0;
const relVel = Math.abs(state.v1 - state.v2);
const comVel = (m1 * state.v1 + m2 * state.v2) / (m1 + m2);
document.getElementById('momentumBefore').textContent = momentumBefore.toFixed(2);
document.getElementById('momentumAfter').textContent = momentumAfter.toFixed(2);
document.getElementById('energyBefore').textContent = energyBefore.toFixed(2);
document.getElementById('energyAfter').textContent = energyAfter.toFixed(2);
document.getElementById('collisionTypeDisplay').textContent = getCollisionType(state.e);
document.getElementById('relativeVelocity').textContent = relVel.toFixed(2);
document.getElementById('comVelocity').textContent = comVel.toFixed(2);
if (energyLostPercent > 0.5) {
document.getElementById('energyLostIndicator').style.display = 'inline';
document.getElementById('noEnergyLost').style.display = 'none';
document.getElementById('energyLostValue').textContent = energyLostPercent.toFixed(1) + '%';
} else {
document.getElementById('energyLostIndicator').style.display = 'none';
document.getElementById('noEnergyLost').style.display = 'inline';
}
// Update bar charts (scaled to max momentum/energy)
const maxMomentum = Math.max(Math.abs(state.m1 * state.v1), Math.abs(state.m2 * state.v2), 10);
const maxEnergy = Math.max(energyBefore, energyAfter, 10);
const p1Before = Math.abs(state.m1 * state.v1);
const p2Before = Math.abs(state.m2 * state.v2);
const p1After = Math.abs(state.m1 * state.currentV1);
const p2After = Math.abs(state.m2 * state.currentV2);
const ke1Before = 0.5 * state.m1 * state.v1 * state.v1;
const ke2Before = 0.5 * state.m2 * state.v2 * state.v2;
const ke1After = 0.5 * state.m1 * state.currentV1 * state.currentV1;
const ke2After = 0.5 * state.m2 * state.currentV2 * state.currentV2;
setBarHeight('barP1Before', p1Before, maxMomentum);
setBarHeight('barP2Before', p2Before, maxMomentum);
setBarHeight('barP1After', p1After, maxMomentum);
setBarHeight('barP2After', p2After, maxMomentum);
setBarHeight('barKE1Before', ke1Before, maxEnergy);
setBarHeight('barKE2Before', ke2Before, maxEnergy);
setBarHeight('barKE1After', ke1After, maxEnergy);
setBarHeight('barKE2After', ke2After, maxEnergy);
}
function setBarHeight(elementId, value, maxValue) {
const element = document.getElementById(elementId).parentElement;
const height = (value / maxValue) * 100;
element.style.height = Math.max(20, height) + 'px';
document.getElementById(elementId).textContent = value.toFixed(1);
}
function getRadiusFromMass(mass) {
return 15 + Math.cbrt(mass) * 5;
}
function getColorFromSpeed(speed, maxSpeed = 10) {
const ratio = Math.min(Math.abs(speed) / maxSpeed, 1);
if (ratio < 0.5) {
const r = Math.floor(68 + ratio * 2 * 92);
const g = Math.floor(136 + ratio * 2 * 24);
return `rgb(${r}, ${g}, 255)`;
} else {
const r = Math.floor(160 + (ratio - 0.5) * 2 * 95);
const g = Math.floor(160 - (ratio - 0.5) * 2 * 160);
return `rgb(${r}, ${g}, 0)`;
}
}
function draw1D() {
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
// Background
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
// Track
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(50, height / 2);
ctx.lineTo(width - 50, height / 2);
ctx.stroke();
// Walls
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(0, height / 2 - 30, 40, 60);
ctx.fillRect(width - 40, height / 2 - 30, 40, 60);
// Calculate positions
const r1 = getRadiusFromMass(state.m1);
const r2 = getRadiusFromMass(state.m2);
const x1 = 100 + state.currentV1 * 20;
const x2 = width - 100 + state.currentV2 * 20;
// Draw trails
ctx.strokeStyle = 'rgba(255, 34, 0, 0.1)';
ctx.lineWidth = 1;
if (state.trail1.length > 1) {
ctx.beginPath();
ctx.moveTo(state.trail1[0], height / 2);
for (let i = 1; i < state.trail1.length; i++) {
ctx.lineTo(state.trail1[i], height / 2);
}
ctx.stroke();
}
ctx.strokeStyle = 'rgba(68, 136, 255, 0.1)';
if (state.trail2.length > 1) {
ctx.beginPath();
ctx.moveTo(state.trail2[0], height / 2);
for (let i = 1; i < state.trail2.length; i++) {
ctx.lineTo(state.trail2[i], height / 2);
}
ctx.stroke();
}
// Draw balls
ctx.fillStyle = '#ff2200';
ctx.beginPath();
ctx.arc(x1, height / 2, r1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#4488ff';
ctx.beginPath();
ctx.arc(x2, height / 2, r2, 0, Math.PI * 2);
ctx.fill();
// Draw velocity arrows
ctx.strokeStyle = '#ff2200';
ctx.fillStyle = '#ff2200';
ctx.lineWidth = 2;
drawArrow(x1, height / 2, x1 + state.currentV1 * 10, height / 2);
ctx.strokeStyle = '#4488ff';
ctx.fillStyle = '#4488ff';
drawArrow(x2, height / 2, x2 + state.currentV2 * 10, height / 2);
// Draw collision indicator
if (state.collided) {
ctx.fillStyle = 'rgba(255, 34, 0, 0.2)';
ctx.fillRect(Math.min(x1, x2) - 40, height / 2 - 60, Math.abs(x2 - x1) + 80, 120);
}
// Label
ctx.fillStyle = '#555555';
ctx.font = '12px "DM Mono"';
ctx.textAlign = 'left';
ctx.fillText(`v₁=${state.currentV1.toFixed(1)} m/s`, x1 - r1 - 50, height / 2 - r1 - 20);
ctx.fillText(`v₂=${state.currentV2.toFixed(1)} m/s`, x2 - r2, height / 2 - r2 - 20);
}
function draw2D() {
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
// Background
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
// Boundaries
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 2;
ctx.strokeRect(30, 30, width - 60, height - 60);
// Draw trails
ctx.strokeStyle = 'rgba(255, 34, 0, 0.15)';
ctx.lineWidth = 1;
for (let i = 0; i < state.trail1.length - 1; i++) {
ctx.beginPath();
ctx.moveTo(state.trail1[i].x, state.trail1[i].y);
ctx.lineTo(state.trail1[i + 1].x, state.trail1[i + 1].y);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(68, 136, 255, 0.15)';
for (let i = 0; i < state.trail2.length - 1; i++) {
ctx.beginPath();
ctx.moveTo(state.trail2[i].x, state.trail2[i].y);
ctx.lineTo(state.trail2[i + 1].x, state.trail2[i + 1].y);
ctx.stroke();
}
// Draw balls
const r1 = getRadiusFromMass(state.m1);
const r2 = getRadiusFromMass(state.m2);
ctx.fillStyle = '#ff2200';
ctx.beginPath();
ctx.arc(state.x1, state.y1, r1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#4488ff';
ctx.beginPath();
ctx.arc(state.x2, state.y2, r2, 0, Math.PI * 2);
ctx.fill();
// Draw velocity arrows
ctx.strokeStyle = '#ff2200';
ctx.fillStyle = '#ff2200';
ctx.lineWidth = 2;
const speed1 = Math.sqrt(state.vx1 * state.vx1 + state.vy1 * state.vy1);
if (speed1 > 0.1) {
drawArrow(state.x1, state.y1, state.x1 + state.vx1 * 10, state.y1 + state.vy1 * 10);
}
ctx.strokeStyle = '#4488ff';
ctx.fillStyle = '#4488ff';
const speed2 = Math.sqrt(state.vx2 * state.vx2 + state.vy2 * state.vy2);
if (speed2 > 0.1) {
drawArrow(state.x2, state.y2, state.x2 + state.vx2 * 10, state.y2 + state.vy2 * 10);
}
}
function drawArrow(fromX, fromY, toX, toY) {
const headlen = 8;
const angle = Math.atan2(toY - fromY, toX - fromX);
ctx.beginPath();
ctx.moveTo(fromX, fromY);
ctx.lineTo(toX, toY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(toX, toY);
ctx.lineTo(toX - headlen * Math.cos(angle - Math.PI / 6), toY - headlen * Math.sin(angle - Math.PI / 6));
ctx.moveTo(toX, toY);
ctx.lineTo(toX - headlen * Math.cos(angle + Math.PI / 6), toY - headlen * Math.sin(angle + Math.PI / 6));
ctx.stroke();
}
function draw() {
if (state.dimension === '1d') {
draw1D();
} else {
draw2D();
}
}
function simulate1D(dt) {
if (!state.running || state.paused) return;
const width = canvas.width / window.devicePixelRatio;
const r1 = getRadiusFromMass(state.m1);
const r2 = getRadiusFromMass(state.m2);
const x1 = 100 + state.currentV1 * 20;
const x2 = width - 100 + state.currentV2 * 20;
// Trail
state.trail1.push(x1);
state.trail2.push(x2);
if (state.trail1.length > state.maxTrailLength) state.trail1.shift();
if (state.trail2.length > state.maxTrailLength) state.trail2.shift();
// Collision detection
const distance = Math.abs(x2 - x1);
if (distance < r1 + r2 && !state.collided) {
state.collided = true;
state.collisionTime = 0;
const { v1_new, v2_new } = calculatePostCollisionVelocities();
state.currentV1 = v1_new;
state.currentV2 = v2_new;
playCollisionSound();
canvas.parentElement.classList.add('collision-flash');
setTimeout(() => canvas.parentElement.classList.remove('collision-flash'), 300);
updateDisplay();
}
// Wall collision
const margin = 50;
if (x1 < margin && state.currentV1 < 0) {
state.currentV1 *= -0.9;
}
if (x1 > width - margin && state.currentV1 > 0) {
state.currentV1 *= -0.9;
}
if (x2 < margin && state.currentV2 < 0) {
state.currentV2 *= -0.9;
}
if (x2 > width - margin && state.currentV2 > 0) {
state.currentV2 *= -0.9;
}
// Check if simulation should stop
if (Math.abs(state.currentV1) < 0.01 && Math.abs(state.currentV2) < 0.01) {
state.running = false;
launchBtn.textContent = 'Launch';
launchBtn.disabled = false;
}
}
function simulate2D(dt) {
if (!state.running || state.paused) return;
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const r1 = getRadiusFromMass(state.m1);
const r2 = getRadiusFromMass(state.m2);
// Update positions
state.x1 += state.vx1 * dt;
state.y1 += state.vy1 * dt;
state.x2 += state.vx2 * dt;
state.y2 += state.vy2 * dt;
// Trail
state.trail1.push({ x: state.x1, y: state.y1 });
state.trail2.push({ x: state.x2, y: state.y2 });
if (state.trail1.length > state.maxTrailLength) state.trail1.shift();
if (state.trail2.length > state.maxTrailLength) state.trail2.shift();
// Ball-ball collision
const dx = state.x2 - state.x1;
const dy = state.y2 - state.y1;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < r1 + r2 && !state.collided) {
state.collided = true;
state.collisionTime = 0;
// 2D collision resolution
const nx = dx / distance;
const ny = dy / distance;
const dvx = state.vx2 - state.vx1;
const dvy = state.vy2 - state.vy1;
const dvn = dvx * nx + dvy * ny;
const m1 = state.m1;
const m2 = state.m2;
const e = state.e;
const impulse = -(1 + e) * dvn / (1/m1 + 1/m2);
state.vx1 -= (impulse / m1) * nx;
state.vy1 -= (impulse / m1) * ny;
state.vx2 += (impulse / m2) * nx;
state.vy2 += (impulse / m2) * ny;
playCollisionSound();
canvas.parentElement.classList.add('collision-flash');
setTimeout(() => canvas.parentElement.classList.remove('collision-flash'), 300);
updateDisplay();
}
// Wall collision
const margin = 30;
if (state.x1 - r1 < margin) {
state.x1 = margin + r1;
state.vx1 *= -0.9;
}
if (state.x1 + r1 > width - margin) {
state.x1 = width - margin - r1;
state.vx1 *= -0.9;
}
if (state.y1 - r1 < margin) {
state.y1 = margin + r1;
state.vy1 *= -0.9;
}
if (state.y1 + r1 > height - margin) {
state.y1 = height - margin - r1;
state.vy1 *= -0.9;
}
if (state.x2 - r2 < margin) {
state.x2 = margin + r2;
state.vx2 *= -0.9;
}
if (state.x2 + r2 > width - margin) {
state.x2 = width - margin - r2;
state.vx2 *= -0.9;
}
if (state.y2 - r2 < margin) {
state.y2 = margin + r2;
state.vy2 *= -0.9;
}
if (state.y2 + r2 > height - margin) {
state.y2 = height - margin - r2;
state.vy2 *= -0.9;
}
// Stop if motion is negligible
const speed1 = Math.sqrt(state.vx1 * state.vx1 + state.vy1 * state.vy1);
const speed2 = Math.sqrt(state.vx2 * state.vx2 + state.vy2 * state.vy2);
if (speed1 < 0.01 && speed2 < 0.01) {
state.running = false;
launchBtn.textContent = 'Launch';
launchBtn.disabled = false;
}
}
function playCollisionSound() {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.frequency.value = 800 + Math.random() * 200;
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
} catch (e) {
// Audio not available
}
}
let lastTime = performance.now();
function animate(currentTime) {
const deltaTime = (currentTime - lastTime) / 1000;
lastTime = currentTime;
const dt = state.slowmotion ? deltaTime * 0.1 : deltaTime * 0.5;
if (state.dimension === '1d') {
simulate1D(dt);
} else {
simulate2D(dt);
}
draw();
requestAnimationFrame(animate);
}
// Initialize
updateDisplay();
draw();
requestAnimationFrame(animate);