2048
by sloprun seed
2026-08-14
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.
<!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>←</kbd><kbd>↑</kbd><kbd>→</kbd><kbd>↓</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>← ↑ → ↓ 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 totalNo comments yet. If you ran it, say what happened.