🎮ArcadeLab

Simple Circuit

by ArcadeLab
1164 lines38.8 KB
▶ Play
<!--ARCADELAB
title: Simple Circuit
description: A tap-to-light circuit lab — flip the switch, slide the dimmer, watch the current meter, fix a broken wire, and compare series vs. parallel.
emoji: 💡
color: gold
-->

<!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>Simple Circuit</title>
<style>
  :root {
    --bg: #0e0c06;
    --panel: rgba(38, 31, 12, 0.86);
    --accent: #ffd24a;
    --accent2: #ffb03a;
    --text: #fff6df;
    --muted: #d8c98f;
  }
  * { 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);
    letter-spacing: 0.2px;
    background: var(--panel);
    border: 1px solid rgba(255, 210, 74, 0.30);
    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(14px, 3.6vw, 16px);
    font-weight: 600;
    color: var(--text);
    background: var(--panel);
    border: 1px solid rgba(255, 210, 74, 0.34);
    border-radius: 13px;
    padding: 11px 16px;
    min-height: 46px;
    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(60, 49, 18, 0.95); border-color: var(--accent); }
  button:active { transform: scale(0.94); }
  button.on {
    background: linear-gradient(180deg, #ffd24a, #ffab2e);
    color: #2a1f04;
    border-color: #ffe79a;
  }
  #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;
  }
  #hint {
    background: var(--panel);
    border: 1px solid rgba(255, 210, 74, 0.24);
    border-radius: 13px;
    padding: 9px 15px;
    font-size: clamp(13px, 3.4vw, 15px);
    color: var(--muted);
    text-align: center;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    max-width: 92vw;
  }
  /* dimmer slider lives in the bottom info bar */
  #dimWrap {
    pointer-events: auto;
    display: flex;
    align-items: center;
    gap: 10px;
    background: var(--panel);
    border: 1px solid rgba(255, 210, 74, 0.30);
    border-radius: 14px;
    padding: 9px 15px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    max-width: 92vw;
  }
  #dimWrap .lbl {
    font-size: clamp(13px, 3.4vw, 15px);
    font-weight: 600;
    color: var(--muted);
    white-space: nowrap;
  }
  #dim {
    -webkit-appearance: none;
    appearance: none;
    width: clamp(120px, 42vw, 220px);
    height: 14px;
    border-radius: 9px;
    background: linear-gradient(90deg, #6e5a22, #ffd24a);
    outline: none;
    margin: 0;
  }
  #dim::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 30px; height: 30px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #fff6df, #ffab2e);
    border: 2px solid #fff6df;
    cursor: pointer;
    box-shadow: 0 1px 5px rgba(0,0,0,0.45);
  }
  #dim::-moz-range-thumb {
    width: 30px; height: 30px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #fff6df, #ffab2e);
    border: 2px solid #fff6df;
    cursor: pointer;
    box-shadow: 0 1px 5px rgba(0,0,0,0.45);
  }
  #dim:disabled { opacity: 0.4; }
  #howBtn { pointer-events: auto; }
  #howPanel {
    pointer-events: auto;
    display: none;
    background: var(--panel);
    border: 1px solid rgba(255, 210, 74, 0.30);
    border-radius: 14px;
    padding: 14px 16px;
    max-width: min(92vw, 420px);
    font-size: clamp(13px, 3.4vw, 15px);
    line-height: 1.5;
    color: var(--text);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  #howPanel.show { display: block; }
  #howPanel b { color: var(--accent); }
  #howPanel p { margin: 0 0 8px; }
  #howPanel p:last-child { margin-bottom: 0; }
  #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>Simple Circuit</span></div>
  <div class="spacer"></div>
  <div class="controls">
    <button id="modeBtn" type="button">Mode: One Bulb</button>
    <button id="challengeBtn" type="button">Fix the Wire</button>
  </div>
</div>

<div id="info">
  <div id="howPanel">
    <p><b>Electricity flows in a loop.</b> When the switch is closed, current travels from the battery, through the wires, and lights the bulb.</p>
    <p><b>Open the switch</b> and the loop breaks — no current, no light.</p>
    <p>The <b>dimmer slider</b> changes how much current flows — slide it up for a brighter bulb, down to dim it.</p>
    <p>The <b>current meter</b> shows how much current is moving. More current means a brighter bulb!</p>
    <p><b>Series</b> puts two bulbs on one path, so they share the energy and look dimmer.</p>
    <p><b>Parallel</b> gives each bulb its own loop, so both shine full bright.</p>
    <p><b>Fix the Wire:</b> a wire is secretly broken. Tap along the wires to find the gap and snap it shut!</p>
  </div>
  <div id="hint">Tap the switch to turn the circuit on and off</div>
  <div id="dimWrap">
    <span class="lbl">Dimmer</span>
    <input id="dim" type="range" min="0" max="100" value="100" aria-label="Dimmer">
  </div>
  <button id="howBtn" type="button">How it works</button>
</div>

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

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

  var modeBtn = document.getElementById("modeBtn");
  var challengeBtn = document.getElementById("challengeBtn");
  var hint = document.getElementById("hint");
  var howBtn = document.getElementById("howBtn");
  var howPanel = document.getElementById("howPanel");
  var dim = document.getElementById("dim");

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

  // --- state ---
  // mode: 0 = single bulb, 1 = series (two bulbs), 2 = parallel (two bulbs)
  var mode = 0;
  var closed = true;          // switch closed?
  var switchAnim = 1;         // 0..1 animated lever position (1 = closed)
  var glow = 1;               // 0..1 animated brightness of the lit state
  var flow = 0;               // phase for current dots

  // dimmer: 0..1 fraction of full current/brightness the user dialed in
  var dimLevel = 1;
  // smoothed current reading shown on the meter (0..1)
  var meter = 1;

  // "Fix the Wire" challenge
  var challenge = false;      // challenge mode active?
  var brokenSeg = -1;         // index into the wire-segment list that is broken
  var brokenFixed = true;     // is the break repaired? (true when no challenge)
  // collected tappable wire segments for this frame, for hit-testing
  var wireSegs = [];
  var sparkT = 0;             // little celebration timer when a fix succeeds
  var brokenFixPt = null;     // {x,y} where the break was fixed, for the sparkle

  var MODE_LABELS = ["Mode: One Bulb", "Mode: Series", "Mode: Parallel"];

  // layout values recomputed on resize
  var L = null;

  function resize() {
    var rect = stage.getBoundingClientRect();
    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);
    computeLayout();
  }

  // The circuit is drawn inside a centered rectangle. Components sit on its edges.
  function computeLayout() {
    var pad = Math.min(W, H) * 0.12;
    // the switch lever + knob protrude past the right edge of the box;
    // reserve room on the right so nothing clips off-screen.
    var switchRoom = 86;
    var availW = W - pad * 2 - switchRoom;
    var availH = H - pad * 2 - 60; // leave headroom for HUD
    // keep a friendly aspect for the loop
    var boxW = Math.min(availW, 620);
    boxW = Math.max(boxW, 160);
    var boxH = Math.min(availH, boxW * 0.62);
    boxH = Math.max(boxH, 180);
    // centre the box+switch cluster; the box sits left of centre by half
    // the switch room so the protruding switch stays balanced.
    var cx = W / 2 - switchRoom / 2;
    cx = Math.max(boxW / 2 + pad, cx);
    var cy = H / 2 + 6;
    var left = cx - boxW / 2;
    var right = cx + boxW / 2;
    var top = cy - boxH / 2;
    var bottom = cy + boxH / 2;
    L = {
      cx: cx, cy: cy,
      left: left, right: right, top: top, bottom: bottom,
      boxW: boxW, boxH: boxH,
      unit: Math.min(boxW, boxH)
    };
  }

  // --- helpers ---
  function lerp(a, b, t) { return a + (b - a) * t; }
  function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }

  // Build the ordered list of points the wire path follows (single closed loop
  // visual). We use it for routing current dots in single & series modes.
  function loopPath() {
    return [
      { x: L.left, y: L.bottom },
      { x: L.left, y: L.top },
      { x: L.right, y: L.top },
      { x: L.right, y: L.bottom }
    ];
  }

  // Get a point at fractional distance t (0..1) along an ordered polyline loop.
  function pointOnLoop(pts, t) {
    var segs = [];
    var total = 0;
    for (var i = 0; i < pts.length; i++) {
      var a = pts[i];
      var b = pts[(i + 1) % pts.length];
      var dx = b.x - a.x, dy = b.y - a.y;
      var len = Math.sqrt(dx * dx + dy * dy);
      segs.push({ a: a, b: b, len: len });
      total += len;
    }
    var d = (t % 1 + 1) % 1 * total;
    for (var j = 0; j < segs.length; j++) {
      if (d <= segs[j].len || j === segs.length - 1) {
        var f = segs[j].len > 0 ? d / segs[j].len : 0;
        return {
          x: lerp(segs[j].a.x, segs[j].b.x, f),
          y: lerp(segs[j].a.y, segs[j].b.y, f)
        };
      }
      d -= segs[j].len;
    }
    return { x: pts[0].x, y: pts[0].y };
  }

  // --- drawing components ---

  // Draw a plain wire segment (no break logic). Used for short stubs.
  function drawWire(x1, y1, x2, y2, lit) {
    ctx.lineCap = "round";
    ctx.lineWidth = 9;
    ctx.strokeStyle = "#5a4a22";
    ctx.beginPath();
    ctx.moveTo(x1, y1); ctx.lineTo(x2, y2);
    ctx.stroke();
    ctx.lineWidth = 5;
    ctx.strokeStyle = lit ? "#ffcf52" : "#9b8a4f";
    ctx.beginPath();
    ctx.moveTo(x1, y1); ctx.lineTo(x2, y2);
    ctx.stroke();
  }

  // Draw a wire segment with a visible break in the middle (challenge mode).
  function drawBrokenWire(x1, y1, x2, y2) {
    var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
    var dx = x2 - x1, dy = y2 - y1;
    var len = Math.sqrt(dx * dx + dy * dy) || 1;
    var ux = dx / len, uy = dy / len;
    var halfGap = Math.min(16, len * 0.32);
    // two stubs, dark and dead
    drawWire(x1, y1, mx - ux * halfGap, my - uy * halfGap, false);
    drawWire(mx + ux * halfGap, my + uy * halfGap, x2, y2, false);
    // glowing copper ends to draw the eye to the gap
    ctx.fillStyle = "#ff8a3a";
    ctx.beginPath();
    ctx.arc(mx - ux * halfGap, my - uy * halfGap, 5.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.beginPath();
    ctx.arc(mx + ux * halfGap, my + uy * halfGap, 5.5, 0, Math.PI * 2);
    ctx.fill();
    // tiny pulsing spark hint between the ends
    var pulse = 0.45 + 0.35 * Math.sin(flow * 0.18);
    ctx.globalAlpha = pulse;
    ctx.fillStyle = "#ffe79a";
    ctx.beginPath();
    ctx.arc(mx, my, 3.4, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
  }

  // Register a tappable loop wire segment AND draw it. If this segment is the
  // hidden break and it is not yet fixed, it is drawn broken and reported so.
  // Returns true if the segment conducts current.
  function wire(idx, x1, y1, x2, y2, lit) {
    wireSegs.push({ idx: idx, x1: x1, y1: y1, x2: x2, y2: y2 });
    if (challenge && !brokenFixed && idx === brokenSeg) {
      drawBrokenWire(x1, y1, x2, y2);
      return false;
    }
    drawWire(x1, y1, x2, y2, lit);
    return true;
  }

  // current dots travelling along a straight segment
  function drawCurrentOnSegment(x1, y1, x2, y2, speedSign, brightness) {
    if (brightness <= 0.02) return;
    var dx = x2 - x1, dy = y2 - y1;
    var len = Math.sqrt(dx * dx + dy * dy);
    if (len < 1) return;
    var ux = dx / len, uy = dy / len;
    var spacing = 34;
    var count = Math.max(1, Math.floor(len / spacing));
    var step = len / count;
    var offset = ((flow * speedSign) % step + step) % step;
    for (var i = 0; i < count; i++) {
      var d = offset + i * step;
      if (d > len) continue;
      var px = x1 + ux * d;
      var py = y1 + uy * d;
      ctx.globalAlpha = brightness;
      var g = ctx.createRadialGradient(px, py, 0, px, py, 7);
      g.addColorStop(0, "#fff4c0");
      g.addColorStop(0.5, "#ffd24a");
      g.addColorStop(1, "rgba(255,210,74,0)");
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(px, py, 7, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = "#fff8d8";
      ctx.beginPath();
      ctx.arc(px, py, 2.6, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
  }

  // Battery drawn centered at (x,y), oriented horizontally, total width w.
  function drawBattery(x, y, w) {
    var h = Math.max(34, L.unit * 0.16);
    var gap = w * 0.16;
    // body wires stubs
    var bodyLeft = x - w / 2 + gap;
    var bodyRight = x + w / 2 - gap;
    drawWire(x - w / 2, y, bodyLeft, y, closed);
    drawWire(bodyRight, y, x + w / 2, y, closed);

    // negative plate (short, thick)
    ctx.fillStyle = "#3a3a44";
    ctx.fillRect(bodyLeft - 5, y - h * 0.32, 9, h * 0.64);
    // positive plate (tall, thin)
    ctx.fillStyle = "#3a3a44";
    ctx.fillRect(bodyRight - 4, y - h * 0.55, 7, h * 1.1);

    // battery cell box
    var bw = bodyRight - bodyLeft - 12;
    var bx = bodyLeft + 6;
    ctx.fillStyle = "#caa23a";
    roundRect(bx, y - h * 0.4, bw, h * 0.8, 8);
    ctx.fill();
    ctx.fillStyle = "#7a5f1a";
    roundRect(bx, y - h * 0.4, bw, h * 0.8, 8);
    ctx.lineWidth = 3;
    ctx.strokeStyle = "#7a5f1a";
    ctx.stroke();
    // + and - labels above the plates
    ctx.fillStyle = "#ffe79a";
    ctx.font = "bold " + Math.round(h * 0.5) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText("+", bodyRight + 2, y - h * 0.86);
    ctx.fillText("−", bodyLeft + 2, y - h * 0.78);
  }

  function roundRect(x, y, w, h, r) {
    r = Math.min(r, w / 2, h / 2);
    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();
  }

  // Switch: a lever that pivots. Returns its hit-box for tapping.
  var switchHit = { x: 0, y: 0, r: 0 };
  function drawSwitch(x, y, w) {
    var pivotX = x - w / 2;
    var endX = x + w / 2;
    // wire stubs into the switch
    drawWire(x - w / 2 - w * 0.35, y, pivotX, y, closed);
    drawWire(endX, y, x + w / 2 + w * 0.35, y, closed);

    // contact terminals
    ctx.fillStyle = "#caa23a";
    ctx.beginPath(); ctx.arc(pivotX, y, 7, 0, Math.PI * 2); ctx.fill();
    ctx.beginPath(); ctx.arc(endX, y, 7, 0, Math.PI * 2); ctx.fill();

    // lever: rotates up when open. switchAnim 1 = closed (flat), 0 = open (lifted)
    var openAng = -0.62; // radians lifted
    var ang = lerp(openAng, 0, switchAnim);
    var leverLen = w + 4;
    var lx = pivotX + Math.cos(ang) * leverLen;
    var ly = y + Math.sin(ang) * leverLen;
    ctx.lineCap = "round";
    ctx.lineWidth = 11;
    ctx.strokeStyle = "#5a4a22";
    ctx.beginPath(); ctx.moveTo(pivotX, y); ctx.lineTo(lx, ly); ctx.stroke();
    ctx.lineWidth = 6;
    ctx.strokeStyle = closed ? "#ffcf52" : "#c9b878";
    ctx.beginPath(); ctx.moveTo(pivotX, y); ctx.lineTo(lx, ly); ctx.stroke();
    // lever knob
    ctx.fillStyle = "#ffe79a";
    ctx.beginPath(); ctx.arc(lx, ly, 9, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = "#7a5f1a";
    ctx.lineWidth = 2.5;
    ctx.stroke();

    // pivot dot
    ctx.fillStyle = "#7a5f1a";
    ctx.beginPath(); ctx.arc(pivotX, y, 5, 0, Math.PI * 2); ctx.fill();

    // tappable region around the whole switch
    switchHit.x = x;
    switchHit.y = y - 8;
    switchHit.r = Math.max(w, 60);

    // little "TAP" label when there's room and circuit is being shown
    ctx.fillStyle = "rgba(255,231,154,0.85)";
    ctx.font = "bold " + Math.round(Math.max(13, L.unit * 0.058)) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(closed ? "ON" : "OFF", x, y + Math.max(28, w * 0.5));
  }

  // Light bulb at (x,y). brightness 0..1. size = diameter of glass.
  function drawBulb(x, y, size, brightness) {
    var r = size / 2;
    // halo
    if (brightness > 0.02) {
      var haloR = r * (2.6 + brightness * 2.2);
      var hg = ctx.createRadialGradient(x, y, 0, x, y, haloR);
      hg.addColorStop(0, "rgba(255,239,170," + (0.55 * brightness) + ")");
      hg.addColorStop(0.4, "rgba(255,210,74," + (0.30 * brightness) + ")");
      hg.addColorStop(1, "rgba(255,210,74,0)");
      ctx.fillStyle = hg;
      ctx.beginPath();
      ctx.arc(x, y, haloR, 0, Math.PI * 2);
      ctx.fill();
    }

    // base/screw
    var baseW = r * 0.95;
    var baseY = y + r * 0.78;
    ctx.fillStyle = "#9a9aa6";
    roundRect(x - baseW / 2, baseY, baseW, r * 0.72, 4);
    ctx.fill();
    ctx.strokeStyle = "#6e6e7a";
    ctx.lineWidth = 2;
    for (var i = 0; i < 3; i++) {
      var yy = baseY + 6 + i * (r * 0.72 - 8) / 2;
      ctx.beginPath();
      ctx.moveTo(x - baseW / 2, yy);
      ctx.lineTo(x + baseW / 2, yy);
      ctx.stroke();
    }

    // glass globe
    var glassCol;
    if (brightness > 0.02) {
      var lit = Math.round(70 + brightness * 28);
      glassCol = "hsla(48,100%," + lit + "%,0.92)";
    } else {
      glassCol = "rgba(225,228,236,0.9)";
    }
    var gg = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
    if (brightness > 0.02) {
      gg.addColorStop(0, "#fffdf2");
      gg.addColorStop(0.55, "hsl(48,100%," + Math.round(72 + brightness * 22) + "%)");
      gg.addColorStop(1, "hsl(42,90%," + Math.round(52 + brightness * 18) + "%)");
    } else {
      gg.addColorStop(0, "#f4f6fb");
      gg.addColorStop(1, "#c4c8d4");
    }
    ctx.fillStyle = gg;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = brightness > 0.02 ? "rgba(180,140,30,0.6)" : "rgba(120,124,138,0.7)";
    ctx.lineWidth = 2.5;
    ctx.stroke();

    // filament
    var filY = y + r * 0.1;
    ctx.lineCap = "round";
    ctx.lineWidth = 3;
    if (brightness > 0.02) {
      ctx.shadowColor = "rgba(255,180,40,0.9)";
      ctx.shadowBlur = 8 + brightness * 14;
      ctx.strokeStyle = "hsl(" + (40 - brightness * 8) + ",100%," + Math.round(60 + brightness * 35) + "%)";
    } else {
      ctx.shadowBlur = 0;
      ctx.strokeStyle = "#8a8a96";
    }
    ctx.beginPath();
    var coilN = 4;
    var coilW = r * 0.62;
    var startX = x - coilW / 2;
    ctx.moveTo(x - r * 0.34, baseY - 3);
    ctx.lineTo(startX, filY);
    for (var c = 0; c <= coilN; c++) {
      var fx = startX + (coilW) * (c / coilN);
      var fy = filY + ((c % 2 === 0) ? -r * 0.22 : r * 0.16);
      ctx.lineTo(fx, fy);
    }
    ctx.lineTo(x + r * 0.34, baseY - 3);
    ctx.stroke();
    ctx.shadowBlur = 0;

    // shine highlight
    ctx.fillStyle = "rgba(255,255,255,0.5)";
    ctx.beginPath();
    ctx.ellipse(x - r * 0.34, y - r * 0.4, r * 0.22, r * 0.32, -0.5, 0, Math.PI * 2);
    ctx.fill();
  }

  // --- scene drawing per mode ---

  function drawScene(time) {
    // background
    var bg = ctx.createRadialGradient(W / 2, H * 0.42, 0, W / 2, H * 0.42, Math.max(W, H) * 0.8);
    bg.addColorStop(0, "#1c1708");
    bg.addColorStop(1, "#0a0904");
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    // subtle grid
    ctx.globalAlpha = 0.10;
    ctx.strokeStyle = "#ffd24a";
    ctx.lineWidth = 1;
    var gs = 44;
    for (var gx = (W / 2 % gs); gx < W; gx += gs) {
      ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke();
    }
    for (var gy = (H / 2 % gs); gy < H; gy += gs) {
      ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke();
    }
    ctx.globalAlpha = 1;

    // a fresh list of tappable wires every frame
    wireSegs = [];

    // circuit only conducts when the switch is closed AND no break remains
    var conducts = closed && brokenFixed;
    var litWire = conducts;
    var br = conducts ? glow * dimLevel : 0;

    if (mode === 0) {
      drawSingle(litWire, br);
    } else if (mode === 1) {
      drawSeries(litWire, br);
    } else {
      drawParallel(litWire, br);
    }

    drawMeter();

    if (challenge) drawChallengeBanner();
  }

  // SINGLE BULB ---------------------------------------------------------
  function drawSingle(litWire, br) {
    var left = L.left, right = L.right, top = L.top, bottom = L.bottom;
    var bulbSize = L.unit * 0.34;

    // top wire holds the bulb at center-top
    var bulbX = (left + right) / 2;
    var bulbY = top;

    // bottom edge holds the battery
    var batW = Math.min(L.boxW * 0.42, 200);
    var batX = (left + right) / 2;
    var batY = bottom;

    // switch on the right edge
    var swY = (top + bottom) / 2;
    var swX = right;
    var swW = Math.min(L.boxH * 0.3, 70);

    var wl = litWire && glow > 0.5;
    // --- wires of the loop (each is a tappable, breakable segment) ---
    wire(0, left, bottom, left, top, wl);
    wire(1, left, top, bulbX - bulbSize * 0.42, top, wl);
    wire(2, bulbX + bulbSize * 0.42, top, right, top, wl);
    wire(3, right, top, right, swY - swW - 4, wl);
    wire(4, right, swY + swW + 4, right, bottom, wl);
    wire(5, right, bottom, batX + batW / 2 + L.boxW * 0.04, bottom, wl);
    wire(6, batX - batW / 2 - L.boxW * 0.04, bottom, left, bottom, wl);

    // --- current dots ---
    // route: along the loop minus the bulb/battery/switch gaps; we just animate
    // dots on each wire segment, all same direction (clockwise).
    var seg = [
      [left, bottom, left, top, 1],
      [left, top, bulbX - bulbSize * 0.42, top, 1],
      [bulbX + bulbSize * 0.42, top, right, top, 1],
      [right, top, right, swY - swW - 4, 1],
      [right, swY + swW + 4, right, bottom, 1],
      [right, bottom, batX + batW / 2 + L.boxW * 0.04, bottom, -1],
      [batX - batW / 2 - L.boxW * 0.04, bottom, left, bottom, -1]
    ];
    if (closed && brokenFixed) {
      for (var s = 0; s < seg.length; s++) {
        drawCurrentOnSegment(seg[s][0], seg[s][1], seg[s][2], seg[s][3], seg[s][4], glow * dimLevel);
      }
    }

    // --- components ---
    drawBattery(batX, batY, batW);
    drawSwitch(swX, swY, swW);
    drawBulb(bulbX, bulbY, bulbSize, br);
  }

  // SERIES --------------------------------------------------------------
  function drawSeries(litWire, br) {
    var left = L.left, right = L.right, top = L.top, bottom = L.bottom;
    var bulbSize = L.unit * 0.30;
    // two bulbs on the top edge
    var b1X = lerp(left, right, 0.34);
    var b2X = lerp(left, right, 0.66);
    var bulbY = top;

    var batW = Math.min(L.boxW * 0.42, 200);
    var batX = (left + right) / 2;
    var batY = bottom;

    var swY = (top + bottom) / 2;
    var swX = right;
    var swW = Math.min(L.boxH * 0.3, 70);

    // series: both bulbs share the path -> dimmer (half-ish)
    var seriesBr = br * 0.5;
    var wireLit = litWire && glow > 0.5;

    wire(0, left, bottom, left, top, wireLit);
    wire(1, left, top, b1X - bulbSize * 0.42, top, wireLit);
    wire(2, b1X + bulbSize * 0.42, top, b2X - bulbSize * 0.42, top, wireLit);
    wire(3, b2X + bulbSize * 0.42, top, right, top, wireLit);
    wire(4, right, top, right, swY - swW - 4, wireLit);
    wire(5, right, swY + swW + 4, right, bottom, wireLit);
    wire(6, right, bottom, batX + batW / 2 + L.boxW * 0.04, bottom, wireLit);
    wire(7, batX - batW / 2 - L.boxW * 0.04, bottom, left, bottom, wireLit);

    if (closed && brokenFixed) {
      var seg = [
        [left, bottom, left, top, 1],
        [left, top, b1X - bulbSize * 0.42, top, 1],
        [b1X + bulbSize * 0.42, top, b2X - bulbSize * 0.42, top, 1],
        [b2X + bulbSize * 0.42, top, right, top, 1],
        [right, top, right, swY - swW - 4, 1],
        [right, swY + swW + 4, right, bottom, 1],
        [right, bottom, batX + batW / 2 + L.boxW * 0.04, bottom, -1],
        [batX - batW / 2 - L.boxW * 0.04, bottom, left, bottom, -1]
      ];
      // series current is smaller -> fewer/dimmer dots
      for (var s = 0; s < seg.length; s++) {
        drawCurrentOnSegment(seg[s][0], seg[s][1], seg[s][2], seg[s][3], seg[s][4], glow * dimLevel * 0.6);
      }
    }

    drawBattery(batX, batY, batW);
    drawSwitch(swX, swY, swW);
    drawBulb(b1X, bulbY, bulbSize, seriesBr);
    drawBulb(b2X, bulbY, bulbSize, seriesBr);

    drawModeTag("SERIES — bulbs share one path, so they are dimmer");
  }

  // PARALLEL ------------------------------------------------------------
  function drawParallel(litWire, br) {
    var left = L.left, right = L.right, top = L.top, bottom = L.bottom;
    var bulbSize = L.unit * 0.28;
    var wireLit = litWire && glow > 0.5;

    var batW = Math.min(L.boxW * 0.38, 190);
    var batX = (left + right) / 2;
    var batY = bottom;

    var swY = (top + bottom) / 2;
    var swX = right;
    var swW = Math.min(L.boxH * 0.3, 66);

    // Two vertical branches each hold a bulb. They connect a top rail and a
    // bottom rail. The battery sits on the bottom rail, the switch on the right.
    var midY = (top + bottom) / 2;
    var branch1X = lerp(left, right, 0.32);
    var branch2X = lerp(left, right, 0.68);
    var railTop = top;
    var railBot = bottom;

    // outer loop wires
    wire(0, left, railBot, left, railTop, wireLit);          // left side
    wire(1, left, railTop, right, railTop, wireLit);          // top rail
    wire(2, right, railTop, right, swY - swW - 4, wireLit);   // right top to switch
    wire(3, right, swY + swW + 4, right, railBot, wireLit);   // switch to bottom
    wire(4, right, railBot, batX + batW / 2 + L.boxW * 0.04, railBot, wireLit);
    wire(5, batX - batW / 2 - L.boxW * 0.04, railBot, left, railBot, wireLit);

    // two branch wires (vertical) each with a bulb in the middle
    var bulbY = midY;
    // branch 1
    wire(6, branch1X, railTop, branch1X, bulbY - bulbSize * 0.42, wireLit);
    wire(7, branch1X, bulbY + bulbSize * 0.42, branch1X, railBot, wireLit);
    // branch 2
    wire(8, branch2X, railTop, branch2X, bulbY - bulbSize * 0.42, wireLit);
    wire(9, branch2X, bulbY + bulbSize * 0.42, branch2X, railBot, wireLit);

    // current dots: main rails + each branch (full brightness in parallel)
    if (closed && brokenFixed) {
      var pg = glow * dimLevel;
      // top rail dots flow left->right toward branches then down
      drawCurrentOnSegment(left, railTop, right, railTop, 1, pg);
      drawCurrentOnSegment(left, railBot, left, railTop, 1, pg);
      drawCurrentOnSegment(right, railTop, right, swY - swW - 4, 1, pg);
      drawCurrentOnSegment(right, swY + swW + 4, right, railBot, 1, pg);
      drawCurrentOnSegment(right, railBot, batX + batW / 2 + L.boxW * 0.04, railBot, -1, pg);
      drawCurrentOnSegment(batX - batW / 2 - L.boxW * 0.04, railBot, left, railBot, -1, pg);
      // branches: current flows down through each
      drawCurrentOnSegment(branch1X, railTop, branch1X, bulbY - bulbSize * 0.42, 1, pg);
      drawCurrentOnSegment(branch1X, bulbY + bulbSize * 0.42, branch1X, railBot, 1, pg);
      drawCurrentOnSegment(branch2X, railTop, branch2X, bulbY - bulbSize * 0.42, 1, pg);
      drawCurrentOnSegment(branch2X, bulbY + bulbSize * 0.42, branch2X, railBot, 1, pg);
    }

    drawBattery(batX, batY, batW);
    drawSwitch(swX, swY, swW);
    // parallel: each bulb gets the full voltage -> full bright
    drawBulb(branch1X, bulbY, bulbSize, br);
    drawBulb(branch2X, bulbY, bulbSize, br);

    drawModeTag("PARALLEL — each bulb has its own loop, so both are bright");
  }

  // --- current meter (ammeter) -----------------------------------------
  // A little gauge in the lower-left corner. The needle swings with current.
  function drawMeter() {
    var size = Math.max(96, Math.min(150, L.unit * 0.42));
    // The controls (hint pill, dimmer, How it works) overlay the bottom of the
    // canvas. Measure where they actually start so the meter never tucks
    // underneath them — the panel grows taller when its buttons wrap on mobile.
    var ctrls = document.getElementById("info");
    var safeBottom = H - 96;
    if (ctrls) {
      var cr = ctrls.getBoundingClientRect();
      var cv = document.getElementById("c").getBoundingClientRect();
      var ctrlsTop = cr.top - cv.top;
      if (ctrlsTop > 0 && ctrlsTop - 12 < safeBottom) safeBottom = ctrlsTop - 12;
    }
    // if the meter can't fit at full size, shrink it to the available band
    var avail = safeBottom - 70;
    if (size > avail) size = Math.max(72, avail);
    var mx = 14 + size / 2;
    var my = H - 14 - size / 2;
    // keep clear of the controls bar at the bottom (esp. on short screens)
    if (my + size / 2 > safeBottom) my = safeBottom - size / 2;
    if (my - size / 2 < 70) my = 70 + size / 2;

    // dial face
    ctx.fillStyle = "rgba(38,31,12,0.9)";
    roundRect(mx - size / 2, my - size / 2, size, size, 14);
    ctx.fill();
    ctx.strokeStyle = "rgba(255,210,74,0.34)";
    ctx.lineWidth = 1.5;
    ctx.stroke();

    var cx = mx, cy = my + size * 0.24;
    var rad = size * 0.34;
    // a clean 180-degree gauge: needle swings from left (PI) to right (2*PI)
    var startA = Math.PI;
    var sweep = Math.PI;

    // arc track
    ctx.lineCap = "round";
    ctx.lineWidth = 5;
    ctx.strokeStyle = "rgba(255,231,154,0.25)";
    ctx.beginPath();
    ctx.arc(cx, cy, rad, startA, startA + sweep, false);
    ctx.stroke();

    // colored fill up to the current reading
    var frac = meter < 0 ? 0 : (meter > 1 ? 1 : meter);
    if (frac > 0.02) {
      ctx.strokeStyle = "hsl(" + Math.round(20 + frac * 28) + ",100%,58%)";
      ctx.beginPath();
      ctx.arc(cx, cy, rad, startA, startA + sweep * frac, false);
      ctx.stroke();
    }

    // needle
    var na = startA + sweep * frac;
    var nx = cx + Math.cos(na) * rad * 0.92;
    var ny = cy + Math.sin(na) * rad * 0.92;
    ctx.lineWidth = 3.5;
    ctx.strokeStyle = "#fff6df";
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(nx, ny);
    ctx.stroke();
    ctx.fillStyle = "#ffd24a";
    ctx.beginPath();
    ctx.arc(cx, cy, 5, 0, Math.PI * 2);
    ctx.fill();

    // label + reading
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillStyle = "#d8c98f";
    ctx.font = "600 " + Math.round(size * 0.12) + "px system-ui, sans-serif";
    ctx.fillText("CURRENT", mx, my - size * 0.30);
    ctx.fillStyle = frac > 0.02 ? "#ffe79a" : "#9b8a4f";
    ctx.font = "bold " + Math.round(size * 0.20) + "px system-ui, sans-serif";
    ctx.fillText((frac * 5).toFixed(1) + " A", mx, my + size * 0.40);
  }

  // banner shown during the Fix the Wire challenge
  function drawChallengeBanner() {
    var solved = brokenFixed;
    var text = solved ? "Fixed it! The light is back on" : "A wire is broken — tap to find the gap";
    ctx.font = "700 " + Math.round(Math.max(13, L.unit * 0.052)) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "alphabetic";
    var w = ctx.measureText(text).width + 30;
    if (w > W - 24) w = W - 24;
    var x = W / 2 - w / 2;
    // sit just below the HUD so it never overlaps wrapped controls
    var hudEl = document.getElementById("hud");
    var hudH = hudEl ? hudEl.getBoundingClientRect().height : 64;
    var y = hudH + 30;
    if (y > H - 130) y = H - 130;
    ctx.fillStyle = solved ? "rgba(60,49,18,0.95)" : "rgba(70,30,10,0.92)";
    roundRect(x, y - 24, w, 34, 12);
    ctx.fill();
    ctx.strokeStyle = solved ? "rgba(255,210,74,0.5)" : "rgba(255,138,58,0.6)";
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.fillStyle = solved ? "#ffe79a" : "#ffd0a6";
    ctx.fillText(text, W / 2, y);

    // celebratory sparkle burst when freshly fixed
    if (sparkT > 0 && brokenFixPt) {
      var p = 1 - sparkT;
      var n = 9;
      for (var i = 0; i < n; i++) {
        var ang = (i / n) * Math.PI * 2;
        var dist = 8 + p * 46;
        var sx = brokenFixPt.x + Math.cos(ang) * dist;
        var sy = brokenFixPt.y + Math.sin(ang) * dist;
        ctx.globalAlpha = sparkT;
        ctx.fillStyle = "#fff4c0";
        ctx.beginPath();
        ctx.arc(sx, sy, 3.5, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    }
  }

  // distance from point p to segment seg
  function distToSeg(px, py, seg) {
    var vx = seg.x2 - seg.x1, vy = seg.y2 - seg.y1;
    var wx = px - seg.x1, wy = py - seg.y1;
    var len2 = vx * vx + vy * vy;
    var t = len2 > 0 ? (wx * vx + wy * vy) / len2 : 0;
    if (t < 0) t = 0; else if (t > 1) t = 1;
    var cxp = seg.x1 + vx * t, cyp = seg.y1 + vy * t;
    var dx = px - cxp, dy = py - cyp;
    return Math.sqrt(dx * dx + dy * dy);
  }

  function drawModeTag(text) {
    ctx.font = "600 " + Math.round(Math.max(12, L.unit * 0.05)) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "alphabetic";
    var w = ctx.measureText(text).width + 26;
    var x = W / 2 - w / 2;
    var y = L.top - Math.max(46, L.unit * 0.2);
    if (y < 70) y = 70;
    ctx.fillStyle = "rgba(38,31,12,0.85)";
    roundRect(x, y - 22, w, 30, 10);
    ctx.fill();
    ctx.strokeStyle = "rgba(255,210,74,0.3)";
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.fillStyle = "#ffe79a";
    ctx.fillText(text, W / 2, y);
  }

  // --- main loop ---
  function frame(time) {
    // animate switch lever toward target
    var target = closed ? 1 : 0;
    switchAnim += (target - switchAnim) * 0.22;
    // animate glow toward target
    var gTarget = closed ? 1 : 0;
    glow += (gTarget - glow) * 0.14;
    glow = clamp01(glow);

    // the meter follows the actual current flowing in the circuit
    var conducting = closed && brokenFixed;
    var meterTarget = conducting ? glow * dimLevel : 0;
    if (mode === 1) meterTarget *= 0.6; // series draws less current
    meter += (meterTarget - meter) * 0.16;
    meter = clamp01(meter);

    // flow advances only when current actually flows
    if (conducting) flow += 1.7 + glow * dimLevel * 1.2;

    // sparkle fades out after a successful fix
    if (sparkT > 0) {
      sparkT -= 0.018;
      if (sparkT < 0) sparkT = 0;
    }

    drawScene(time);
    requestAnimationFrame(frame);
  }

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

  function handleTap(pt) {
    // tapping near the switch always toggles it
    var dx = pt.x - switchHit.x;
    var dy = pt.y - switchHit.y;
    var onSwitch = (dx * dx + dy * dy < switchHit.r * switchHit.r);

    if (challenge && !brokenFixed) {
      // during the hunt, look for a tap on the broken wire segment
      var best = null, bestD = 999999;
      for (var i = 0; i < wireSegs.length; i++) {
        if (wireSegs[i].idx !== brokenSeg) continue;
        var d = distToSeg(pt.x, pt.y, wireSegs[i]);
        if (d < bestD) { bestD = d; best = wireSegs[i]; }
      }
      if (best && bestD < 40) {
        brokenFixed = true;
        brokenFixPt = {
          x: (best.x1 + best.x2) / 2,
          y: (best.y1 + best.y2) / 2
        };
        sparkT = 1;
        hint.textContent = "You fixed the wire! Current is flowing again";
        return;
      }
      // not the broken wire — let the switch still work, but don't toggle on a miss
      if (onSwitch) { closed = !closed; updateHint(); }
      else { hint.textContent = "Not there — keep tapping along the wires"; }
      return;
    }

    if (onSwitch) {
      closed = !closed;
      updateHint();
      return;
    }
    // tapping anywhere also toggles for tiny kids (whole canvas is a big target)
    closed = !closed;
    updateHint();
  }

  function startChallenge() {
    challenge = true;
    brokenFixed = false;
    brokenFixPt = null;
    sparkT = 0;
    closed = true; // switch on, so the only thing keeping the bulb dark is the break
    // pick a random wire segment for the current mode's loop
    var counts = [7, 8, 10];
    brokenSeg = Math.floor(Math.random() * counts[mode]);
    challengeBtn.textContent = "Stop Challenge";
    challengeBtn.classList.add("on");
    hint.textContent = "A wire is broken! Tap along the wires to find the gap";
  }

  function stopChallenge() {
    challenge = false;
    brokenFixed = true;
    brokenSeg = -1;
    sparkT = 0;
    challengeBtn.textContent = "Fix the Wire";
    challengeBtn.classList.remove("on");
    updateHint();
  }

  function updateHint() {
    if (challenge && !brokenFixed) {
      hint.textContent = "A wire is broken! Tap along the wires to find the gap";
    } else if (closed) {
      hint.textContent = "Circuit ON — tap again to break the loop";
    } else {
      hint.textContent = "Circuit OFF — tap to close the switch";
    }
  }

  canvas.addEventListener("pointerdown", function (e) {
    e.preventDefault();
    handleTap(pointFromEvent(e));
  });

  modeBtn.addEventListener("click", function () {
    mode = (mode + 1) % 3;
    modeBtn.textContent = MODE_LABELS[mode];
    // if a challenge is running, pick a fresh break valid for the new mode
    if (challenge) {
      var counts = [7, 8, 10];
      brokenSeg = Math.floor(Math.random() * counts[mode]);
      brokenFixed = false;
      brokenFixPt = null;
      sparkT = 0;
      hint.textContent = "New circuit, new broken wire — tap to find it";
    }
  });

  challengeBtn.addEventListener("click", function () {
    if (challenge) stopChallenge();
    else startChallenge();
  });

  dim.addEventListener("input", function () {
    var v = parseInt(dim.value, 10);
    if (isNaN(v)) v = 100;
    dimLevel = v / 100;
  });

  howBtn.addEventListener("click", function () {
    var showing = howPanel.classList.toggle("show");
    howBtn.textContent = showing ? "Hide" : "How it works";
  });

  window.addEventListener("resize", resize);

  // --- boot ---
  (function initDim() {
    var v = parseInt(dim.value, 10);
    dimLevel = isNaN(v) ? 1 : v / 100;
  })();
  resize();
  updateHint();
  requestAnimationFrame(frame);
  requestAnimationFrame(function () { stage.classList.add("ready"); });
})();
</script>
</body>
</html>

Game Source: Simple Circuit

Creator: ArcadeLab

Libraries: none

Complexity: complex (1164 lines, 38.8 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: simple-circuit-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.