$sloprun.dev

2048

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

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

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

Faithful 2048 — slide, merge, reach the tile everyone remembers — except it never hands you an empty board. A depth-2 expectimax solver plays a real game in the ten milliseconds before the page paints and passes you the controls once it has built a 512, so the score, moves and merges in the panel are its actual totals, not decoration. The tile ladder is mixed live from the page's own theme tokens, which is why it stays legible from pale amber to deep red in both light and dark, and why 2048 lands on the one green this design reserves for something that worked.

Try a few moves and then hit Undo — one level of history is kept, and it is offered again on the game-over card. Arrows, WASD and swipe all work; N starts fresh and Best holds your high score for as long as the tab is open.

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

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1249 lines 50.4 KB index.html
demos/game-2048/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>2048 — 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/shadows need alpha; tokens are opaque hex) */
:root {
  --scrim: rgba(246,246,243,.72);
  --shadow: 0 1px 2px rgba(26,29,33,.06);
  --lift: 0 6px 20px rgba(26,29,33,.10);
  --etch: inset 0 0 0 1px rgba(26,29,33,.06);
  --gap: clamp(7px, 1.7vw, 12px);
  --slide: 118ms;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --scrim: rgba(20,22,26,.72);
    --shadow: 0 1px 2px rgba(0,0,0,.35);
    --lift: 0 6px 20px rgba(0,0,0,.45);
    --etch: inset 0 0 0 1px rgba(255,255,255,.05);
  }
}
:root[data-theme="dark"] {
  --scrim: rgba(20,22,26,.72);
  --shadow: 0 1px 2px rgba(0,0,0,.35);
  --lift: 0 6px 20px rgba(0,0,0,.45);
  --etch: inset 0 0 0 1px rgba(255,255,255,.05);
}

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

/* ── header ───────────────────────────────────────────────────────────────── */
h1 {
  margin: 0 0 6px; font-family: var(--font-mono);
  font-size: clamp(26px, 6vw, 38px); letter-spacing: -.03em; line-height: 1;
  font-weight: 700;
}
.lede { margin: 0; max-width: 62ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
kbd {
  font-family: var(--font-mono); font-size: .84em; color: var(--accent);
  background: var(--accent-soft); border: 1px solid var(--line);
  border-radius: 5px; padding: 1px 5px; white-space: nowrap;
}

/* ── layout ───────────────────────────────────────────────────────────────── */
.stage {
  display: grid; gap: 14px; align-items: start;
  grid-template-columns: minmax(0, 1fr) 244px;
  grid-template-areas: "board panel";
}
.panel { grid-area: panel; display: flex; flex-direction: column; gap: 12px; }
.boardwrap { grid-area: board; display: flex; flex-direction: column; gap: 9px; min-width: 0; }
@media (max-width: 780px) {
  .stage { grid-template-columns: minmax(0, 1fr); grid-template-areas: "score" "board" "extras"; }
  .panel { display: contents; }
  .scorebar { grid-area: score; }
  .extras { grid-area: extras; }
}

/* ── score bar ────────────────────────────────────────────────────────────── */
.scorebar { display: flex; flex-direction: column; gap: 10px; }
@media (max-width: 780px) {
  .scorebar { flex-direction: row; align-items: stretch; }
  .scorebar, .extras { width: 100%; max-width: 452px; margin-left: auto; margin-right: auto; }
}
@media (max-width: 520px) { .scorebar { flex-direction: column; } }

.scores { display: flex; gap: 10px; flex: 1 1 auto; }
.card {
  position: relative; flex: 1 1 0; min-width: 0;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 9px 11px 10px;
  box-shadow: var(--shadow);
}
.card .k {
  display: block; font-family: var(--font-mono); font-size: 9.5px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
}
.card .v {
  display: block; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: clamp(19px, 4.6vw, 25px); font-weight: 700; line-height: 1.15;
  letter-spacing: -.02em; margin-top: 2px;
}
.card.best .v { color: var(--muted); }
.float {
  position: absolute; right: 11px; top: 6px; pointer-events: none;
  font-family: var(--font-mono); font-size: 13px; font-weight: 700; color: var(--accent);
  animation: floatUp 780ms ease-out forwards;
}
@keyframes floatUp {
  0%   { opacity: 0; transform: translateY(6px) scale(.9); }
  22%  { opacity: 1; transform: translateY(0) scale(1); }
  100% { opacity: 0; transform: translateY(-20px) scale(1); }
}

/* ── buttons ──────────────────────────────────────────────────────────────── */
.acts { display: flex; gap: 8px; align-items: center; }
.btn {
  flex: 1 1 auto; font: inherit; font-size: 13px; font-weight: 550;
  padding: 9px 12px; cursor: pointer;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  box-shadow: var(--shadow);
  transition: background 120ms ease, border-color 120ms ease, transform 90ms ease;
  white-space: nowrap;
}
.btn:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
:root[data-theme="dark"] .btn.primary { color: #14161A; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .btn.primary { color: #14161A; } }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
.btn .mk { font-family: var(--font-mono); color: inherit; opacity: .6; font-size: 11px; margin-left: 5px; }
/* comfortable thumb targets once the layout stacks for phones */
@media (max-width: 780px) { .btn { min-height: 44px; } }

/* ── board ────────────────────────────────────────────────────────────────── */
.board {
  position: relative; width: 100%; max-width: 452px; margin: 0 auto;
  aspect-ratio: 1 / 1; padding: var(--gap);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: calc(var(--radius) + 4px);
  box-shadow: var(--lift);
  touch-action: none; -webkit-user-select: none; user-select: none;
  container-type: inline-size;
}
.board:focus { outline: none; }
.board:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.cells {
  position: absolute; inset: var(--gap);
  display: grid; gap: var(--gap);
  grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr);
}
.cell { border-radius: calc(var(--radius) - 2px); background: var(--cell, #EEEDE8); }

.layer { position: absolute; inset: var(--gap); }
.layer.nudge { animation: nudge 180ms ease; }
@keyframes nudge {
  0%,100% { transform: translate(0,0); }
  35% { transform: translate(var(--nx, 0), var(--ny, 0)); }
}

.tile {
  position: absolute; top: 0; left: 0;
  width: calc((100% - 3 * var(--gap)) / 4);
  height: calc((100% - 3 * var(--gap)) / 4);
  display: flex; align-items: center; justify-content: center;
  border-radius: calc(var(--radius) - 2px);
  font-family: var(--font-mono); font-weight: 700; letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
  box-shadow: var(--etch);
  will-change: transform;
  transform: translate(calc((100% + var(--gap)) * var(--c, 0)), calc((100% + var(--gap)) * var(--r, 0)));
  transition: transform var(--slide) cubic-bezier(.22, .72, .3, 1);
}
.tile.veil { opacity: 0; }
.tile.dying { z-index: 3; }
.tile.appear { animation: popIn 190ms cubic-bezier(.2, 1.3, .5, 1) both; }
.tile.merged { animation: popMerge 220ms cubic-bezier(.2, 1.3, .5, 1) both; z-index: 4; }
.tile.restored { animation: fadeIn 170ms ease both; }
@keyframes popIn { from { transform: translate(calc((100% + var(--gap)) * var(--c,0)), calc((100% + var(--gap)) * var(--r,0))) scale(.32); opacity: 0; }
                   to   { transform: translate(calc((100% + var(--gap)) * var(--c,0)), calc((100% + var(--gap)) * var(--r,0))) scale(1); opacity: 1; } }
@keyframes popMerge {
  0%   { transform: translate(calc((100% + var(--gap)) * var(--c,0)), calc((100% + var(--gap)) * var(--r,0))) scale(.6); opacity: .4; }
  55%  { transform: translate(calc((100% + var(--gap)) * var(--c,0)), calc((100% + var(--gap)) * var(--r,0))) scale(1.14); opacity: 1; }
  100% { transform: translate(calc((100% + var(--gap)) * var(--c,0)), calc((100% + var(--gap)) * var(--r,0))) scale(1); opacity: 1; }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

.tv { font-size: 30px; }
.tv { font-size: 9.6cqw; }
.tile[data-len="3"] .tv { font-size: 24px; font-size: 7.8cqw; }
.tile[data-len="4"] .tv { font-size: 19px; font-size: 6.1cqw; }
.tile[data-len="5"] .tv { font-size: 15px; font-size: 4.9cqw; }

.tile[data-k="1"]  { background: var(--t1);  color: var(--f1); }
.tile[data-k="2"]  { background: var(--t2);  color: var(--f2); }
.tile[data-k="3"]  { background: var(--t3);  color: var(--f3); }
.tile[data-k="4"]  { background: var(--t4);  color: var(--f4); }
.tile[data-k="5"]  { background: var(--t5);  color: var(--f5); }
.tile[data-k="6"]  { background: var(--t6);  color: var(--f6); }
.tile[data-k="7"]  { background: var(--t7);  color: var(--f7); }
.tile[data-k="8"]  { background: var(--t8);  color: var(--f8); }
.tile[data-k="9"]  { background: var(--t9);  color: var(--f9); }
.tile[data-k="10"] { background: var(--t10); color: var(--f10); }
.tile[data-k="11"] { background: var(--t11); color: var(--f11); box-shadow: var(--etch), 0 0 0 2px var(--run-soft); }
.tile[data-k="12"] { background: var(--t12); color: var(--f12); box-shadow: var(--etch), 0 0 0 2px var(--run-soft); }
.tile[data-k="13"] { background: var(--t13); color: var(--f13); box-shadow: var(--etch), 0 0 0 2px var(--run-soft); }

/* ── overlay ──────────────────────────────────────────────────────────────── */
.overlay {
  position: absolute; inset: 0; z-index: 9;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 16px; text-align: center;
  background: var(--scrim); backdrop-filter: blur(4px);
  border-radius: calc(var(--radius) + 4px);
  animation: fadeIn 220ms ease both;
}
.overlay[hidden] { display: none; }
.overlay .sheet {
  display: flex; flex-direction: column; align-items: center; gap: 11px;
  width: 100%; max-width: 330px; padding: 18px 18px 17px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--lift);
}
.overlay .big {
  font-family: var(--font-mono); font-size: clamp(22px, 6.4vw, 32px);
  font-weight: 700; letter-spacing: -.02em; line-height: 1.1;
}
.overlay .big.good { color: var(--run); }
.overlay .big.stop { color: var(--danger); }
.overlay .sub { font-size: 13.5px; color: var(--muted); max-width: 34ch; line-height: 1.5; }
.overlay .sub b { color: var(--ink); font-family: var(--font-mono); font-weight: 700; }
.overlay .ovacts { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.overlay .btn { flex: 0 0 auto; }

/* ── extras ───────────────────────────────────────────────────────────────── */
.extras { display: flex; flex-direction: column; gap: 12px; }
.box {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 11px; box-shadow: var(--shadow);
}
.box > .h {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 8px;
}
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 10px; }
@media (max-width: 780px) { .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.stat .sk {
  display: block; font-family: var(--font-mono); font-size: 9.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
}
.stat .sv {
  display: block; font-family: var(--font-mono); font-size: 15px; font-weight: 700;
  font-variant-numeric: tabular-nums; margin-top: 1px;
}
.stat .sv.good { color: var(--run); }
.stat .sv.stop { color: var(--danger); }
.stat.wide { grid-column: 1 / -1; }
.stat .sv.state { font-size: 12.5px; font-weight: 600; letter-spacing: 0; }

.ladder { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
  font-family: var(--font-mono); font-size: 10px; font-weight: 700;
  padding: 3px 6px; border-radius: 5px; min-width: 30px; text-align: center;
  box-shadow: var(--etch); opacity: .3; transition: opacity 200ms ease;
  font-variant-numeric: tabular-nums;
}
.chip.on { opacity: 1; }
.chip[data-k="1"]  { background: var(--t1);  color: var(--f1); }
.chip[data-k="2"]  { background: var(--t2);  color: var(--f2); }
.chip[data-k="3"]  { background: var(--t3);  color: var(--f3); }
.chip[data-k="4"]  { background: var(--t4);  color: var(--f4); }
.chip[data-k="5"]  { background: var(--t5);  color: var(--f5); }
.chip[data-k="6"]  { background: var(--t6);  color: var(--f6); }
.chip[data-k="7"]  { background: var(--t7);  color: var(--f7); }
.chip[data-k="8"]  { background: var(--t8);  color: var(--f8); }
.chip[data-k="9"]  { background: var(--t9);  color: var(--f9); }
.chip[data-k="10"] { background: var(--t10); color: var(--f10); }
.chip[data-k="11"] { background: var(--t11); color: var(--f11); }

.hint {
  margin: 0; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
  letter-spacing: .04em; text-align: center; line-height: 1.7;
}
.hint .sep { opacity: .45; margin: 0 5px; }
.note { margin: 0; font-size: 12px; color: var(--muted); line-height: 1.5; }
.note b { color: var(--ink); font-weight: 600; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

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;
    animation-iteration-count: 1 !important;
  }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>2048</h1>
    <p class="lede">Slide every tile one direction; two equal numbers that touch merge into their double. Reach <b>2048</b> before the board fills up. A small solver plays the opening for you, so you land in a real mid-game position — take it from there with <kbd>&larr;</kbd><kbd>&uarr;</kbd><kbd>&rarr;</kbd><kbd>&darr;</kbd>, <kbd>WASD</kbd>, or a swipe.</p>
  </header>

  <div class="stage">

    <div class="boardwrap">
      <div class="board" id="board" tabindex="0" role="application"
           aria-label="2048 board, 4 by 4. Press arrow keys or W A S D to slide the tiles."
           aria-describedby="boardhelp boardText">
        <div class="cells" aria-hidden="true"></div>
        <div class="layer" id="layer"></div>
        <div class="overlay" id="overlay" role="dialog" aria-modal="true"
             aria-labelledby="ovTitle" aria-describedby="ovSub" hidden>
          <div class="sheet">
            <div class="big" id="ovTitle"></div>
            <p class="sub" id="ovSub"></p>
            <div class="ovacts">
              <button class="btn primary" id="ovMain" type="button"></button>
              <button class="btn" id="ovAlt" type="button" hidden></button>
            </div>
          </div>
        </div>
      </div>
      <p class="hint" id="boardhelp">
        <span>&larr; &uarr; &rarr; &darr; or WASD</span><span class="sep">·</span><span>swipe</span><span class="sep">·</span><span>U undo</span><span class="sep">·</span><span>N new game</span>
      </p>
    </div>

    <div class="panel">
      <div class="scorebar">
        <div class="scores">
          <div class="card" id="scoreCard">
            <span class="k">Score</span>
            <span class="v" id="score">0</span>
          </div>
          <div class="card best">
            <span class="k">Best</span>
            <span class="v" id="best">0</span>
          </div>
        </div>
        <div class="acts">
          <button class="btn primary" id="newBtn" type="button">New game<span class="mk">N</span></button>
          <button class="btn" id="undoBtn" type="button" disabled>Undo<span class="mk">U</span></button>
        </div>
      </div>

      <div class="extras">
        <div class="box">
          <div class="h">Run stats</div>
          <div class="stats">
            <div class="stat"><span class="sk">Moves</span><span class="sv" id="stMoves">0</span></div>
            <div class="stat"><span class="sk">Merges</span><span class="sv" id="stMerges">0</span></div>
            <div class="stat"><span class="sk">Largest</span><span class="sv" id="stTop">0</span></div>
            <div class="stat"><span class="sk">Free</span><span class="sv" id="stFree">0</span></div>
            <div class="stat wide"><span class="sk">State</span><span class="sv state" id="stState">in play</span></div>
          </div>
          <p class="note" id="origin" style="margin-top:9px"></p>
        </div>

        <div class="box">
          <div class="h">Tile ladder</div>
          <div class="ladder" id="ladder" aria-hidden="true"></div>
          <p class="note" style="margin-top:9px">Colours are mixed live from this page's theme tokens — pale amber heats to red and keeps deepening as the numbers climb, and <b>2048</b> lands on the green this page reserves for a finished job.</p>
        </div>
      </div>
    </div>

  </div>

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

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

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

  var N = 4;
  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);

  var board   = document.getElementById("board");
  var layer   = document.getElementById("layer");
  var cellsEl = document.querySelector(".cells");
  var overlay = document.getElementById("overlay");
  var ovTitle = document.getElementById("ovTitle");
  var ovSub   = document.getElementById("ovSub");
  var ovMain  = document.getElementById("ovMain");
  var ovAlt   = document.getElementById("ovAlt");
  var scoreEl = document.getElementById("score");
  var bestEl  = document.getElementById("best");
  var scoreCard = document.getElementById("scoreCard");
  var newBtn  = document.getElementById("newBtn");
  var undoBtn = document.getElementById("undoBtn");
  var ladderEl = document.getElementById("ladder");
  var originEl = document.getElementById("origin");
  var live    = document.getElementById("live");
  var boardText = document.getElementById("boardText");
  var st = {
    moves:  document.getElementById("stMoves"),
    merges: document.getElementById("stMerges"),
    top:    document.getElementById("stTop"),
    free:   document.getElementById("stFree"),
    state:  document.getElementById("stState")
  };

  /* ── colour helpers ─────────────────────────────────────────────────────── */
  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) {
    var v = getComputedStyle(root).getPropertyValue(name);
    return hexToRgb(v) || fb;
  }
  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 css(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
  function lum(c) {
    var f = function (v) { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
    return 0.2126*f(c[0]) + 0.7152*f(c[1]) + 0.0722*f(c[2]);
  }
  var DARK_TEXT = [20, 22, 26];    /* dark-theme --bg  */
  var LIGHT_TEXT = [246, 246, 243]; /* light-theme --bg */
  function contrast(a, b) {
    var x = lum(a), y = lum(b);
    if (x < y) { var t = x; x = y; y = t; }
    return (x + 0.05) / (y + 0.05);
  }
  /* Pick the label colour for a tile. The two theme neutrals are preferred so
     the board stays in the palette; if neither clears a comfortable 5:1 (the
     10px ladder chips reuse these pairs, so small text has to hold up too) we
     fall back to flat white or black, whichever reads further from the tile. */
  var CANDIDATES = [DARK_TEXT, LIGHT_TEXT, [255, 255, 255], [0, 0, 0]];
  function textOn(bg) {
    var i, r, bestC = CANDIDATES[0], bestR = 0;
    for (i = 0; i < 2; i++) {
      r = contrast(bg, CANDIDATES[i]);
      if (r > bestR) { bestR = r; bestC = CANDIDATES[i]; }
    }
    if (bestR >= 5) return bestC;
    for (i = 2; i < CANDIDATES.length; i++) {
      r = contrast(bg, CANDIDATES[i]);
      if (r > bestR) { bestR = r; bestC = CANDIDATES[i]; }
    }
    return bestC;
  }

  /* Tile palette, derived entirely from the theme tokens:
     grey steps -> amber ramp -> red heat -> earned green at 2048. */
  function paintPalette() {
    var surface = tok("--surface", [255,255,255]);
    var line    = tok("--line",    [227,226,220]);
    var muted   = tok("--muted",   [90,96,104]);
    var soft    = tok("--accent-soft", [255,243,230]);
    var accent  = tok("--accent",  [192,86,0]);
    var danger  = tok("--danger",  [201,60,55]);
    var run     = tok("--run",     [26,127,55]);
    var ink     = tok("--ink",     [26,29,33]);
    var pageBg  = tok("--bg",      [246,246,243]);

    /* Above 64 the accent → danger travel is almost pure hue: in the light theme
       64, 128, 256, 512 and 1024 all land within three L* of each other, so the
       hot end of the ladder reads as one orange block. The fix is to give that
       half a lightness slope as well, pulling each step toward whichever token is
       darkest in the current theme — --ink on a light page, --bg on a dark one —
       so the ramp keeps moving in a straight line whichever way the page flips. */
    var lightPage = lum(pageBg) > 0.5;
    var deep = lightPage ? ink : pageBg;
    var heat = lightPage ? [0.14, 0.30, 0.44, 0.58] : [0.12, 0.26, 0.40, 0.52];

    var bgs = [
      null,
      mix(line, muted, 0.10),                       /* 2    */
      mix(line, muted, 0.36),                       /* 4    */
      mix(soft, accent, 0.50),                      /* 8    */
      mix(soft, accent, 0.70),                      /* 16   */
      mix(soft, accent, 0.88),                      /* 32   */
      accent,                                       /* 64   */
      mix(mix(accent, danger, 0.40), deep, heat[0]),/* 128  */
      mix(mix(accent, danger, 0.72), deep, heat[1]),/* 256  */
      mix(danger, deep, heat[2]),                   /* 512  */
      mix(danger, deep, heat[3]),                   /* 1024 */
      run,                                          /* 2048 — earned */
      mix(run, ink, 0.34),                          /* 4096 */
      mix(run, ink, 0.60)                           /* 8192+ */
    ];
    for (var k = 1; k <= 13; k++) {
      var c = bgs[k];
      root.style.setProperty("--t" + k, css(c));
      root.style.setProperty("--f" + k, css(textOn(c)));
    }
    root.style.setProperty("--cell", css(mix(surface, line, 0.78)));
  }

  /* ── static board scaffolding ───────────────────────────────────────────── */
  (function buildCells() {
    var frag = document.createDocumentFragment();
    for (var i = 0; i < N * N; i++) {
      var d = document.createElement("div");
      d.className = "cell";
      frag.appendChild(d);
    }
    cellsEl.appendChild(frag);
  })();

  (function buildLadder() {
    var frag = document.createDocumentFragment();
    for (var k = 1; k <= 11; k++) {
      var s = document.createElement("span");
      s.className = "chip";
      s.setAttribute("data-k", String(k));
      s.textContent = Math.pow(2, k);
      frag.appendChild(s);
    }
    ladderEl.appendChild(frag);
  })();

  /* ── game state ─────────────────────────────────────────────────────────── */
  var grid, tiles, idSeq, score, best, moves, merges, won, keepGoing, over, snap, pending;

  function emptyGrid() {
    var g = [];
    for (var r = 0; r < N; r++) { g.push([]); for (var c = 0; c < N; c++) g[r].push(null); }
    return g;
  }

  function tierOf(v) { return Math.min(13, Math.round(Math.log(v) / Math.LN2)); }

  function styleTile(t) {
    var n = t.node;
    n.style.setProperty("--r", String(t.r));
    n.style.setProperty("--c", String(t.c));
    n.setAttribute("data-k", String(tierOf(t.v)));
    n.setAttribute("data-len", String(t.v).length);
    n.firstChild.textContent = t.v;
  }

  function makeTile(v, r, c, cls) {
    var t = { id: ++idSeq, v: v, r: r, c: c, node: null };
    var n = document.createElement("div");
    n.className = "tile" + (cls ? " " + cls : "");
    n.setAttribute("aria-hidden", "true");
    var sp = document.createElement("span");
    sp.className = "tv";
    n.appendChild(sp);
    t.node = n;
    styleTile(t);
    layer.appendChild(n);
    tiles[t.id] = t;
    return t;
  }

  layer.addEventListener("animationend", function (e) {
    if (e.target && e.target.classList) e.target.classList.remove("appear", "merged", "restored");
  });

  function dropTile(t) {
    if (t.node && t.node.parentNode) t.node.parentNode.removeChild(t.node);
    delete tiles[t.id];
  }

  function clearBoard() {
    for (var id in tiles) if (Object.prototype.hasOwnProperty.call(tiles, id)) dropTile(tiles[id]);
    tiles = {};
    grid = emptyGrid();
  }

  /* Finish any in-flight slide immediately (keeps rapid input honest). */
  function flush() {
    if (!pending) return;
    clearTimeout(pending.timer);
    var i;
    for (i = 0; i < pending.kill.length; i++) dropTile(pending.kill[i]);
    for (i = 0; i < pending.show.length; i++) {
      var e = pending.show[i];
      e.t.node.classList.remove("veil");
      if (!reduced) e.t.node.classList.add(e.cls);
    }
    pending = null;
  }

  function schedule(kill, show) {
    pending = { kill: kill, show: show, timer: 0 };
    if (reduced) { flush(); return; }
    pending.timer = setTimeout(flush, 118);
  }

  /* ── board queries ──────────────────────────────────────────────────────── */
  function emptyCells() {
    var out = [];
    for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) if (!grid[r][c]) out.push([r, c]);
    return out;
  }
  function movesLeft() {
    for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
      var t = grid[r][c];
      if (!t) return true;
      if (c < N-1 && grid[r][c+1] && grid[r][c+1].v === t.v) return true;
      if (r < N-1 && grid[r+1][c] && grid[r+1][c].v === t.v) return true;
    }
    return false;
  }
  function largest() {
    var m = 0;
    for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) if (grid[r][c] && grid[r][c].v > m) m = grid[r][c].v;
    return m;
  }

  function addRandom(cls, bucket) {
    var free = emptyCells();
    if (!free.length) return null;
    var p = free[(Math.random() * free.length) | 0];
    var v = Math.random() < 0.9 ? 2 : 4;
    var t = makeTile(v, p[0], p[1], "veil");
    grid[p[0]][p[1]] = t;
    bucket.push({ t: t, cls: cls });
    return t;
  }

  /* ── snapshots (one level of undo) ──────────────────────────────────────── */
  function takeSnap() {
    var g = [];
    for (var r = 0; r < N; r++) { g.push([]); for (var c = 0; c < N; c++) g[r].push(grid[r][c] ? grid[r][c].v : 0); }
    return { g: g, score: score, moves: moves, merges: merges, won: won, keepGoing: keepGoing };
  }
  function restore(s) {
    clearBoard();
    for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
      if (s.g[r][c]) grid[r][c] = makeTile(s.g[r][c], r, c, reduced ? "" : "restored");
    }
    score = s.score; moves = s.moves; merges = s.merges;
    won = s.won; keepGoing = s.keepGoing; over = false;
  }

  /* ── the move ───────────────────────────────────────────────────────────── */
  var DIRS = { up: 0, right: 1, down: 2, left: 3 };
  var DIRNAME = ["Up", "Right", "Down", "Left"];
  var NUDGE = [[0,"-5px"], ["5px",0], [0,"5px"], ["-5px",0]];

  function lineCells(dir) {
    var lines = [], i, j, r, c, line;
    for (i = 0; i < N; i++) {
      line = [];
      for (j = 0; j < N; j++) {
        if (dir === 3)      { r = i;       c = j; }
        else if (dir === 1) { r = i;       c = N-1-j; }
        else if (dir === 0) { r = j;       c = i; }
        else                { r = N-1-j;   c = i; }
        line.push([r, c]);
      }
      lines.push(line);
    }
    return lines;
  }

  function move(dir) {
    if (over || !overlay.hidden) return false;
    flush();

    var before = takeSnap();
    var moved = false, gained = 0, madeMerges = 0, hitWin = false;
    var kill = [], show = [];
    var lines = lineCells(dir);

    for (var L = 0; L < lines.length; L++) {
      var cells = lines[L], seq = [], i, p;

      for (i = 0; i < cells.length; i++) {
        p = cells[i];
        if (grid[p[0]][p[1]]) seq.push(grid[p[0]][p[1]]);
      }
      for (i = 0; i < cells.length; i++) { p = cells[i]; grid[p[0]][p[1]] = null; }

      var out = [];
      i = 0;
      while (i < seq.length) {
        if (i + 1 < seq.length && seq[i].v === seq[i+1].v) {
          out.push({ a: seq[i], b: seq[i+1], v: seq[i].v * 2 });
          i += 2;
        } else {
          out.push({ t: seq[i] });
          i += 1;
        }
      }

      for (i = 0; i < out.length; i++) {
        var e = out[i], r = cells[i][0], c = cells[i][1];
        if (e.t) {
          if (e.t.r !== r || e.t.c !== c) { moved = true; e.t.r = r; e.t.c = c; styleTile(e.t); }
          grid[r][c] = e.t;
        } else {
          moved = true;
          e.a.r = r; e.a.c = c; e.a.node.classList.add("dying"); styleTile(e.a);
          e.b.r = r; e.b.c = c; e.b.node.classList.add("dying"); styleTile(e.b);
          kill.push(e.a, e.b);
          var nt = makeTile(e.v, r, c, "veil");
          grid[r][c] = nt;
          show.push({ t: nt, cls: "merged" });
          gained += e.v;
          madeMerges++;
          if (e.v >= 2048 && !won) { won = true; hitWin = true; }
        }
      }
    }

    if (!moved) {
      /* nothing shifted: every tile was written straight back to its own cell */
      bump(dir);
      say(DIRNAME[dir] + ". Nothing moves that way.");
      return false;
    }

    snap = before;
    score += gained;
    if (score > best) best = score;
    moves++;
    merges += madeMerges;

    addRandom("appear", show);
    schedule(kill, show);

    if (gained > 0 && !reduced) floatScore(gained);
    sync();

    if (hitWin && !keepGoing) {
      showOverlay("win");
      say("2048 reached. Score " + score + ".");
    } else if (endIfDead()) {
      /* handled */
    } else {
      say(DIRNAME[dir] + ". " + (gained ? "Merged " + gained + " points. " : "") + "Score " + score + ".");
    }
    return true;
  }

  /* The winning merge can also be the one that fills the board. Checking this
     from both the move and the "Keep going" button means a win on a dead board
     still ends in a game-over card instead of a silently unplayable grid. */
  function endIfDead() {
    if (over || movesLeft()) return false;
    over = true;
    sync();
    showOverlay("over");
    say("No moves left. Final score " + score + ".");
    return true;
  }

  var bumpTimer = 0;
  function bump(dir) {
    if (reduced) return;
    layer.style.setProperty("--nx", NUDGE[dir][0]);
    layer.style.setProperty("--ny", NUDGE[dir][1]);
    layer.classList.remove("nudge");
    void layer.offsetWidth;
    layer.classList.add("nudge");
    clearTimeout(bumpTimer);
    bumpTimer = setTimeout(function () { layer.classList.remove("nudge"); }, 220);
  }

  function floatScore(n) {
    var f = document.createElement("span");
    f.className = "float";
    f.textContent = "+" + n;
    scoreCard.appendChild(f);
    setTimeout(function () { if (f.parentNode) f.parentNode.removeChild(f); }, 820);
  }

  /* ── undo ───────────────────────────────────────────────────────────────── */
  function undo() {
    if (!snap) return;
    flush();
    hideOverlay();
    restore(snap);
    snap = null;
    sync();
    say("Move undone. Score " + score + ".");
  }

  /* ── new game / preloaded game ──────────────────────────────────────────── */
  function newGame() {
    flush();
    hideOverlay();
    clearBoard();
    score = 0; moves = 0; merges = 0; won = false; keepGoing = false; over = false; snap = null;
    var show = [];
    addRandom("appear", show);
    addRandom("appear", show);
    schedule([], show);
    setOrigin(0);
    sync();
    say("New game. Two tiles on the board.");
    board.focus();
  }

  /* ── the opening is played, not faked ────────────────────────────────────
     A depth-2 expectimax solver actually plays a game here at load time on a
     plain array of numbers — no DOM, a few milliseconds — and hands over the
     moment it has built a 512 with the board starting to crowd. Every number
     in the panel above is what that game really earned. ------------------- */

  var SIM_LINES = (function () {
    var out = [];
    for (var d = 0; d < 4; d++) {
      var ls = [];
      for (var i = 0; i < N; i++) {
        var line = [];
        for (var j = 0; j < N; j++) {
          var r, c;
          if (d === 3)      { r = i;       c = j; }
          else if (d === 1) { r = i;       c = N-1-j; }
          else if (d === 0) { r = j;       c = i; }
          else              { r = N-1-j;   c = i; }
          line.push(r * N + c);
        }
        ls.push(line);
      }
      out.push(ls);
    }
    return out;
  })();

  function simMove(g, d) {
    var ls = SIM_LINES[d], moved = false, gained = 0, merged = 0;
    var out = g.slice();
    for (var L = 0; L < N; L++) {
      var idx = ls[L], seq = [], i, v;
      for (i = 0; i < N; i++) { v = g[idx[i]]; if (v) seq.push(v); }
      var res = [];
      i = 0;
      while (i < seq.length) {
        if (i + 1 < seq.length && seq[i] === seq[i+1]) { res.push(seq[i] * 2); gained += seq[i] * 2; merged++; i += 2; }
        else { res.push(seq[i]); i += 1; }
      }
      for (i = 0; i < N; i++) {
        var nv = i < res.length ? res[i] : 0;
        if (g[idx[i]] !== nv) moved = true;
        out[idx[i]] = nv;
      }
    }
    return { g: out, moved: moved, gained: gained, merged: merged };
  }

  function simLogs(g) {
    var L = new Array(16);
    for (var i = 0; i < 16; i++) L[i] = g[i] ? Math.log(g[i]) / Math.LN2 : 0;
    return L;
  }
  /* neighbouring tiles that are close in value are easy to merge later */
  function simSmooth(L) {
    var s = 0, r, c, cc, rr, a;
    for (r = 0; r < N; r++) for (c = 0; c < N; c++) {
      a = L[r*N+c]; if (!a) continue;
      for (cc = c+1; cc < N; cc++) if (L[r*N+cc]) { s -= Math.abs(a - L[r*N+cc]); break; }
      for (rr = r+1; rr < N; rr++) if (L[rr*N+c]) { s -= Math.abs(a - L[rr*N+c]); break; }
    }
    return s;
  }
  /* rows and columns that only ever climb (or only ever fall) stay tidy */
  function simMono(L) {
    var t = [0, 0, 0, 0], r, c, cur, next, a, b;
    for (c = 0; c < N; c++) {
      cur = 0; while (cur < N && !L[cur*N+c]) cur++;
      if (cur >= N) cur = N - 1;
      next = cur + 1;
      while (next < N) {
        while (next < N && !L[next*N+c]) next++;
        if (next >= N) next = N - 1;
        a = L[cur*N+c] || 0; b = L[next*N+c] || 0;
        if (a > b) t[0] += b - a; else if (b > a) t[1] += a - b;
        cur = next; next++;
      }
    }
    for (r = 0; r < N; r++) {
      cur = 0; while (cur < N && !L[r*N+cur]) cur++;
      if (cur >= N) cur = N - 1;
      next = cur + 1;
      while (next < N) {
        while (next < N && !L[r*N+next]) next++;
        if (next >= N) next = N - 1;
        a = L[r*N+cur] || 0; b = L[r*N+next] || 0;
        if (a > b) t[2] += b - a; else if (b > a) t[3] += a - b;
        cur = next; next++;
      }
    }
    return Math.max(t[0], t[1]) + Math.max(t[2], t[3]);
  }
  function simEval(g) {
    var L = simLogs(g), free = 0, mx = 0, i;
    for (i = 0; i < 16; i++) { if (!g[i]) free++; if (L[i] > mx) mx = L[i]; }
    return simSmooth(L) * 0.1 + simMono(L) * 1.0 + Math.log(free + 1) * 2.7 + mx;
  }
  function simMax(g, depth) {
    var b = -Infinity, d, m, s;
    for (d = 0; d < 4; d++) {
      m = simMove(g, d);
      if (!m.moved) continue;
      s = depth <= 0 ? simEval(m.g) : simChance(m.g, depth);
      if (s > b) b = s;
    }
    return b === -Infinity ? simEval(g) - 100 : b;
  }
  function simChance(g, depth) {
    var free = [], i;
    for (i = 0; i < 16; i++) if (!g[i]) free.push(i);
    if (!free.length) return simEval(g);
    var pick = free;
    if (pick.length > 4) { pick = []; for (i = 0; i < 4; i++) pick.push(free[(Math.random() * free.length) | 0]); }
    var tot = 0;
    for (i = 0; i < pick.length; i++) {
      var g2 = g.slice(); g2[pick[i]] = 2;
      tot += simMax(g2, depth - 1);
    }
    return tot / pick.length;
  }
  function simBestDir(g) {
    var bs = -Infinity, bd = -1, d, m, s;
    for (d = 0; d < 4; d++) {
      m = simMove(g, d);
      if (!m.moved) continue;
      s = simChance(m.g, 1);
      if (s > bs) { bs = s; bd = d; }
    }
    return bd;
  }
  function simSpawn(g) {
    var free = [], i;
    for (i = 0; i < 16; i++) if (!g[i]) free.push(i);
    if (!free.length) return false;
    g[free[(Math.random() * free.length) | 0]] = Math.random() < 0.9 ? 2 : 4;
    return true;
  }

  /* One solver game, stopped at a handover point that is still fun to play. */
  function simPlay(deadline) {
    var g = [], i;
    for (i = 0; i < 16; i++) g.push(0);
    simSpawn(g); simSpawn(g);
    var sc = 0, mv = 0, mg = 0;
    for (i = 0; i < 2500; i++) {
      var d = simBestDir(g);
      if (d < 0) break;
      var m = simMove(g, d);
      g = m.g; sc += m.gained; mg += m.merged; mv++;
      simSpawn(g);
      var mx = 0, free = 0, k;
      for (k = 0; k < 16; k++) { if (g[k] > mx) mx = g[k]; if (!g[k]) free++; }
      if (mx >= 512 && free >= 3 && free <= 6) return { g: g, score: sc, moves: mv, merges: mg, ok: true };
      if ((i & 31) === 31 && now() > deadline) break;
    }
    return { g: g, score: sc, moves: mv, merges: mg, ok: false };
  }

  function now() {
    return (window.performance && performance.now) ? performance.now() : Date.now();
  }

  /* A hand-written stand-in, used only if the solver somehow comes up empty.
     Its score is exactly what building these tiles out of 2s pays out. */
  var FALLBACK = [512, 256, 64, 4, 32, 16, 8, 4, 4, 2, 2, 0, 2, 0, 0, 0];
  function fallbackRun() {
    var sc = 0, mg = 0, i, v;
    for (i = 0; i < 16; i++) {
      v = FALLBACK[i];
      if (!v) continue;
      sc += v * (Math.round(Math.log(v) / Math.LN2) - 1);
      mg += v / 2 - 1;
    }
    /* -1 marks a move count nobody can honestly claim to know */
    return { g: FALLBACK.slice(), score: sc, moves: -1, merges: mg, ok: false };
  }

  function loadOpening() {
    var deadline = now() + 220;
    var run = null;
    for (var a = 0; a < 4; a++) {
      var r = simPlay(deadline);
      if (r.ok) { run = r; break; }
      if (!run || r.score > run.score) run = r;
      if (now() > deadline) break;
    }
    var mx = 0, free = 0, k;
    for (k = 0; k < 16; k++) { if (run.g[k] > mx) mx = run.g[k]; if (!run.g[k]) free++; }
    if (mx < 128 || free < 2) run = fallbackRun();

    clearBoard();
    for (var r2 = 0; r2 < N; r2++) for (var c2 = 0; c2 < N; c2++) {
      var v = run.g[r2 * N + c2];
      if (v) grid[r2][c2] = makeTile(v, r2, c2, "");
    }
    score = run.score; best = run.score;
    moves = run.moves < 0 ? 0 : run.moves; merges = run.merges;
    won = false; keepGoing = false; over = false; snap = null;
    setOrigin(run.moves);
    sync();
  }

  function setOrigin(solverMoves) {
    if (solverMoves < 0) {
      originEl.innerHTML = "Loaded position (the solver ran out of its time budget). Score and merges are exactly what building this board out of 2s costs; the move count starts at <b>0</b> because nobody can honestly claim to know it.";
    } else if (solverMoves > 0) {
      originEl.innerHTML = "Opening played by the built-in solver: <b>" + solverMoves +
        "</b> moves before you arrived. The counts above are its real totals, and they keep climbing as you play.";
    } else {
      originEl.textContent = "Fresh board, two tiles, nothing played yet. Every count here is yours.";
    }
  }

  /* ── overlays ───────────────────────────────────────────────────────────── */
  var ovMode = "";
  function plural(n, word) { return n === 1 ? word : word + "s"; }
  function showOverlay(mode) {
    ovMode = mode;
    if (mode === "win") {
      /* The winning merge can also be the one that strands the board, so the
         card reports what is actually left rather than promising room. */
      var free = emptyCells().length;
      var tail = !movesLeft() ? "That was also the last legal move."
               : free ? "The board still has <b>" + free + "</b> free " + plural(free, "cell") + "."
               : "The board is full, but there are merges left in it.";
      ovTitle.textContent = "2048";
      ovTitle.className = "big good";
      ovSub.innerHTML = "You built it in <b>" + moves + "</b> " + plural(moves, "move") +
        " for <b>" + score + "</b> points. " + tail;
      ovMain.textContent = "Keep going";
      ovAlt.textContent = "New game";
      ovAlt.hidden = false;
    } else {
      ovTitle.textContent = "no moves left";
      ovTitle.className = "big stop";
      ovSub.innerHTML = "Final score <b>" + score + "</b> across <b>" + moves + "</b> " + plural(moves, "move") + ". Largest tile <b>" + largest() + "</b>.";
      ovMain.textContent = "New game";
      ovAlt.textContent = snap ? "Undo last move" : "";
      ovAlt.hidden = !snap;
    }
    overlay.hidden = false;
    ovMain.focus();
  }
  function hideOverlay() {
    /* keep the keyboard alive: if focus was parked on an overlay button, hand
       it back to the board rather than dropping it on <body> */
    var wasInside = overlay.contains(document.activeElement);
    overlay.hidden = true; ovMode = "";
    if (wasInside) board.focus();
  }

  /* aria-modal="true" hides the rest of the page from assistive tech, so the
     keyboard should not wander out of the sheet either — cycle the buttons. */
  overlay.addEventListener("keydown", function (e) {
    if (e.key !== "Tab" || overlay.hidden) return;
    var stops = ovAlt.hidden ? [ovMain] : [ovMain, ovAlt];
    var i = stops.indexOf(document.activeElement);
    e.preventDefault();
    stops[(i + (e.shiftKey ? stops.length - 1 : 1) + stops.length) % stops.length].focus();
  });

  ovMain.addEventListener("click", function () {
    if (ovMode === "win") {
      keepGoing = true; hideOverlay();
      if (endIfDead()) return;
      sync(); board.focus();
    } else newGame();
  });
  ovAlt.addEventListener("click", function () {
    if (ovMode === "win") newGame();
    else undo();
  });

  /* ── UI sync ────────────────────────────────────────────────────────────── */
  function sync() {
    scoreEl.textContent = score;
    bestEl.textContent = best;
    st.moves.textContent = moves;
    st.merges.textContent = merges;
    var top = largest();
    st.top.textContent = top;
    st.free.textContent = emptyCells().length;

    st.state.className = "sv state";
    if (over) { st.state.textContent = "no moves left"; st.state.className = "sv state stop"; }
    else if (won) { st.state.textContent = "2048 reached"; st.state.className = "sv state good"; }
    else { st.state.textContent = "in play"; }

    undoBtn.disabled = !snap;

    var chips = ladderEl.children;
    for (var k = 0; k < chips.length; k++) {
      chips[k].classList.toggle("on", Math.pow(2, k + 1) <= top);
    }

    var lines = [];
    for (var r = 0; r < N; r++) {
      var row = [];
      for (var c = 0; c < N; c++) row.push(grid[r][c] ? grid[r][c].v : "empty");
      lines.push("Row " + (r+1) + ": " + row.join(", ") + ".");
    }
    boardText.textContent = "Board. " + lines.join(" ");
  }

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

  /* ── input ──────────────────────────────────────────────────────────────── */
  var KEYS = {
    ArrowUp: 0, ArrowRight: 1, ArrowDown: 2, ArrowLeft: 3,
    Up: 0, Right: 1, Down: 2, Left: 3,
    w: 0, d: 1, s: 2, a: 3,
    W: 0, D: 1, S: 2, A: 3
  };

  document.addEventListener("keydown", function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    var tag = e.target && e.target.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA") return;

    /* Escape dismisses the win card the same way "Keep going" does. The
       game-over card has nothing playable behind it, so Escape leaves it up. */
    if (e.key === "Escape" || e.key === "Esc") {
      if (!overlay.hidden && ovMode === "win") { e.preventDefault(); ovMain.click(); }
      return;
    }

    if (Object.prototype.hasOwnProperty.call(KEYS, e.key)) {
      if (!overlay.hidden) return;
      e.preventDefault();
      move(KEYS[e.key]);
      return;
    }
    if (e.key === "u" || e.key === "U") {
      if (!undoBtn.disabled) { e.preventDefault(); undo(); }
      return;
    }
    if (e.key === "n" || e.key === "N") { e.preventDefault(); newGame(); }
  });

  newBtn.addEventListener("click", newGame);
  undoBtn.addEventListener("click", function () { undo(); board.focus(); });

  /* swipe / drag — pointer events cover touch, pen and mouse */
  var sx = 0, sy = 0, pid = null;
  board.addEventListener("pointerdown", function (e) {
    if (pid !== null) return;
    /* The overlay sits inside the board, so its buttons would otherwise lose
       their click: capturing the pointer here retargets the release to the
       board and the click lands on the common ancestor instead of the button. */
    if (!overlay.hidden || (e.target && e.target.closest && e.target.closest(".overlay"))) return;
    pid = e.pointerId; sx = e.clientX; sy = e.clientY;
    if (board.setPointerCapture) { try { board.setPointerCapture(pid); } catch (err) { void err; } }
  });
  function endPointer(e) {
    if (pid === null || e.pointerId !== pid) return;
    var dx = e.clientX - sx, dy = e.clientY - sy;
    pid = null;
    var ax = Math.abs(dx), ay = Math.abs(dy);
    if (Math.max(ax, ay) < 22) return;
    if (ax > ay) move(dx > 0 ? DIRS.right : DIRS.left);
    else move(dy > 0 ? DIRS.down : DIRS.up);
  }
  board.addEventListener("pointerup", endPointer);
  board.addEventListener("pointercancel", function (e) { if (e.pointerId === pid) pid = null; });
  /* If the capture is ever torn away without an up or a cancel reaching us, the
     gesture id would stay parked and every later swipe would be ignored. */
  board.addEventListener("lostpointercapture", function (e) { if (e.pointerId === pid) pid = null; });

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  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);
      paintPalette();
    }
  });
  if (mqDark) {
    var onScheme = function () { paintPalette(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }
  if (mqRM) {
    var onRM = function () { reduced = mqRM.matches; };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  /* ── boot ───────────────────────────────────────────────────────────────── */
  idSeq = 0; tiles = {}; grid = emptyGrid(); pending = null;
  score = 0; best = 0; moves = 0; merges = 0;
  won = false; keepGoing = false; over = false; snap = null;

  paintPalette();
  loadOpening();
})();
</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