$sloprun.dev

Gradient Descent Arena

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 11 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Three optimizers — plain SGD, momentum, and Adam — race downhill across the same contour map from the same starting dot, reading the exact analytic slope at every step, so the only thing separating their trails is the update rule itself. It opens mid-race on a two-minima landscape and settles in a few seconds: momentum reaches the shallow left dip still carrying speed and coasts over the ridge to the real bottom, while SGD and Adam sink into that trap and can never leave. Click anywhere to drop all three somewhere new, then switch to the narrow valley and push the learning rate to the top to watch plain steps overshoot and blow up while the other two still land. The saddle leaves SGD crawling across a nearly flat middle for twice as many steps as anyone else, and the ripple field's corrugations catch whoever steps too carefully.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/gradient-descent" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Gradient Descent Arena — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/gradient-descent

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1073 lines 47.5 KB index.html
demos/gradient-descent/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gradient Descent Arena — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local: series hues for the three optimizers + alpha helpers.
   Background field stays neutral so colour means "which optimizer", nothing else. */
:root {
  --s-sgd: #2E6FBF; --s-mom: #C05600; --s-ada: #7B4BC0;
  --shadow: 0 1px 2px rgba(26,29,33,.05);
  --scrim: rgba(255,255,255,.86);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --s-sgd: #6FA8FF; --s-mom: #FFB454; --s-ada: #B892FF;
    --shadow: 0 1px 2px rgba(0,0,0,.35);
    --scrim: rgba(28,31,38,.86);
  }
}
:root[data-theme="dark"] {
  --s-sgd: #6FA8FF; --s-mom: #FFB454; --s-ada: #B892FF;
  --shadow: 0 1px 2px rgba(0,0,0,.35);
  --scrim: rgba(28,31,38,.86);
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1160px; margin: 0 auto; padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 70ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }

.stage { display: grid; grid-template-columns: minmax(0,1fr) 312px; gap: 16px; align-items: start; }
.stage > .colmain { grid-column: 1; grid-row: 1; }
.stage > .panel   { grid-column: 2; grid-row: 1 / span 2; }
.stage > .rulecard{ grid-column: 1; grid-row: 2; }
@media (max-width: 920px) {
  .stage { grid-template-columns: minmax(0,1fr); }
  /* specificity must match the placement rules above, or the panel keeps
     grid-column:2 and conjures a second implicit column that crushes the map */
  .stage > .colmain, .stage > .panel, .stage > .rulecard { grid-column: 1; grid-row: auto; }
}

.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); aspect-ratio: 8 / 5; box-shadow: var(--shadow);
}
@media (max-width: 920px) { .field { aspect-ratio: 4 / 3; } }
@media (max-width: 470px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; width: 100%; height: 100%; touch-action: manipulation; cursor: crosshair; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.hint {
  position: absolute; left: 10px; bottom: 10px; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .45s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }
.hint[hidden] { display: none; }

.scale {
  position: absolute; right: 10px; top: 10px; pointer-events: none;
  display: flex; align-items: center; gap: 7px;
  background: var(--scrim); border: 1px solid var(--line);
  border-radius: 999px; padding: 4px 9px;
}
.scale .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.scale .bar { width: 46px; height: 6px; border-radius: 3px; }

.keys { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; padding: 2px 2px 0; }
.key { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .02em; }
.key .sw { width: 15px; height: 3px; border-radius: 2px; flex: none; }
.key .ring { width: 11px; height: 11px; border-radius: 50%; border: 1.5px solid var(--muted); flex: none; }
.key .ring.g { border-color: var(--run); }

.panel { display: flex; flex-direction: column; gap: 12px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
  display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
}
.card h2 .n { letter-spacing: .04em; text-transform: none; color: var(--muted); font-weight: 400; }

/* leaderboard */
.board { display: flex; flex-direction: column; gap: 9px; }
.rw { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 3px 8px; align-items: baseline; }
.rw .dot { width: 10px; height: 10px; border-radius: 50%; align-self: center; grid-row: 1 / span 2; }
.rw .dot.sq { border-radius: 2px; transform: rotate(45deg); width: 9px; height: 9px; }
.rw .dot.tri { border-radius: 1px; width: 0; height: 0; background: none !important; border-left: 6px solid transparent; border-right: 6px solid transparent; }
.rw .nm { font-size: 13.5px; font-weight: 600; letter-spacing: -.005em; min-width: 0; }
.rw .loss { font-family: var(--font-mono); font-size: 15px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; text-align: right; }
.rw .sub { grid-column: 2; font-family: var(--font-mono); font-size: 10px; color: var(--muted); letter-spacing: .02em; }
.rw .st {
  grid-column: 3; justify-self: end; font-family: var(--font-mono); font-size: 9.5px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; white-space: nowrap;
}
.rw .st.good { color: var(--run); background: var(--run-soft); border-color: transparent; }
.rw .st.warn { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.rw .st.bad  { color: var(--danger); border-color: currentColor; }
.rw.out .nm, .rw.out .loss { opacity: .95; }

.verdict {
  margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--line);
  font-size: 12.5px; line-height: 1.5; color: var(--muted); min-height: 3.1em;
}
.verdict b { color: var(--ink); font-weight: 600; }

.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.grid2 button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.note { margin: 9px 0 0; font-size: 12px; line-height: 1.45; color: var(--muted); }

.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
.ticks { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: .04em; margin-top: 1px; }

input[type=range] {
  -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
.row { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.row > button { flex: 1 1 auto; }

.rules { margin: 0; display: flex; flex-direction: column; gap: 7px; }
.rules div { font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; color: var(--muted); letter-spacing: -.01em; overflow-x: auto; }
.rules .who { color: var(--ink); font-weight: 600; }

footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Gradient Descent Arena</h1>
    <p class="lede">
      Three ways to walk downhill on the same map. They read the same slope from the same starting dot
      and differ only in <b>how much of the past they carry</b> — and on this map that alone decides who
      finds the real bottom. <b>Click anywhere</b> to drop all three somewhere new.
    </p>
  </header>

  <main class="stage">

    <div class="colmain">
      <div class="field" id="field">
        <canvas id="c" tabindex="0" aria-label="Loss landscape map. Arrow keys move the drop point, Enter drops all three optimizers there.">
          A contour map of a loss landscape with three optimizer paths drawn on it.
        </canvas>
        <div class="scale" aria-hidden="true">
          <span class="lbl">low</span><span class="bar" id="scaleBar"></span><span class="lbl">high</span>
        </div>
        <div class="hint" id="hint">click the map to drop them somewhere new</div>
      </div>
      <div class="keys" aria-hidden="true">
        <span class="key"><span class="sw" style="background:var(--s-sgd)"></span>SGD</span>
        <span class="key"><span class="sw" style="background:var(--s-mom)"></span>momentum</span>
        <span class="key"><span class="sw" style="background:var(--s-ada)"></span>adam</span>
        <span class="key"><span class="ring"></span>lowest point on the map</span>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Race readout">
        <h2>Race <span class="n" id="stepCount">step 0</span></h2>
        <div class="board" id="board"></div>
        <p class="verdict" id="verdict"></p>
      </section>

      <section class="card" aria-label="Landscape">
        <h2>Landscape</h2>
        <div class="grid2" id="lands" role="group" aria-label="Choose a landscape"></div>
        <p class="note" id="landNote"></p>
      </section>

      <section class="card" aria-label="Controls">
        <h2>Step size</h2>
        <div class="ctl-top">
          <label for="lr">Learning rate</label><span class="val" id="lrV">0.050</span>
        </div>
        <input type="range" id="lr" min="0" max="1000" step="1" value="500"
               aria-describedby="lrHelp">
        <div class="ticks" aria-hidden="true"><span>0.005</span><span>0.05</span><span>0.5</span></div>
        <p class="note" id="lrHelp">How far each step moves per unit of slope. Too small and nobody gets anywhere. Push it to the top on the narrow valley and plain SGD overshoots and blows up.</p>
        <div class="row">
          <button type="button" class="primary" id="playBtn">Pause</button>
          <button type="button" id="resetBtn">Reset start</button>
        </div>
      </section>

    </div>

    <section class="card rulecard" aria-label="Update rules">
        <h2>The three rules</h2>
        <div class="rules">
          <div><span class="who">sgd</span> &nbsp;x ← x − lr·g</div>
          <div><span class="who">momentum</span> &nbsp;v ← 0.9·v + g<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;x ← x − lr·v</div>
          <div><span class="who">adam</span> &nbsp;m ← 0.9·m + 0.1·g<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;v ← 0.999·v + 0.001·g²<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;x ← x − lr·m̂/(√v̂+1e−8)</div>
          <div style="color:var(--muted);font-family:var(--font-sans);font-size:11.5px;line-height:1.45">
            g is the exact slope at the current point — no sampling noise — so the only thing separating
            the three paths is the update rule itself.
          </div>
        </div>
    </section>

  </main>

  <footer>demo · sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

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

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  var root = document.documentElement;
  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
  var reduced = !!(mqRM && mqRM.matches);

  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      onThemeChange();
    }
  });
  if (mqDark) {
    var onScheme = function () { onThemeChange(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── colour helpers ─────────────────────────────────────────────────────── */
  var COL = {};
  function hexToRgb(h) {
    h = String(h).trim();
    if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    var n = parseInt(h, 16);
    if (isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  function css(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
  function mix(a, b, t) {
    return [Math.round(a[0]+(b[0]-a[0])*t), Math.round(a[1]+(b[1]-a[1])*t), Math.round(a[2]+(b[2]-a[2])*t)];
  }
  function readTheme() {
    COL.surface = tok("--surface", [255,255,255]);
    COL.ink     = tok("--ink",     [26,29,33]);
    COL.muted   = tok("--muted",   [90,96,104]);
    COL.line    = tok("--line",    [227,226,220]);
    COL.accent  = tok("--accent",  [192,86,0]);
    COL.run     = tok("--run",     [26,127,55]);
    COL.danger  = tok("--danger",  [201,60,55]);
    COL.sgd     = tok("--s-sgd",   [46,111,191]);
    COL.mom     = tok("--s-mom",   [192,86,0]);
    COL.ada     = tok("--s-ada",   [123,75,192]);
    COL.lo = mix(COL.surface, COL.ink, 0.02);
    COL.hi = mix(COL.surface, COL.ink, 0.42);
    var bar = document.getElementById("scaleBar");
    bar.style.background = "linear-gradient(90deg," + css(COL.lo) + "," + css(COL.hi) + ")";
  }

  /* ── landscapes ─────────────────────────────────────────────────────────
     Every gradient below is the exact analytic derivative of the loss above it. */
  var LANDS = [
    {
      id: "bowl", name: "Narrow valley",
      note: "A single smooth bowl, but far steeper across than along. There is nowhere to get stuck — the only question is how many steps it takes.",
      f: function (x, y) { return 0.5 * (0.30 * x * x + 5.0 * y * y); },
      gx: function (x, y) { return 0.30 * x; },
      gy: function (x, y) { return 5.0 * y; },
      rx: 3.0, ry: 2.0, start: [-2.6, 1.55],
      fmin: 0, globals: [[0, 0]], traps: [], flats: []
    },
    {
      id: "saddle", name: "Saddle",
      note: "Flat where it counts: the middle is downhill one way and uphill the other. A tiny slope means tiny steps, so plain descent crawls for ages before it tips off.",
      f: function (x, y) { return 0.5*x*x - 0.2*y*y + 0.035*y*y*y*y; },
      gx: function (x, y) { return x; },
      gy: function (x, y) { return -0.4*y + 0.14*y*y*y; },
      rx: 3.0, ry: 2.2, start: [2.1, 0.05],
      fmin: -0.2857142857, globals: [[0, 1.690309], [0, -1.690309]], traps: [], flats: [[0, 0]]
    },
    {
      id: "two", name: "Two minima",
      note: "A shallow dip on the left, the real bottom on the right, a low ridge between them. Only a runner that arrives carrying speed gets over.",
      f: function (x, y) { return 0.055*x*x*x*x - 0.42*x*x - 0.5*x + 0.35*y*y; },
      gx: function (x, y) { return 0.22*x*x*x - 0.84*x - 0.5; },
      gy: function (x, y) { return 0.7*y; },
      rx: 3.25, ry: 2.0, start: [-2.6, 1.4],
      fmin: -1.844398, globals: [[2.202307, 0]], traps: [[-1.526082, 0]], flats: []
    },
    {
      id: "ripple", name: "Ripple",
      note: "A wide bowl with corrugations on top. A ring of shallow dips around one true bottom in the middle, and the ripples are just deep enough to hold a careful walker.",
      f: function (x, y) { return 0.19*(x*x + y*y) + 0.35*(2 - Math.cos(2.4*x) - Math.cos(2.4*y)); },
      gx: function (x, y) { return 0.38*x + 0.84*Math.sin(2.4*x); },
      gy: function (x, y) { return 0.38*y + 0.84*Math.sin(2.4*y); },
      rx: 3.0, ry: 2.2, start: [-2.6, 1.15],
      fmin: 0, globals: [[0, 0]], traps: [], flats: []
    }
  ];

  var MAXSTEPS = 800, STEPS_PER_SEC = 55;
  var GTOL = 1e-3, DTOL = 1e-4, GLOBTOL = 0.02, ESCAPE = 8;

  /* ── optimizers (textbook forms) ────────────────────────────────────────── */
  var SPECS = [
    // hr: head radius. They shrink in draw order (adam painted first, sgd last)
    // so that when two optimizers settle on the exact same point — which is the
    // whole point of the two-minima map — every marker still peeks out.
    { key: "sgd", name: "SGD", col: "sgd", shape: "circle", hr: 4.2 },
    { key: "mom", name: "Momentum", col: "mom", shape: "diamond", hr: 4.9 },
    { key: "ada", name: "Adam", col: "ada", shape: "triangle", hr: 5.4 }
  ];
  function makeRunner(spec, sx, sy) {
    return {
      key: spec.key, name: spec.name, col: spec.col, shape: spec.shape, hr: spec.hr,
      x: sx, y: sy, vx: 0, vy: 0, mx: 0, my: 0, sx2: 0, sy2: 0, t: 0,
      path: [sx, sy], steps: 0, gn: 0, dn: 0, state: "run", atGlobal: false
    };
  }
  function stepRunner(r, L, lr) {
    var gx = L.gx(r.x, r.y), gy = L.gy(r.x, r.y), dx = 0, dy = 0;
    if (r.key === "sgd") {
      dx = -lr * gx; dy = -lr * gy;
    } else if (r.key === "mom") {
      r.vx = 0.9 * r.vx + gx; r.vy = 0.9 * r.vy + gy;
      dx = -lr * r.vx; dy = -lr * r.vy;
    } else {
      r.t++;
      r.mx = 0.9 * r.mx + 0.1 * gx;      r.my = 0.9 * r.my + 0.1 * gy;
      r.sx2 = 0.999 * r.sx2 + 0.001 * gx * gx;
      r.sy2 = 0.999 * r.sy2 + 0.001 * gy * gy;
      var b1 = 1 - Math.pow(0.9, r.t), b2 = 1 - Math.pow(0.999, r.t);
      dx = -lr * (r.mx / b1) / (Math.sqrt(r.sx2 / b2) + 1e-8);
      dy = -lr * (r.my / b1) / (Math.sqrt(r.sy2 / b2) + 1e-8);
    }
    r.x += dx; r.y += dy; r.steps++;
    r.gn = Math.sqrt(gx * gx + gy * gy);
    r.dn = Math.sqrt(dx * dx + dy * dy);
    r.path.push(r.x, r.y);

    if (!isFinite(r.x) || !isFinite(r.y) || Math.abs(r.x) > ESCAPE || Math.abs(r.y) > ESCAPE) {
      r.state = "diverged"; return;
    }
    if (r.gn < GTOL && r.dn < DTOL) {
      r.state = "settled";
      r.atGlobal = (L.f(r.x, r.y) - L.fmin) < GLOBTOL;
      return;
    }
    if (r.steps >= MAXSTEPS) r.state = "maxed";
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var field = document.getElementById("field");
  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  var hint = document.getElementById("hint");
  var boardEl = document.getElementById("board");
  var verdictEl = document.getElementById("verdict");
  var stepCountEl = document.getElementById("stepCount");
  var landsEl = document.getElementById("lands");
  var landNote = document.getElementById("landNote");
  var lrEl = document.getElementById("lr");
  var lrV = document.getElementById("lrV");
  var playBtn = document.getElementById("playBtn");
  var resetBtn = document.getElementById("resetBtn");
  var live = document.getElementById("live");

  /* ── state ──────────────────────────────────────────────────────────────── */
  var land = LANDS[2];                 // opens on "two minima"
  var startPt = land.start.slice();
  var runners = [];
  var playing = !reduced;
  var lr = 0.05;
  var W = 0, H = 0, dpr = 1, scale = 1, ox = 0, oy = 0;
  var bg = document.createElement("canvas"), bgc = bg.getContext("2d");
  var tmp = document.createElement("canvas"), tmpc = tmp.getContext("2d");
  var bgDirty = true, needsDraw = true, rafId = 0, lastTs = 0, acc = 0;
  var kbCursor = null;

  /* ── geometry ───────────────────────────────────────────────────────────── */
  function layout() {
    var r = field.getBoundingClientRect();
    var cw = Math.max(120, Math.round(r.width)), ch = Math.max(120, Math.round(r.height));
    dpr = Math.min(2, window.devicePixelRatio || 1);
    var nw = Math.round(cw * dpr), nh = Math.round(ch * dpr);
    if (nw !== W || nh !== H) {
      W = nw; H = nh;
      canvas.width = W; canvas.height = H;
      canvas.style.width = cw + "px"; canvas.style.height = ch + "px";
      bg.width = W; bg.height = H;
      bgDirty = true;
    }
    // fit the required world rect inside the canvas; the shorter axis governs
    scale = Math.min(W / (2 * land.rx), H / (2 * land.ry));
    ox = W / 2; oy = H / 2;
    needsDraw = true;
  }
  function sxOf(x) { return ox + x * scale; }
  function syOf(y) { return oy - y * scale; }
  function wxOf(px) { return (px - ox) / scale; }
  function wyOf(py) { return (oy - py) / scale; }

  /* ── background: heat field + marching-squares contours ─────────────────── */
  function renderBackground() {
    if (!W || !H) return;
    var f = land.f;
    // value range over the visible rect (coarse scan)
    var vmin = Infinity, vmax = -Infinity, i, j;
    for (i = 0; i <= 72; i++) {
      for (j = 0; j <= 72; j++) {
        var v = f(wxOf(i / 72 * W), wyOf(j / 72 * H));
        if (v < vmin) vmin = v; if (v > vmax) vmax = v;
      }
    }
    if (!(vmax > vmin)) vmax = vmin + 1;

    // heat field on a coarse grid, upscaled smoothly. The field is smooth, so a
    // few hundred samples per axis is visually identical to one per pixel and
    // keeps a landscape/theme switch well inside one frame.
    var hw = Math.max(2, Math.min(400, Math.ceil(W / 2)));
    var hh = Math.max(2, Math.min(400, Math.round(hw * H / W)));
    var stepX = W / hw, stepY = H / hh;
    tmp.width = hw; tmp.height = hh;
    var img = tmpc.createImageData(hw, hh), d = img.data, p = 0;
    var lo = COL.lo, hi = COL.hi, span = vmax - vmin;
    var dr = hi[0] - lo[0], dg = hi[1] - lo[1], db = hi[2] - lo[2];
    var xs = new Float64Array(hw);
    for (i = 0; i < hw; i++) xs[i] = wxOf((i + 0.5) * stepX);
    for (j = 0; j < hh; j++) {
      var wy = wyOf((j + 0.5) * stepY);
      for (i = 0; i < hw; i++) {
        var t = (f(xs[i], wy) - vmin) / span;
        t = t < 0 ? 0 : t > 1 ? 1 : t;
        t = Math.pow(t, 0.62);
        d[p++] = lo[0] + dr * t;
        d[p++] = lo[1] + dg * t;
        d[p++] = lo[2] + db * t;
        d[p++] = 255;
      }
    }
    tmpc.putImageData(img, 0, 0);
    bgc.setTransform(1, 0, 0, 1, 0, 0);
    bgc.clearRect(0, 0, W, H);
    bgc.imageSmoothingEnabled = true;
    bgc.drawImage(tmp, 0, 0, hw, hh, 0, 0, W, H);

    // contour grid
    var cell = Math.max(6, Math.round(8 * dpr));
    var nx = Math.ceil(W / cell), ny = Math.ceil(H / cell);
    var gw = nx + 1, gh = ny + 1;
    var vals = new Float64Array(gw * gh);
    for (j = 0; j < gh; j++) {
      var yy = wyOf(j * cell);
      for (i = 0; i < gw; i++) vals[j * gw + i] = f(wxOf(i * cell), yy);
    }
    var K = 12, levels = [], segs = [];
    for (var k = 0; k < K; k++) {
      levels.push(vmin + span * Math.pow((k + 0.55) / K, 1.75));
      segs.push([]);
    }
    for (j = 0; j < ny; j++) {
      for (i = 0; i < nx; i++) {
        var a = vals[j * gw + i], b = vals[j * gw + i + 1];
        var c = vals[(j + 1) * gw + i + 1], e = vals[(j + 1) * gw + i];
        var mn = Math.min(a, b, c, e), mx = Math.max(a, b, c, e);
        if (mn === mx) continue;
        var x0 = i * cell, y0 = j * cell;
        for (k = 0; k < K; k++) {
          var Lv = levels[k];
          if (Lv < mn || Lv > mx) continue;
          marching(segs[k], Lv, a, b, c, e, x0, y0, cell);
        }
      }
    }
    for (k = 0; k < K; k++) {
      var s = segs[k];
      if (!s.length) continue;
      var major = (k % 3 === 0);
      bgc.strokeStyle = rgba(COL.ink, major ? 0.26 : 0.13);
      bgc.lineWidth = (major ? 1.35 : 0.9) * dpr;
      bgc.beginPath();
      for (var q = 0; q < s.length; q += 4) {
        bgc.moveTo(s[q], s[q + 1]); bgc.lineTo(s[q + 2], s[q + 3]);
      }
      bgc.stroke();
    }
    bgDirty = false;
  }

  // corners: a=TL b=TR c=BR e=BL, in screen space
  function marching(out, L, a, b, c, e, x, y, w) {
    var idx = (a > L ? 8 : 0) | (b > L ? 4 : 0) | (c > L ? 2 : 0) | (e > L ? 1 : 0);
    if (idx === 0 || idx === 15) return;
    var T = [x + w * (L - a) / (b - a), y];
    var R = [x + w, y + w * (L - b) / (c - b)];
    var B = [x + w * (L - e) / (c - e), y + w];
    var Lf = [x, y + w * (L - a) / (e - a)];
    function push(p, q) { out.push(p[0], p[1], q[0], q[1]); }
    switch (idx) {
      case 1: case 14: push(B, Lf); break;
      case 2: case 13: push(R, B); break;
      case 3: case 12: push(R, Lf); break;
      case 4: case 11: push(T, R); break;
      case 6: case 9:  push(T, B); break;
      case 7: case 8:  push(T, Lf); break;
      case 5:  // b,e above — ambiguous; centre value decides how it connects
        if ((a + b + c + e) / 4 > L) { push(T, Lf); push(R, B); }
        else { push(T, R); push(B, Lf); }
        break;
      case 10: // a,c above — ambiguous
        if ((a + b + c + e) / 4 > L) { push(T, R); push(B, Lf); }
        else { push(T, Lf); push(R, B); }
        break;
    }
  }

  /* ── drawing ────────────────────────────────────────────────────────────── */
  var MONOFACE = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';

  function pill(g, x, y, w, h) {
    var r = h / 2;
    g.beginPath();
    g.moveTo(x + r, y);
    g.arcTo(x + w, y, x + w, y + h, r);
    g.arcTo(x + w, y + h, x, y + h, r);
    g.arcTo(x, y + h, x, y, r);
    g.arcTo(x, y, x + w, y, r);
    g.closePath();
  }

  // a small mono caption anchored under (or over) a map marker, kept on-canvas
  var pendingTags = [];
  function tagLater(px, py, text, col) { pendingTags.push([px, py, text, col]); }
  function drawTags() {
    var fs = Math.round(10 * dpr);
    ctx.font = "600 " + fs + "px " + MONOFACE;
    ctx.textAlign = "left"; ctx.textBaseline = "middle";
    for (var i = 0; i < pendingTags.length; i++) {
      var t = pendingTags[i], px = t[0], py = t[1];
      var padX = 5 * dpr, bh = fs + 6 * dpr;
      var bw = ctx.measureText(t[2]).width + padX * 2;
      var bx = px - bw / 2;
      var by = py + 13 * dpr;                       // below the marker by default
      if (by + bh > H - 4 * dpr) by = py - 13 * dpr - bh;   // flip up near the floor
      bx = Math.max(4 * dpr, Math.min(W - bw - 4 * dpr, bx));
      by = Math.max(4 * dpr, Math.min(H - bh - 4 * dpr, by));
      pill(ctx, bx, by, bw, bh);
      ctx.fillStyle = rgba(COL.surface, 0.86); ctx.fill();
      ctx.lineWidth = dpr; ctx.strokeStyle = rgba(t[3], 0.4); ctx.stroke();
      ctx.fillStyle = css(t[3]);
      ctx.fillText(t[2], bx + padX, by + bh / 2 + 0.5 * dpr);
    }
    pendingTags.length = 0;
  }

  function head(g, r, px, py, col, rad) {
    g.beginPath();
    if (r.shape === "circle") {
      g.arc(px, py, rad, 0, Math.PI * 2);
    } else if (r.shape === "diamond") {
      g.moveTo(px, py - rad * 1.25); g.lineTo(px + rad * 1.25, py);
      g.lineTo(px, py + rad * 1.25); g.lineTo(px - rad * 1.25, py); g.closePath();
    } else {
      g.moveTo(px, py - rad * 1.35); g.lineTo(px + rad * 1.25, py + rad);
      g.lineTo(px - rad * 1.25, py + rad); g.closePath();
    }
    g.lineWidth = 2.2 * dpr; g.strokeStyle = css(COL.surface); g.stroke();
    g.fillStyle = css(col); g.fill();
  }

  function draw() {
    if (bgDirty) renderBackground();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, W, H);
    ctx.drawImage(bg, 0, 0);

    var i, k, r, px, py;

    // the true bottom(s) — the ring turns green only once somebody actually lands there
    for (i = 0; i < land.globals.length; i++) {
      var gmin = land.globals[i];
      var reached = false;
      for (k = 0; k < runners.length; k++) {
        if (runners[k].atGlobal &&
            Math.abs(runners[k].x - gmin[0]) < 0.25 && Math.abs(runners[k].y - gmin[1]) < 0.25) reached = true;
      }
      var mc = reached ? COL.run : COL.ink;
      px = sxOf(gmin[0]); py = syOf(gmin[1]);
      ctx.beginPath(); ctx.arc(px, py, 9 * dpr, 0, Math.PI * 2);
      ctx.lineWidth = 1.8 * dpr; ctx.strokeStyle = rgba(mc, reached ? 1 : 0.55); ctx.stroke();
      ctx.beginPath(); ctx.arc(px, py, 2.2 * dpr, 0, Math.PI * 2);
      ctx.fillStyle = rgba(mc, reached ? 1 : 0.55); ctx.fill();
      tagLater(px, py, reached ? "lowest point ✓" : "lowest point", reached ? COL.run : COL.muted);
    }
    // shallow dips that hold a walker forever
    for (i = 0; i < land.traps.length; i++) {
      px = sxOf(land.traps[i][0]); py = syOf(land.traps[i][1]);
      ctx.beginPath(); ctx.arc(px, py, 6 * dpr, 0, Math.PI * 2);
      ctx.setLineDash([2.5 * dpr, 2.5 * dpr]);
      ctx.lineWidth = 1.3 * dpr; ctx.strokeStyle = rgba(COL.ink, 0.5); ctx.stroke();
      ctx.setLineDash([]);
      tagLater(px, py, "shallow dip", COL.muted);
    }
    // saddle points: slope is almost nothing, so plain steps crawl
    for (i = 0; i < (land.flats || []).length; i++) {
      px = sxOf(land.flats[i][0]); py = syOf(land.flats[i][1]);
      ctx.beginPath();
      ctx.moveTo(px - 6 * dpr, py); ctx.lineTo(px + 6 * dpr, py);
      ctx.moveTo(px, py - 6 * dpr); ctx.lineTo(px, py + 6 * dpr);
      ctx.lineWidth = 1.3 * dpr; ctx.strokeStyle = rgba(COL.ink, 0.5); ctx.stroke();
      tagLater(px, py, "flat spot", COL.muted);
    }

    // start dot
    px = sxOf(startPt[0]); py = syOf(startPt[1]);
    ctx.beginPath(); ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2);
    ctx.lineWidth = 1.5 * dpr; ctx.strokeStyle = rgba(COL.ink, 0.55); ctx.stroke();

    // keyboard drop cursor
    if (kbCursor) {
      px = sxOf(kbCursor[0]); py = syOf(kbCursor[1]);
      ctx.strokeStyle = css(COL.accent); ctx.lineWidth = 1.5 * dpr;
      ctx.beginPath();
      ctx.moveTo(px - 9 * dpr, py); ctx.lineTo(px + 9 * dpr, py);
      ctx.moveTo(px, py - 9 * dpr); ctx.lineTo(px, py + 9 * dpr);
      ctx.stroke();
      ctx.beginPath(); ctx.arc(px, py, 5 * dpr, 0, Math.PI * 2); ctx.stroke();
    }

    // trails
    ctx.lineJoin = "round"; ctx.lineCap = "round";
    for (k = runners.length - 1; k >= 0; k--) {
      r = runners[k];
      var col = COL[r.col], pth = r.path;
      if (pth.length >= 4) {
        ctx.beginPath();
        ctx.moveTo(sxOf(pth[0]), syOf(pth[1]));
        for (i = 2; i < pth.length; i += 2) ctx.lineTo(sxOf(pth[i]), syOf(pth[i + 1]));
        ctx.strokeStyle = rgba(col, 0.16); ctx.lineWidth = 5.5 * dpr; ctx.stroke();
        ctx.strokeStyle = rgba(col, 0.95); ctx.lineWidth = 1.9 * dpr; ctx.stroke();
      }
      if (r.state !== "diverged") head(ctx, r, sxOf(r.x), syOf(r.y), col, r.hr * dpr);
    }

    drawTags();   // captions ride on top of everything
  }

  /* ── readouts ───────────────────────────────────────────────────────────── */
  var rowEls = {};
  function buildBoard() {
    boardEl.textContent = "";
    rowEls = {};
    for (var i = 0; i < SPECS.length; i++) {
      var s = SPECS[i];
      var row = document.createElement("div");
      row.className = "rw";
      var dot = document.createElement("span");
      dot.className = "dot" + (s.shape === "diamond" ? " sq" : s.shape === "triangle" ? " tri" : "");
      if (s.shape === "triangle") dot.style.borderBottom = "10px solid var(--s-" + s.col + ")";
      else dot.style.background = "var(--s-" + s.col + ")";
      var nm = document.createElement("span"); nm.className = "nm"; nm.textContent = s.name;
      var loss = document.createElement("span"); loss.className = "loss"; loss.textContent = "—";
      var sub = document.createElement("span"); sub.className = "sub"; sub.textContent = "";
      var st = document.createElement("span"); st.className = "st"; st.textContent = "ready";
      row.appendChild(dot); row.appendChild(nm); row.appendChild(loss);
      row.appendChild(sub); row.appendChild(st);
      boardEl.appendChild(row);
      rowEls[s.key] = { loss: loss, sub: sub, st: st, row: row };
    }
  }
  function fmt(v) {
    if (!isFinite(v)) return "∞";
    var a = Math.abs(v);
    if (a >= 1e6) return v.toExponential(2);
    return v.toFixed(4);
  }
  function fmtSci(v) {
    if (!isFinite(v)) return "∞";
    if (v === 0) return "0";
    if (v >= 0.001) return v.toFixed(3);
    return v.toExponential(1).replace("e-", "e−");
  }
  function updateBoard() {
    var maxSteps = 0;
    for (var i = 0; i < runners.length; i++) {
      var r = runners[i], el = rowEls[r.key];
      if (r.steps > maxSteps) maxSteps = r.steps;
      var lv = land.f(r.x, r.y);
      el.loss.textContent = r.state === "diverged" ? "—" : fmt(lv);
      el.loss.style.color = r.state === "diverged" ? "var(--danger)" : "";
      el.sub.textContent = r.steps + " steps · ‖∇f‖ " + (r.state === "diverged" ? "—" : fmtSci(r.gn));
      var cls = "st", txt;
      if (r.state === "diverged") { cls += " bad"; txt = "diverged"; }
      else if (r.state === "settled" && r.atGlobal) { cls += " good"; txt = "lowest point"; }
      else if (r.state === "settled") { cls += " warn"; txt = "stuck higher up"; }
      else if (r.state === "maxed") { txt = "still moving"; }
      else { txt = "descending"; }
      el.st.className = cls; el.st.textContent = txt;
    }
    stepCountEl.textContent = "step " + maxSteps;
  }

  function done() {
    for (var i = 0; i < runners.length; i++) if (runners[i].state === "run") return false;
    return true;
  }

  function verdict() {
    if (!done()) {
      verdictEl.innerHTML = "Racing… all three take their steps in lockstep from the same starting dot.";
      return;
    }
    var win = [], lose = [], blew = [], slow = [];
    for (var i = 0; i < runners.length; i++) {
      var r = runners[i];
      if (r.state === "diverged") blew.push(r);
      else if (r.state === "maxed") slow.push(r);
      else if (r.atGlobal) win.push(r);
      else lose.push(r);
    }
    function names(a) {
      var n = a.map(function (r) { return r.name; });
      if (n.length === 1) return n[0];
      if (n.length === 2) return n[0] + " and " + n[1];
      return n.slice(0, -1).join(", ") + " and " + n[n.length - 1];
    }
    var parts = [];
    if (blew.length) {
      parts.push("<b>" + names(blew) + "</b> blew up: at this step size each move overshoots the valley and lands somewhere steeper still.");
    }
    if (win.length === 3) {
      parts.push("All three reached the lowest point — nothing on this map can hold them.");
      var quick = win.slice().sort(function (a, b) { return a.steps - b.steps; })[0];
      parts.push("Fastest there: <b>" + quick.name + "</b>, in " + quick.steps + " steps.");
    } else if (win.length && lose.length) {
      parts.push("<b>" + names(win) + "</b> reached the lowest point; <b>" + names(lose) +
                 "</b> stopped at a higher one and can't leave — the slope there is zero in every direction.");
      var momWin = win.some(function (r) { return r.key === "mom"; });
      var sgdLost = lose.some(function (r) { return r.key === "sgd"; });
      if (momWin && sgdLost) {
        parts.push("Momentum keeps the speed it built on the way down, so it coasts over the ridge; plain SGD only ever moves downhill and stops in the first dip it finds.");
      }
      var ada = lose.filter(function (r) { return r.key === "ada"; })[0];
      if (ada) parts.push("Adam got to its answer in " + ada.steps + " steps — fast, but to the wrong bottom.");
    } else if (win.length) {
      parts.push("<b>" + names(win) + "</b> reached the lowest point.");
    } else if (lose.length === 3) {
      parts.push("All three settled in the same higher basin. Push the learning rate up and see who gets out first.");
    } else if (lose.length) {
      parts.push("<b>" + names(lose) + "</b> settled at a higher point than the lowest one on the map.");
    }
    if (slow.length) {
      parts.push("<b>" + names(slow) + "</b> hit the " + MAXSTEPS +
                 "-step limit still moving — the slope is too gentle for this learning rate.");
    }
    verdictEl.innerHTML = parts.join(" ");
  }

  var sayTimer = 0;
  function say(msg) { clearTimeout(sayTimer); sayTimer = setTimeout(function () { live.textContent = msg; }, 150); }

  /* ── race control ───────────────────────────────────────────────────────── */
  function startRace(announce, stepwise) {
    runners = SPECS.map(function (s) { return makeRunner(s, startPt[0], startPt[1]); });
    acc = 0; lastTs = 0;
    if (reduced) {
      // no animation allowed: land on the finished race unless the visitor
      // explicitly asked to walk it forward a chunk at a time
      if (!stepwise) runAll();
      playing = false;
    } else {
      playing = true;
    }
    updateBoard(); verdict(); syncButtons(); needsDraw = true; loop();
    if (announce) say("Race restarted at x " + startPt[0].toFixed(2) + ", y " + startPt[1].toFixed(2) + ".");
  }
  function runAll() {
    for (var n = 0; n < MAXSTEPS; n++) {
      var any = false;
      for (var i = 0; i < runners.length; i++) {
        if (runners[i].state === "run") { stepRunner(runners[i], land, lr); any = true; }
      }
      if (!any) break;
    }
  }
  function advance(n) {
    for (var s = 0; s < n; s++) {
      var any = false;
      for (var i = 0; i < runners.length; i++) {
        if (runners[i].state === "run") { stepRunner(runners[i], land, lr); any = true; }
      }
      if (!any) return;
    }
  }
  function syncButtons() {
    if (done()) { playBtn.textContent = "Race again"; }
    else if (reduced) { playBtn.textContent = "Step ×25"; }
    else if (playing) { playBtn.textContent = "Pause"; }
    else { playBtn.textContent = "Play"; }
    playBtn.setAttribute("aria-label", playBtn.textContent);
  }

  function loop() {
    if (rafId) return;
    rafId = requestAnimationFrame(tick);
  }
  function tick(ts) {
    rafId = 0;
    var stillRacing = !done();
    if (playing && stillRacing && !reduced) {
      if (!lastTs) lastTs = ts;
      var dt = Math.min(0.25, (ts - lastTs) / 1000);
      lastTs = ts;
      acc += dt * STEPS_PER_SEC;
      var n = Math.min(24, Math.floor(acc));
      if (n > 0) { acc -= n; advance(n); updateBoard(); needsDraw = true; }
      if (done()) { verdict(); syncButtons(); announceFinish(); }
    } else {
      lastTs = 0;
    }
    if (needsDraw) { draw(); needsDraw = false; }
    if ((playing && !done() && !reduced) || needsDraw) rafId = requestAnimationFrame(tick);
  }
  function announceFinish() {
    var bits = runners.map(function (r) {
      return r.name + " " + (r.state === "diverged" ? "diverged" :
        r.atGlobal ? "reached the lowest point in " + r.steps + " steps" :
        "stopped at loss " + fmt(land.f(r.x, r.y)) + " after " + r.steps + " steps");
    });
    say("Race finished. " + bits.join(". ") + ".");
  }

  /* ── landscape picker ───────────────────────────────────────────────────── */
  function buildLands() {
    landsEl.textContent = "";
    LANDS.forEach(function (L, i) {
      var b = document.createElement("button");
      b.type = "button"; b.textContent = L.name;
      b.setAttribute("aria-pressed", L === land ? "true" : "false");
      b.addEventListener("click", function () { pickLand(i); });
      landsEl.appendChild(b);
    });
  }
  function syncLands() {
    var bs = landsEl.querySelectorAll("button");
    for (var i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", LANDS[i] === land ? "true" : "false");
    landNote.textContent = land.note;
  }
  function pickLand(i) {
    land = LANDS[i];
    startPt = land.start.slice();
    kbCursor = null;
    bgDirty = true;
    layout();
    syncLands();
    startRace(false);
    say(land.name + " landscape loaded. " + land.note);
  }

  /* ── input ──────────────────────────────────────────────────────────────── */
  function clampWorld(p) {
    var hx = W / (2 * scale), hy = H / (2 * scale);
    p[0] = Math.max(-hx + 0.05, Math.min(hx - 0.05, p[0]));
    p[1] = Math.max(-hy + 0.05, Math.min(hy - 0.05, p[1]));
    return p;
  }
  function dropAt(wx, wy) {
    // a perfectly flat ridge has zero slope in every direction, so nothing would
    // ever move; nudge a hair off the exact centre line of the saddle.
    if (land.id === "saddle" && Math.abs(wy) < 4e-3) wy = 4e-3;
    startPt = clampWorld([wx, wy]);
    startRace(true);
    dismissHint();
  }
  canvas.addEventListener("pointerdown", function (e) {
    if (e.cancelable) e.preventDefault();
    var r = canvas.getBoundingClientRect();
    var px = (e.clientX - r.left) * (W / r.width);
    var py = (e.clientY - r.top) * (H / r.height);
    kbCursor = null;
    canvas.focus();
    dropAt(wxOf(px), wyOf(py));
  });
  canvas.addEventListener("keydown", function (e) {
    var k = e.key, big = e.shiftKey ? 4 : 1, d = 0.09 * big, moved = false;
    if (!kbCursor && (k === "ArrowLeft" || k === "ArrowRight" || k === "ArrowUp" || k === "ArrowDown")) {
      kbCursor = startPt.slice();
    }
    if (k === "ArrowLeft") { kbCursor[0] -= d; moved = true; }
    else if (k === "ArrowRight") { kbCursor[0] += d; moved = true; }
    else if (k === "ArrowUp") { kbCursor[1] += d; moved = true; }
    else if (k === "ArrowDown") { kbCursor[1] -= d; moved = true; }
    else if (k === "Enter" || k === " " || k === "Spacebar") {
      e.preventDefault();
      var p = kbCursor || startPt;
      dropAt(p[0], p[1]);
      kbCursor = null;
      needsDraw = true; loop();
      return;
    }
    if (moved) {
      e.preventDefault();
      clampWorld(kbCursor);
      needsDraw = true; loop();
      say("Drop point x " + kbCursor[0].toFixed(2) + ", y " + kbCursor[1].toFixed(2) +
          ", loss " + fmt(land.f(kbCursor[0], kbCursor[1])) + ". Press Enter to drop.");
      dismissHint();
    }
  });
  canvas.addEventListener("blur", function () { if (kbCursor) { kbCursor = null; needsDraw = true; loop(); } });

  function lrFromSlider(v) { return 0.005 * Math.pow(100, v / 1000); }
  lrEl.addEventListener("input", function () {
    lr = lrFromSlider(+lrEl.value);
    lrV.textContent = lr < 0.01 ? lr.toFixed(4) : lr.toFixed(3);
    startRace(false);
  });
  playBtn.addEventListener("click", function () {
    if (reduced) {
      if (done()) {
        startRace(false, true);
        say("Race rewound to step 0. Press again to advance 25 steps at a time.");
      } else {
        advance(25); updateBoard(); verdict(); syncButtons();
        needsDraw = true; loop();
        if (done()) announceFinish(); else say("Now at " + stepCountEl.textContent + ".");
      }
      return;
    }
    if (done()) { startRace(false); say("Race restarted."); }
    else { playing = !playing; lastTs = 0; syncButtons(); loop(); say(playing ? "Playing." : "Paused."); }
  });
  resetBtn.addEventListener("click", function () {
    startPt = land.start.slice();
    kbCursor = null;
    startRace(true);
  });

  // reduced motion never animates, so say so where the play control lives
  function reducedMotionNote() {
    var p = document.createElement("p");
    p.className = "note";
    p.textContent = "Motion is off in your system settings, so each race resolves at once. Use Step ×25 to walk one forward.";
    playBtn.parentNode.parentNode.appendChild(p);
  }

  var hintTimer = 0;
  function dismissHint() {
    if (hint.hidden || hint.classList.contains("gone")) return;
    hint.classList.add("gone");
    clearTimeout(hintTimer);
    setTimeout(function () { hint.hidden = true; }, 500);
  }

  function onThemeChange() {
    readTheme();
    bgDirty = true; needsDraw = true; loop();
  }

  document.addEventListener("visibilitychange", function () {
    if (!document.hidden) { lastTs = 0; loop(); }
  });
  if (window.ResizeObserver) {
    new ResizeObserver(function () { layout(); loop(); }).observe(field);
  } else {
    window.addEventListener("resize", function () { layout(); loop(); });
  }

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  buildBoard();
  buildLands();
  syncLands();
  lr = lrFromSlider(+lrEl.value);
  lrV.textContent = lr.toFixed(3);
  layout();
  startRace(false);
  if (reduced) {
    hint.textContent = "click the map to drop them somewhere new";
    reducedMotionNote();
  } else {
    hintTimer = setTimeout(dismissHint, 9000);
  }
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong