Simulating an Electric Field in Canvas 2D From Scratch
A point-charge electric field visualiser is one of the most satisfying small simulations to build: a handful of charges, one formula (Coulomb's law), and a canvas full of arrows or curved field lines that respond instantly as you drag a charge around. This tutorial builds the whole thing — vector grid, field-line tracing, and dragging — in well under 150 lines of plain Canvas 2D JavaScript, no WebGL required.
1. Charge Data Model
Each point charge needs just a position and a signed magnitude (positive or negative). That's the entire state the simulation has to track — everything else (arrows, field lines, colors) is derived from this array every frame or on demand.
const charges = [
{ x: 250, y: 300, q: +1 }, // positive charge
{ x: 550, y: 300, q: -1 }, // negative charge (dipole pair)
];
const K = 8000; // Coulomb constant, rescaled for pixel-space units
2. Coulomb Superposition at a Point
The electric field at any point is the vector sum of the contribution from every charge — Coulomb's law applied once per charge, then added together:
function fieldAt(x, y, charges) {
let ex = 0, ey = 0;
for (const c of charges) {
const dx = x - c.x, dy = y - c.y;
const distSq = Math.max(dx*dx + dy*dy, 36); // clamp near the charge itself
const dist = Math.sqrt(distSq);
const strength = K * c.q / distSq;
ex += strength * (dx / dist);
ey += strength * (dy / dist);
}
return { x: ex, y: ey };
}
3. Drawing a Vector-Arrow Grid
The simplest visualisation samples fieldAt() on a
coarse grid across the canvas and draws a short line (with an
arrowhead) in the field's direction at each sample point, scaled
so it doesn't overwhelm neighbouring arrows:
function drawArrowGrid(ctx, charges, spacing = 40) {
for (let y = spacing / 2; y < ctx.canvas.height; y += spacing) {
for (let x = spacing / 2; x < ctx.canvas.width; x += spacing) {
const e = fieldAt(x, y, charges);
const mag = Math.hypot(e.x, e.y);
if (mag < 1e-6) continue;
const len = Math.min(spacing * 0.4, mag * 0.02); // cap arrow length
const ux = e.x / mag, uy = e.y / mag;
const x2 = x + ux * len, y2 = y + uy * len;
ctx.strokeStyle = 'rgba(251,191,36,0.85)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
// small arrowhead
const ang = Math.atan2(uy, ux);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 5 * Math.cos(ang - 0.4), y2 - 5 * Math.sin(ang - 0.4));
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 5 * Math.cos(ang + 0.4), y2 - 5 * Math.sin(ang + 0.4));
ctx.stroke();
}
}
}
4. Tracing Field Lines with RK4
Field lines are smoother and more informative than a grid of arrows — they show the actual path a small test charge would follow. Trace one by integrating the (normalised) field direction step by step, starting just outside a positive charge. 4th-order Runge-Kutta gives much smoother, less jittery curves than a naive Euler step:
function traceFieldLine(startX, startY, charges, stepSize = 4, maxSteps = 400) {
const path = [{ x: startX, y: startY }];
let x = startX, y = startY;
const dir = (px, py) => {
const e = fieldAt(px, py, charges);
const mag = Math.max(Math.hypot(e.x, e.y), 1e-9);
return { x: e.x / mag, y: e.y / mag };
};
for (let s = 0; s < maxSteps; s++) {
// classic RK4 on the unit field-direction vector
const k1 = dir(x, y);
const k2 = dir(x + k1.x * stepSize / 2, y + k1.y * stepSize / 2);
const k3 = dir(x + k2.x * stepSize / 2, y + k2.y * stepSize / 2);
const k4 = dir(x + k3.x * stepSize, y + k3.y * stepSize);
x += (stepSize / 6) * (k1.x + 2*k2.x + 2*k3.x + k4.x);
y += (stepSize / 6) * (k1.y + 2*k2.y + 2*k3.y + k4.y);
path.push({ x, y });
if (x < 0 || x > 800 || y < 0 || y > 600) break; // left the canvas
// stop if the line has walked into a negative charge (a "sink")
if (charges.some(c => c.q < 0 && Math.hypot(x - c.x, y - c.y) < 10)) break;
}
return path;
}
Seed several starting points around each positive charge (e.g. 12
points evenly spaced on a small circle), trace one field line per
seed, and stroke each resulting path array as a
polyline. Field lines start on positive charges and end on
negative ones (or run off to infinity, i.e. the canvas edge).
5. Color-Coding Field Magnitude
Direction alone doesn't show field strength. A quick way
to add that dimension without a full heatmap pass is to color
each arrow (or a background grid of small dots) by
log(magnitude), since raw magnitude spans many
orders of size near a charge:
function magnitudeColor(mag) {
// compress the huge dynamic range near a charge with log()
const t = Math.min(Math.log(1 + mag) / 8, 1);
const hue = (1 - t) * 220; // blue (weak) -> red (strong)
return `hsl(${hue}, 90%, 55%)`;
}
strokeStyle in drawArrowGrid() for
magnitudeColor(mag), or draw a separate low-res
background layer of colored dots underneath the arrows for an
at-a-glance heatmap.
6. Interactive Dragging of Charges
Letting the user drag charges around turns a static diagram into
an experiment. The pattern mirrors any Canvas 2D drag interaction:
find the closest charge on mousedown, follow the
mouse on mousemove, release on mouseup:
let dragged = null;
canvas.addEventListener('mousedown', (e) => {
const { x, y } = getCanvasCoords(e);
dragged = charges.find(c => Math.hypot(c.x - x, c.y - y) < 14) || null;
});
canvas.addEventListener('mousemove', (e) => {
if (!dragged) return;
const { x, y } = getCanvasCoords(e);
dragged.x = x; dragged.y = y;
redraw(); // re-trace field lines and re-draw the arrow grid
});
addEventListener('mouseup', () => { dragged = null; });
A small quality-of-life addition: on a right-click (or a keyboard modifier + click), flip the sign of the charge under the cursor, so users can flip positive to negative without a separate UI control.
7. Performance: Redraw Only When Needed
Unlike a physics simulation with moving bodies, a static electrostatic field only changes when a charge moves. Recomputing the full arrow grid and re-tracing every field line 60 times a second when nothing has changed wastes CPU for no visual benefit:
let dirty = true; // set true whenever a charge moves or is added/removed
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const lines = charges
.filter(c => c.q > 0)
.flatMap(c => seedPointsAround(c).map(p => traceFieldLine(p.x, p.y, charges)));
lines.forEach(path => strokePath(ctx, path));
drawArrowGrid(ctx, charges);
charges.forEach(c => drawCharge(ctx, c));
dirty = false;
}
function animate() {
if (dirty) redraw();
requestAnimationFrame(animate);
}
animate();
Frequently Asked Questions
What will I learn in this tutorial?
Build a 2D electric field simulator in plain Canvas: point charges, Coulomb superposition, a vector-arrow grid, field-line tracing with RK4, and a draggable charge demo — under 150 lines of JavaScript.
What topics are covered in this tutorial?
This tutorial covers: Charge Data Model, Coulomb Superposition at a Point, Drawing a Vector-Arrow Grid, Tracing Field Lines with RK4, Color-Coding Field Magnitude, Interactive Dragging of Charges, Performance: Redraw Only When Needed.
How long does this tutorial take?
This tutorial takes approximately 25 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.