Simple Circuit
by ArcadeLab1164 lines38.8 KB
<!--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.