Tutorial · Electromagnetism · Canvas 2D · JavaScript
📅 July 2026 ⏱ ≈ 25 min 🎯 Intermediate

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
Why rescale K? the real Coulomb constant (≈ 8.99 × 10⁹ N·m²/C²) is meaningless in pixel coordinates. Pick a value that makes the arrows and field lines a readable size for your canvas — a few thousand works well for a charge magnitude of ±1 on an 800×600 canvas.

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:

E(P) = Σᵢ K · qᵢ · (P − Pᵢ) / |P − Pᵢ|³
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 };
}
The distSq clamp matters: without it, a sample point that lands exactly on (or very near) a charge produces a field magnitude that blows up toward infinity, making that arrow dominate the whole drawing. Clamping the minimum squared distance keeps arrows near a charge large but finite.

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%)`;
}
Where to apply it: swap the fixed 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();
Result: the canvas sits idle at near-zero CPU usage whenever the user isn't dragging a charge, and instantly redraws in response to input — the same idle-until-dirty pattern used by any interactive diagram, not just physics simulations.

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.