๐ŸŽฎArcadeLab

Magnet Playground

by ArcadeLab
1094 lines32.6 KB
โ–ถ Play
<!--ARCADELAB
title: Magnet Playground
description: Drag a bar magnet and an electromagnet coil, watch their field lines bend, and slide the current to power the coil up, down, or flip its poles โ€” with a compass and iron filings that snap into the field.
emoji: ๐Ÿงฒ
color: blue
-->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Magnet Playground</title>
<style>
  :root {
    --bg: #07101f;
    --panel: rgba(16, 30, 54, 0.86);
    --accent: #5fb8ff;
    --accent2: #7affd6;
    --text: #e7f1ff;
    --muted: #9fb7d6;
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0;
    height: 100%;
    overflow: hidden;
    background: var(--bg);
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    color: var(--text);
    -webkit-tap-highlight-color: transparent;
  }
  #stage { position: fixed; inset: 0; }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
    touch-action: none;
  }
  #hud {
    position: fixed;
    top: 0; left: 0; right: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px 10px;
    padding: clamp(10px, 2.4vw, 18px);
    pointer-events: none;
    z-index: 5;
  }
  .title {
    display: flex;
    align-items: center;
    gap: 8px;
    font-weight: 700;
    font-size: clamp(17px, 4.6vw, 22px);
    background: var(--panel);
    border: 1px solid rgba(95, 184, 255, 0.32);
    border-radius: 14px;
    padding: 8px 13px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  .title .em { font-size: 1.15em; }
  .spacer { flex: 1 1 auto; }
  .controls {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    pointer-events: auto;
  }
  button {
    pointer-events: auto;
    font: inherit;
    font-size: clamp(13px, 3.6vw, 15px);
    font-weight: 600;
    color: var(--text);
    background: var(--panel);
    border: 1px solid rgba(95, 184, 255, 0.36);
    border-radius: 13px;
    padding: 11px 15px;
    min-height: 44px;
    cursor: pointer;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    transition: transform .12s ease, background .18s ease, border-color .18s ease;
  }
  button:hover { background: rgba(26, 46, 80, 0.95); border-color: var(--accent); }
  button:active { transform: scale(0.94); }
  button.on { border-color: var(--accent2); color: var(--accent2); }

  #help {
    position: fixed;
    left: clamp(10px, 2.4vw, 18px);
    right: clamp(10px, 2.4vw, 18px);
    top: 64px;
    z-index: 6;
    pointer-events: none;
  }
  #helpPanel {
    pointer-events: auto;
    max-width: min(340px, 88vw);
    background: var(--panel);
    border: 1px solid rgba(95, 184, 255, 0.3);
    border-radius: 14px;
    padding: 12px 14px;
    font-size: clamp(13px, 3.5vw, 15px);
    line-height: 1.5;
    color: var(--text);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    display: none;
  }
  #helpPanel.open { display: block; }
  #helpPanel p { margin: 6px 0; }

  #info {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    padding: clamp(10px, 2.4vw, 18px);
    pointer-events: none;
    z-index: 5;
  }
  #coilPanel {
    pointer-events: auto;
    background: var(--panel);
    border: 1px solid rgba(95, 184, 255, 0.26);
    border-radius: 16px;
    padding: 10px 16px 12px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    width: min(520px, 94vw);
  }
  .crow {
    display: flex;
    align-items: center;
    gap: 10px;
  }
  .crow .lbl {
    flex: 0 0 auto;
    font-weight: 700;
    font-size: clamp(13px, 3.5vw, 15px);
    display: flex;
    align-items: center;
    gap: 5px;
    min-width: 86px;
  }
  input[type=range] {
    -webkit-appearance: none;
    appearance: none;
    flex: 1 1 auto;
    height: 44px;
    background: transparent;
    cursor: pointer;
    margin: 0;
  }
  input[type=range]::-webkit-slider-runnable-track {
    height: 12px;
    border-radius: 8px;
    background: linear-gradient(90deg, #2f7be0, #1a2740, #e8453c);
  }
  input[type=range]::-moz-range-track {
    height: 12px;
    border-radius: 8px;
    background: linear-gradient(90deg, #2f7be0, #1a2740, #e8453c);
  }
  input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 30px;
    height: 30px;
    margin-top: -9px;
    border-radius: 50%;
    background: #fff;
    border: 4px solid #5fb8ff;
    box-shadow: 0 2px 8px rgba(0,0,0,0.45);
  }
  input[type=range]::-moz-range-thumb {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: #fff;
    border: 4px solid #5fb8ff;
    box-shadow: 0 2px 8px rgba(0,0,0,0.45);
  }
  #coilReadout {
    margin-top: 6px;
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: clamp(12px, 3.3vw, 14px);
    color: var(--muted);
  }
  #coilReadout .strack {
    flex: 1 1 auto;
    height: 12px;
    border-radius: 8px;
    background: rgba(255,255,255,0.12);
    overflow: hidden;
    position: relative;
  }
  #coilReadout .sfill {
    position: absolute;
    left: 50%; top: 0; bottom: 0;
    border-radius: 6px;
    background: linear-gradient(90deg, #7affd6, #ffd166);
    width: 0%;
    transition: width .1s ease, left .1s ease, background .15s ease;
  }
  #coilVal { font-weight: 700; color: var(--accent2); font-variant-numeric: tabular-nums; min-width: 84px; text-align: right; }
  #hint {
    background: var(--panel);
    border: 1px solid rgba(95, 184, 255, 0.22);
    border-radius: 13px;
    padding: 9px 15px;
    font-size: clamp(12px, 3.4vw, 14px);
    color: var(--muted);
    text-align: center;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    max-width: 92vw;
  }
  #stage { opacity: 0; transition: opacity .8s ease; }
  #stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
  <canvas id="c"></canvas>
</div>

<div id="hud">
  <div class="title"><span class="em">๐Ÿงฒ</span><span>Magnet Playground</span></div>
  <div class="spacer"></div>
  <div class="controls">
    <button id="filingsBtn" type="button" class="on">โœจ Filings: On</button>
    <button id="resetBtn" type="button">โ†บ Reset</button>
    <button id="helpBtn" type="button">โ“ How it works</button>
  </div>
</div>

<div id="help">
  <div id="helpPanel">
    <p>๐Ÿงฒ Drag a magnet around. The glowing lines show its invisible magnetic field.</p>
    <p>๐Ÿ”ด Red is the North pole, ๐Ÿ”ต blue is South. Opposite poles pull together, the same poles push apart.</p>
    <p>๐Ÿงญ The compass needle always turns to point along the field.</p>
    <p>โœจ The tiny iron filings line up with the field, just like real ones do!</p>
    <p>๐Ÿ”Œ The <b>coil</b> is an electromagnet. Slide the <b>current</b> up to make it stronger, slide it the other way to <b>flip its poles</b>, or set it to zero to turn the magnetism off!</p>
  </div>
</div>

<div id="info">
  <div id="coilPanel">
    <div class="crow">
      <div class="lbl">๐Ÿ”Œ Current</div>
      <input id="currentSlider" type="range" min="-100" max="100" value="60" aria-label="Electromagnet current">
    </div>
    <div id="coilReadout">
      <span>field</span>
      <div class="strack"><div id="fieldFill" class="sfill"></div></div>
      <span id="coilVal">N: medium</span>
    </div>
  </div>
  <div id="hint">Drag the magnets ยท slide the current to power the coil</div>
</div>

<script>
(function () {
  "use strict";

  var stage = document.getElementById("stage");
  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  if (!ctx) return;

  var filingsBtn = document.getElementById("filingsBtn");
  var resetBtn = document.getElementById("resetBtn");
  var helpBtn = document.getElementById("helpBtn");
  var helpPanel = document.getElementById("helpPanel");
  var currentSlider = document.getElementById("currentSlider");
  var fieldFill = document.getElementById("fieldFill");
  var coilVal = document.getElementById("coilVal");

  var W = 0, H = 0, DPR = 1;
  var showFilings = true;

  // ---- magnet model ----
  // Each magnet is a bar with a center (x,y) and an angle.
  // length & width scale with the screen. It has two "poles":
  // N at one end (+charge), S at the other (-charge).
  function makeMagnet(x, y, angle) {
    return {
      x: x, y: y,
      angle: angle,        // radians; 0 = horizontal, N on the right
      vx: 0, vy: 0,        // velocity (for magnet 2 being pushed)
      vAngle: 0,
      length: 150,
      width: 52,
      strength: 9000,      // pole strength
      held: false,
      isCoil: false        // bar magnets are not coils
    };
  }

  // An electromagnet coil. Same field model as a bar magnet (two poles),
  // but its pole strength is set by the current slider. The current can be
  // negative, which flips which end is North โ€” or zero, which turns it off.
  // maxStrength is the pole strength at full current.
  function makeCoil(x, y, angle) {
    return {
      x: x, y: y,
      angle: angle,
      vx: 0, vy: 0,
      vAngle: 0,
      length: 150,
      width: 52,
      current: 60,         // -100 .. 100, from the slider
      maxStrength: 11000,  // pole strength at |current| = 100
      held: false,
      isCoil: true
    };
  }

  // signed pole strength of any magnet (bar magnets are constant)
  function poleStrength(m) {
    if (m.isCoil) return (m.current / 100) * m.maxStrength;
    return m.strength;
  }

  // The real magnetic North pole is the geometric +end when strength is
  // positive, and the -end when a coil's current is reversed.
  function northPoleOf(m) {
    return poleOf(m, poleStrength(m) >= 0 ? 1 : -1);
  }
  function southPoleOf(m) {
    return poleOf(m, poleStrength(m) >= 0 ? -1 : 1);
  }

  var magnets = [];
  var coil = null;
  var compass = { x: 0, y: 0, angle: 0 };
  var filings = [];

  function magnetScale() {
    return Math.max(0.6, Math.min(1.25, Math.min(W, H) / 620));
  }

  function poleOf(m, sign) {
    // sign +1 => North end, -1 => South end (geometric end of the bar)
    var half = (m.length * 0.5) - m.width * 0.18;
    return {
      x: m.x + Math.cos(m.angle) * half * sign,
      y: m.y + Math.sin(m.angle) * half * sign
    };
  }

  function setupWorld() {
    var sc = magnetScale();
    var mLen = 160 * sc;
    var wid = 56 * sc;
    var m1, c1;
    // narrow / tall screens: stack the two magnets vertically so they
    // start clearly separated instead of snapping together.
    if (0.3 * W < mLen + 90) {
      m1 = makeMagnet(W * 0.62, H * 0.40, 0);
      c1 = makeCoil(W * 0.62, H * 0.66, 0);
      compass.x = W * 0.27;
      compass.y = H * 0.28;
    } else {
      m1 = makeMagnet(W * 0.34, H * 0.48, 0);
      c1 = makeCoil(W * 0.68, H * 0.48, 0);
      compass.x = W * 0.5;
      compass.y = H * 0.24;
    }
    m1.length = mLen; m1.width = wid;
    c1.length = mLen; c1.width = wid;
    c1.current = parseFloat(currentSlider.value) || 0;
    coil = c1;
    magnets = [m1, c1];

    compass.angle = 0;
    buildFilings();
    updateCoilReadout();
  }

  function buildFilings() {
    filings = [];
    var spacing = Math.max(34, Math.min(W, H) / 16);
    for (var gx = spacing * 0.6; gx < W; gx += spacing) {
      for (var gy = spacing * 0.6; gy < H; gy += spacing) {
        // small jitter so it doesn't look like a rigid grid
        filings.push({
          x: gx + (Math.random() - 0.5) * spacing * 0.5,
          y: gy + (Math.random() - 0.5) * spacing * 0.5,
          angle: 0
        });
      }
    }
  }

  // ---- field math ----
  // Field from a single point pole (monopole approximation, sign = +/-).
  // Returns {x, y} field vector contribution.
  function poleField(px, py, x, y, charge) {
    var dx = x - px;
    var dy = y - py;
    var d2 = dx * dx + dy * dy;
    var soft = 220; // softening so it doesn't blow up near the pole
    d2 += soft;
    var d = Math.sqrt(d2);
    var inv = charge / (d2 * d); // 1/r^2 falloff, direction = unit vector
    return { x: dx * inv, y: dy * inv };
  }

  // Total field at (x,y) from all magnets. Each magnet = N pole + S pole.
  function fieldAt(x, y, ignoreMagnet) {
    var fx = 0, fy = 0;
    for (var i = 0; i < magnets.length; i++) {
      var m = magnets[i];
      if (m === ignoreMagnet) continue;
      var n = poleOf(m, 1);
      var s = poleOf(m, -1);
      var q = poleStrength(m);
      var fn = poleField(n.x, n.y, x, y, q);
      var fs = poleField(s.x, s.y, x, y, -q);
      fx += fn.x + fs.x;
      fy += fn.y + fs.y;
    }
    return { x: fx, y: fy };
  }

  // ---- layout ----
  function resize() {
    var rect = stage.getBoundingClientRect();
    var first = (W === 0);
    W = Math.max(1, rect.width);
    H = Math.max(1, rect.height);
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(W * DPR);
    canvas.height = Math.round(H * DPR);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    if (first) {
      setupWorld();
    } else {
      // keep magnets on screen, rebuild filings for new size
      for (var i = 0; i < magnets.length; i++) {
        var m = magnets[i];
        m.x = Math.max(40, Math.min(W - 40, m.x));
        m.y = Math.max(80, Math.min(H - 80, m.y));
      }
      compass.x = Math.max(40, Math.min(W - 40, compass.x));
      compass.y = Math.max(80, Math.min(H - 80, compass.y));
      buildFilings();
    }
  }

  // ---- input ----
  function pointFromEvent(e) {
    var rect = canvas.getBoundingClientRect();
    return { x: e.clientX - rect.left, y: e.clientY - rect.top };
  }

  // is (x,y) on this magnet's body? returns true within a generous box
  function hitMagnet(m, x, y) {
    var dx = x - m.x;
    var dy = y - m.y;
    var cos = Math.cos(-m.angle), sin = Math.sin(-m.angle);
    var lx = dx * cos - dy * sin;
    var ly = dx * sin + dy * cos;
    var pad = 18; // generous tap target
    return Math.abs(lx) <= m.length / 2 + pad && Math.abs(ly) <= m.width / 2 + pad;
  }

  function hitCompass(x, y) {
    var dx = x - compass.x, dy = y - compass.y;
    var r = compassRadius() + 16;
    return dx * dx + dy * dy <= r * r;
  }

  function compassRadius() {
    return Math.max(34, Math.min(W, H) / 13);
  }

  // active drags keyed by pointerId
  var drags = {};

  canvas.addEventListener("pointerdown", function (e) {
    e.preventDefault();
    var pt = pointFromEvent(e);
    // check magnets first (topmost = last drawn = magnet[1]? draw order below)
    for (var i = magnets.length - 1; i >= 0; i--) {
      var m = magnets[i];
      if (hitMagnet(m, pt.x, pt.y)) {
        m.held = true;
        m.vx = 0; m.vy = 0; m.vAngle = 0;
        drags[e.pointerId] = {
          type: "magnet", target: m,
          offx: pt.x - m.x, offy: pt.y - m.y
        };
        capture(e.pointerId);
        return;
      }
    }
    if (hitCompass(pt.x, pt.y)) {
      drags[e.pointerId] = {
        type: "compass",
        offx: pt.x - compass.x, offy: pt.y - compass.y
      };
      capture(e.pointerId);
      return;
    }
  });

  function capture(id) {
    if (canvas.setPointerCapture) {
      try { canvas.setPointerCapture(id); } catch (err) {}
    }
  }

  canvas.addEventListener("pointermove", function (e) {
    var d = drags[e.pointerId];
    if (!d) return;
    e.preventDefault();
    var pt = pointFromEvent(e);
    if (d.type === "magnet") {
      var m = d.target;
      m.x = Math.max(30, Math.min(W - 30, pt.x - d.offx));
      m.y = Math.max(70, Math.min(H - 30, pt.y - d.offy));
    } else if (d.type === "compass") {
      compass.x = Math.max(30, Math.min(W - 30, pt.x - d.offx));
      compass.y = Math.max(70, Math.min(H - 30, pt.y - d.offy));
    }
  });

  function endDrag(e) {
    var d = drags[e.pointerId];
    if (!d) return;
    if (d.type === "magnet") {
      d.target.held = false;
    }
    delete drags[e.pointerId];
  }
  canvas.addEventListener("pointerup", endDrag);
  canvas.addEventListener("pointercancel", endDrag);

  // ---- buttons ----
  filingsBtn.addEventListener("click", function () {
    showFilings = !showFilings;
    filingsBtn.textContent = showFilings ? "โœจ Filings: On" : "โœจ Filings: Off";
    filingsBtn.classList.toggle("on", showFilings);
  });
  resetBtn.addEventListener("click", function () {
    currentSlider.value = "60";
    setupWorld();
  });
  helpBtn.addEventListener("click", function () {
    // anchor the panel just below the HUD โ€” the HUD wraps to a taller
    // height on narrow screens, so measure it instead of assuming one row.
    var hud = document.getElementById("hud");
    var helpWrap = document.getElementById("help");
    if (hud && helpWrap) {
      helpWrap.style.top = Math.round(hud.getBoundingClientRect().bottom + 8) + "px";
    }
    helpPanel.classList.toggle("open");
  });

  // current slider drives the electromagnet's strength (and pole direction)
  currentSlider.addEventListener("input", function () {
    if (coil) coil.current = parseFloat(currentSlider.value) || 0;
    updateCoilReadout();
  });

  // update the field-strength bar + word label under the slider
  function updateCoilReadout() {
    if (!coil) return;
    var cur = coil.current;
    var mag = Math.abs(cur) / 100; // 0..1
    // bar grows out from the centre toward the side of the current
    var halfPct = mag * 50;
    if (cur >= 0) {
      fieldFill.style.left = "50%";
      fieldFill.style.width = halfPct + "%";
      fieldFill.style.background = "linear-gradient(90deg, #ffd166, #e8453c)";
    } else {
      fieldFill.style.left = (50 - halfPct) + "%";
      fieldFill.style.width = halfPct + "%";
      fieldFill.style.background = "linear-gradient(90deg, #2f7be0, #7affd6)";
    }
    var word;
    if (mag < 0.03) word = "off";
    else if (mag < 0.4) word = "weak";
    else if (mag < 0.75) word = "medium";
    else word = "strong";
    if (mag < 0.03) {
      coilVal.textContent = "off";
    } else {
      coilVal.textContent = (cur >= 0 ? "N โ–ถ " : "โ—€ S ") + word;
    }
  }

  // ---- physics: magnet-magnet interaction ----
  // Magnet 2 (and any non-held magnet) gets pushed by forces between poles.
  function stepPhysics(dt) {
    for (var i = 0; i < magnets.length; i++) {
      var m = magnets[i];
      if (m.held) continue;

      // compute force & torque from every OTHER magnet's poles on m's poles
      var fx = 0, fy = 0, torque = 0;
      var mq = poleStrength(m);
      var mPoles = [
        { p: poleOf(m, 1), q: mq },
        { p: poleOf(m, -1), q: -mq }
      ];
      for (var a = 0; a < mPoles.length; a++) {
        var mp = mPoles[a];
        var field = fieldAt(mp.p.x, mp.p.y, m); // field from others
        // force on a pole = charge * field
        var Fx = field.x * mp.q;
        var Fy = field.y * mp.q;
        fx += Fx;
        fy += Fy;
        // torque about magnet center
        var rx = mp.p.x - m.x;
        var ry = mp.p.y - m.y;
        torque += rx * Fy - ry * Fx;
      }

      // scale forces to a comfortable visible motion
      var fScale = 0.00045;
      var tScale = 0.0000016;
      m.vx += fx * fScale * dt;
      m.vy += fy * fScale * dt;
      m.vAngle += torque * tScale * dt;

      // damping so motion settles
      var damp = Math.pow(0.0008, dt);
      m.vx *= damp;
      m.vy *= damp;
      m.vAngle *= Math.pow(0.0006, dt);

      // clamp speeds
      var maxV = 520;
      var sp = Math.sqrt(m.vx * m.vx + m.vy * m.vy);
      if (sp > maxV) { m.vx *= maxV / sp; m.vy *= maxV / sp; }
      if (m.vAngle > 6) m.vAngle = 6;
      if (m.vAngle < -6) m.vAngle = -6;

      m.x += m.vx * dt;
      m.y += m.vy * dt;
      m.angle += m.vAngle * dt;

      // bounce gently off walls
      var pad = 30;
      if (m.x < pad) { m.x = pad; m.vx = Math.abs(m.vx) * 0.4; }
      if (m.x > W - pad) { m.x = W - pad; m.vx = -Math.abs(m.vx) * 0.4; }
      if (m.y < 70) { m.y = 70; m.vy = Math.abs(m.vy) * 0.4; }
      if (m.y > H - pad) { m.y = H - pad; m.vy = -Math.abs(m.vy) * 0.4; }
    }

    // compass needle turns toward local field
    var cf = fieldAt(compass.x, compass.y, null);
    var mag = Math.sqrt(cf.x * cf.x + cf.y * cf.y);
    if (mag > 1e-7) {
      var target = Math.atan2(cf.y, cf.x);
      // shortest-angle interpolation
      var diff = target - compass.angle;
      while (diff > Math.PI) diff -= Math.PI * 2;
      while (diff < -Math.PI) diff += Math.PI * 2;
      compass.angle += diff * Math.min(1, dt * 9);
    }

    // iron filings align with the field at their spot
    if (showFilings) {
      for (var k = 0; k < filings.length; k++) {
        var fl = filings[k];
        var ff = fieldAt(fl.x, fl.y, null);
        var fm = Math.sqrt(ff.x * ff.x + ff.y * ff.y);
        if (fm > 1e-7) {
          var ta = Math.atan2(ff.y, ff.x);
          // filings have no N/S, so align to nearest of ta or ta+PI
          var d1 = angleDiff(ta, fl.angle);
          var d2 = angleDiff(ta + Math.PI, fl.angle);
          var goal = (Math.abs(d1) <= Math.abs(d2)) ? ta : ta + Math.PI;
          var gd = angleDiff(goal, fl.angle);
          fl.angle += gd * Math.min(1, dt * 12);
        }
      }
    }
  }

  function angleDiff(a, b) {
    var d = a - b;
    while (d > Math.PI) d -= Math.PI * 2;
    while (d < -Math.PI) d += Math.PI * 2;
    return d;
  }

  // ---- field line tracing ----
  // Start near the N pole, step along the field until we reach an S pole
  // or leave the screen.
  function traceFieldLine(startX, startY, dir) {
    var pts = [startX, startY];
    var x = startX, y = startY;
    var stepLen = 7;
    var maxSteps = 460;
    for (var i = 0; i < maxSteps; i++) {
      var f = fieldAt(x, y, null);
      var mag = Math.sqrt(f.x * f.x + f.y * f.y);
      if (mag < 1e-9) break;
      var nx = (f.x / mag) * stepLen * dir;
      var ny = (f.y / mag) * stepLen * dir;
      x += nx;
      y += ny;
      pts.push(x, y);
      if (x < -60 || x > W + 60 || y < -60 || y > H + 60) break;
      // stop if we got very close to any magnet's South pole (the end the
      // field flows into). For a coil, negative current swaps which end
      // that is, so use the signed strength to find the real South end.
      var done = false;
      for (var j = 0; j < magnets.length; j++) {
        var southEnd = southPoleOf(magnets[j]);
        var dx = x - southEnd.x, dy = y - southEnd.y;
        if (dx * dx + dy * dy < 16 * 16) { done = true; break; }
      }
      if (done) break;
    }
    return pts;
  }

  // ---- drawing ----
  function drawFieldLines() {
    for (var i = 0; i < magnets.length; i++) {
      var m = magnets[i];
      // a coil with no current has no field โ€” nothing to draw from it
      if (Math.abs(poleStrength(m)) < 1) continue;
      var n = northPoleOf(m);
      var ringCount = 9;
      var ringR = m.width * 0.42;
      for (var r = 0; r < ringCount; r++) {
        var a = (r / ringCount) * Math.PI * 2 + 0.2;
        var sx = n.x + Math.cos(a) * ringR;
        var sy = n.y + Math.sin(a) * ringR;
        var line = traceFieldLine(sx, sy, 1);
        strokeFieldLine(line);
      }
    }
  }

  function strokeFieldLine(pts) {
    if (pts.length < 4) return;
    ctx.beginPath();
    ctx.moveTo(pts[0], pts[1]);
    for (var i = 2; i < pts.length; i += 2) {
      ctx.lineTo(pts[i], pts[i + 1]);
    }
    ctx.strokeStyle = "rgba(122, 200, 255, 0.5)";
    ctx.lineWidth = 2;
    ctx.stroke();

    // little arrowheads along the line to show direction N -> S
    var stepEvery = 18; // every N points
    for (var k = stepEvery; k < pts.length / 2 - 2; k += stepEvery) {
      var x = pts[k * 2], y = pts[k * 2 + 1];
      var px = pts[(k - 1) * 2], py = pts[(k - 1) * 2 + 1];
      var ang = Math.atan2(y - py, x - px);
      var ah = 6;
      ctx.fillStyle = "rgba(160, 220, 255, 0.85)";
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x - ah * Math.cos(ang - 0.5), y - ah * Math.sin(ang - 0.5));
      ctx.lineTo(x - ah * Math.cos(ang + 0.5), y - ah * Math.sin(ang + 0.5));
      ctx.closePath();
      ctx.fill();
    }
  }

  function drawFilings() {
    ctx.lineCap = "round";
    for (var i = 0; i < filings.length; i++) {
      var fl = filings[i];
      var ff = fieldAt(fl.x, fl.y, null);
      var fm = Math.sqrt(ff.x * ff.x + ff.y * ff.y);
      // length & brightness scale a bit with field strength
      var strength = Math.min(1, fm * 2600);
      var len = 5 + strength * 9;
      var alpha = 0.22 + strength * 0.62;
      var cos = Math.cos(fl.angle), sin = Math.sin(fl.angle);
      ctx.strokeStyle = "rgba(200, 224, 255, " + alpha.toFixed(3) + ")";
      ctx.lineWidth = 2.4;
      ctx.beginPath();
      ctx.moveTo(fl.x - cos * len, fl.y - sin * len);
      ctx.lineTo(fl.x + cos * len, fl.y + sin * len);
      ctx.stroke();
    }
  }

  function roundRectPath(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }

  function drawMagnet(m) {
    if (m.isCoil) { drawCoil(m); return; }
    ctx.save();
    ctx.translate(m.x, m.y);
    ctx.rotate(m.angle);
    var L = m.length, Wd = m.width;
    var r = Math.min(14, Wd * 0.3);

    // shadow
    ctx.shadowColor = "rgba(0,0,0,0.5)";
    ctx.shadowBlur = 16;
    ctx.shadowOffsetY = 5;

    // South half (left, blue)
    ctx.fillStyle = "#2f7be0";
    roundRectPath(-L / 2, -Wd / 2, L / 2, Wd, r);
    ctx.fill();
    // North half (right, red)
    ctx.fillStyle = "#e8453c";
    roundRectPath(0, -Wd / 2, L / 2, Wd, r);
    ctx.fill();

    ctx.shadowColor = "transparent";
    ctx.shadowBlur = 0;
    ctx.shadowOffsetY = 0;

    // glossy highlight strip
    ctx.fillStyle = "rgba(255,255,255,0.22)";
    roundRectPath(-L / 2 + 4, -Wd / 2 + 4, L - 8, Wd * 0.32, 6);
    ctx.fill();

    // pole letters
    var fs = Math.round(Wd * 0.5);
    ctx.fillStyle = "#fff";
    ctx.font = "800 " + fs + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    // letters should stay upright-ish: counter-rotate text
    drawUprightLetter("N", L / 4, 0, m.angle, fs);
    drawUprightLetter("S", -L / 4, 0, m.angle, fs);

    ctx.restore();
  }

  // Draw the electromagnet: an iron bar wrapped in copper wire coils,
  // with end caps that light up red/blue depending on the current sign.
  function drawCoil(m) {
    var q = poleStrength(m);
    var on = Math.abs(q) >= 1;
    var posCurrent = m.current >= 0;
    ctx.save();
    ctx.translate(m.x, m.y);
    ctx.rotate(m.angle);
    var L = m.length, Wd = m.width;
    var r = Math.min(14, Wd * 0.3);

    // shadow under the whole coil
    ctx.shadowColor = "rgba(0,0,0,0.5)";
    ctx.shadowBlur = 16;
    ctx.shadowOffsetY = 5;

    // iron core bar (grey)
    ctx.fillStyle = "#9aa6b8";
    roundRectPath(-L / 2, -Wd * 0.34, L, Wd * 0.68, r * 0.7);
    ctx.fill();

    ctx.shadowColor = "transparent";
    ctx.shadowBlur = 0;
    ctx.shadowOffsetY = 0;

    // end caps coloured by the REAL poles (swap when current is reversed)
    var northRight = q >= 0; // geometric +end is North when q positive
    var capW = L * 0.16;
    ctx.fillStyle = on ? (northRight ? "#e8453c" : "#2f7be0") : "#5a6373";
    roundRectPath(L / 2 - capW, -Wd * 0.40, capW, Wd * 0.80, r * 0.6);
    ctx.fill();
    ctx.fillStyle = on ? (northRight ? "#2f7be0" : "#e8453c") : "#5a6373";
    roundRectPath(-L / 2, -Wd * 0.40, capW, Wd * 0.80, r * 0.6);
    ctx.fill();

    // copper wire coils wrapped around the core
    var loops = 9;
    var coilStart = -L / 2 + capW + 6;
    var coilEnd = L / 2 - capW - 6;
    var coilSpan = coilEnd - coilStart;
    var loopGap = coilSpan / loops;
    var coilColor = on ? "#ff9d4d" : "#b8763c";
    ctx.lineWidth = Math.max(3, Wd * 0.12);
    ctx.lineCap = "round";
    for (var i = 0; i < loops; i++) {
      var lx = coilStart + (i + 0.5) * loopGap;
      ctx.strokeStyle = coilColor;
      ctx.beginPath();
      // a slanted oval gives a wrapped-wire look
      ctx.moveTo(lx - loopGap * 0.34, -Wd * 0.46);
      ctx.bezierCurveTo(
        lx + loopGap * 0.5, -Wd * 0.20,
        lx + loopGap * 0.5, Wd * 0.20,
        lx - loopGap * 0.34, Wd * 0.46
      );
      ctx.stroke();
      // thin highlight on each loop
      ctx.strokeStyle = "rgba(255,255,255,0.30)";
      ctx.lineWidth = Math.max(1, Wd * 0.04);
      ctx.beginPath();
      ctx.moveTo(lx - loopGap * 0.34, -Wd * 0.46);
      ctx.bezierCurveTo(
        lx + loopGap * 0.5, -Wd * 0.20,
        lx + loopGap * 0.5, Wd * 0.20,
        lx - loopGap * 0.34, Wd * 0.46
      );
      ctx.stroke();
      ctx.lineWidth = Math.max(3, Wd * 0.12);
    }

    // pole letters on the caps (real poles)
    var fs = Math.round(Wd * 0.42);
    if (on) {
      drawUprightLetter(northRight ? "N" : "S", L / 2 - capW / 2, 0, m.angle, fs);
      drawUprightLetter(northRight ? "S" : "N", -L / 2 + capW / 2, 0, m.angle, fs);
    }

    // little "off" label when no current flows
    if (!on) {
      ctx.save();
      ctx.rotate(-m.angle);
      ctx.fillStyle = "rgba(255,255,255,0.85)";
      ctx.font = "700 " + Math.round(Wd * 0.30) + "px system-ui, sans-serif";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillText("off", 0, 0);
      ctx.restore();
    }

    ctx.restore();
  }

  function drawUprightLetter(letter, lx, ly, magAngle, fs) {
    ctx.save();
    ctx.translate(lx, ly);
    ctx.rotate(-magAngle); // undo magnet rotation so text reads upright
    ctx.fillStyle = "#fff";
    ctx.font = "800 " + fs + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(letter, 0, 1);
    ctx.restore();
  }

  function drawCompass() {
    var R = compassRadius();
    ctx.save();
    ctx.translate(compass.x, compass.y);

    // body
    ctx.shadowColor = "rgba(0,0,0,0.5)";
    ctx.shadowBlur = 14;
    ctx.shadowOffsetY = 4;
    ctx.fillStyle = "#f3f7ff";
    ctx.beginPath();
    ctx.arc(0, 0, R, 0, Math.PI * 2);
    ctx.fill();
    ctx.shadowColor = "transparent";
    ctx.shadowBlur = 0;
    ctx.shadowOffsetY = 0;

    // rim
    ctx.strokeStyle = "#c9b07a";
    ctx.lineWidth = Math.max(4, R * 0.12);
    ctx.beginPath();
    ctx.arc(0, 0, R - ctx.lineWidth / 2, 0, Math.PI * 2);
    ctx.stroke();

    // tick marks
    ctx.strokeStyle = "rgba(60,80,110,0.55)";
    ctx.lineWidth = 2;
    for (var t = 0; t < 8; t++) {
      var a = t / 8 * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(Math.cos(a) * (R * 0.78), Math.sin(a) * (R * 0.78));
      ctx.lineTo(Math.cos(a) * (R * 0.88), Math.sin(a) * (R * 0.88));
      ctx.stroke();
    }

    // needle: points along compass.angle (toward field direction)
    ctx.rotate(compass.angle);
    var nl = R * 0.74;
    // red half (points toward where field goes / "north-seeking" tip)
    ctx.fillStyle = "#e8453c";
    ctx.beginPath();
    ctx.moveTo(nl, 0);
    ctx.lineTo(-nl * 0.18, R * 0.16);
    ctx.lineTo(-nl * 0.18, -R * 0.16);
    ctx.closePath();
    ctx.fill();
    // blue half
    ctx.fillStyle = "#2f7be0";
    ctx.beginPath();
    ctx.moveTo(-nl, 0);
    ctx.lineTo(nl * 0.18, R * 0.16);
    ctx.lineTo(nl * 0.18, -R * 0.16);
    ctx.closePath();
    ctx.fill();

    // hub
    ctx.fillStyle = "#3a3a3a";
    ctx.beginPath();
    ctx.arc(0, 0, R * 0.12, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();
  }

  // ---- main loop ----
  var lastTime = 0;
  function frame(time) {
    var dt = lastTime ? (time - lastTime) / 1000 : 0;
    if (dt > 0.05) dt = 0.05;
    lastTime = time;

    // physics in a couple of substeps for stability
    var sub = 3;
    for (var s = 0; s < sub; s++) {
      stepPhysics(dt / sub);
    }

    // background
    var g = ctx.createRadialGradient(W * 0.5, H * 0.4, 0, W * 0.5, H * 0.4, Math.max(W, H) * 0.9);
    g.addColorStop(0, "#0e1d33");
    g.addColorStop(1, "#060c18");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);

    if (showFilings) drawFilings();
    drawFieldLines();

    // pole glows โ€” scale with strength so a weak coil glows faintly,
    // and the real (current-dependent) poles light up red/blue.
    for (var i = 0; i < magnets.length; i++) {
      var m = magnets[i];
      var q = poleStrength(m);
      if (Math.abs(q) < 1) continue;
      var strengthFrac = Math.min(1, Math.abs(q) / 11000);
      var n = northPoleOf(m);
      var sp = southPoleOf(m);
      var ga = (0.18 + 0.40 * strengthFrac).toFixed(3);
      var gr = m.width * (0.55 + 0.45 * strengthFrac);
      glow(n.x, n.y, gr, "rgba(255,90,80," + ga + ")");
      glow(sp.x, sp.y, gr, "rgba(80,150,255," + ga + ")");
    }

    for (var k = 0; k < magnets.length; k++) {
      drawMagnet(magnets[k]);
    }
    drawCompass();

    requestAnimationFrame(frame);
  }

  function glow(x, y, r, color) {
    var grd = ctx.createRadialGradient(x, y, 0, x, y, r);
    grd.addColorStop(0, color);
    grd.addColorStop(1, color.replace(/[\d.]+\)$/, "0)"));
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }

  // ---- boot ----
  window.addEventListener("resize", resize);
  resize();
  requestAnimationFrame(frame);
  requestAnimationFrame(function () {
    stage.classList.add("ready");
  });
})();
</script>
</body>
</html>

Game Source: Magnet Playground

Creator: ArcadeLab

Libraries: none

Complexity: complex (1094 lines, 32.6 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: magnet-playground-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.