$sloprun.dev

Snake

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

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

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

It is already playing when you arrive — a small solver drives the snake, and it only chases the apple when it can still trace a path back to its own tail, so it survives long enough to be worth watching. Press an arrow key, WASD, or tap the pad and the board is yours: every apple adds a cell and a little speed, dragging the board from under 7 cells a second up to 16, and apples pay out more the longer you get. Turns are buffered two deep, which is the whole difference between a game that feels crisp and one that feels like it is ignoring you — a fast up → right around a corner always lands. Try it first on solid edges, then switch to wrap around: same board, completely different game.

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

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1179 lines 48.3 KB index.html
demos/snake/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">
<link rel="icon" href="data:,">
<title>Snake — 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,.66); --shadow: 0 1px 2px rgba(26,29,33,.05); --lift: 0 8px 28px rgba(26,29,33,.14); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.66); --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 28px rgba(0,0,0,.5); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.66); --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 28px rgba(0,0,0,.5); }

* { 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: 1000px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}
@media (max-width: 420px) { .wrap { padding: 16px 12px 20px; gap: 13px; } }

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 kbd {
  font-family: var(--font-mono); font-size: .84em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px; border: 0;
}

.stage { display: grid; grid-template-columns: minmax(0,1fr) 268px; gap: 16px; align-items: start; }
.boardwrap { grid-column: 1; grid-row: 1; }
.panel     { grid-column: 2; grid-row: 1 / span 2; }
.keyscard  { grid-column: 1; grid-row: 2; }
@media (max-width: 880px) {
  .stage { grid-template-columns: minmax(0,1fr); }
  .boardwrap, .panel, .keyscard { grid-column: auto; grid-row: auto; }
  .steercard { order: -1; }   /* thumbs first: the pad sits right under the board */
}

.boardwrap { display: flex; flex-direction: column; align-items: center; gap: 9px; min-width: 0; }
.board {
  position: relative; width: 100%; max-width: 620px; aspect-ratio: 1 / 1;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; box-shadow: var(--shadow);
}
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.hint {
  margin: 0; min-height: 15px; text-align: center; max-width: 100%;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); transition: opacity .35s ease;
}
.hint.off { opacity: 0; }
@media (max-width: 420px) { .hint { font-size: 10px; line-height: 1.35; } }

.pops { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pop {
  position: absolute; transform: translate(-50%, -50%);
  font-family: var(--font-mono); font-size: 13px; font-weight: 600;
  color: var(--run); white-space: nowrap;
  text-shadow: 0 0 3px var(--surface), 0 0 3px var(--surface);
}
@media (prefers-reduced-motion: no-preference) {
  .pop { animation: rise .85s cubic-bezier(.2,.7,.3,1) forwards; }
}
@keyframes rise {
  0%   { opacity: 0; transform: translate(-50%, -30%) scale(.85); }
  22%  { opacity: 1; transform: translate(-50%, -60%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -170%) scale(1); }
}

.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: var(--scrim); padding: 16px; text-align: center;
}
.overlay[hidden] { display: none; }
.overlay .box {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  max-width: 34ch; padding: 18px 20px 16px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--lift);
}
@media (max-width: 420px) { .overlay .box { padding: 14px 14px 12px; gap: 8px; } }
.overlay h2 { margin: 0; font-size: clamp(19px, 4.4vw, 25px); letter-spacing: -.02em; line-height: 1.1; }
.overlay p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--muted); }
.overlay .final {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--ink);
  font-variant-numeric: tabular-nums; letter-spacing: .01em;
}
.overlay .keyhint { font-family: var(--font-mono); font-size: 11px; letter-spacing: .03em; }
.tag {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; color: var(--muted); background: var(--bg);
}
.tag.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.tag.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); }

.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.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;
}

.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 11px 8px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 19px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap;
}
.stat .v.sm { font-size: 15px; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}
.stat .v.good { color: var(--run); }
.stat .v .u { font-size: .62em; color: var(--muted); letter-spacing: 0; }

.barrow { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 12px; }
.barrow .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.barrow .num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.bar { height: 6px; border-radius: 3px; background: var(--line); margin-top: 7px; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 3px; transition: width .2s ease; }

.pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 12px;
  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.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.ok .dot { background: var(--run); }
.pill.live { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.live .dot { background: var(--accent); }
.pill.bad { color: var(--danger); border-color: var(--danger); background: var(--surface); }
.pill.bad .dot { background: var(--danger); }

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;
  -webkit-tap-highlight-color: transparent;
}
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; }
.seg { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
.seg.two { grid-template-columns: repeat(2, minmax(0,1fr)); }
.seg button { padding: 7px 4px; font-size: 12.5px; }
.seg button[aria-pressed="true"] {
  background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600;
}

@media (max-width: 880px) {
  button { padding: 11px 12px; }        /* roomier thumb targets */
  .seg button { padding: 12px 4px; }
  button.big { padding: 12px 20px; }
}
.sublbl {
  display: block; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
.sublbl.gap { margin-top: 12px; }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap code { font-family: var(--font-mono); font-size: .92em; color: var(--ink); }

/* d-pad */
.dpad {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
  width: 100%; max-width: 186px; margin: 0 auto;
}
@media (max-width: 880px) { .dpad { max-width: 234px; gap: 8px; } }
.dpad button {
  aspect-ratio: 1 / 1; display: grid; place-items: center; padding: 0;
  font-size: 16px; line-height: 1; color: var(--muted);
}
.dpad button:hover:not(:disabled) { color: var(--accent); }
.dpad button.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.dpad .gap { visibility: hidden; pointer-events: none; }
.dpad .mid { color: var(--ink); }
.dpad .mid.hot { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
/* the middle key is a transport control, not a direction — the ring keeps its
   play triangle from reading as another ► steer arrow right next to it */
.ico {
  display: block; width: 21px; height: 21px; position: relative;
  border: 1.6px solid currentColor; border-radius: 50%;
}
.ico.pause::before, .ico.pause::after {
  content: ""; position: absolute; top: 6px; width: 2.5px; height: 9px;
  background: currentColor; border-radius: 1px;
}
.ico.pause::before { left: 6px; } .ico.pause::after { right: 6px; }
.ico.play::before {
  content: ""; position: absolute; top: 50%; left: 57%; transform: translate(-50%, -50%);
  border-left: 7px solid currentColor; border-top: 4.5px solid transparent; border-bottom: 4.5px solid transparent;
}

.keys { display: flex; flex-direction: column; gap: 6px; margin: 0; }
.keys.keyrow { flex-direction: row; flex-wrap: wrap; gap: 8px 20px; }
.keys div { display: flex; align-items: baseline; gap: 9px; font-size: 12.5px; color: var(--muted); }
.keys kbd {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px;
  border-radius: 5px; padding: 2px 6px; min-width: 5.4em; text-align: center; flex: none;
}

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>Snake</h1>
    <p class="lede">
      One snake, one apple, and a board that speeds up as you grow. It is
      <b>already playing itself</b> — press <kbd>&larr;</kbd> <kbd>&rarr;</kbd>, <kbd>WASD</kbd>,
      or tap the pad to take the board.
    </p>
  </header>

  <main class="stage">

    <div class="boardwrap">
      <div class="board" id="board">
        <canvas id="c" tabindex="0" aria-label="Snake board. Arrow keys or W A S D steer; space pauses.">
          A canvas game: a snake chases an apple around a grid. Your browser does not support canvas.
        </canvas>
        <div class="pops" id="pops" aria-hidden="true"></div>
        <div class="overlay" id="overlay" hidden>
          <div class="box">
            <span class="tag" id="ovTag">paused</span>
            <h2 id="ovTitle">Paused</h2>
            <p id="ovNote">Nothing is moving.</p>
            <p class="final" id="ovFinal" hidden></p>
            <button class="primary big" id="ovBtn" type="button">Resume</button>
            <p class="keyhint" id="ovKey">space to resume</p>
          </div>
        </div>
      </div>
      <p class="hint" id="hint"><span id="hintText">watching the demo play &middot; press an arrow to take over</span></p>
    </div>

    <div class="panel">

      <section class="card" aria-label="Run readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sScore">0</span><span class="k">score</span></div>
          <div class="stat"><span class="v" id="sBest">&mdash;</span><span class="k">your best</span></div>
          <div class="stat"><span class="v" id="sLen">3</span><span class="k">length</span></div>
          <div class="stat"><span class="v" id="sApples">0</span><span class="k">apples</span></div>
          <div class="stat"><span class="v sm" id="sSpeed">6.7<span class="u"> c/s</span></span><span class="k">speed</span></div>
          <div class="stat"><span class="v sm" id="sNext">+10</span><span class="k">next apple</span></div>
        </div>
        <div class="barrow"><span class="lbl">pace</span><span class="num" id="sPace">calm</span></div>
        <div class="bar"><i id="barFill"></i></div>
        <span class="pill live" id="pill"><span class="dot"></span><span id="pillText">demo playing</span></span>
      </section>

      <section class="card steercard" aria-label="Steering pad">
        <h2>Steer</h2>
        <div class="dpad" id="dpad">
          <button type="button" class="gap" tabindex="-1" aria-hidden="true"></button>
          <button type="button" data-d="up" aria-label="Steer up">&#9650;</button>
          <button type="button" class="gap" tabindex="-1" aria-hidden="true"></button>
          <button type="button" data-d="left" aria-label="Steer left">&#9668;</button>
          <button type="button" class="mid" id="midBtn" aria-label="Pause"><span class="ico pause" id="midIco"></span></button>
          <button type="button" data-d="right" aria-label="Steer right">&#9658;</button>
          <button type="button" class="gap" tabindex="-1" aria-hidden="true"></button>
          <button type="button" data-d="down" aria-label="Steer down">&#9660;</button>
          <button type="button" class="gap" tabindex="-1" aria-hidden="true"></button>
        </div>
        <p class="cap">Turns are buffered two deep, so a fast <code>up &rarr; right</code> around a corner always lands. You can also swipe on the board.</p>
      </section>

      <section class="card" aria-label="Game controls">
        <h2>Game</h2>
        <div class="row">
          <button type="button" class="primary" id="mainBtn">Take over</button>
          <button type="button" id="watchBtn">Watch demo</button>
        </div>
      </section>

      <section class="card" aria-label="Board rules">
        <h2>Rules</h2>
        <span class="sublbl" id="lblEdges">Edges</span>
        <div class="seg two" id="edges" role="group" aria-labelledby="lblEdges">
          <button type="button" data-e="0" aria-pressed="true">Solid</button>
          <button type="button" data-e="1" aria-pressed="false">Wrap around</button>
        </div>
        <span class="sublbl gap" id="lblSize">Cells per side</span>
        <div class="seg" id="sizes" role="group" aria-labelledby="lblSize">
          <button type="button" data-n="15" aria-pressed="false">15</button>
          <button type="button" data-n="21" aria-pressed="true">21</button>
          <button type="button" data-n="27" aria-pressed="false">27</button>
        </div>
        <p class="cap" id="edgeCap">Hit a wall and the run ends. Same board, much less room to think. Changing either setting starts a fresh run.</p>
      </section>

    </div>

    <section class="card keyscard" aria-label="Keys">
      <h2>Keys</h2>
      <div class="keys keyrow">
        <div><kbd>&larr; &uarr; &rarr; &darr;</kbd><span>steer</span></div>
        <div><kbd>W A S D</kbd><span>steer</span></div>
        <div><kbd>space</kbd><span>pause &middot; resume &middot; play again</span></div>
        <div><kbd>R</kbd><span>new run</span></div>
      </div>
    </section>
  </main>

  <footer>demo &middot; 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);
      readTheme();
    }
  });
  if (mqDark) {
    if (mqDark.addEventListener) mqDark.addEventListener("change", readTheme);
    else if (mqDark.addListener) mqDark.addListener(readTheme);
  }
  /* also follow data-theme when a host sets the attribute directly (no message) */
  if (window.MutationObserver) {
    new MutationObserver(function () { readTheme(); })
      .observe(root, { attributes: true, attributeFilter: ["data-theme"] });
  }
  if (mqRM) {
    var onRM = function () { reduced = mqRM.matches; };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  /* ── colours read from the tokens ───────────────────────────────────────── */
  var COL = {
    bg: [246,246,243], surface: [255,255,255], ink: [26,29,33], muted: [90,96,104],
    line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55]
  };
  var TAIL = [150,150,150];
  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);
    if (h.length !== 6) return null;
    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 rgb(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
  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.bg      = tok("--bg", COL.bg);
    COL.surface = tok("--surface", COL.surface);
    COL.ink     = tok("--ink", COL.ink);
    COL.muted   = tok("--muted", COL.muted);
    COL.line    = tok("--line", COL.line);
    COL.accent  = tok("--accent", COL.accent);
    COL.run     = tok("--run", COL.run);
    COL.danger  = tok("--danger", COL.danger);
    TAIL = mix(COL.surface, COL.ink, 0.52);
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var boardEl = document.getElementById("board");
  var canvas  = document.getElementById("c");
  var ctx     = canvas.getContext("2d");
  var popsEl  = document.getElementById("pops");
  var hintEl  = document.getElementById("hint");
  var hintTx  = document.getElementById("hintText");
  var overlay = document.getElementById("overlay");
  var ovTag   = document.getElementById("ovTag");
  var ovTitle = document.getElementById("ovTitle");
  var ovNote  = document.getElementById("ovNote");
  var ovFinal = document.getElementById("ovFinal");
  var ovBtn   = document.getElementById("ovBtn");
  var ovKey   = document.getElementById("ovKey");
  var sScore  = document.getElementById("sScore");
  var sBest   = document.getElementById("sBest");
  var sLen    = document.getElementById("sLen");
  var sApples = document.getElementById("sApples");
  var sSpeed  = document.getElementById("sSpeed");
  var sNext   = document.getElementById("sNext");
  var sPace   = document.getElementById("sPace");
  var barFill = document.getElementById("barFill");
  var pill    = document.getElementById("pill");
  var pillTx  = document.getElementById("pillText");
  var dpad    = document.getElementById("dpad");
  var midBtn  = document.getElementById("midBtn");
  var midIco  = document.getElementById("midIco");
  var mainBtn = document.getElementById("mainBtn");
  var watchBtn= document.getElementById("watchBtn");
  var edgesEl = document.getElementById("edges");
  var edgeCap = document.getElementById("edgeCap");
  var sizesEl = document.getElementById("sizes");
  var live    = document.getElementById("live");

  /* ── model ──────────────────────────────────────────────────────────────── */
  var UP = {x:0,y:-1}, DOWN = {x:0,y:1}, LEFT = {x:-1,y:0}, RIGHT = {x:1,y:0};
  var DIRLIST = [UP, RIGHT, DOWN, LEFT];
  var DIRNAME = { up: UP, down: DOWN, left: LEFT, right: RIGHT };

  var TICK_SLOW = 150, TICK_FAST = 62, RAMP = 3.4;

  var N = 21;                 // cells per side
  var wrap = false;           // edges
  var mode = "demo";          // "demo" | "you"
  var state = "run";          // "run" | "paused" | "over" | "won"

  var body = [], occ = null, food = {x:0,y:0}, dir = RIGHT, old = null;
  var queue = [];             // buffered turns, max 2
  var score = 0, apples = 0, best = 0, bestAtStart = 0, newBest = false;
  var elapsed = 0, crash = null, deadReason = "";
  var rings = [], shake = 0, demoTimer = 0;

  var blk = null, vis = null, dist = null, bq = null;
  function allocScratch() {
    var s = N * N;
    blk = new Uint8Array(s); vis = new Uint8Array(s);
    dist = new Int16Array(s); bq = new Int32Array(s);
    occ = new Uint8Array(s);
  }

  function head() { return body[0]; }
  function tickMs() {
    return Math.max(TICK_FAST, TICK_SLOW - (body.length - 3) * RAMP);
  }
  function valueFor(len) { return 10 + 5 * Math.floor(Math.max(0, len - 3) / 5); }
  function paceWord(ms) {
    var f = (TICK_SLOW - ms) / (TICK_SLOW - TICK_FAST);
    if (f < 0.18) return "calm";
    if (f < 0.42) return "steady";
    if (f < 0.66) return "brisk";
    if (f < 0.88) return "quick";
    return "flat out";
  }

  function reset(startDir) {
    allocScratch();
    dir = startDir || RIGHT;
    var c = Math.floor(N / 2);
    body = [];
    for (var i = 0; i < 3; i++) {
      var x = ((c - dir.x * i) % N + N) % N;
      var y = ((c - dir.y * i) % N + N) % N;
      body.push({ x: x, y: y });
      occ[y * N + x] = 1;
    }
    old = null; queue = []; rings = []; crash = null; deadReason = "";
    score = 0; apples = 0; elapsed = 0; shake = 0; newBest = false;
    bestAtStart = best;
    placeFood();
  }

  function placeFood() {
    var free = [], h = head();
    for (var y = 0; y < N; y++) for (var x = 0; x < N; x++) {
      if (!occ[y * N + x]) free.push(y * N + x);
    }
    if (!free.length) { winRun(); return; }
    var far = [];
    for (var i = 0; i < free.length; i++) {
      var fx = free[i] % N, fy = (free[i] - fx) / N;
      if (Math.abs(fx - h.x) + Math.abs(fy - h.y) >= 3) far.push(free[i]);
    }
    var pool = far.length ? far : free;
    var pick = pool[(Math.random() * pool.length) | 0];
    food.x = pick % N; food.y = (pick - food.x) / N;
  }

  /* ── the demo driver: greedy toward the apple, but only when it can still
        trace a path back to its own tail ─────────────────────────────────── */
  function evalMove(d) {
    var h = head(), nx = h.x + d.x, ny = h.y + d.y;
    if (wrap) { nx = (nx + N) % N; ny = (ny + N) % N; }
    else if (nx < 0 || ny < 0 || nx >= N || ny >= N) return null;

    var idx = ny * N + nx, L = body.length;
    var tail = body[L - 1], tIdx = tail.y * N + tail.x;
    var eats = (nx === food.x && ny === food.y);
    if (occ[idx] && !(!eats && idx === tIdx)) return null;

    blk.set(occ);
    if (!eats) blk[tIdx] = 0;
    blk[idx] = 1;
    var nt = eats ? tail : (L >= 2 ? body[L - 2] : { x: nx, y: ny });
    var ntIdx = nt.y * N + nt.x;
    blk[ntIdx] = 0;

    vis.fill(0);
    var qh = 0, qt = 0;
    bq[qt++] = idx; vis[idx] = 1; dist[idx] = 0;
    var space = 0, fIdx = food.y * N + food.x, fd = eats ? 0 : -1;
    while (qh < qt) {
      var cur = bq[qh++], cx = cur % N, cy = (cur - cx) / N, cd = dist[cur];
      for (var k = 0; k < 4; k++) {
        var dd = DIRLIST[k], ax = cx + dd.x, ay = cy + dd.y;
        if (wrap) { ax = (ax + N) % N; ay = (ay + N) % N; }
        else if (ax < 0 || ay < 0 || ax >= N || ay >= N) continue;
        var ai = ay * N + ax;
        if (vis[ai] || blk[ai]) continue;
        vis[ai] = 1; dist[ai] = cd + 1; bq[qt++] = ai; space++;
        if (ai === fIdx && fd < 0) fd = cd + 1;
      }
    }
    return { d: d, safe: vis[ntIdx] === 1, space: space, dist: fd };
  }

  function demoDir() {
    var opts = [], i;
    for (i = 0; i < 4; i++) {
      var d = DIRLIST[i];
      if (d.x === -dir.x && d.y === -dir.y) continue;
      var r = evalMove(d);
      if (r) opts.push(r);
    }
    if (!opts.length) return dir;
    var safe = [];
    for (i = 0; i < opts.length; i++) if (opts[i].safe) safe.push(opts[i]);
    var pool = safe.length ? safe : opts;
    var chase = [];
    for (i = 0; i < pool.length; i++) if (pool[i].dist >= 0) chase.push(pool[i]);
    var use = chase.length ? chase : pool;
    var bestO = null, bs = -Infinity;
    for (i = 0; i < use.length; i++) {
      var o = use[i];
      var s = chase.length ? (-o.dist * 1000 + o.space) : (o.space * 10);
      if (o.d === dir) s += 3;
      if (s > bs) { bs = s; bestO = o; }
    }
    return bestO ? bestO.d : dir;
  }

  /* ── one step ───────────────────────────────────────────────────────────── */
  function tick() {
    if (mode === "demo") { dir = demoDir(); }
    else if (queue.length) { dir = queue.shift(); }

    old = [];
    for (var i = 0; i < body.length; i++) old.push({ x: body[i].x, y: body[i].y });

    var h = body[0], nx = h.x + dir.x, ny = h.y + dir.y;
    if (wrap) { nx = (nx + N) % N; ny = (ny + N) % N; }
    else if (nx < 0 || ny < 0 || nx >= N || ny >= N) { die(nx, ny, "wall"); return; }

    var idx = ny * N + nx, L = body.length;
    var tail = body[L - 1], tIdx = tail.y * N + tail.x;
    var eats = (nx === food.x && ny === food.y);
    if (occ[idx] && !(!eats && idx === tIdx)) { die(nx, ny, "self"); return; }

    if (!eats) { body.pop(); occ[tIdx] = 0; }
    body.unshift({ x: nx, y: ny }); occ[idx] = 1;
    if (eats) eat(nx, ny);
    sync();
  }

  /* one definition of "this run is your best", used by the overlay, the pill and
     the readout so they can never disagree. `newBest` alone means "you passed a
     score you had already set"; the first scoring run has nothing to pass. */
  function bestRun() {
    return newBest || (mode === "you" && score > 0 && bestAtStart === 0 && score >= best);
  }

  function eat(x, y) {
    apples++;
    var val = valueFor(body.length - 1);
    score += val;
    addPop(x, y, "+" + val);
    if (!reduced) rings.push({ x: x, y: y, t: 0 });
    if (mode === "you" && score > best) {
      best = score;
      if (bestAtStart > 0 && !newBest) { newBest = true; say("New best: " + score + "."); }
    }
    placeFood();
  }

  function die(x, y, reason) {
    crash = { x: x, y: y };
    deadReason = reason;
    old = null;
    if (!reduced) shake = 7;
    if (mode === "you") {
      state = "over";
      say("Run over. Score " + score + ", length " + body.length + ".");
      showOverlay();
    } else {
      state = "over";
      demoTimer = 1500;
    }
    sync();
  }

  function winRun() {
    old = null;
    state = "won";
    if (mode === "you") {
      if (score > best) { best = score; if (bestAtStart > 0) newBest = true; }
      showOverlay(); say("Board full. Perfect run.");
    }
    else demoTimer = 2200;
    sync();
  }

  /* ── state moves ────────────────────────────────────────────────────────── */
  function startDemo() {
    mode = "demo"; state = "run";
    reset(RIGHT);
    hintTx.textContent = "watching the demo play · press an arrow to take over";
    hintEl.classList.remove("off");
    hideOverlay(); sync();
  }
  function takeOver(d) {
    mode = "you"; state = "run";
    reset(d || RIGHT);
    hintTx.textContent = "your run · space pauses · R for a fresh one";
    hintEl.classList.remove("off");
    hideOverlay(); sync();
    setTimeout(function () { if (mode === "you" && state === "run") hintEl.classList.add("off"); }, 3000);
  }
  function pause() {
    if (mode !== "you" || state !== "run") return;
    state = "paused"; showOverlay(); say("Paused."); sync();
  }
  function resume() {
    if (state !== "paused") return;
    state = "run"; hideOverlay(); say("Resumed."); sync();
  }
  function again() { takeOver(dir); }

  function showOverlay() {
    if (state === "paused") {
      ovTag.className = "tag"; ovTag.textContent = "paused";
      ovTitle.textContent = "Paused";
      ovNote.textContent = "The board is holding still.";
      ovFinal.hidden = false;
      ovFinal.textContent = "score " + score + " · length " + body.length;
      ovBtn.textContent = "Resume";
      ovKey.textContent = "space to resume";
    } else if (state === "won") {
      ovTag.className = "tag ok"; ovTag.textContent = "board full";
      ovTitle.textContent = "Perfect run";
      ovNote.textContent = "Every cell is snake. There is nowhere left to put an apple.";
      ovFinal.hidden = false;
      ovFinal.textContent = "score " + score + " · length " + body.length + " · " + clock(elapsed);
      ovBtn.textContent = "Play again";
      ovKey.textContent = "space to play again";
    } else {
      var isBest = bestRun();
      ovTag.className = isBest ? "tag ok" : "tag bad";
      ovTag.textContent = isBest ? "new best" : (deadReason === "wall" ? "hit the wall" : "hit itself");
      ovTitle.textContent = isBest ? "Best run yet"
        : (score >= 100 ? "Good run" : (score === 0 ? "That was quick" : "Run over"));
      ovNote.textContent = deadReason === "wall"
        ? "The head ran off the edge. Try the wrap-around edges for more room."
        : "The head ran into the body. Longer snake, tighter corners.";
      ovFinal.hidden = false;
      ovFinal.textContent = "score " + score + " · length " + body.length +
        " · " + apples + " apples · " + clock(elapsed);
      ovBtn.textContent = "Play again";
      ovKey.textContent = "space or R to play again";
    }
    overlay.hidden = false;
    try { ovBtn.focus({ preventScroll: true }); } catch (e) { ovBtn.focus(); }
  }
  function hideOverlay() {
    var inside = overlay.contains(document.activeElement);
    overlay.hidden = true;
    if (inside && canvas.focus) { try { canvas.focus({ preventScroll: true }); } catch (e) { canvas.focus(); } }
  }

  function clock(ms) {
    var s = Math.floor(ms / 1000);
    var m = Math.floor(s / 60);
    var r = s % 60;
    return m + ":" + (r < 10 ? "0" : "") + r;
  }
  function say(t) { live.textContent = t; }

  /* ── readout ────────────────────────────────────────────────────────────── */
  function sync() {
    var ms = tickMs();
    var ended = (state === "over" || state === "won");
    var wonBest = ended && bestRun();     // celebrate only once the run is done
    sScore.textContent = score;
    sBest.textContent = best > 0 ? best : "—";
    sBest.className = "v" + (best > 0 && (newBest || wonBest) ? " good" : "");
    sLen.textContent = body.length;
    sApples.textContent = apples;
    sSpeed.innerHTML = (1000 / ms).toFixed(1) + '<span class="u"> c/s</span>';
    sNext.textContent = "+" + valueFor(body.length);
    sPace.textContent = paceWord(ms);
    var f = (TICK_SLOW - ms) / (TICK_SLOW - TICK_FAST);
    barFill.style.width = Math.round(Math.max(0, Math.min(1, f)) * 100) + "%";

    if (mode === "demo") {
      pill.className = "pill live";
      pillTx.textContent = state === "run" ? "demo playing" : "demo run ended";
    } else if (state === "run") {
      pill.className = newBest ? "pill ok" : "pill";
      pillTx.textContent = newBest ? "new best" : "your run";
    } else if (state === "paused") {
      pill.className = "pill"; pillTx.textContent = "paused";
    } else if (state === "won") {
      pill.className = "pill ok"; pillTx.textContent = "board full";
    } else {
      pill.className = wonBest ? "pill ok" : "pill bad";
      pillTx.textContent = wonBest ? "new best" : "run over";
    }

    if (mode === "demo") {
      mainBtn.textContent = "Take over";
      midBtn.setAttribute("aria-label", "Take over");
      midIco.className = "ico play";
      midBtn.classList.add("hot");
    } else if (state === "run") {
      mainBtn.textContent = "Pause";
      midBtn.setAttribute("aria-label", "Pause");
      midIco.className = "ico pause";
      midBtn.classList.remove("hot");
    } else if (state === "paused") {
      mainBtn.textContent = "Resume";
      midBtn.setAttribute("aria-label", "Resume");
      midIco.className = "ico play";
      midBtn.classList.add("hot");
    } else {
      mainBtn.textContent = "Play again";
      midBtn.setAttribute("aria-label", "Play again");
      midIco.className = "ico play";
      midBtn.classList.add("hot");
    }
    // never let focus fall on the floor when this button disables itself
    if (mode === "demo" && document.activeElement === watchBtn) {
      try { canvas.focus({ preventScroll: true }); } catch (e) { canvas.focus(); }
    }
    watchBtn.disabled = (mode === "demo");

    var kids = dpad.children;
    for (var i = 0; i < kids.length; i++) {
      var k = kids[i], nm = k.getAttribute("data-d");
      if (!nm) continue;
      var d = DIRNAME[nm];
      var live2 = (mode === "you" && state === "run" && d.x === dir.x && d.y === dir.y);
      if (live2) k.classList.add("on"); else k.classList.remove("on");
    }
  }

  /* ── score pops ─────────────────────────────────────────────────────────── */
  function addPop(x, y, text) {
    if (!canvas.width) return;
    var el = document.createElement("span");
    el.className = "pop";
    el.textContent = text;
    el.style.left = ((offX + (x + 0.5) * cell) / canvas.width * 100) + "%";
    el.style.top  = ((offY + (y + 0.5) * cell) / canvas.height * 100) + "%";
    popsEl.appendChild(el);
    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 900);
  }

  /* ── geometry ───────────────────────────────────────────────────────────── */
  var DPR = 1, cell = 16, boardPx = 0, offX = 0, offY = 0;
  function resize() {
    var rect = boardEl.getBoundingClientRect();
    var w = Math.max(120, Math.round(rect.width));
    var h = Math.max(120, Math.round(rect.height));
    DPR = Math.min(2.5, window.devicePixelRatio || 1);
    var dw = Math.round(w * DPR), dh = Math.round(h * DPR);
    if (canvas.width !== dw || canvas.height !== dh) { canvas.width = dw; canvas.height = dh; }
    cell = Math.max(3, Math.floor(Math.min(dw, dh) / N));
    boardPx = cell * N;
    offX = Math.floor((dw - boardPx) / 2);
    offY = Math.floor((dh - boardPx) / 2);
  }

  function cc(v) { return v * cell + cell / 2; }
  function rr(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }

  /* ── draw ───────────────────────────────────────────────────────────────── */
  var tt = 0, nowMs = 0;

  function draw() {
    var W = canvas.width, H = canvas.height;
    if (!W || !H) return;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.fillStyle = rgb(COL.surface);
    ctx.fillRect(0, 0, W, H);

    var sx = 0, sy = 0;
    if (shake > 0.05 && !reduced) {
      sx = (Math.random() * 2 - 1) * shake * DPR;
      sy = (Math.random() * 2 - 1) * shake * DPR;
    }
    ctx.setTransform(1, 0, 0, 1, offX + sx, offY + sy);
    ctx.save();
    ctx.beginPath(); ctx.rect(-offX - 4, -offY - 4, W + 8, H + 8); ctx.clip();

    drawGrid();
    drawFood();
    drawRings();
    drawSnake();
    drawCrash();

    ctx.restore();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  }

  function drawGrid() {
    var lw = Math.max(1, Math.round(DPR * 0.5));
    ctx.fillStyle = rgb(COL.line);
    for (var i = 1; i < N; i++) {
      var p = Math.round(i * cell);
      ctx.fillRect(p, 0, lw, boardPx);
      ctx.fillRect(0, p, boardPx, lw);
    }
    if (wrap) {
      ctx.save();
      ctx.setLineDash([Math.max(3, cell * 0.36), Math.max(3, cell * 0.36)]);
      ctx.lineWidth = Math.max(2, DPR * 1.6);
      ctx.strokeStyle = rgba(COL.accent, 0.5);
      var o = ctx.lineWidth / 2;
      ctx.strokeRect(o, o, boardPx - ctx.lineWidth, boardPx - ctx.lineWidth);
      ctx.restore();
    }
  }

  function drawFood() {
    if (state === "won") return;
    var x = cc(food.x), y = cc(food.y);
    var pulse = reduced ? 1 : (1 + 0.07 * Math.sin(nowMs / 340));
    var r = cell * 0.30 * pulse;
    ctx.fillStyle = rgba(COL.accent, 0.15);
    ctx.beginPath(); ctx.arc(x, y, r * 1.85, 0, 6.2832); ctx.fill();
    ctx.fillStyle = rgb(COL.accent);
    ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); ctx.fill();
    ctx.fillStyle = rgba(COL.surface, 0.5);
    ctx.beginPath(); ctx.arc(x - r * 0.32, y - r * 0.34, Math.max(1, r * 0.22), 0, 6.2832); ctx.fill();
  }

  function drawRings() {
    for (var i = 0; i < rings.length; i++) {
      var g = rings[i], k = g.t / 420;
      if (k > 1) continue;
      var x = cc(g.x), y = cc(g.y);
      ctx.strokeStyle = rgba(COL.run, 0.5 * (1 - k));
      ctx.lineWidth = Math.max(1.5, DPR * 1.6);
      ctx.beginPath();
      ctx.arc(x, y, cell * (0.34 + k * 0.95), 0, 6.2832);
      ctx.stroke();
    }
  }

  function drawSnake() {
    var L = body.length, pts = [], i;
    for (i = 0; i < L; i++) {
      var c = body[i], p = (old && old[i]) ? old[i] : c;
      var dx = c.x - p.x, dy = c.y - p.y, px, py;
      if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { px = c.x; py = c.y; }
      else { px = p.x + dx * tt; py = p.y + dy * tt; }
      pts.push([cc(px), cc(py)]);
    }
    var w = cell * 0.74;
    var gap = cell * 1.6;
    ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.lineWidth = w;
    for (i = L - 1; i > 0; i--) {
      var f = (L > 1) ? i / (L - 1) : 0;
      ctx.strokeStyle = rgb(mix(COL.ink, TAIL, f));
      var a = pts[i], b = pts[i - 1];
      ctx.beginPath();
      if (Math.abs(a[0] - b[0]) > gap || Math.abs(a[1] - b[1]) > gap) {
        ctx.moveTo(a[0], a[1]); ctx.lineTo(a[0], a[1]);
      } else {
        ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]);
      }
      ctx.stroke();
    }
    // head
    var hx = pts[0][0], hy = pts[0][1];
    var hs = cell * 0.84, hr = hs * 0.34;
    var dead = (state === "over");
    ctx.fillStyle = dead ? rgb(COL.danger) : rgb(COL.ink);
    rr(hx - hs / 2, hy - hs / 2, hs, hs, hr);
    ctx.fill();
    // eyes
    var ex = dir.y !== 0 ? 1 : 0, ey = dir.x !== 0 ? 1 : 0;
    var fwd = cell * 0.15, side = cell * 0.19, er = Math.max(1, cell * 0.085);
    ctx.fillStyle = rgb(COL.surface);
    for (var s = -1; s <= 1; s += 2) {
      ctx.beginPath();
      ctx.arc(hx + dir.x * fwd + ex * side * s, hy + dir.y * fwd + ey * side * s, er, 0, 6.2832);
      ctx.fill();
    }
  }

  function drawCrash() {
    if (!crash) return;
    var x = cc(crash.x), y = cc(crash.y), r = cell * 0.34;
    ctx.strokeStyle = rgb(COL.danger);
    ctx.lineWidth = Math.max(2, DPR * 1.8);
    ctx.lineCap = "round";
    ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x - r * 0.5, y - r * 0.5); ctx.lineTo(x + r * 0.5, y + r * 0.5);
    ctx.moveTo(x + r * 0.5, y - r * 0.5); ctx.lineTo(x - r * 0.5, y + r * 0.5);
    ctx.stroke();
  }

  /* ── loop ───────────────────────────────────────────────────────────────── */
  var acc = 0, last = 0;
  function frame(now) {
    requestAnimationFrame(frame);
    nowMs = now;
    var dt = last ? Math.min(120, now - last) : 16;
    last = now;

    for (var i = rings.length - 1; i >= 0; i--) {
      rings[i].t += dt;
      if (rings[i].t > 460) rings.splice(i, 1);
    }
    if (shake > 0) shake = Math.max(0, shake - dt * 0.03);

    if (state === "run") {
      elapsed += dt;
      acc += dt;
      var iv = tickMs();
      var guard = 0;
      while (acc >= iv && state === "run" && guard++ < 6) {
        acc -= iv;
        tick();
        iv = tickMs();
      }
      tt = state === "run" ? Math.max(0, Math.min(1, acc / iv)) : 1;
    } else if (state === "over" && mode === "demo") {
      demoTimer -= dt;
      if (demoTimer <= 0) startDemo();
    } else if (state === "won" && mode === "demo") {
      demoTimer -= dt;
      if (demoTimer <= 0) startDemo();
    }
    draw();
  }

  /* ── input ──────────────────────────────────────────────────────────────── */
  function pushDir(d) {
    if (!d) return;
    if (mode === "demo") { takeOver(d); return; }
    if (state === "over" || state === "won") { takeOver(d); return; }
    if (state === "paused") resume();
    if (state !== "run") return;
    var last2 = queue.length ? queue[queue.length - 1] : dir;
    if (d.x === -last2.x && d.y === -last2.y) return;
    if (d.x === last2.x && d.y === last2.y) return;
    if (queue.length >= 2) return;
    queue.push(d);
    sync();
  }

  function mainAction() {
    if (mode === "demo") takeOver(dir);
    else if (state === "run") pause();
    else if (state === "paused") resume();
    else again();
  }

  var KEYS = {
    ArrowUp: UP, ArrowDown: DOWN, ArrowLeft: LEFT, ArrowRight: RIGHT,
    w: UP, a: LEFT, s: DOWN, d: RIGHT,
    W: UP, A: LEFT, S: DOWN, D: RIGHT,
    Up: UP, Down: DOWN, Left: LEFT, Right: RIGHT
  };
  function keyDir(k) {
    return Object.prototype.hasOwnProperty.call(KEYS, k) ? KEYS[k] : null;
  }

  window.addEventListener("keydown", function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    var k = e.key;
    var tag = e.target && e.target.tagName;
    var onControl = (tag === "BUTTON" || tag === "INPUT" || tag === "A");
    var kd = keyDir(k);
    if (kd) {
      e.preventDefault();
      pushDir(kd);
      return;
    }
    if (k === " " || k === "Spacebar" || k === "Enter") {
      if (onControl) return;              // let the focused control handle it
      e.preventDefault();
      mainAction();
      return;
    }
    if (k === "p" || k === "P") { e.preventDefault(); if (state === "paused") resume(); else pause(); return; }
    if (k === "r" || k === "R") { e.preventDefault(); takeOver(RIGHT); say("Fresh run."); return; }
    if (k === "Escape") { pause(); }
  });

  dpad.addEventListener("pointerdown", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-d]") : null;
    if (!b) return;
    e.preventDefault();
    pushDir(DIRNAME[b.getAttribute("data-d")]);
  });
  dpad.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-d]") : null;
    if (!b) return;
    pushDir(DIRNAME[b.getAttribute("data-d")]);
  });
  midBtn.addEventListener("click", mainAction);
  mainBtn.addEventListener("click", mainAction);
  watchBtn.addEventListener("click", function () { startDemo(); say("Demo is driving."); });
  ovBtn.addEventListener("click", function () {
    if (state === "paused") resume(); else again();
  });

  edgesEl.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-e]") : null;
    if (!b) return;
    wrap = b.getAttribute("data-e") === "1";
    var kids = edgesEl.children;
    for (var i = 0; i < kids.length; i++) {
      kids[i].setAttribute("aria-pressed", (kids[i] === b) ? "true" : "false");
    }
    edgeCap.textContent = (wrap
      ? "Slide off one edge and reappear on the other. The dashed border shows the openings."
      : "Hit a wall and the run ends. Same board, much less room to think.")
      + " Changing either setting starts a fresh run.";
    restartSame();
    say(wrap ? "Edges wrap around." : "Edges are solid.");
  });

  sizesEl.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-n]") : null;
    if (!b) return;
    N = parseInt(b.getAttribute("data-n"), 10) || 21;
    var kids = sizesEl.children;
    for (var i = 0; i < kids.length; i++) {
      kids[i].setAttribute("aria-pressed", (kids[i] === b) ? "true" : "false");
    }
    resize();
    restartSame();
    say("Board is " + N + " by " + N + " cells.");
  });

  function restartSame() {
    if (mode === "demo") startDemo();
    else takeOver(RIGHT);
  }

  /* swipe on the board */
  var sw = null;
  canvas.addEventListener("pointerdown", function (e) {
    sw = { x: e.clientX, y: e.clientY, used: false };
    if (canvas.setPointerCapture && e.pointerId != null) {
      try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
    }
  });
  canvas.addEventListener("pointermove", function (e) {
    if (!sw || sw.used) return;
    var dx = e.clientX - sw.x, dy = e.clientY - sw.y;
    if (Math.abs(dx) < 22 && Math.abs(dy) < 22) return;
    sw.used = true;
    pushDir(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? RIGHT : LEFT) : (dy > 0 ? DOWN : UP));
  });
  canvas.addEventListener("pointerup", function (e) {
    if (sw && !sw.used && e.pointerType === "touch") mainAction();
    sw = null;
  });
  canvas.addEventListener("pointercancel", function () { sw = null; });

  document.addEventListener("visibilitychange", function () {
    if (document.hidden) { acc = 0; pause(); }
  });

  if (window.ResizeObserver) {
    new ResizeObserver(function () { resize(); }).observe(boardEl);
  }
  window.addEventListener("resize", resize);
  window.addEventListener("orientationchange", function () { setTimeout(resize, 120); });

  /* ── go ─────────────────────────────────────────────────────────────────── */
  readTheme();
  resize();
  startDemo();
  requestAnimationFrame(frame);
})();
</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