$sloprun.dev

Boids Flocking

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

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

▶ 118 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

Four hundred birds, no leader and no plan: each one looks only at the neighbours inside its own small radius and obeys three rules — steer toward them, steer with them, don't crowd them — and a murmuration falls out of the arithmetic for free. Nothing in the code knows what a flock is, which is why the flock order readout measures it from the outside: the Vicsek order parameter, |Σv̂| / N over all 400 headings, where 1.00 is one mind and the number sags every time the flock tears into groups pulling different ways. Colour is earned the same way — each bird is shaded by how crowded it is right now, normalised against the flock's own density, so the thick of it burns amber while stragglers on the fringe fade to grey.

Try this first: click the field to drop a predator and watch order dive as the flock splits and reforms the way starlings do to a falcon. Then switch on motion trails and hit Highway, which braids everything into lanes and holds order up around 0.94. The field is a torus, so birds that leave one edge are still neighbours with the ones on the other side.

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/boids" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Boids Flocking — 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/boids

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1140 lines 47.4 KB index.html
demos/boids/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Boids Flocking — 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 additions (scrims need alpha, tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.88); --scrim-soft: rgba(246,246,243,.55); --shadow: 0 1px 2px rgba(26,29,33,.05); --lift: 0 6px 24px rgba(26,29,33,.16); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.88); --scrim-soft: rgba(20,22,26,.55); --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 28px rgba(0,0,0,.55); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.88); --scrim-soft: rgba(20,22,26,.55); --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 28px rgba(0,0,0,.55); }

* { 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: 1140px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

/* header */
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 66ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code {
  font-family: var(--font-mono); font-size: .87em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}

/* layout */
.stage { display: grid; grid-template-columns: minmax(0,1fr) 296px; gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }

.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; }
.tbsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
@media (max-width: 480px) { .tbsep { display: none; } }

/* the field */
.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background-color: var(--surface);
  background-image: radial-gradient(var(--line) 1.1px, transparent 1.2px);
  background-size: 26px 26px; background-position: -13px -13px;
  box-shadow: var(--shadow);
  /* fill the viewport height the header and footer leave behind, within sane bounds */
  height: clamp(300px, calc(100vh - 330px), 680px);
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; height: auto; } }
@media (max-width: 560px) { .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; }
/* clicking the field focuses it so Enter keeps working, but a mouse user should not
   get a keyboard focus ring for it — the class is dropped the moment they type */
canvas.ptr-focus:focus-visible { outline: none; }

/* hint chip over the field */
.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[hidden] { display: none; }
.hint.gone { opacity: 0; }

/* paused overlay */
.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: var(--scrim-soft); padding: 18px; text-align: center;
}
.overlay[hidden] { display: none; }
.overlay .box {
  display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 34ch;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 16px 18px; box-shadow: var(--lift);
}
.overlay p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--muted); }

/* panel */
.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 h3.sub {
  margin: 15px 0 8px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
}
.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;
}

/* stats */
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 11px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 20px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}
.stat .v.warn { color: var(--accent); }

.pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  padding: 3px 9px 3px 7px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }

/* controls */
.ctl { display: block; margin-bottom: 12px; }
.ctl:last-child { margin-bottom: 2px; }
.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(--muted);
  font-variant-numeric: tabular-nums;
}
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.35; 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; }

/* buttons */
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); }
button.big { font-size: 14px; padding: 9px 18px; }

.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.presets { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.presets button[aria-pressed="true"] {
  background: var(--accent-soft); color: var(--accent); border-color: var(--accent);
}

/* trails switch */
.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; margin-left: auto; }
@media (max-width: 560px) { .switch { margin-left: 0; } }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
  width: 34px; height: 19px; border-radius: 999px; background: var(--line);
  border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
  content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
  border-radius: 50%; background: var(--surface); transition: transform .15s ease;
  box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

/* legend */
.legend { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.legend .bar { flex: 1 1 auto; height: 6px; border-radius: 3px; }
.legend .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.legend .num { font-size: 11px; letter-spacing: 0; text-transform: none; color: var(--ink); font-variant-numeric: tabular-nums; min-width: 4ch; text-align: right; }
.cap { margin: 8px 0 0; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
.cap b { 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>Boids Flocking</h1>
    <p class="lede">
      400 birds, no leader, no plan. Each one follows three local rules —
      <b>steer toward</b> neighbours, <b>steer with</b> neighbours, <b>don't crowd</b> neighbours —
      and the murmuration falls out for free. Move the sliders mid-flight;
      <b>click the field</b> to drop a predator and watch the flock split around it.
    </p>
  </header>

  <main class="stage">

    <div class="left">
    <div class="field" id="field">
      <canvas id="c" tabindex="0" aria-label="Flock field: 400 simulated birds. Press Enter to drop a predator into the flock.">
        A canvas animation of 400 flocking birds. Your browser does not support canvas.
      </canvas>
      <div class="hint" id="hint">click the field to drop a predator</div>
      <div class="overlay" id="overlay" hidden>
        <div class="box">
          <button class="primary big" id="overlayPlay" type="button">▶&nbsp; Play simulation</button>
          <p id="overlayNote">Paused.</p>
        </div>
      </div>
    </div>

      <div class="card toolbar" role="group" aria-label="Simulation controls">
        <button type="button" class="primary" id="playBtn">Pause</button>
        <button type="button" id="resetBtn">Reset flock</button>
        <span class="tbsep" aria-hidden="true"></span>
        <button type="button" id="dropBtn">Drop predator</button>
        <button type="button" id="clearBtn" disabled>Clear</button>
        <label class="switch" for="trails">
          <input type="checkbox" id="trails">
          <span class="track" aria-hidden="true"></span>
          <span>Motion trails</span>
        </label>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Simulation readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sFps">—</span><span class="k">fps</span></div>
          <div class="stat"><span class="v" id="sOrder">—</span><span class="k">flock order</span></div>
          <div class="stat"><span class="v" id="sBoids">400</span><span class="k">boids</span></div>
          <div class="stat"><span class="v" id="sPred">0</span><span class="k">predators</span></div>
        </div>
        <span class="pill" id="pill"><span class="dot"></span><span id="pillText">idle</span></span>
        <div class="legend" aria-hidden="true">
          <span class="lbl">alone</span>
          <span class="bar" id="legendBar"></span>
          <span class="lbl">crowded</span>
        </div>
        <p class="cap">Colour shows crowding: <b>grey</b> on the fringe, <b>amber</b> in the thick of it. <b>Flock order</b> is how closely all 400 headings agree &mdash; <b>1.00</b> is one mind, and it sags whenever the flock splits into groups pulling different ways. Drop a predator and watch it fall.</p>
      </section>

      <section class="card" aria-label="Flocking rules">
        <h2>Rules</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="coh">Cohesion</label><span class="val" id="cohV" aria-hidden="true">1.30</span>
          </div>
          <input type="range" id="coh" min="0" max="2" step="0.05" value="1.3">
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="ali">Alignment</label><span class="val" id="aliV" aria-hidden="true">1.55</span>
          </div>
          <input type="range" id="ali" min="0" max="2" step="0.05" value="1.55">
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="sep">Separation</label><span class="val" id="sepV" aria-hidden="true">1.05</span>
          </div>
          <input type="range" id="sep" min="0" max="2" step="0.05" value="1.05">
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="spd">Speed</label><span class="val" id="spdV" aria-hidden="true">×1.00</span>
          </div>
          <input type="range" id="spd" min="0.3" max="2.5" step="0.05" value="1">
        </div>

        <h3 class="sub">Presets</h3>
        <div class="presets" id="presets" role="group" aria-label="Rule presets">
          <button type="button" data-p="0" aria-pressed="true">Murmuration</button>
          <button type="button" data-p="1" aria-pressed="false">Tight swarm</button>
          <button type="button" data-p="2" aria-pressed="false">Highway</button>
          <button type="button" data-p="3" aria-pressed="false">Scatter</button>
        </div>
      </section>

    </div>
  </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;

  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);
      readTheme();
      if (!active) draw(0);
    }
  });
  if (mqDark) {
    var onScheme = function () { readTheme(); if (!active) draw(0); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── colour reading ─────────────────────────────────────────────────────── */
  var COL = { surface: [255,255,255], muted: [90,96,104], accent: [192,86,0], danger: [201,60,55], line: [227,226,220] };
  var RAMP_N = 12, ramp = [];

  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, fallback) {
    var v = getComputedStyle(root).getPropertyValue(name);
    return hexToRgb(v) || fallback;
  }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  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", COL.surface);
    COL.muted   = tok("--muted",   COL.muted);
    COL.accent  = tok("--accent",  COL.accent);
    COL.danger  = tok("--danger",  COL.danger);
    COL.line    = tok("--line",    COL.line);
    ramp.length = 0;
    for (var k = 0; k < RAMP_N; k++) {
      var t = k / (RAMP_N - 1);
      var c = mix(COL.muted, COL.accent, t);
      ramp.push(rgba(c, 0.55 + 0.45 * t));
    }
    var lb = document.getElementById("legendBar");
    lb.style.background = "linear-gradient(90deg," + rgba(COL.muted, .7) + "," + rgba(COL.accent, 1) + ")";
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var field   = document.getElementById("field");
  var canvas  = document.getElementById("c");
  var ctx     = canvas.getContext("2d");
  var hint    = document.getElementById("hint");
  var overlay = document.getElementById("overlay");
  var overlayPlay = document.getElementById("overlayPlay");
  var overlayNote = document.getElementById("overlayNote");
  var sFps  = document.getElementById("sFps");
  var sPred = document.getElementById("sPred");
  var sOrder = document.getElementById("sOrder");
  var pill  = document.getElementById("pill");
  var pillText = document.getElementById("pillText");
  var live  = document.getElementById("live");
  var playBtn  = document.getElementById("playBtn");
  var resetBtn = document.getElementById("resetBtn");
  var dropBtn  = document.getElementById("dropBtn");
  var clearBtn = document.getElementById("clearBtn");
  var trailsEl = document.getElementById("trails");
  var presetsEl = document.getElementById("presets");
  var sliders = {
    coh: document.getElementById("coh"),
    ali: document.getElementById("ali"),
    sep: document.getElementById("sep"),
    spd: document.getElementById("spd")
  };
  var vals = {
    coh: document.getElementById("cohV"),
    ali: document.getElementById("aliV"),
    sep: document.getElementById("sepV"),
    spd: document.getElementById("spdV")
  };

  /* ── simulation state ───────────────────────────────────────────────────── */
  var N = 400;
  var px = new Float32Array(N), py = new Float32Array(N);
  var vx = new Float32Array(N), vy = new Float32Array(N);
  var ux = new Float32Array(N), uy = new Float32Array(N);   // unit headings, rebuilt each step
  var den = new Float32Array(N);                            // neighbours in view — drives colour

  var W = 0, H = 0, U = 30;           // U = mean spacing, the sim's length unit
  var R = 40, R2 = 1600, SEP = 22, SEP2 = 484;
  var BASE = 150, MAXS = 150, MAXF = 225, FLEE = 120, FLEE2 = 14400;
  var meanOrder = 0, orderInit = false, denAvg = 6;

  var wCoh = 1.3, wAli = 1.55, wSep = 1.05, spdMul = 1;
  var preds = [];
  var MAX_PRED = 6;

  var trails = false;
  var seeded = false;
  var userRunning = true;
  var active = false;
  var rafId = 0, lastT = 0;

  var reduced = !!(mqRM && mqRM.matches);

  /* ── spatial grid (toroidal) ────────────────────────────────────────────── */
  var cols = 1, rows = 1, cellW = 1, cellH = 1, buckets = [], useGrid = false;

  function rebuildGrid() {
    cols = Math.max(1, Math.floor(W / R));
    rows = Math.max(1, Math.floor(H / R));
    useGrid = (cols >= 3 && rows >= 3);
    cellW = W / cols; cellH = H / rows;
    var need = cols * rows;
    buckets = new Array(need);
    for (var i = 0; i < need; i++) buckets[i] = [];
  }

  function fillGrid() {
    for (var i = 0, n = buckets.length; i < n; i++) buckets[i].length = 0;
    for (var b = 0; b < N; b++) {
      var cx = Math.floor(px[b] / cellW); if (cx < 0) cx = 0; else if (cx >= cols) cx = cols - 1;
      var cy = Math.floor(py[b] / cellH); if (cy < 0) cy = 0; else if (cy >= rows) cy = rows - 1;
      buckets[cy * cols + cx].push(b);
    }
  }

  /* ── sizing ─────────────────────────────────────────────────────────────── */
  function resize() {
    var rect = field.getBoundingClientRect();
    var cw = Math.max(120, Math.round(rect.width));
    var ch = Math.max(120, Math.round(rect.height));
    if (cw === W && ch === H && canvas.width) return;

    var dpr = Math.min(2, window.devicePixelRatio || 1);
    canvas.width  = Math.round(cw * dpr);
    canvas.height = Math.round(ch * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    if (W > 0 && H > 0) {
      var sx = cw / W, sy = ch / H, i;
      for (i = 0; i < N; i++) { px[i] *= sx; py[i] *= sy; }
      for (i = 0; i < preds.length; i++) { preds[i].x *= sx; preds[i].y *= sy; }
    }
    W = cw; H = ch;
    U = Math.sqrt(W * H / N);
    R = 1.35 * U; R2 = R * R;
    SEP = 0.44 * R; SEP2 = SEP * SEP;
    BASE = 5.0 * U;
    FLEE = 4.2 * U; FLEE2 = FLEE * FLEE;
    rebuildGrid();
    if (!active && seeded) draw(0);
  }

  /* ── seeding ────────────────────────────────────────────────────────────── */
  function seed(coherent) {
    // Default: three loose clusters pointing three different ways, so the opening
    // seconds show them finding each other. Coherent: one flock already of one mind —
    // used when the sim starts paused, where the first frame is the ONLY frame.
    var base = Math.random() * Math.PI * 2;
    for (var i = 0; i < N; i++) {
      var gx, gy, spread, hd;
      if (coherent) {
        // two lobes ~57 deg apart: a single blob settles at a degenerate-looking
        // 0.99, two braided subflocks land where a real murmuration sits (~0.90)
        var lobe = i & 1;
        gx = (0.36 + 0.28 * lobe) * W; gy = (0.44 + 0.14 * lobe) * H;
        spread = Math.min(W, H) * 0.30;
        hd = base + (lobe ? 1.0 : 0) + (Math.random() - 0.5) * 0.7;
      } else {
        var g = i % 3;
        gx = (0.25 + 0.25 * g) * W; gy = (0.32 + 0.18 * ((g * 2) % 3)) * H;
        spread = Math.min(W, H) * 0.22;
        hd = (g / 3) * Math.PI * 2 + (Math.random() - 0.5) * 0.9;
      }
      var a = Math.random() * Math.PI * 2;
      var r = Math.pow(Math.random(), coherent ? 0.5 : 0.6) * spread;
      px[i] = ((gx + Math.cos(a) * r) % W + W) % W;
      py[i] = ((gy + Math.sin(a) * r) % H + H) % H;
      var sp = BASE * spdMul * (0.8 + Math.random() * 0.2);
      vx[i] = Math.cos(hd) * sp; vy[i] = Math.sin(hd) * sp;
    }
    orderInit = false;
    seeded = true;
  }

  // Run the sim forward without painting, inside a strict time budget, so a paused
  // demo can still show a settled flock and a truthful order readout.
  function warmUp(steps, budgetMs) {
    var t0 = (window.performance && performance.now) ? performance.now() : 0;
    for (var i = 0; i < steps; i++) {
      step(1 / 60);
      if ((i & 15) === 15 && t0 && performance.now() - t0 > budgetMs) break;
    }
    sOrder.textContent = meanOrder.toFixed(2);
  }

  /* ── predators ──────────────────────────────────────────────────────────── */
  function addPredator(x, y) {
    if (preds.length >= MAX_PRED) preds.shift();
    var a = Math.random() * Math.PI * 2;
    preds.push({ x: x, y: y, vx: Math.cos(a) * BASE * 0.6, vy: Math.sin(a) * BASE * 0.6, wander: a });
    syncPred();
    say(preds.length === 1 ? "Predator dropped. The flock scatters." : preds.length + " predators in the field.");
    dismissHint();
    if (!active) draw(0);
  }
  function clearPredators() {
    if (!preds.length) return;
    preds.length = 0;
    syncPred();
    say("Predators cleared.");
    if (!active) draw(0);
  }
  function syncPred() {
    sPred.textContent = String(preds.length);
    clearBtn.disabled = preds.length === 0;
  }

  /* ── physics step ───────────────────────────────────────────────────────── */
  var nbrCells = new Int32Array(9);

  function step(dt) {
    MAXS = BASE * spdMul;
    MAXF = 1.5 * MAXS;

    // one sqrt per boid: unit headings feed both the alignment rule and the colour ramp
    for (var u = 0; u < N; u++) {
      var um = Math.sqrt(vx[u] * vx[u] + vy[u] * vy[u]);
      if (um > 1e-6) { ux[u] = vx[u] / um; uy[u] = vy[u] / um; }
      else { ux[u] = 1; uy[u] = 0; }
    }

    if (useGrid) fillGrid();
    var denSum = 0;

    var hw = W * 0.5, hh = H * 0.5;
    var i, j, k;

    for (i = 0; i < N; i++) {
      var x = px[i], y = py[i], bvx = vx[i], bvy = vy[i];
      var cnt = 0, sepCnt = 0;
      var cx = 0, cy = 0, ax = 0, ay = 0, sx = 0, sy = 0;

      if (useGrid) {
        var gcx = Math.floor(x / cellW); if (gcx < 0) gcx = 0; else if (gcx >= cols) gcx = cols - 1;
        var gcy = Math.floor(y / cellH); if (gcy < 0) gcy = 0; else if (gcy >= rows) gcy = rows - 1;
        var m = 0;
        for (var oy = -1; oy <= 1; oy++) {
          var ry = (gcy + oy + rows) % rows;
          for (var ox = -1; ox <= 1; ox++) {
            nbrCells[m++] = ry * cols + ((gcx + ox + cols) % cols);
          }
        }
        for (k = 0; k < 9; k++) {
          var list = buckets[nbrCells[k]];
          for (var q = 0, ql = list.length; q < ql; q++) {
            j = list[q]; if (j === i) continue;
            var dx = px[j] - x; if (dx > hw) dx -= W; else if (dx < -hw) dx += W;
            var dy = py[j] - y; if (dy > hh) dy -= H; else if (dy < -hh) dy += H;
            var d2 = dx * dx + dy * dy;
            if (d2 > 0 && d2 < R2) {
              cnt++; cx += dx; cy += dy; ax += ux[j]; ay += uy[j];
              if (d2 < SEP2) {
                var d = Math.sqrt(d2);
                var f = (SEP - d) / SEP;
                sx -= (dx / d) * f; sy -= (dy / d) * f; sepCnt++;
              }
            }
          }
        }
      } else {
        for (j = 0; j < N; j++) {
          if (j === i) continue;
          var dx2 = px[j] - x; if (dx2 > hw) dx2 -= W; else if (dx2 < -hw) dx2 += W;
          var dy2 = py[j] - y; if (dy2 > hh) dy2 -= H; else if (dy2 < -hh) dy2 += H;
          var dd2 = dx2 * dx2 + dy2 * dy2;
          if (dd2 > 0 && dd2 < R2) {
            cnt++; cx += dx2; cy += dy2; ax += ux[j]; ay += uy[j];
            if (dd2 < SEP2) {
              var dd = Math.sqrt(dd2);
              var ff = (SEP - dd) / SEP;
              sx -= (dx2 / dd) * ff; sy -= (dy2 / dd) * ff; sepCnt++;
            }
          }
        }
      }

      var accx = 0, accy = 0, mag, tx, ty;

      den[i] = cnt; denSum += cnt;

      if (cnt > 0) {
        // alignment: match the average heading of neighbours
        if (wAli > 0) {
          tx = ax; ty = ay;
          mag = Math.sqrt(tx * tx + ty * ty);
          if (mag > 1e-6) {
            var adx = (tx / mag) * MAXS - bvx, ady = (ty / mag) * MAXS - bvy;
            var am = Math.sqrt(adx * adx + ady * ady);
            if (am > MAXF) { adx = adx / am * MAXF; ady = ady / am * MAXF; }
            accx += adx * wAli; accy += ady * wAli;
          }
        }
        // cohesion: steer toward the local centre of mass, but fade it out once you
        // are already at the centre — without this dead zone the flock collapses into
        // a single hard ball instead of a murmuration
        if (wCoh > 0) {
          tx = cx / cnt; ty = cy / cnt;
          mag = Math.sqrt(tx * tx + ty * ty);
          if (mag > 1e-6) {
            var near = mag / (0.30 * R); if (near > 1) near = 1;
            var cdx = (tx / mag) * MAXS - bvx, cdy = (ty / mag) * MAXS - bvy;
            var cm = Math.sqrt(cdx * cdx + cdy * cdy);
            if (cm > MAXF) { cdx = cdx / cm * MAXF; cdy = cdy / cm * MAXF; }
            accx += cdx * wCoh * near; accy += cdy * wCoh * near;
          }
        }
      }
      // separation: push away from anyone too close
      if (sepCnt > 0 && wSep > 0) {
        mag = Math.sqrt(sx * sx + sy * sy);
        if (mag > 1e-6) {
          var sdx = (sx / mag) * MAXS - bvx, sdy = (sy / mag) * MAXS - bvy;
          var sm = Math.sqrt(sdx * sdx + sdy * sdy);
          if (sm > MAXF) { sdx = sdx / sm * MAXF; sdy = sdy / sm * MAXF; }
          accx += sdx * wSep * 1.8; accy += sdy * wSep * 1.8;
        }
      }
      // flee: predators override everything nearby
      for (k = 0; k < preds.length; k++) {
        var p = preds[k];
        var pdx = p.x - x; if (pdx > hw) pdx -= W; else if (pdx < -hw) pdx += W;
        var pdy = p.y - y; if (pdy > hh) pdy -= H; else if (pdy < -hh) pdy += H;
        var pd2 = pdx * pdx + pdy * pdy;
        if (pd2 < FLEE2 && pd2 > 1e-6) {
          var pd = Math.sqrt(pd2);
          var w = 1 - pd / FLEE;
          var fdx = (-pdx / pd) * MAXS - bvx, fdy = (-pdy / pd) * MAXS - bvy;
          var fm = Math.sqrt(fdx * fdx + fdy * fdy);
          var lim = MAXF * 3;
          if (fm > lim) { fdx = fdx / fm * lim; fdy = fdy / fm * lim; }
          accx += fdx * w * 3.2; accy += fdy * w * 3.2;
        }
      }

      bvx += accx * dt; bvy += accy * dt;
      // birds cruise: steering turns them, it does not stall them. Hard turns bleed a
      // little speed, and they spend it back over the next fraction of a second.
      var s = Math.sqrt(bvx * bvx + bvy * bvy);
      if (s < 1e-4) {
        var ra = Math.random() * 6.2831853;
        bvx = Math.cos(ra) * MAXS; bvy = Math.sin(ra) * MAXS;
      } else {
        var want = s + (MAXS - s) * Math.min(1, dt * 6);
        if (want > MAXS) want = MAXS;
        else if (want < MAXS * 0.72) want = MAXS * 0.72;
        bvx = bvx / s * want; bvy = bvy / s * want;
      }
      vx[i] = bvx; vy[i] = bvy;
      var nx = x + bvx * dt, ny = y + bvy * dt;
      if (nx < 0) nx += W; else if (nx >= W) nx -= W;
      if (ny < 0) ny += H; else if (ny >= H) ny -= H;
      px[i] = nx; py[i] = ny;
    }

    // Flock order = the Vicsek order parameter, |Σ v̂| / N over ALL N birds: 1.00 when
    // every bird points the same way, ~0 when the headings cancel out. This is a global
    // measure on purpose — averaging *local* neighbour agreement instead reads ~0.95 even
    // with all three rules switched off, because neighbours are trivially alike.
    var gsx = 0, gsy = 0;
    for (i = 0; i < N; i++) {
      var gm = Math.sqrt(vx[i] * vx[i] + vy[i] * vy[i]);
      if (gm > 1e-6) { gsx += vx[i] / gm; gsy += vy[i] / gm; }
    }
    var orderNow = Math.sqrt(gsx * gsx + gsy * gsy) / N;
    // light easing (~0.2 s) so the digits read as an instrument, not a strobe
    if (!orderInit) { meanOrder = orderNow; orderInit = true; }
    else meanOrder += (orderNow - meanOrder) * Math.min(1, dt * 5);
    // the flock condenses far past uniform density, so normalise colour against the
    // flock's own current crowding, eased so the ramp never flickers
    denAvg += (denSum / N - denAvg) * Math.min(1, dt * 2);
    if (denAvg < 1) denAvg = 1;
    stepPredators(dt, hw, hh);
  }

  function stepPredators(dt, hw, hh) {
    var pspeed = MAXS * 0.9, hunt = 7 * U, hunt2 = hunt * hunt;
    for (var k = 0; k < preds.length; k++) {
      var p = preds[k], tx = 0, ty = 0, cnt = 0;
      for (var i = 0; i < N; i++) {
        var dx = px[i] - p.x; if (dx > hw) dx -= W; else if (dx < -hw) dx += W;
        var dy = py[i] - p.y; if (dy > hh) dy -= H; else if (dy < -hh) dy += H;
        if (dx * dx + dy * dy < hunt2) { tx += dx; ty += dy; cnt++; }
      }
      var dvx, dvy;
      if (cnt > 0) {
        var m = Math.sqrt(tx * tx + ty * ty);
        if (m > 1e-6) { dvx = (tx / m) * pspeed; dvy = (ty / m) * pspeed; }
        else { dvx = p.vx; dvy = p.vy; }
      } else {
        p.wander += (Math.random() - 0.5) * 1.6 * dt;
        dvx = Math.cos(p.wander) * pspeed; dvy = Math.sin(p.wander) * pspeed;
      }
      // ease toward the desired velocity so the chase reads as a curve, not a snap
      var e = Math.min(1, dt * 1.9);
      p.vx += (dvx - p.vx) * e; p.vy += (dvy - p.vy) * e;
      var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
      if (sp > pspeed) { p.vx = p.vx / sp * pspeed; p.vy = p.vy / sp * pspeed; }
      p.x += p.vx * dt; p.y += p.vy * dt;
      if (p.x < 0) p.x += W; else if (p.x >= W) p.x -= W;
      if (p.y < 0) p.y += H; else if (p.y >= H) p.y -= H;
      if (cnt === 0) p.wander = Math.atan2(p.vy, p.vx);
    }
  }

  /* ── rendering ──────────────────────────────────────────────────────────── */
  var groups = [];
  for (var gi = 0; gi < RAMP_N; gi++) groups.push([]);

  // triangle for one bird, appended to whatever path is already open
  function tri(x, y, hx, hy, L, Wd) {
    var bx = x - hx * L * 0.45, by = y - hy * L * 0.45;
    ctx.moveTo(x + hx * L * 0.55, y + hy * L * 0.55);
    ctx.lineTo(bx - hy * Wd, by + hx * Wd);
    ctx.lineTo(bx + hy * Wd, by - hx * Wd);
    ctx.closePath();
  }

  var offX = [0, 0], offY = [0, 0];

  // dt > 0 means "another animation frame"; dt <= 0 is a static repaint (paused, theme
  // swap, slider nudge). Static repaints must be idempotent: fading and re-stamping on
  // every `input` event would scrub the trails away and pile the predator ring up to
  // full opacity, so they always start from a clean canvas instead.
  function draw(dt) {
    if (!W || !H) return;

    var live = dt > 0;
    var fade = 1;
    if (trails && live) {
      fade = 1 - Math.pow(0.86, dt * 60);
      ctx.globalCompositeOperation = "destination-out";
      ctx.fillStyle = "rgba(0,0,0," + fade.toFixed(4) + ")";
      ctx.fillRect(0, 0, W, H);
      ctx.globalCompositeOperation = "source-over";
    } else {
      ctx.clearRect(0, 0, W, H);
    }

    var i, k, ox, oy, nox, noy;

    // predator influence fields, drawn under the flock. The field wraps like the sim
    // does, so a predator near an edge still shows the ring the birds across the seam
    // are actually fleeing from.
    for (k = 0; k < preds.length; k++) {
      var p = preds[k];
      // with trails on this ring is re-stamped every frame, so pre-scale it by the fade
      // rate to land on the same steady-state opacity as the no-trails path
      ctx.fillStyle = rgba(COL.danger, 0.055 * fade);
      ctx.strokeStyle = rgba(COL.danger, 0.22 * fade);
      ctx.lineWidth = 1;
      nox = 1; noy = 1; offX[0] = 0; offY[0] = 0;
      if (p.x < FLEE) { offX[1] = W; nox = 2; } else if (p.x > W - FLEE) { offX[1] = -W; nox = 2; }
      if (p.y < FLEE) { offY[1] = H; noy = 2; } else if (p.y > H - FLEE) { offY[1] = -H; noy = 2; }
      for (ox = 0; ox < nox; ox++) {
        for (oy = 0; oy < noy; oy++) {
          ctx.beginPath();
          ctx.arc(p.x + offX[ox], p.y + offY[oy], FLEE, 0, 6.2831853);
          ctx.fill(); ctx.stroke();
        }
      }
    }

    // bucket boids by crowding so we issue ~12 fills instead of 400 strokes
    for (k = 0; k < RAMP_N; k++) groups[k].length = 0;
    var dLo = 0.25 * denAvg, dSpan = 1.6 * denAvg;
    for (i = 0; i < N; i++) {
      var t = (den[i] - dLo) / dSpan;
      var b = (t * RAMP_N) | 0;
      if (b < 0) b = 0; else if (b >= RAMP_N) b = RAMP_N - 1;
      groups[b].push(i);
    }

    var L = Math.max(5, U * 0.30), Wd = L * 0.44;
    for (k = 0; k < RAMP_N; k++) {
      var g = groups[k];
      if (!g.length) continue;
      ctx.beginPath();
      for (var q = 0; q < g.length; q++) {
        var id = g[q];
        var hx = vx[id], hy = vy[id];
        var m = Math.sqrt(hx * hx + hy * hy) || 1;
        hx /= m; hy /= m;
        var x = px[id], y = py[id];
        tri(x, y, hx, hy, L, Wd);
        // the space is a torus, so a bird straddling an edge is drawn on both sides —
        // otherwise it winks out at the border instead of gliding through it
        var wrapX = x < L ? W : (x > W - L ? -W : 0);
        var wrapY = y < L ? H : (y > H - L ? -H : 0);
        if (wrapX) tri(x + wrapX, y, hx, hy, L, Wd);
        if (wrapY) tri(x, y + wrapY, hx, hy, L, Wd);
        if (wrapX && wrapY) tri(x + wrapX, y + wrapY, hx, hy, L, Wd);
      }
      ctx.fillStyle = ramp[k];
      ctx.fill();
    }

    // predators on top
    var PL = Math.max(11, U * 0.8), PW = PL * 0.46;
    for (k = 0; k < preds.length; k++) {
      var pr = preds[k];
      var pm = Math.sqrt(pr.vx * pr.vx + pr.vy * pr.vy) || 1;
      var ax = pr.vx / pm, ay = pr.vy / pm;
      ctx.fillStyle = rgba(COL.danger, 1);
      ctx.lineWidth = 1.5;
      ctx.strokeStyle = rgba(COL.surface, 0.85);
      nox = 1; noy = 1; offX[0] = 0; offY[0] = 0;
      if (pr.x < PL) { offX[1] = W; nox = 2; } else if (pr.x > W - PL) { offX[1] = -W; nox = 2; }
      if (pr.y < PL) { offY[1] = H; noy = 2; } else if (pr.y > H - PL) { offY[1] = -H; noy = 2; }
      for (ox = 0; ox < nox; ox++) {
        for (oy = 0; oy < noy; oy++) {
          var prx = pr.x + offX[ox], pry = pr.y + offY[oy];
          var pbx = prx - ax * PL * 0.45, pby = pry - ay * PL * 0.45;
          ctx.beginPath();
          ctx.moveTo(prx + ax * PL * 0.6, pry + ay * PL * 0.6);
          ctx.lineTo(pbx - ay * PW, pby + ax * PW);
          ctx.lineTo(pbx + ax * PL * 0.18, pby + ay * PL * 0.18);
          ctx.lineTo(pbx + ay * PW, pby - ax * PW);
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
        }
      }
    }
  }

  /* ── loop ───────────────────────────────────────────────────────────────── */
  var fCnt = 0, fShown = 0;

  function frame(now) {
    rafId = requestAnimationFrame(frame);
    var dt = (now - lastT) / 1000;
    lastT = now;
    if (!(dt > 0)) return;
    if (dt > 0.05) dt = 0.05;

    step(dt);
    draw(dt);

    fCnt++;
    // wall clock, not the clamped sim dt — otherwise a 10fps device reports 20
    var span = now - fShown;
    if (span > 260) {
      var fps = fCnt * 1000 / span;
      fShown = now; fCnt = 0;
      sFps.textContent = String(Math.round(fps));
      sFps.className = fps < 40 ? "v warn" : "v";
      sOrder.textContent = meanOrder.toFixed(2);
    }
  }

  function start() {
    if (active) return;
    active = true;
    lastT = performance.now();
    fCnt = 0; fShown = lastT;
    rafId = requestAnimationFrame(frame);
    setStatus();
  }
  function stop() {
    if (!active) return;
    active = false;
    cancelAnimationFrame(rafId);
    sFps.textContent = "—";
    sFps.className = "v";
    // land on the crisp still frame the static repaint path will keep redrawing, so a
    // paused canvas does not sit on a half-faded trail smear
    if (seeded) draw(0);
    setStatus();
  }

  function setStatus() {
    var on = active;
    pill.className = on ? "pill on" : "pill";
    pillText.textContent = on ? "running" : "paused";
    playBtn.textContent = userRunning ? "Pause" : "Play";
    playBtn.setAttribute("aria-label", userRunning ? "Pause simulation" : "Play simulation");
    overlay.hidden = userRunning;
  }

  function setRunning(on) {
    userRunning = on;
    if (on && !document.hidden) start(); else stop();
    setStatus();
    if (on) revealHint();
    say(on ? "Simulation running." : "Simulation paused.");
  }

  /* ── controls ───────────────────────────────────────────────────────────── */
  var PRESETS = [
    { name: "Murmuration", coh: 1.30, ali: 1.55, sep: 1.05, spd: 1.00 },
    { name: "Tight swarm", coh: 2.00, ali: 0.35, sep: 1.55, spd: 0.75 },
    { name: "Highway",     coh: 0.55, ali: 2.00, sep: 1.15, spd: 1.70 },
    { name: "Scatter",     coh: 0.10, ali: 0.20, sep: 1.90, spd: 1.35 }
  ];

  function fmt(key, v) { return key === "spd" ? "×" + v.toFixed(2) : v.toFixed(2); }

  function syncSlider(key) {
    var v = parseFloat(sliders[key].value);
    vals[key].textContent = fmt(key, v);
    if (key === "coh") wCoh = v;
    else if (key === "ali") wAli = v;
    else if (key === "sep") wSep = v;
    else spdMul = v;
  }

  Object.keys(sliders).forEach(function (key) {
    sliders[key].addEventListener("input", function () {
      syncSlider(key);
      markPreset(-1);
      if (!active) draw(0);
    });
  });

  function markPreset(idx) {
    var btns = presetsEl.querySelectorAll("button");
    for (var i = 0; i < btns.length; i++) {
      btns[i].setAttribute("aria-pressed", String(i === idx));
    }
  }

  presetsEl.addEventListener("click", function (e) {
    var btn = e.target.closest ? e.target.closest("button[data-p]") : null;
    if (!btn) return;
    var idx = parseInt(btn.getAttribute("data-p"), 10);
    var p = PRESETS[idx];
    sliders.coh.value = p.coh; sliders.ali.value = p.ali;
    sliders.sep.value = p.sep; sliders.spd.value = p.spd;
    ["coh", "ali", "sep", "spd"].forEach(syncSlider);
    markPreset(idx);
    say(p.name + " preset applied.");
    dismissHint();
    if (!active) draw(0);
  });

  trailsEl.addEventListener("change", function () {
    trails = trailsEl.checked;
    ctx.clearRect(0, 0, W, H);
    if (!active) draw(0);
  });

  playBtn.addEventListener("click", function () { setRunning(!userRunning); });
  overlayPlay.addEventListener("click", function () { setRunning(true); canvas.focus(); });
  // the scrim covers the whole field, so a click on it should mean "play", not nothing
  overlay.addEventListener("click", function (e) { if (e.target === overlay) setRunning(true); });

  resetBtn.addEventListener("click", function () {
    // reset means "back to the opening state", and the opening state differs: running
    // starts scattered so you can watch it organise, paused must land on a settled
    // still frame because no frame after this one is coming
    var still = !active;
    seed(still);
    preds.length = 0; syncPred();
    ctx.clearRect(0, 0, W, H);
    if (still) { warmUp(150, 110); draw(0); }
    say(still ? "Flock reset." : "Flock reset. 400 boids re-scattered.");
  });

  dropBtn.addEventListener("click", function () {
    var i = (Math.random() * N) | 0;      // drop it right on top of a bird — instant drama
    addPredator(px[i], py[i]);
  });
  clearBtn.addEventListener("click", clearPredators);

  function canvasPoint(e) {
    var r = canvas.getBoundingClientRect();
    return { x: (e.clientX - r.left) / r.width * W, y: (e.clientY - r.top) / r.height * H };
  }
  canvas.addEventListener("pointerdown", function (e) {
    if (e.button !== undefined && e.button !== 0) return;
    var p = canvasPoint(e);
    addPredator(p.x, p.y);
    canvas.classList.add("ptr-focus");
    canvas.focus();
  });
  canvas.addEventListener("blur", function () { canvas.classList.remove("ptr-focus"); });
  canvas.addEventListener("keydown", function (e) {
    canvas.classList.remove("ptr-focus");
    if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
      e.preventDefault();
      var i = (Math.random() * N) | 0;
      addPredator(px[i], py[i]);
    }
  });

  var hintTimer = 0, hintShown = false;
  // a reduced-motion visitor opens behind the play overlay, where "click the field"
  // would mean "resume", so the hint waits until they have actually pressed play
  function revealHint() {
    if (hintShown) return;
    hintShown = true;
    hint.hidden = false;
    hintTimer = setTimeout(dismissHint, 7000);
  }
  function dismissHint() {
    hintShown = true;
    if (hint.hidden || hint.classList.contains("gone")) return;
    hint.classList.add("gone");
    clearTimeout(hintTimer);
    setTimeout(function () { hint.hidden = true; }, 500);
  }

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

  /* ── lifecycle ──────────────────────────────────────────────────────────── */
  document.addEventListener("visibilitychange", function () {
    if (document.hidden) stop();
    else if (userRunning) start();
  });

  if (window.ResizeObserver) {
    new ResizeObserver(function () { resize(); }).observe(field);
  } else {
    window.addEventListener("resize", resize);
  }
  window.addEventListener("resize", resize);

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  resize();
  // pin the rules to the Murmuration preset before seeding: a browser that restores
  // form values across a reload must not leave the sliders, the highlighted preset
  // and the seed speed disagreeing with each other
  sliders.coh.value = PRESETS[0].coh; sliders.ali.value = PRESETS[0].ali;
  sliders.sep.value = PRESETS[0].sep; sliders.spd.value = PRESETS[0].spd;
  ["coh", "ali", "sep", "spd"].forEach(syncSlider);
  markPreset(0);
  syncPred();

  if (reduced) {
    userRunning = false;
    trailsEl.checked = false; trails = false;
    overlayNote.textContent = "Your system asks for reduced motion, so this simulation starts paused. It animates continuously once you press play.";
    // The still frame is the only frame a reduced-motion visitor sees before they opt
    // in, so seed a flock that is already of one mind and settle it just long enough
    // for real local structure — the raw three-cluster seed reads as random confetti,
    // and integrating it to a murmuration takes ~15s of sim, far past any load budget.
    seed(true);
    warmUp(150, 110);
    draw(0);
    setStatus();
    hint.hidden = true;
  } else {
    seed();
    trails = trailsEl.checked;   // honour a checkbox the browser restored on reload
    setStatus();
    start();
    revealHint();
  }
})();
</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