Tutorial · Molecular Dynamics · Statistical Mechanics · JavaScript
📅 July 2026 ⏱ ≈ 20 min 🎯 Intermediate

Simulate a Lennard-Jones Gas From Scratch

Every molecular dynamics package — from a 30-line teaching script to LAMMPS running on a supercomputer — is built on the same handful of ideas: a pairwise potential, an integrator that conserves energy, periodic boundaries so a small box behaves like bulk matter, and a cutoff so the force computation doesn't grow as N². This tutorial builds a real 2D Lennard-Jones gas simulator from those pieces, the same one that powers this site's own Lennard-Jones simulation.

1. Why Lennard-Jones

Real atoms and small molecules attract each other weakly at moderate distance (van der Waals / London dispersion forces) and repel ferociously once their electron clouds start to overlap. The Lennard-Jones (LJ) potential, proposed by John Lennard-Jones in 1924, captures both effects with a single simple formula that is cheap to evaluate millions of times per frame — which is exactly why it remains the default test system for teaching, benchmarking, and studying generic phase behaviour (gas, liquid, solid) without committing to any one real chemical species.

Scope: this tutorial builds a 2D simulation for clarity and rendering simplicity. Every formula generalizes to 3D by simply adding a z component to positions, velocities, and forces — nothing about the physics changes.

2. The LJ Potential and Force

The 12-6 form balances a steep repulsive term (electron overlap, exponent 12) against a gentler attractive term (dispersion, exponent 6):

U(r) = 4ε [ (σ/r)^12 − (σ/r)^6 ] ε — depth of the potential well (interaction strength) σ — distance at which U(r) = 0 (an effective particle diameter) r — distance between the pair of particles Force (negative gradient of U): F(r) = 24ε/r · [ 2(σ/r)^12 − (σ/r)^6 ] (magnitude, directed along r̂)

The minimum of U(r) sits at r = 2^(1/6) σ ≈ 1.122σ, the natural equilibrium spacing between two isolated particles. Below that distance the force is strongly repulsive; above it, weakly attractive, decaying to zero as r → ∞.

function ljForceMagnitude(r, epsilon = 1, sigma = 1) {
  const sr6  = Math.pow(sigma / r, 6);
  const sr12 = sr6 * sr6;
  return 24 * epsilon / r * (2 * sr12 - sr6); // positive = repulsive, negative = attractive
}

3. Reduced (LJ) Units

MD codes almost never simulate in SI units. Instead they set ε = σ = m = 1 (particle mass) and express everything else — time, temperature, pressure — as a dimensionless combination of these. This keeps numbers near unity (numerically well behaved) and makes one simulation applicable to any real substance simply by rescaling.

Reduced length: r* = r / σ Reduced energy: U* = U / ε Reduced temperature: T* = k_B T / ε Reduced time: t* = t · √(ε / (m σ²)) Reduced density: ρ* = N σ³ / V (σ² · N/A in 2D) Example: argon has ε/k_B ≈ 120 K, σ ≈ 0.34 nm. T* = 1.0 corresponds to a real temperature of ≈ 120 K for argon.

4. Velocity Verlet Integration

Velocity Verlet is the standard MD integrator: it is symplectic (conserves phase-space volume), time-reversible, and only needs one force evaluation per step despite being second-order accurate — critical when the force computation is the expensive part of every frame.

Step 1: x(t+dt) = x(t) + v(t)·dt + 0.5·a(t)·dt² Step 2: compute new forces → a(t+dt) Step 3: v(t+dt) = v(t) + 0.5·(a(t) + a(t+dt))·dt
function velocityVerletStep(particles, forces, dt, computeForces) {
  // Step 1: update positions using old accelerations
  for (const p of particles) {
    p.x += p.vx * dt + 0.5 * p.ax * dt * dt;
    p.y += p.vy * dt + 0.5 * p.ay * dt * dt;
  }
  // Step 2: recompute forces at the new positions
  const oldAx = particles.map(p => p.ax);
  const oldAy = particles.map(p => p.ay);
  computeForces(particles); // sets p.ax, p.ay for the new configuration

  // Step 3: update velocities using the average of old and new accelerations
  particles.forEach((p, i) => {
    p.vx += 0.5 * (oldAx[i] + p.ax) * dt;
    p.vy += 0.5 * (oldAy[i] + p.ay) * dt;
  });
}

5. Periodic Boundaries and Minimum Image

A box of a few hundred particles is dominated by surface effects unless you remove the surface entirely: periodic boundary conditions tile the simulation box infinitely in every direction, so a particle exiting the right edge re-enters on the left. Force calculations then use the minimum image convention — for each pair, use whichever periodic copy of the second particle is closest.

function wrapPosition(p, boxSize) {
  p.x = ((p.x % boxSize) + boxSize) % boxSize;
  p.y = ((p.y % boxSize) + boxSize) % boxSize;
}

function minimumImageDelta(dx, dy, boxSize) {
  // shift delta into (-boxSize/2, boxSize/2] so pairs "see" the nearest periodic copy
  if (dx >  boxSize / 2) dx -= boxSize;
  if (dx <= -boxSize / 2) dx += boxSize;
  if (dy >  boxSize / 2) dy -= boxSize;
  if (dy <= -boxSize / 2) dy += boxSize;
  return { dx, dy };
}

6. Cutoff Radius and Neighbour Lists

Beyond a few multiples of σ, LJ attraction is negligible. Truncating the potential at a cutoff radius — conventionally r_c = 2.5σ — turns an O(N²) all-pairs force loop into one that only needs to check pairs within r_c, and a spatial grid (cell list) makes that check O(N) on average instead of O(N²).

Shifted potential (removes the small jump at r = r_c): U_shifted(r) = U(r) − U(r_c) for r ≤ r_c U_shifted(r) = 0 for r > r_c Typical value: r_c = 2.5σ → U(r_c)/ε ≈ −0.0163 (small but nonzero without the shift, which shows up as tiny energy drift over long runs)
function buildCellList(particles, boxSize, cutoff) {
  const nCells = Math.max(1, Math.floor(boxSize / cutoff));
  const cellSize = boxSize / nCells;
  const cells = Array.from({ length: nCells * nCells }, () => []);
  particles.forEach((p, i) => {
    const cx = Math.floor(p.x / cellSize) % nCells;
    const cy = Math.floor(p.y / cellSize) % nCells;
    cells[cy * nCells + cx].push(i);
  });
  return { cells, nCells, cellSize }; // only check the 3×3 block of neighbouring cells per particle
}

7. Thermostatting by Velocity Rescaling

Left alone, an MD system conserves total energy (microcanonical, NVE) but temperature drifts as kinetic and potential energy trade back and forth. To hold a target temperature (canonical, NVT), the simplest thermostat rescales every velocity by a common factor each step so the instantaneous kinetic temperature matches the target exactly:

Instantaneous kinetic temperature (2D, N particles): T_inst = (1/N) · Σ (vx_i² + vy_i²) / k_B (in reduced units, k_B = 1) Rescale factor: λ = √(T_target / T_inst) v_i ← λ · v_i for every particle
Caveat: naive velocity rescaling every step is not a physically correct canonical thermostat (it under-samples energy fluctuations) — it is a simple "isokinetic" trick good enough for visual demos and equilibration. Production MD codes use Nosé-Hoover or Langevin thermostats, which correctly reproduce the canonical (NVT) ensemble's fluctuation-dissipation behaviour.
function rescaleVelocities(particles, targetT) {
  const n = particles.length;
  const kineticSum = particles.reduce((s, p) => s + p.vx**2 + p.vy**2, 0);
  const currentT = kineticSum / n;
  if (currentT < 1e-9) return;
  const lambda = Math.sqrt(targetT / currentT);
  for (const p of particles) { p.vx *= lambda; p.vy *= lambda; }
}

8. The Full Simulation Loop

Assembling force computation, integration, boundaries, and thermostatting into a single per-frame function:

function computeForces(particles, boxSize, cutoff = 2.5, epsilon = 1, sigma = 1) {
  for (const p of particles) { p.ax = 0; p.ay = 0; }
  for (let i = 0; i < particles.length; i++) {
    for (let j = i + 1; j < particles.length; j++) {
      let dx = particles[j].x - particles[i].x;
      let dy = particles[j].y - particles[i].y;
      ({ dx, dy } = minimumImageDelta(dx, dy, boxSize));
      const r2 = dx * dx + dy * dy;
      if (r2 > cutoff * cutoff || r2 < 1e-6) continue;
      const r = Math.sqrt(r2);
      const fMag = ljForceMagnitude(r, epsilon, sigma) / r; // pre-divide to project onto dx, dy
      particles[i].ax -= fMag * dx; particles[i].ay -= fMag * dy;
      particles[j].ax += fMag * dx; particles[j].ay += fMag * dy;
    }
  }
}

function simulate(particles, boxSize, dt, targetT) {
  velocityVerletStep(particles, null, dt, p => computeForces(p, boxSize));
  for (const p of particles) wrapPosition(p, boxSize);
  if (targetT !== null) rescaleVelocities(particles, targetT);
}

Run this loop at low density and high T* and you get a disordered gas; lower the temperature or raise the density and particles condense into liquid droplets, then a crystalline solid — the same phase behaviour explored qualitatively in the site's van der Waals equation article and quantitatively in this LJ system's phase diagram.

9. Common Pitfalls

Going further: once this loop feels solid, try adding a Verlet neighbour list (a cached list of nearby pairs, rebuilt every few steps) on top of the cell list for a further speedup, or swap the simple rescaling thermostat for Langevin dynamics — see the site's fluctuation-dissipation theorem article for exactly how a physically correct thermostat's noise and friction must be related.

Frequently Asked Questions

What will I learn in this tutorial?

Build a 2D Lennard-Jones molecular dynamics simulator in JavaScript: the LJ potential, reduced units, velocity Verlet integration, periodic boundaries, neighbour cutoffs, and a velocity-rescaling thermostat.

What topics are covered in this tutorial?

This tutorial covers: Why Lennard-Jones, The LJ Potential and Force, Reduced (LJ) Units, Velocity Verlet Integration, Periodic Boundaries and Minimum Image, Cutoff Radius and Neighbour Lists, Thermostatting by Velocity Rescaling, The Full Simulation Loop.

How long does this tutorial take?

This tutorial takes approximately 20 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.