gamekitui
component2048source: ok · hit
2048
The addictive sliding-tile puzzle. Reach 2048. Swipe or arrow keys.
npx shadcn add "https://gamekitui.com/r/2048.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/14/2026, 1:55:29 PM
registry/games/2048.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Engine */
/* -------------------------------------------------------------------------- */
type Dir = "up" | "down" | "left" | "right";
const SIZE = 4;
/** A tile has a value and a stable id for React keying / animation. */
interface Tile {
id: number;
value: number;
/** true the frame it was spawned */
fresh: boolean;
/** true the frame it was merged */
merged: boolean;
}
type Board = (Tile | null)[][];
interface GameState {
board: Board;
score: number;
won: boolean;
over: boolean;
/** Whether the player chose to keep going after 2048 */
keepGoing: boolean;
}
let nextId = 1;
function mkTile(value: number, fresh = true, merged = false): Tile {
return { id: nextId++, value, fresh, merged };
}
function emptyBoard(): Board {
return Array.from({ length: SIZE }, () => Array<Tile | null>(SIZE).fill(null));
}
function emptyCells(board: Board): [number, number][] {
const cells: [number, number][] = [];
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (board[r]?.[c] == null) cells.push([r, c]);
}
}
return cells;
}
function spawnRandom(board: Board): Board {
const cells = emptyCells(board);
if (cells.length === 0) return board;
const idx = Math.floor(Math.random() * cells.length);
const cell = cells[idx];
if (!cell) return board;
const [r, c] = cell;
const value = Math.random() < 0.9 ? 2 : 4;
const next: Board = board.map((row) => [...row]);
const row = next[r];
if (row) row[c] = mkTile(value, true, false);
return next;
}
function initBoard(): Board {
let board = emptyBoard();
board = spawnRandom(board);
board = spawnRandom(board);
return board;
}
/** Returns a fresh board where each tile's fresh/merged flags are cleared. */
function clearFlags(board: Board): Board {
return board.map((row) =>
row.map((tile) =>
tile ? { ...tile, fresh: false, merged: false } : null,
),
);
}
/** Slide + merge a single row to the left. Returns { row, gained }. */
function slideLeft(row: (Tile | null)[]): { row: (Tile | null)[]; gained: number } {
// Compact non-null tiles
const tiles = row.filter((t): t is Tile => t !== null);
const result: (Tile | null)[] = Array<Tile | null>(SIZE).fill(null);
let gained = 0;
let wi = 0;
let ri = 0;
while (ri < tiles.length) {
const cur = tiles[ri];
const nxt = tiles[ri + 1];
if (!cur) { ri++; continue; }
if (nxt && cur.value === nxt.value) {
// Merge
const mergedValue = cur.value * 2;
gained += mergedValue;
result[wi++] = mkTile(mergedValue, false, true);
ri += 2;
} else {
result[wi++] = { ...cur, fresh: false, merged: false };
ri++;
}
}
return { row: result, gained };
}
function rotateBoard(board: Board): Board {
// Rotate 90° clockwise: new[c][SIZE-1-r] = old[r][c]
const next = emptyBoard();
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const tile = board[r]?.[c] ?? null;
const newRow = next[c];
if (newRow) newRow[SIZE - 1 - r] = tile;
}
}
return next;
}
function rotateN(board: Board, n: number): Board {
let b = board;
for (let i = 0; i < n; i++) b = rotateBoard(b);
return b;
}
/** Apply a move direction. Returns { board, gained, changed }. */
function applyMove(board: Board, dir: Dir): { board: Board; gained: number; changed: boolean } {
// Rotate so we always slide left, then rotate back.
const rotations: Record<Dir, number> = { left: 0, up: 1, right: 2, down: 3 };
const rot = rotations[dir];
let working = rotateN(board, rot);
let totalGained = 0;
let changed = false;
const nextBoard: Board = working.map((row) => {
const before = row.map((t) => t?.id ?? null);
const { row: slid, gained } = slideLeft(row);
const after = slid.map((t) => t?.id ?? null);
if (JSON.stringify(before) !== JSON.stringify(after)) changed = true;
totalGained += gained;
return slid;
});
working = nextBoard;
// Rotate back
const backRot = (4 - rot) % 4;
working = rotateN(working, backRot);
return { board: working, gained: totalGained, changed };
}
function hasMovesLeft(board: Board): boolean {
// Any empty cell?
if (emptyCells(board).length > 0) return true;
// Any adjacent equal values?
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const val = board[r]?.[c]?.value;
if (val == null) continue;
if (board[r]?.[c + 1]?.value === val) return true;
if (board[r + 1]?.[c]?.value === val) return true;
}
}
return false;
}
function has2048(board: Board): boolean {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if ((board[r]?.[c]?.value ?? 0) >= 2048) return true;
}
}
return false;
}
// Deterministic empty state for the idle screen — keeps server and client
// markup identical so there's no hydration mismatch from random tiles.
function createInitialState(): GameState {
return {
board: emptyBoard(),
score: 0,
won: false,
over: false,
keepGoing: false,
};
}
// Playable state with two starting tiles — only created client-side on start.
function createPlayingState(): GameState {
return {
board: initBoard(),
score: 0,
won: false,
over: false,
keepGoing: false,
};
}
/* -------------------------------------------------------------------------- */
/* Shared primitives */
/* -------------------------------------------------------------------------- */
function usePrefersReducedMotion(): boolean {
const [reduce, setReduce] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduce(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduce;
}
/* -------------------------------------------------------------------------- */
/* Tile theming */
/* -------------------------------------------------------------------------- */
function tileClasses(value: number, fresh: boolean, merged: boolean, reduce: boolean): string {
const base =
"absolute inset-0 flex items-center justify-center rounded-md font-bold tabular-nums select-none transition-all";
let colorClass: string;
if (value <= 4) {
colorClass = "bg-secondary text-secondary-foreground";
} else if (value <= 32) {
colorClass = "bg-accent text-accent-foreground";
} else {
colorClass = "bg-primary text-primary-foreground";
}
const ringClass =
!reduce && (fresh || merged)
? "ring-2 ring-ring ring-offset-1 ring-offset-background"
: "";
const animClass =
!reduce && fresh
? "animate-[pop_150ms_ease-out]"
: !reduce && merged
? "animate-[pop_120ms_ease-out]"
: "";
// Text sizing: use container-query units so font scales with tile size.
// 4-digit values (1024, 2048, 4096…) get a smaller clamp.
const textClass =
value >= 1024
? "text-[clamp(0.6rem,8cqi,1.5rem)]"
: value >= 128
? "text-[clamp(0.75rem,9cqi,1.75rem)]"
: "text-[clamp(0.875rem,11cqi,2rem)]";
return cn(base, colorClass, textClass, ringClass, animClass);
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface Game2048Props {
className?: string;
/** Optional max-width override (e.g. "320px", "24rem"). Defaults to max-w-sm. */
width?: string;
autoFocus?: boolean;
/**
* When true (the default), the game listens for keys on `window` so it works
* without being focused first — ideal for single-game pages (404s, loading /
* empty states). Set false when multiple games share a page, or the game sits
* in scrollable content, so it only responds while focused. Ignores keys aimed
* at form fields.
*/
captureGlobalKeys?: boolean;
persistHighScore?: boolean | string;
onScoreChange?: (score: number) => void;
onGameOver?: (r: { score: number; won: boolean }) => void;
onStart?: () => void;
}
export function Game2048({
className,
width,
autoFocus = true,
captureGlobalKeys = true,
persistHighScore = true,
onScoreChange,
onGameOver,
onStart,
}: Game2048Props) {
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const reduce = usePrefersReducedMotion();
const storageKey =
typeof persistHighScore === "string"
? persistHighScore
: persistHighScore === false
? null
: "gamekitui:2048:hi";
const [status, setStatus] = React.useState<"idle" | "playing" | "won" | "gameover">("idle");
const [gameState, setGameState] = React.useState<GameState>(createInitialState);
const [high, setHigh] = React.useState(0);
const [announcement, setAnnouncement] = React.useState("");
// Mutable ref so event handlers always see latest status without re-subscribing.
const statusRef = React.useRef(status);
statusRef.current = status;
const gameStateRef = React.useRef(gameState);
gameStateRef.current = gameState;
// Load persisted high score.
React.useEffect(() => {
if (!storageKey) return;
try {
const raw = window.localStorage.getItem(storageKey);
if (raw) setHigh(Number(raw) || 0);
} catch {
/* ignore */
}
}, [storageKey]);
const submitHigh = React.useCallback(
(s: number) => {
setHigh((prev) => {
if (s <= prev) return prev;
if (storageKey) {
try {
window.localStorage.setItem(storageKey, String(s));
} catch {
/* ignore */
}
}
return s;
});
},
[storageKey],
);
const startGame = React.useCallback(() => {
const state = createPlayingState();
setGameState(state);
setStatus("playing");
onStart?.();
onScoreChange?.(0);
setAnnouncement("Game started. Score: 0");
// Focus so keyboard/WASD work immediately after the Start button unmounts.
wrapperRef.current?.focus();
}, [onScoreChange, onStart]);
/** Process a move direction. */
const move = React.useCallback(
(dir: Dir) => {
if (statusRef.current !== "playing") return;
setGameState((prev) => {
// Clear previous animation flags first
const cleanBoard = clearFlags(prev.board);
const { board: moved, gained, changed } = applyMove(cleanBoard, dir);
if (!changed) return prev;
const newScore = prev.score + gained;
const boardWithSpawn = spawnRandom(moved);
const nowWon = !prev.keepGoing && has2048(boardWithSpawn);
const nowOver = !hasMovesLeft(boardWithSpawn);
const next: GameState = {
board: boardWithSpawn,
score: newScore,
won: nowWon || prev.won,
over: nowOver,
keepGoing: prev.keepGoing,
};
// Side effects scheduled via queueMicrotask to avoid setState-in-setState warnings.
queueMicrotask(() => {
onScoreChange?.(newScore);
if (nowWon && !prev.keepGoing) {
setStatus("won");
setAnnouncement(`You reached 2048! Score: ${newScore}`);
submitHigh(newScore);
onGameOver?.({ score: newScore, won: true });
} else if (nowOver) {
setStatus("gameover");
setAnnouncement(`Game over! Final score: ${newScore}`);
submitHigh(newScore);
onGameOver?.({ score: newScore, won: prev.won || nowWon });
} else {
setAnnouncement(`Score: ${newScore}`);
}
});
return next;
});
},
[onScoreChange, onGameOver, submitHigh],
);
const keepGoing = React.useCallback(() => {
setGameState((prev) => ({ ...prev, keepGoing: true, won: false }));
setStatus("playing");
setAnnouncement("Continuing game…");
}, []);
// Keyboard handler on the wrapper (not window).
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// In global mode, ignore keys aimed at form fields / editable content.
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
const dirMap: Record<string, Dir> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
w: "up",
s: "down",
a: "left",
d: "right",
W: "up",
S: "down",
A: "left",
D: "right",
};
const dir = dirMap[e.key];
if (dir) {
e.preventDefault();
if (statusRef.current === "idle") {
startGame();
} else {
move(dir);
}
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (statusRef.current !== "playing") startGame();
}
};
// Single-game pages can opt into window-level capture so keys work without
// focusing the game; otherwise listen on the wrapper (focus-scoped).
const target: Window | HTMLElement | null = captureGlobalKeys
? window
: wrapperRef.current;
target?.addEventListener("keydown", onKey as EventListener);
return () => {
target?.removeEventListener("keydown", onKey as EventListener);
};
}, [startGame, move, captureGlobalKeys]);
// Touch swipe. A single deliberate swipe should cause AT MOST one move: we
// only register a direction on touchend (never on touchmove), so drag-holds
// and circular drags that return near the origin don't move anything.
React.useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
// Minimum net travel for a swipe to count.
const THRESHOLD = 24;
// Dominant axis must clearly win (1.3×) for a diagonal-ish swipe to register.
const DOMINANCE = 1.3;
let sx = 0;
let sy = 0;
let active = false;
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0];
if (!t) return;
sx = t.clientX;
sy = t.clientY;
active = true;
if (statusRef.current === "idle") startGame();
};
const onTouchMove = (e: TouchEvent) => {
// Don't move tiles here — only prevent the page from scrolling while the
// gesture is over the board.
if (active) e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => {
if (!active) return;
active = false;
const t = e.changedTouches[0];
if (!t) return;
const dx = t.clientX - sx;
const dy = t.clientY - sy;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
// Too short — treat as a tap / drag-hold, not a swipe.
if (Math.max(adx, ady) < THRESHOLD) return;
// One axis must clearly dominate; otherwise the gesture is ambiguous.
const horizontal = adx >= ady;
if (horizontal && adx < ady * DOMINANCE) return;
if (!horizontal && ady < adx * DOMINANCE) return;
const dir: Dir = horizontal
? dx > 0
? "right"
: "left"
: dy > 0
? "down"
: "up";
move(dir);
};
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: false });
el.addEventListener("touchend", onTouchEnd, { passive: true });
el.addEventListener("touchcancel", onTouchEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
el.removeEventListener("touchcancel", onTouchEnd);
};
}, [startGame, move]);
React.useEffect(() => {
if (autoFocus) wrapperRef.current?.focus();
}, [autoFocus]);
const { board, score } = gameState;
return (
<div
ref={wrapperRef}
tabIndex={0}
role="application"
aria-label="2048 game"
style={width ? { maxWidth: width } : undefined}
className={cn(
"relative flex w-full max-w-sm select-none flex-col gap-2 outline-none",
// Focus ring only matters when keys are focus-scoped; global capture hides it.
!captureGlobalKeys &&
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
>
{/* Score row */}
<div className="flex items-center justify-between gap-3 text-sm">
<span
className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums"
aria-live="polite"
aria-atomic="true"
>
Score {score}
</span>
<span className="text-muted-foreground tabular-nums">Best {high}</span>
</div>
{/* Hidden aria-live region for game state announcements */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
{/* Board */}
<div className="relative rounded-lg border bg-muted p-2">
{/* Inject pop keyframe animation */}
<style>{`@keyframes pop{0%{transform:scale(.8);opacity:.6}60%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}`}</style>
{/* @container enables cqi units inside for tile font scaling */}
<div
className="@container grid grid-cols-4 grid-rows-4 w-full aspect-square gap-[2.5%]"
>
{Array.from({ length: SIZE }, (_, r) =>
Array.from({ length: SIZE }, (_, c) => {
const tile = board[r]?.[c] ?? null;
return (
<div
key={`${r}-${c}`}
className="relative size-full rounded-md bg-background border border-border"
>
{tile && (
<div
key={tile.id}
className={tileClasses(tile.value, tile.fresh, tile.merged, reduce)}
>
{tile.value}
</div>
)}
</div>
);
}),
)}
</div>
{/* Overlays */}
{status !== "playing" && (
<div className="absolute inset-0 grid place-items-center rounded-lg bg-background/80 backdrop-blur-[1px]">
<div className="flex flex-col items-center gap-3 px-6 text-center">
{status === "gameover" ? (
<>
<p className="text-lg font-semibold">Game over</p>
<p className="text-sm text-muted-foreground">
You scored {score}
{score >= high && score > 0 ? " — new best!" : ""}
</p>
</>
) : status === "won" ? (
<>
<p className="text-lg font-semibold">You reached 2048!</p>
<p className="text-sm text-muted-foreground">Score: {score}</p>
<div className="flex gap-2">
<button
type="button"
onClick={keepGoing}
className={cn(
"inline-flex h-9 items-center justify-center rounded-md border bg-secondary px-4 text-sm font-medium text-secondary-foreground",
"transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
Keep going
</button>
<button
type="button"
onClick={startGame}
className={cn(
"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground",
"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
New game
</button>
</div>
</>
) : (
<>
<p className="text-lg font-semibold">2048</p>
<p className="text-sm text-muted-foreground">
Arrow keys, WASD, or swipe to play.
</p>
</>
)}
{(status === "idle" || status === "gameover") && (
<button
type="button"
onClick={startGame}
className={cn(
"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground",
"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{status === "gameover" ? "Play again" : "Start"}
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}
export default Game2048;