Code — how this page is built
Under the hood
The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.
Game2048view on GitHub ↗
A 16-cell grid; each direction is a set of index-lines that get slid + merged. Keyboard and swipe input, spawn-on-move, game-over detection, best score in localStorage.
'use client';
import * as React from 'react';
import { Win98Button } from '@/components/ui/win-98-button';
const KEY = 'kaspirius:2048:best';
const LINES: Record<string, number[][]> = {
left: [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]],
right: [[3, 2, 1, 0], [7, 6, 5, 4], [11, 10, 9, 8], [15, 14, 13, 12]],
up: [[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]],
down: [[12, 8, 4, 0], [13, 9, 5, 1], [14, 10, 6, 2], [15, 11, 7, 3]],
};
const COLORS: Record<number, string> = {
2: '#efe6d2', 4: '#ecdfc0', 8: '#e2b07a', 16: '#dd9657', 32: '#d97b4a',
64: '#c2453a', 128: '#cBA23f', 256: '#c9a23f', 512: '#bca233', 1024: '#6f7d4a', 2048: '#4c8c7a',
};
function spawn(g: number[]) {
const empty = g.map((v, i) => (v === 0 ? i : -1)).filter((i) => i >= 0);
if (!empty.length) return;
g[empty[Math.floor(Math.random() * empty.length)]] = Math.random() < 0.9 ? 2 : 4;
}
function slide(line: number[]) {
const arr = line.filter((v) => v);
let gained = 0;
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] === arr[i + 1]) {
arr[i] *= 2;
gained += arr[i];
arr.splice(i + 1, 1);
}
}
while (arr.length < 4) arr.push(0);
return { arr, gained };
}
function isOver(g: number[]) {
if (g.includes(0)) return false;
for (let r = 0; r < 4; r++)
for (let c = 0; c < 4; c++) {
const i = r * 4 + c;
if (c < 3 && g[i] === g[i + 1]) return false;
if (r < 3 && g[i] === g[i + 4]) return false;
}
return true;
}
export function Game2048() {
const [grid, setGrid] = React.useState<number[]>(() => Array(16).fill(0));
const [score, setScore] = React.useState(0);
const [best, setBest] = React.useState(0);
const [over, setOver] = React.useState(false);
const reset = React.useCallback(() => {
const g = Array(16).fill(0);
spawn(g);
spawn(g);
setGrid(g);
setScore(0);
setOver(false);
}, []);
React.useEffect(() => {
setBest(Number(localStorage.getItem(KEY)) || 0);
reset();
}, [reset]);
const move = React.useCallback(
(dir: string) => {
setGrid((prev) => {
if (over) return prev;
const g = [...prev];
let moved = false;
let gained = 0;
for (const idxs of LINES[dir]) {
const { arr, gained: gg } = slide(idxs.map((i) => g[i]));
gained += gg;
idxs.forEach((i, k) => {
if (g[i] !== arr[k]) moved = true;
g[i] = arr[k];
});
}
if (!moved) return prev;
spawn(g);
if (gained) {
setScore((s) => {
const ns = s + gained;
setBest((b) => {
const nb = Math.max(b, ns);
localStorage.setItem(KEY, String(nb));
return nb;
});
return ns;
});
}
if (isOver(g)) setOver(true);
return g;
});
},
[over],
);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const map: Record<string, string> = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' };
if (map[e.key]) {
e.preventDefault();
move(map[e.key]);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [move]);
const touch = React.useRef<{ x: number; y: number } | null>(null);
return (
<div className="flex flex-col items-center gap-5">
<div className="flex gap-10 font-mono">
<div className="text-center">
<div className="text-3xl font-bold leading-none">{score}</div>
<div className="mt-1.5 text-[11px] uppercase tracking-[0.12em] opacity-55">score</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold leading-none">{best}</div>
<div className="mt-1.5 text-[11px] uppercase tracking-[0.12em] opacity-55">best</div>
</div>
</div>
<div
className="relative grid grid-cols-4 gap-2.5 rounded-lg bg-foreground/10 p-2.5"
style={{ width: 'min(88vw, 380px)', touchAction: 'none' }}
onPointerDown={(e) => (touch.current = { x: e.clientX, y: e.clientY })}
onPointerUp={(e) => {
if (!touch.current) return;
const dx = e.clientX - touch.current.x;
const dy = e.clientY - touch.current.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) > 24) {
move(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up');
}
touch.current = null;
}}
>
{grid.map((v, i) => (
<div
key={i}
className="flex aspect-square items-center justify-center rounded-md text-2xl font-bold"
style={{
background: v ? COLORS[v] ?? '#3a352b' : 'rgba(20,17,11,0.06)',
color: v >= 8 ? '#faf7ef' : 'var(--ink)',
}}
>
{v || ''}
</div>
))}
{over ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-lg bg-[var(--paper)]/80">
<div className="text-xl font-bold">Game over</div>
<Win98Button onClick={reset} className="h-9 px-5">Again</Win98Button>
</div>
) : null}
</div>
<div className="flex items-center gap-3">
<Win98Button onClick={reset} className="h-9 px-5">New game</Win98Button>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
arrow keys or swipe
</span>
</div>
</div>
);
}