physics • kinetic theory
IDEAL GAS
Interactive simulation of the ideal gas law (PV = nRT) using kinetic theory. Observe how particle collisions create pressure, explore thermodynamic processes, and verify the Maxwell-Boltzmann speed distribution.
How to use: Adjust temperature, volume, or particle count. Drag the piston to change volume.
Watch pressure, particle speeds, and speed distributions update in real-time.
Simulation Controls
500 K
0.60
200
Particle Box (Drag Piston →)
Color: Blue (cold) → Green (warm) → Red (hot) | Drag right edge to change volume
Pressure Gauge
Pa (Pascals) — Real-time collision rate
P vs T (Constant V)
Linear relationship: P ∝ T
P vs 1/V (Constant T)
Boyle's Law: P ∝ 1/V
Speed Distribution
Maxwell-Boltzmann curve overlay
Temperature
500
K
Pressure
0
Pa
Volume
0.6
m³ (×1e-3)
Particle Count
200
particles
PV (check)
0
Pa·m³
RMS Speed
0
m/s
Avg KE
0
J
Collision Rate
0
Hz
Mean Free Path
∞
mm
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// Physics constants
const k_B = 1.380649e-23; // Boltzmann constant (J/K)
const AVOGADRO = 6.02214076e23;
// Simulation state
let state = {
T: 500, // Temperature (K)
V: 0.6, // Volume (relative, default = 1.0)
N: 200, // Particle count
mass: 1.0, // Particle mass (relative)
process: 'none',
collisionMode: 'all',
particles: [],
collisionCount: 0,
frameCount: 0,
speedHistory: [],
pressureHistory: [],
temperatureHistory: [],
volumeHistory: [],
lastCollisionTime: 0,
initialP: 0,
initialV: 0,
surfaceplotVisible: false,
pistonDragging: false,
pistonX: 0,
simSpeed: 1,
};
// Canvas elements
const particleCanvas = document.getElementById('particleCanvas');
const particleCtx = particleCanvas.getContext('2d');
const pressureCanvas = document.getElementById('pressureGauge');
const pressureCtx = pressureCanvas.getContext('2d');
const graphPvsT = document.getElementById('graphPvsT');
const graphPvV = document.getElementById('graphPvV');
const graphSpeedDist = document.getElementById('graphSpeedDist');
// Resize canvases
function resizeCanvases() {
particleCanvas.width = particleCanvas.offsetWidth * window.devicePixelRatio;
particleCanvas.height = particleCanvas.offsetHeight * window.devicePixelRatio;
particleCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
pressureCanvas.width = pressureCanvas.offsetWidth * window.devicePixelRatio;
pressureCanvas.height = pressureCanvas.offsetHeight * window.devicePixelRatio;
pressureCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
graphPvsT.width = graphPvsT.offsetWidth;
graphPvsT.height = graphPvsT.offsetHeight;
graphPvV.width = graphPvV.offsetWidth;
graphPvV.height = graphPvV.offsetHeight;
graphSpeedDist.width = graphSpeedDist.offsetWidth;
graphSpeedDist.height = graphSpeedDist.offsetHeight;
}
resizeCanvases();
window.addEventListener('resize', resizeCanvases);
// Particle class
class Particle {
constructor(x, y, vx, vy, mass) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.mass = mass;
this.radius = 3;
}
speed() {
return Math.sqrt(this.vx ** 2 + this.vy ** 2);
}
update(boxWidth, boxHeight, dt) {
this.x += this.vx * dt * 100;
this.y += this.vy * dt * 100;
}
collideWall(boxWidth, boxHeight) {
const collisions = [];
if (this.x - this.radius <= 0) {
this.vx = Math.abs(this.vx);
this.x = this.radius;
collisions.push('left');
}
if (this.x + this.radius >= boxWidth) {
this.vx = -Math.abs(this.vx);
this.x = boxWidth - this.radius;
collisions.push('right');
}
if (this.y - this.radius <= 0) {
this.vy = Math.abs(this.vy);
this.y = this.radius;
collisions.push('top');
}
if (this.y + this.radius >= boxHeight) {
this.vy = -Math.abs(this.vy);
this.y = boxHeight - this.radius;
collisions.push('bottom');
}
return collisions;
}
getColor() {
const speed = this.speed();
const maxSpeed = 500; // Normalize for color
const norm = Math.min(speed / maxSpeed, 1);
if (norm < 0.33) {
// Blue to green
const t = norm / 0.33;
const r = Math.floor(0 + t * 0);
const g = Math.floor(0 + t * 255);
const b = Math.floor(255 + t * -100);
return `rgb(${r}, ${g}, ${Math.max(0, b)})`;
} else if (norm < 0.67) {
// Green to yellow
const t = (norm - 0.33) / 0.34;
const r = Math.floor(0 + t * 255);
const g = 255;
const b = Math.floor(155 + t * -155);
return `rgb(${r}, ${g}, ${Math.max(0, b)})`;
} else {
// Yellow to red/white
const t = (norm - 0.67) / 0.33;
const r = 255;
const g = Math.floor(255 + t * -100);
const b = Math.floor(0 + t * 100);
return `rgb(${r}, ${Math.max(0, g)}, ${Math.min(255, b)})`;
}
}
}
// Initialize particles with Maxwell-Boltzmann distribution
function initializeParticles() {
state.particles = [];
const boxWidth = particleCanvas.offsetWidth * state.V;
const boxHeight = particleCanvas.offsetHeight;
for (let i = 0; i < state.N; i++) {
const x = Math.random() * (boxWidth - 12) + 6;
const y = Math.random() * (boxHeight - 12) + 6;
// Maxwell-Boltzmann: v ~ sqrt(kT/m)
const sigma = Math.sqrt((k_B * state.T) / state.mass);
const vx = (Math.random() - 0.5) * sigma * 0.1;
const vy = (Math.random() - 0.5) * sigma * 0.1;
state.particles.push(new Particle(x, y, vx, vy, state.mass));
}
state.collisionCount = 0;
state.lastCollisionTime = performance.now();
}
// Update particle speeds based on temperature (Maxwell-Boltzmann)
function updateParticleSpeeds() {
const sigma = Math.sqrt((k_B * state.T) / state.mass);
for (let particle of state.particles) {
const currentSpeed = particle.speed();
const newSpeed = sigma * (Math.random() * 2);
if (currentSpeed > 0) {
const scale = newSpeed / currentSpeed;
particle.vx *= scale;
particle.vy *= scale;
} else {
particle.vx = (Math.random() - 0.5) * sigma * 0.1;
particle.vy = (Math.random() - 0.5) * sigma * 0.1;
}
}
}
// Calculate pressure from collision rate
function calculatePressure(boxWidth, boxHeight) {
const collisionsPerSecond = state.collisionCount / (state.frameCount / 60);
if (collisionsPerSecond === 0) return 0;
// P = (2/3) * (N/V) * KE_avg
const avgKE = (3 / 2) * k_B * state.T;
const volumeM3 = (boxWidth * boxHeight) * 1e-6; // Convert to m³
const P = (state.N / volumeM3) * avgKE;
return Math.max(0, P);
}
// Collision detection and handling
function handleCollisions(boxWidth, boxHeight) {
for (let particle of state.particles) {
const wallCollisions = particle.collideWall(boxWidth, boxHeight);
if (wallCollisions.length > 0) {
state.collisionCount++;
}
}
if (state.collisionMode === 'all') {
for (let i = 0; i < state.particles.length; i++) {
for (let j = i + 1; j < state.particles.length; j++) {
const p1 = state.particles[i];
const p2 = state.particles[j];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = p1.radius + p2.radius;
if (dist < minDist && dist > 0) {
// Elastic collision
const angle = Math.atan2(dy, dx);
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const vx1 = p1.vx * cos + p1.vy * sin;
const vy1 = p1.vy * cos - p1.vx * sin;
const vx2 = p2.vx * cos + p2.vy * sin;
const vy2 = p2.vy * cos - p2.vx * sin;
const m1 = p1.mass;
const m2 = p2.mass;
const newVx1 = ((m1 - m2) * vx1 + 2 * m2 * vx2) / (m1 + m2);
const newVx2 = ((m2 - m1) * vx2 + 2 * m1 * vx1) / (m1 + m2);
p1.vx = newVx1 * cos - vy1 * sin;
p1.vy = newVx1 * sin + vy1 * cos;
p2.vx = newVx2 * cos - vy2 * sin;
p2.vy = newVx2 * sin + vy2 * cos;
const overlap = (minDist - dist) / 2;
p1.x -= overlap * cos;
p1.y -= overlap * sin;
p2.x += overlap * cos;
p2.y += overlap * sin;
state.collisionCount++;
}
}
}
}
}
// Draw particle box
function drawParticleBox() {
const width = particleCanvas.offsetWidth;
const height = particleCanvas.offsetHeight;
const boxWidth = width * state.V;
particleCtx.fillStyle = '#111111';
particleCtx.fillRect(0, 0, width, height);
// Draw box outline
particleCtx.strokeStyle = '#1e1e1e';
particleCtx.lineWidth = 2;
particleCtx.strokeRect(0, 0, boxWidth, height);
// Draw piston
particleCtx.fillStyle = 'rgba(255, 34, 0, 0.3)';
particleCtx.fillRect(boxWidth - 10, 0, 10, height);
particleCtx.strokeStyle = '#ff2200';
particleCtx.lineWidth = 2;
particleCtx.strokeRect(boxWidth - 10, 0, 10, height);
// Draw particles
for (let particle of state.particles) {
particleCtx.fillStyle = particle.getColor();
particleCtx.beginPath();
particleCtx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
particleCtx.fill();
}
state.pistonX = boxWidth;
}
// Draw pressure gauge
function drawPressureGauge(pressure) {
const width = pressureCanvas.offsetWidth;
const height = pressureCanvas.offsetHeight;
const centerX = width * 0.5;
const centerY = height * 0.65;
const radius = Math.min(width, height) * 0.3;
pressureCtx.fillStyle = '#111111';
pressureCtx.fillRect(0, 0, width, height);
// Draw gauge background
pressureCtx.fillStyle = '#161616';
pressureCtx.beginPath();
pressureCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
pressureCtx.fill();
pressureCtx.strokeStyle = '#1e1e1e';
pressureCtx.lineWidth = 2;
pressureCtx.beginPath();
pressureCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
pressureCtx.stroke();
// Draw danger zone (red arc)
const dangerAngle = (2 * Math.PI) * 0.3;
pressureCtx.strokeStyle = '#ff5555';
pressureCtx.lineWidth = 4;
pressureCtx.beginPath();
pressureCtx.arc(centerX, centerY, radius, Math.PI - dangerAngle * 0.5, Math.PI + dangerAngle * 0.5);
pressureCtx.stroke();
// Draw needle
const maxPressure = 100000; // Pa
const angle = Math.PI - (Math.min(pressure, maxPressure) / maxPressure) * Math.PI;
const needleLen = radius * 0.8;
const needleX = centerX + needleLen * Math.cos(angle);
const needleY = centerY + needleLen * Math.sin(angle);
pressureCtx.strokeStyle = '#ff2200';
pressureCtx.lineWidth = 3;
pressureCtx.beginPath();
pressureCtx.moveTo(centerX, centerY);
pressureCtx.lineTo(needleX, needleY);
pressureCtx.stroke();
// Draw center circle
pressureCtx.fillStyle = '#ff2200';
pressureCtx.beginPath();
pressureCtx.arc(centerX, centerY, 6, 0, Math.PI * 2);
pressureCtx.fill();
// Draw labels
pressureCtx.fillStyle = '#e8e0d5';
pressureCtx.font = '12px "DM Mono"';
pressureCtx.textAlign = 'center';
pressureCtx.fillText('0 Pa', centerX - radius * 0.7, centerY + radius * 0.5);
pressureCtx.fillText((maxPressure / 1000).toFixed(0) + 'k Pa', centerX + radius * 0.7, centerY + radius * 0.5);
// Pressure display
pressureCtx.font = 'bold 24px "Bebas Neue"';
pressureCtx.fillText((pressure / 1000).toFixed(1) + ' kPa', centerX, centerY - radius * 0.8);
}
// Draw graphs
function drawGraphs() {
drawGraphPvsT();
drawGraphPvV();
drawSpeedDistribution();
}
function drawGraphPvsT() {
const ctx = graphPvsT.getContext('2d');
const w = graphPvsT.width;
const h = graphPvsT.height;
const padding = 30;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, w, h);
if (state.temperatureHistory.length < 2) return;
// Axes
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, h - padding);
ctx.lineTo(padding, padding);
ctx.lineTo(w - padding, padding);
ctx.stroke();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '10px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillText('T (K)', w / 2, h - 5);
ctx.save();
ctx.translate(10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P (Pa)', 0, 0);
ctx.restore();
// Data line
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.beginPath();
const maxT = Math.max(...state.temperatureHistory, 1);
const maxP = Math.max(...state.pressureHistory, 1);
for (let i = 0; i < state.temperatureHistory.length; i++) {
const x = padding + (state.temperatureHistory[i] / maxT) * (w - 2 * padding);
const y = h - padding - (state.pressureHistory[i] / maxP) * (h - 2 * padding);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawGraphPvV() {
const ctx = graphPvV.getContext('2d');
const w = graphPvV.width;
const h = graphPvV.height;
const padding = 30;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, w, h);
if (state.volumeHistory.length < 2) return;
// Axes
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, h - padding);
ctx.lineTo(padding, padding);
ctx.lineTo(w - padding, padding);
ctx.stroke();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '10px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillText('1/V', w / 2, h - 5);
ctx.save();
ctx.translate(10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P (Pa)', 0, 0);
ctx.restore();
// Data line
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.beginPath();
const invVolumes = state.volumeHistory.map(v => (v > 0 ? 1 / v : 0));
const maxInvV = Math.max(...invVolumes, 1);
const maxP = Math.max(...state.pressureHistory, 1);
for (let i = 0; i < invVolumes.length; i++) {
const x = padding + (invVolumes[i] / maxInvV) * (w - 2 * padding);
const y = h - padding - (state.pressureHistory[i] / maxP) * (h - 2 * padding);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawSpeedDistribution() {
const ctx = graphSpeedDist.getContext('2d');
const w = graphSpeedDist.width;
const h = graphSpeedDist.height;
const padding = 30;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, w, h);
if (state.particles.length === 0) return;
// Axes
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, h - padding);
ctx.lineTo(padding, padding);
ctx.lineTo(w - padding, padding);
ctx.stroke();
// Histogram
const speeds = state.particles.map(p => p.speed());
const maxSpeed = Math.max(...speeds, 1);
const bins = 20;
const binWidth = maxSpeed / bins;
const histogram = new Array(bins).fill(0);
for (let speed of speeds) {
const bin = Math.floor((speed / maxSpeed) * bins);
if (bin < bins) histogram[bin]++;
}
const maxCount = Math.max(...histogram, 1);
// Draw histogram bars
ctx.fillStyle = 'rgba(255, 34, 0, 0.5)';
for (let i = 0; i < bins; i++) {
const x = padding + (i / bins) * (w - 2 * padding);
const barHeight = (histogram[i] / maxCount) * (h - 2 * padding);
const y = h - padding - barHeight;
const barWidth = (w - 2 * padding) / bins;
ctx.fillRect(x, y, barWidth - 1, barHeight);
}
// Maxwell-Boltzmann curve
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.beginPath();
const sigma = Math.sqrt((k_B * state.T) / state.mass);
for (let i = 0; i < bins; i++) {
const v = (i / bins) * maxSpeed;
const maxwell = 4 * Math.PI * Math.pow(state.mass / (2 * Math.PI * k_B * state.T), 1.5) * v * v * Math.exp(-(state.mass * v * v) / (2 * k_B * state.T));
const y = h - padding - maxwell * (h - 2 * padding) * 1000;
const x = padding + (i / bins) * (w - 2 * padding);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '10px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillText('Speed (m/s)', w / 2, h - 5);
}
// Update statistics display
function updateStats() {
const boxWidth = particleCanvas.offsetWidth * state.V;
const boxHeight = particleCanvas.offsetHeight;
const volumeM3 = (boxWidth * boxHeight) * 1e-6;
const pressure = calculatePressure(boxWidth, boxHeight);
const avgSpeed = state.particles.length > 0 ? state.particles.reduce((sum, p) => sum + p.speed(), 0) / state.particles.length : 0;
const vrms = Math.sqrt(3 * k_B * state.T / state.mass);
const avgKE = (3 / 2) * k_B * state.T;
const pv = pressure * volumeM3;
const collisionRate = state.frameCount > 0 ? (state.collisionCount / (state.frameCount / 60)).toFixed(1) : 0;
// Mean free path approximation
const particleDensity = state.N / volumeM3;
const crossSection = Math.PI * (2e-9) ** 2; // ~2 nm diameter
const mfp = particleDensity > 0 ? (1 / (Math.sqrt(2) * particleDensity * crossSection)) * 1e6 : Infinity;
document.getElementById('statT').textContent = state.T;
document.getElementById('statP').textContent = pressure.toFixed(0);
document.getElementById('statV').textContent = (state.V * 1000).toFixed(0);
document.getElementById('statN').textContent = state.N;
document.getElementById('statPV').textContent = pv.toExponential(2);
document.getElementById('statVrms').textContent = vrms.toFixed(1);
document.getElementById('statKE').textContent = avgKE.toExponential(2);
document.getElementById('statCollisions').textContent = collisionRate;
document.getElementById('statMFP').textContent = mfp === Infinity ? '∞' : mfp.toFixed(2);
// Store history
state.pressureHistory.push(pressure);
state.temperatureHistory.push(state.T);
state.volumeHistory.push(state.V);
if (state.pressureHistory.length > 500) {
state.pressureHistory.shift();
state.temperatureHistory.shift();
state.volumeHistory.shift();
}
}
// Animation loop
function animate() {
const boxWidth = particleCanvas.offsetWidth * state.V;
const boxHeight = particleCanvas.offsetHeight;
// Update particles
for (let particle of state.particles) {
particle.update(boxWidth, boxHeight, 0.016 * state.simSpeed); // ~60fps
}
// Handle collisions
handleCollisions(boxWidth, boxHeight);
// Handle process constraints
if (state.process === 'isothermal') {
// T constant, but if V changes, P must change
} else if (state.process === 'isobaric') {
// P constant - adjust T or V to maintain pressure
} else if (state.process === 'isochoric') {
// V constant - keep piston locked
} else if (state.process === 'adiabatic') {
// Q = 0: P*V^γ = constant, where γ = 5/3 for monatomic
}
state.frameCount++;
// Update display every frame
drawParticleBox();
drawPressureGauge(calculatePressure(boxWidth, boxHeight));
updateStats();
drawGraphs();
requestAnimationFrame(animate);
}
// UI Controls
document.getElementById('tempSlider').addEventListener('input', (e) => {
state.T = parseFloat(e.target.value);
document.getElementById('tempDisplay').textContent = state.T + ' K';
updateParticleSpeeds();
});
document.getElementById('volumeSlider').addEventListener('input', (e) => {
state.V = parseFloat(e.target.value);
document.getElementById('volumeDisplay').textContent = state.V.toFixed(2);
});
document.getElementById('particleSlider').addEventListener('input', (e) => {
state.N = parseInt(e.target.value);
document.getElementById('particleDisplay').textContent = state.N;
initializeParticles();
});
document.getElementById('massSelect').addEventListener('change', (e) => {
const massMap = { light: 0.5, medium: 1.0, heavy: 2.0 };
state.mass = massMap[e.target.value];
initializeParticles();
updateParticleSpeeds();
});
document.getElementById('processSelect').addEventListener('change', (e) => {
state.process = e.target.value;
});
document.getElementById('collisionToggle').addEventListener('change', (e) => {
state.collisionMode = e.target.value;
});
document.getElementById('simSpeedSlider').addEventListener('input', (e) => {
state.simSpeed = parseFloat(e.target.value);
document.getElementById('simSpeedValue').textContent = state.simSpeed.toFixed(1);
});
document.getElementById('heatPulseBtn').addEventListener('click', () => {
state.T = Math.min(2000, state.T + 200);
document.getElementById('tempSlider').value = state.T;
document.getElementById('tempDisplay').textContent = state.T + ' K';
updateParticleSpeeds();
});
document.getElementById('resetBtn').addEventListener('click', () => {
state.T = 500;
state.V = 0.6;
state.N = 200;
state.process = 'none';
state.collisionMode = 'all';
state.speedHistory = [];
state.pressureHistory = [];
state.temperatureHistory = [];
state.volumeHistory = [];
state.collisionCount = 0;
state.frameCount = 0;
state.mass = 1.0;
document.getElementById('tempSlider').value = 500;
document.getElementById('tempDisplay').textContent = '500 K';
document.getElementById('volumeSlider').value = 0.6;
document.getElementById('volumeDisplay').textContent = '0.60';
document.getElementById('particleSlider').value = 200;
document.getElementById('particleDisplay').textContent = '200';
document.getElementById('massSelect').value = 'medium';
document.getElementById('processSelect').value = 'none';
document.getElementById('collisionToggle').value = 'all';
initializeParticles();
});
document.getElementById('surfacePlotToggle').addEventListener('click', () => {
const container = document.getElementById('surfacePlotContainer');
const btn = document.getElementById('surfacePlotToggle');
state.surfaceplotVisible = !state.surfaceplotVisible;
container.style.display = state.surfaceplotVisible ? 'block' : 'none';
btn.classList.toggle('active');
});
// Piston dragging
particleCanvas.addEventListener('mousedown', (e) => {
const rect = particleCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
if (x > state.pistonX - 15) {
state.pistonDragging = true;
}
});
document.addEventListener('mousemove', (e) => {
if (!state.pistonDragging) return;
const rect = particleCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const maxX = particleCanvas.offsetWidth;
const newV = Math.max(0.2, Math.min(1.0, x / maxX));
state.V = newV;
document.getElementById('volumeSlider').value = newV;
document.getElementById('volumeDisplay').textContent = newV.toFixed(2);
});
document.addEventListener('mouseup', () => {
state.pistonDragging = false;
});
// Initialize and start
initializeParticles();
animate();