Build a Molecular Gas Simulation in One Evening
You don't need a physics engine, a game framework, or WebGL to watch a gas condense into a liquid before your eyes. With plain canvas 2D and about 150 lines of JavaScript, you can build a Lennard-Jones molecular gas simulation from scratch — complete with realistic forces, stable integration, and a live temperature control — in a single evening.
1. Project Setup
A single HTML file with a canvas and a script tag is all we need — no build step, no dependencies:
<!doctype html>
<html>
<body style="margin:0; background:#0a0a0f;">
<canvas id="c" width="800" height="600"></canvas>
<input id="temp" type="range" min="0" max="3" step="0.05" value="0.6"
style="position:fixed; bottom:20px; left:20px; width:300px;">
<script src="gas.js"></script>
</body>
</html>
Everything else lives in gas.js, built up in the
sections below.
2. Particle Data and Initial Conditions
Store particle state in flat typed arrays rather than an array of objects — this avoids per-particle allocation overhead and keeps memory access contiguous, which matters once you have a few hundred particles updating 60 times a second:
const N = 300; // number of particles
const px = new Float64Array(N), py = new Float64Array(N);
const vx = new Float64Array(N), vy = new Float64Array(N);
const fx = new Float64Array(N), fy = new Float64Array(N);
const W = 800, H = 600;
const SIGMA = 8, EPSILON = 40; // Lennard-Jones parameters (pixel units)
// Seed particles on a grid with small random jitter and random velocity
let idx = 0;
outer:
for (let row = 0; row < 30 && idx < N; row++) {
for (let col = 0; col < 30 && idx < N; col++) {
px[idx] = 40 + col * 22 + (Math.random() - 0.5) * 4;
py[idx] = 40 + row * 22 + (Math.random() - 0.5) * 4;
const speed = 40;
const angle = Math.random() * Math.PI * 2;
vx[idx] = Math.cos(angle) * speed;
vy[idx] = Math.sin(angle) * speed;
idx++;
if (idx >= N) break outer;
}
}
3. Lennard-Jones Forces
For each pair of particles within range, compute the LJ force (see our companion article on the Lennard-Jones potential for the full derivation) and accumulate it onto both particles (Newton's third law: equal and opposite):
const CUTOFF = SIGMA * 2.5;
const CUTOFF_SQ = CUTOFF * CUTOFF;
function computeForcePair(i, j) {
let dx = px[i] - px[j];
let dy = py[i] - py[j];
const r2 = dx * dx + dy * dy;
if (r2 > CUTOFF_SQ || r2 < 1e-6) return;
const sr2 = (SIGMA * SIGMA) / r2;
const sr6 = sr2 * sr2 * sr2;
const sr12 = sr6 * sr6;
// F(r)/r, so we can multiply directly by (dx, dy) without normalising
const forceOverR = (24 * EPSILON / r2) * (2 * sr12 - sr6);
fx[i] += forceOverR * dx; fy[i] += forceOverR * dy;
fx[j] -= forceOverR * dx; fy[j] -= forceOverR * dy;
}
Note the shortcut: since the true force direction is (dx, dy)/r
and the LJ magnitude formula already divides by r once, we
divide by r² instead of r and skip the square root entirely —
Math.sqrt is one of the more expensive operations
in a tight inner loop, so avoiding it whenever possible is worth
the small extra algebra.
4. Spatial Hashing for Speed
Calling computeForcePair for every pair costs
O(N²) — fine for 300 particles (about 45,000 pairs), but it
will not scale to a few thousand. Since the LJ force is
essentially zero beyond the cutoff radius, we only need to check
particles in nearby cells of a uniform grid:
const CELL = CUTOFF;
const cols = Math.ceil(W / CELL), rows = Math.ceil(H / CELL);
function buildGrid() {
const grid = new Map(); // key: "col,row" -> array of particle indices
for (let i = 0; i < N; i++) {
const col = Math.floor(px[i] / CELL);
const row = Math.floor(py[i] / CELL);
const key = col + ',' + row;
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(i);
}
return grid;
}
function computeAllForces() {
fx.fill(0); fy.fill(0);
const grid = buildGrid();
for (const [key, cellParticles] of grid) {
const [col, row] = key.split(',').map(Number);
// Check this cell and the 8 neighbours (3x3 block)
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
const neighbourKey = (col + dc) + ',' + (row + dr);
const neighbourParticles = grid.get(neighbourKey);
if (!neighbourParticles) continue;
for (const i of cellParticles) {
for (const j of neighbourParticles) {
if (j > i) computeForcePair(i, j); // avoid double-counting
}
}
}
}
}
}
5. Velocity Verlet Integration
With forces known, advance every particle with velocity Verlet (see our companion article on molecular dynamics for why this is preferred over simple Euler integration):
const DT = 0.01;
const MASS = 1;
function step() {
computeAllForces();
const ax = new Float64Array(N), ay = new Float64Array(N);
for (let i = 0; i < N; i++) {
ax[i] = fx[i] / MASS;
ay[i] = fy[i] / MASS;
px[i] += vx[i] * DT + 0.5 * ax[i] * DT * DT;
py[i] += vy[i] * DT + 0.5 * ay[i] * DT * DT;
}
computeAllForces(); // forces at NEW positions
for (let i = 0; i < N; i++) {
const axNew = fx[i] / MASS, ayNew = fy[i] / MASS;
vx[i] += 0.5 * (ax[i] + axNew) * DT;
vy[i] += 0.5 * (ay[i] + ayNew) * DT;
}
applyWalls();
}
6. Walls, Rendering, and a Temperature Slider
Bounce particles elastically off the four walls of the canvas:
function applyWalls() {
const R = SIGMA * 0.5; // visual particle radius
for (let i = 0; i < N; i++) {
if (px[i] < R) { px[i] = R; vx[i] = Math.abs(vx[i]); }
if (px[i] > W - R) { px[i] = W - R; vx[i] = -Math.abs(vx[i]); }
if (py[i] < R) { py[i] = R; vy[i] = Math.abs(vy[i]); }
if (py[i] > H - R) { py[i] = H - R; vy[i] = -Math.abs(vy[i]); }
}
}
Draw each particle, coloured by its speed (a cheap visual proxy for local kinetic temperature):
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function render() {
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < N; i++) {
const speed = Math.hypot(vx[i], vy[i]);
const hue = 220 - Math.min(speed * 2, 200); // blue (cold) to red (hot)
ctx.fillStyle = `hsl(${hue}, 90%, 60%)`;
ctx.beginPath();
ctx.arc(px[i], py[i], SIGMA * 0.5, 0, Math.PI * 2);
ctx.fill();
}
}
Finally, wire up the temperature slider using a simple velocity rescaling thermostat (see our companion Metropolis/statistical mechanics article for more rigorous alternatives):
const tempSlider = document.getElementById('temp');
function applyThermostat(targetKineticPerParticle) {
let totalKE = 0;
for (let i = 0; i < N; i++) totalKE += 0.5 * MASS * (vx[i]**2 + vy[i]**2);
const currentAvg = totalKE / N;
if (currentAvg < 1e-6) return;
const scale = Math.sqrt(targetKineticPerParticle / currentAvg);
for (let i = 0; i < N; i++) { vx[i] *= scale; vy[i] *= scale; }
}
function loop() {
step();
applyThermostat(parseFloat(tempSlider.value) * 500);
render();
requestAnimationFrame(loop);
}
loop();
That's it — roughly 150 lines total. Slide the temperature down and watch the gas condense into liquid droplets; slide it up and watch the droplets evaporate back into a chaotic gas.
Next Steps
- Add mouse interaction: attract or repel particles near the cursor
- Colour particles by local density instead of speed, to visualise clustering
- Swap the O(N²) fallback pairs for a proper Verlet neighbour list to push N into the thousands
- Port the force loop to a WebGL compute shader for real GPU parallelism (see our WebGL compute particles tutorial)
Frequently Asked Questions
Why does the gas sometimes explode instead of condensing?
Usually this means DT is too large for the given SIGMA/EPSILON combination, or two particles started too close together, producing a huge initial repulsive force. Try reducing DT, increasing the initial grid spacing, or clamping the maximum force magnitude per pair as a safety measure while you debug.
Why use a Map for the spatial grid instead of a 2D array?
A Map keyed by "col,row" strings only allocates memory for cells that actually contain particles, which matters when particles cluster in one region and leave large parts of the domain empty — a fixed 2D array would waste memory (and iteration time, if you loop over every cell) on those empty regions. For a small, bounded domain a typed 2D array is a viable and slightly faster alternative; the Map approach generalises better to larger or unbounded domains.
How much faster is the spatial hashing version than the naive O(N²) loop?
For N=300 with a cutoff covering roughly 5% of the domain area, the grid approach checks only a small constant number of neighbours per particle rather than all 299 others — typically a 5-10x speedup at this scale, growing to orders of magnitude as N increases into the thousands, since the naive approach scales as N² while the grid approach scales close to linearly with N (for roughly constant particle density).
Can this same approach be extended to 3D?
Yes — replace the 2D position/velocity/force arrays with 3D equivalents, extend the grid key to "col,row,layer", and check a 3×3×3 block of neighbouring cells instead of 3×3. Rendering becomes more involved (you'll want a simple orthographic or perspective projection, or a library like Three.js), but the physics and integration code are essentially unchanged.
Why does the thermostat rescale ALL velocities by the same factor?
This is the simplest possible thermostat (see our Metropolis algorithm article for more principled alternatives like Nosé-Hoover). Multiplying every velocity by the same scale factor preserves the direction of each particle's motion while adjusting the overall kinetic energy (and hence temperature) toward the target — it is crude and does not reproduce realistic thermal fluctuations, but it is more than adequate for an interactive visual demo.
Frequently Asked Questions
What will I learn in this tutorial?
Step-by-step tutorial: build a 2D Lennard-Jones molecular gas simulation from scratch in canvas JavaScript in one evening — velocity Verlet, spatial hashing, and a live temperature slider.
What topics are covered in this tutorial?
This tutorial covers: Project Setup, Particle Data and Initial Conditions, Lennard-Jones Forces, Spatial Hashing for Speed, Velocity Verlet Integration, Walls, Rendering, and a Temperature Slider.
What prerequisites do I need before starting?
This is a Beginner-Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.