2048 · nex-n2-pro
Build a playable 2048 game with arrow-key tile sliding and merging, smooth animations, a score, and win and lose states.
transcript · 6 messages
Build a playable 2048 game with arrow-key tile sliding and merging, smooth animations, a score, and win and lose states.
thinking
We need respond to user by creating index.html in cwd /tmp/ws. Need build playable 2048 single page self-contained. Need inspect current dir maybe. Need write file. Need likely include HTML CSS JS. Need ensure no external resources. Need implement 2048 with arrow key tile sliding and merging, smooth animations, score, win/lose states. Need perhaps use DOM/CSS animations. Need produce final concise. Need use write. But before maybe check dir. Could just write index.html. Need be careful implement robust. Need maybe include touch/swipe? User only arrow-key but playable maybe mouse? Could include. Need one file. Need "smooth animations". Implement with CSS transitions using absolute positioned tiles with data positions? Simpler: render grid with tiles as absolute divs with transform translate. Need animate moves and merges. Need maintain tile objects with id, value, x,y, mergedFrom? Classic. Need spawn after move. Need scoring. Win overlay at 2048; allow continue? Need lose overlay. Need restart. Need arrow key controls. Need focus? body tabindex.
Need think implementation. Need maybe use CSS variables --size, --gap. Board 4x4. Use JS create board element. Use tile elements keyed by id. Use position transform: translate(calc(var(--gap) + x*(var(--cell)+var(--gap))), ...). But if board dimensions known. Could use CSS calc.
Need include smooth animation: tile moves via CSS transition transform 120ms. Merge animation: when tile mergedFrom, we can create two old tile elements? Need robust.
Simpler DOM rendering:
- Keep tiles array of tile objects {id,value,x,y, mergedFrom:[tileA,tileB]}.
- On move, compute result with tile objects maybe clone. Need animate.
- Render tiles before move, then update positions of existing tile IDs, remove merged old IDs, add new tile.
Need implement move logic with tile objects. Approach:
1. For each direction, define traversal order and line cells.
2. For each line, process cells in direction. Need gather existing tiles in line sorted along movement direction? Let's define function move(direction).
- Determine lines: for each row/col.
- We need preserve tile IDs.
- For each line, collect tiles from cells along line in movement order? Actually when moving left, process from left to right, merging adjacent in that order. For right, process from right to left. For up, top to bottom; down, bottom to top.
- For each line, iterate cells in traversal order, if tile exists, push tile.
- Then combine:
targetIndex=0;
for i=0; i<arr.length; i++:
if i+1<arr.length && arr[i].value === arr[i+1].value:
mergedTile = {id:nextId(), value:tile.value*2, x:targetCell.x, y:targetCell.y, mergedFrom:[arr[i], arr[i+1]]}
newTiles.push(mergedTile)
score += mergedTile.value
i++;
else:
newTiles.push({...arr[i], x:targetCell.x,y:targetCell.y}) // preserve id
targetIndex++;
- But need targetCell based on line index targetIndex. For right/down traversal order from far side, targetCell is line[targetIndex], which is far side first. Good.
- The non-merged tile object's x,y updated to target position. The mergedFrom tiles retain old positions. The new tile gets new id, mergedFrom references old tile objects. Need render.
- Need replace cells with newTiles.
- Detect moved if any tile id not in same x/y or merged occurred. For non-merged, compare old x/y to new x/y. For merged old tiles removed. If moved true.
- After move, add new tile to cells? Wait newTiles are moved/merged tiles occupying some cells. Need add random tile to empty cell. But mergedFrom old tiles should animate from old positions to merged new position? We can render old tiles with positions? Need tile elements for old IDs. For mergedFrom, the old tile elements should move to mergedTile's x,y then disappear, while new merged tile element appears at x,y with pop.
- Render pipeline:
- Before move, DOM has elements for all current tile IDs.
- When move called, compute newTiles.
- To animate existing moved tiles, update their element transform to new x/y.
- For mergedFrom old tiles, update their elements transform to mergedTile x/y, add .merging maybe.
- For non-merged moved, same element update.
- For new merged tile, create element at final x/y with class new/merge? If created after transform? CSS transition won't animate from old? Need show old elements animate, then after transition remove them and create merged element with pop.
- For newly spawned tile, create at x/y with class new.
- For removed tiles not mergedFrom (none if moved? Actually if no moved, no change).
- Implementation:
- renderTiles({phase?})?
- Need create tile elements for all current tiles after previous moves.
- On move:
- Add class moving to board?
- For each tile in currentTiles:
- If tile is in mergedFrom of any new tile:
- get element by id, set transform to mergedTile position, add class merging.
- Else if tile exists in newTiles by id (moved or stayed):
- element transform to tile.x/y.
- Else: maybe? Shouldn't happen.
- After MOVE_MS (e.g. 130), set currentTiles = newTiles; remove old mergedFrom elements; create new merged tile elements; add new random tile; render all.
- But if we remove mergedFrom and create new after transition, the new merged tile appears with class "new" maybe animate pop. It can be created after transition, but then no fade? Class new with scale from 0.1 to 1.
- Need update score during move maybe immediate.
- For non-merged moved, element transform transitions.
- Need ensure elements have transition.
- Need board has absolute tiles. Use tileMap Map id -> element.
- function positionFor(x,y): CSS transform translate(calc(var(--gap) + x * (var(--cell) + var(--gap))), ...). CSS supports calc with custom properties and multiplication? CSS calc doesn't support multiplying numbers like x * var. Need either set inline style transform = `translate(${gap + x*(cell+gap)}px, ${gap + y*(cell+gap)}px)` computed from board dimensions, or use CSS variables --x, --y and calc(var(--x) * (var(--cell-size) + var(--gap)))? CSS calc can multiply? CSS doesn't support multiplication until maybe not widely. Use JS compute.
- Set --cell-size and --gap in CSS. But JS can compute pixel based on board.clientWidth. Use function updateTransform(el, x,y): const gap = parseFloat(getComputedStyle(board).getPropertyValue('--gap')); const cell = (board.clientWidth - gap*5)/4; const px = gap + x*(cell+gap); el.style.transform = `translate(${px}px,${pxY}px)`.
- Use ResizeObserver or window resize to update all.
- Need board dimensions responsive. CSS:
.board { --gap: clamp(8px, 1.6vw, 14px); width: min(92vw, 480px); aspect-ratio:1; border-radius; background; position:relative; padding: var(--gap); box-sizing:border-box; }
.grid-cells absolute inset: var(--gap); display:grid; grid-template-columns repeat(4,1fr); gap:var(--gap); pointer-events:none;
.tile { position:absolute; width: calc((100% - 5*var(--gap))/4)? But tile absolute relative board, width can be calc((100% - 5*gap)/4). However board width includes padding? If tile inside board with padding? Simpler board has no padding, grid-cells inset var(--gap), tile width/height calc((100% - 5*gap)/4). Tile absolute top/left 0. transform from gap. Good.
- board width min(92vw, 480px), max. Use aspect ratio.
- .tile { transition: transform 130ms ease-in-out; }
- .tile.new { animation: appear 160ms ease-out both; }
- .tile.merging { animation: pop 160ms ease-out both; z-index:3; }
- But when we add class merging to old element, it animates scale. Need remove class after? We remove element after transition.
- Need z-index maybe tile.value class? Color.
Need implement game logic:
- const SIZE=4;
- let tiles = []; let score=0; let best = parseInt(localStorage...) maybe localStorage no network okay. But self-contained; okay. Could use localStorage. Need no external.
- Need cells map: Map key `${x},${y}` to tile.
- function buildCellMap(tileList=tiles)
- startNewGame: clear board tile layer, tiles=[], score=0, won=false, gameOver=false, continueAfterWin=false, update score, addRandomTile twice, render.
- addRandomTile: empty = all cells not occupied; pick random; value Math.random()<0.9?2:4; id nextId++; tiles.push({id,value,x,y}); create element with class new.
- move(direction): if gameOver or (won && !keepPlaying) maybe return. Need if won overlay with buttons. If won and !continuePlaying, ignore.
- Build lines:
const lines = [];
if dir==='left': for y=0..3 lines.push([{x:0,y}, {x:1,y}...]);
right: for y lines reverse
up: for x lines.push y 0..3
down: for x lines.push y 3..0
- For each line:
const arr = line.map(pos => cellMap.get(key)).filter(Boolean)
let target=0;
for i=0; i<arr.length; i++:
const targetPos = line[target];
const current = arr[i];
const next = arr[i+1];
if next && current.value===next.value:
const merged = {id: nextTileId++, value: current.value*2, x: targetPos.x, y: targetPos.y, mergedFrom:[current,next]};
nextTiles.push(merged);
score += merged.value;
mergedIds.add(current.id); mergedIds.add(next.id);
i++;
else:
nextTiles.push({...current, x:targetPos.x, y:targetPos.y});
target++;
- moved check:
const oldById = new Map(tiles.map(t=>[t.id,t]));
if nextTiles.some(nt => {
const old = oldById.get(nt.id);
return !old || old.x !== nt.x || old.y !== nt.y;
}) || score changed? But score can only change if merge. If merge without position change? In 2048 merging always if adjacent in line; but if no movement but merge possible? If board has [2,2,0,0] and move left, movement occurs. If [0,2,2,0] move right, movement. If [2,2,2,2] move left, first two merge at 0, second two at 1, movement. If [2,0,2,0] move left, first tile stays, second moves to 1, merge. If [2,2,0,0] move right, both move. If [2,2,2,0] move right: tile at 0 moves to 2, tile at1 stays? merge with tile at2? Movement. No merge without movement likely? Could [2,2,0,0] move right both move. Good. Need moved = nextTiles.some changed || mergedIds.size>0 maybe.
- If !moved: maybe bump board? return.
- animate:
const mergedById? For each old tile:
if mergedIds.has(tile.id):
const target = newTiles.find(t=>t.mergedFrom?.some(m=>m.id===tile.id));
const el = tileElements.get(tile.id);
el.style.transform = positionFor(target.x,target.y);
el.classList.add('merging');
else:
const nt = newTiles.find(t=>t.id===tile.id);
if nt: el.style.transform = positionFor(nt.x,nt.y);
// remove any elements for tiles not in newTiles and not mergedFrom? none
- setTimeout(() => {
tiles = newTiles;
// remove old elements not in tiles?
for (const [id,el] of tileElements) if (!tiles.some(t=>t.id===id)) { el.remove(); tileElements.delete(id);}
// create elements for new merged tiles not in DOM
for (const tile of tiles) if (!tileElements.has(tile.id)) {
createTileElement(tile, tile.mergedFrom ? 'merging' : 'new'? Actually new merged tile should pop. Use class 'new merge'. For non-merged newly moved created? no.
}
const empty = addRandomTile();
updateScore();
checkWin/lose;
render?
}, MOVE_MS + maybe 40)
- Need if addRandomTile creates element and maybe render all? We can create.
- But if after move some old merged elements removed and new merged tile created after transition, if new merged tile class "new" appears. Good.
- Need ensure score updates immediately before animation? Could update after final. But score display can update immediately.
- Need check win: if !won && tile.value >= 2048: won=true; showWinOverlay. Need if keep playing, no ignore.
- check lose after add random? In 2048, game over if no moves after move and spawn. Yes.
- show overlay:
- win overlay initially hidden. Text "You win!" score. Buttons "Keep playing" and "New game". If win, maybe show overlay with Keep playing. If keep playing, hide overlay, keepPlaying=true.
- lose overlay text "Game over". Button "Try again".
- Need if win and lose? If reach 2048 and no moves? But after spawn maybe still maybe no moves; show win first. Fine.
- Need "win and lose states". We can have modal overlay.
- Need keyboard arrow key. Prevent default. Maybe WASD? Could add.
- Need touch? Optional.
- Need buttons: New game, maybe undo? Not requested. Keep simple.
- Need accessible: aria labels.
- Need CSS polished.
Potential issues with localStorage in sandbox? Could catch. Fine.
Need maybe include no external fonts. Use system font.
Need write index.html. Need maybe include meta viewport.
Need test maybe no actual browser. Could use grep. Need maybe run maybe no server. But final just present.
Let's craft high-quality index.html. Need all in one file.
Let's think through animation details in code.
HTML:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2048</title>
<style>...</style>
</head>
<body>
<main class="app">
<header class="topbar">
<div>
<h1>2048</h1>
<p>Join the tiles, get to <strong>2048</strong>!</p>
</div>
<div class="scores">
<div class="score-card"><span>Score</span><strong id="score">0</strong></div>
<div class="score-card"><span>Best</span><strong id="best">0</strong></div>
</div>
</header>
<div class="controls">
<button id="newGameBtn">New game</button>
<span>Use arrow keys to move tiles.</span>
</div>
<section class="board-wrap">
<div id="board" class="board" aria-label="2048 board">
<div class="grid" aria-hidden="true">
<span></span>...
</div>
<div id="tileLayer" class="tile-layer"></div>
</div>
<div id="overlay" class="overlay hidden" role="dialog" aria-modal="true" aria-labelledby="overlayTitle">
<div class="modal">
<p id="overlayKicker"></p>
<h2 id="overlayTitle"></h2>
<p id="overlayText"></p>
<div id="overlayActions" class="modal-actions"></div>
</div>
</div>
</section>
</main>
<script>
(() => { ... })();
</script>
</body>
</html>
CSS:
:root {
color-scheme: dark;
--bg: #faf8ef? Maybe dark? 2048 classic beige. Use warm.
}
* box-sizing.
body min-height 100vh; margin 0; display grid; place-items center; background: radial-gradient(...), #faf8ef; color #776e65; font-family...
.app width min(96vw, 560px); padding 24px 0.
.topbar display flex; justify-content space-between; gap; align-items flex-start; flex-wrap.
h1 font-size clamp(3rem, 12vw, 5.5rem); margin; line-height .9; color #776e65;
p...
.scores display flex gap; margin-top.
.score-card background #bbada0; color #fff; min-width 74px; padding; border-radius; text-align center.
.score-card span font-size .75rem uppercase; opacity .8
.score-card strong display block font-size 1.5rem
.controls...
button...
.board-wrap position relative; margin-top.
.board width min(92vw, 480px); aspect-ratio:1; margin:0 auto; position:relative; padding:0; background:#bbada0; border-radius:12px; box-shadow; overflow:hidden; --gap: clamp(8px, 1.8vw, 14px); --cell-size: calc((100% - 5 * var(--gap)) / 4); But CSS calc multiplication invalid? Actually CSS calc supports *? Historically not. Need avoid 5 * var. Use calc((100% - (var(--gap) * 5)) / 4) also multiplication not supported. Need alternative.
CSS can't multiply custom property by number? Wait CSS Values Level 4 has multiplication but not widely. To be safe, don't use var in calc with multiplication. Use grid cells with padding? Another approach: board has padding: var(--gap); .grid inset 0; grid-template columns repeat(4,1fr); gap:var(--gap); .cell no issue. .tile width: calc((100% - var(--gap) * 5) / 4) same issue. Could instead tile elements be inside .tile-layer with padding: var(--gap); display:grid; grid-template-columns repeat(4,1fr); gap:var(--gap); and tile elements position absolute? Need tile width can be 100% of grid column if use left? Let's design:
.board { padding: var(--gap); }
.tile-layer { position:absolute; inset: var(--gap); }
.tile { width: calc((100% - 3 * var(--gap)) / 4); } again multiplication.
Could use CSS grid for tile layer and place tile in grid? But animations with transform? Could set style gridColumn/Row and transition? CSS grid placement transitions not smooth. Use absolute with top/left based on percentages? Use transform: translate(calc(var(--x) * (100% + var(--gap))), ...)? Multiplication issue. Could set inline style --x and --y and use calc(var(--x) * (100% + var(--gap))) maybe multiplication unsupported. Could use CSS custom property with px values from JS for --xpx, --ypx and transform: translate(var(--xpx), var(--ypx)); tile width: var(--tile-size). Then JS sets tile size and gap pixel. Great.
.board { --gap: 12px; --tile-size: 96px; } .grid inset var(--gap); grid-template columns repeat(4, var(--tile-size)); gap:var(--gap); But if responsive, JS set. Or CSS can use grid-template repeat(4, 1fr) for grid cells; tile layer uses JS variables.
.tile { width: var(--tile-size); height: var(--tile-size); position:absolute; left:0; top:0; transform: translate(var(--xpx), var(--ypx)); transition: transform 130ms cubic-bezier(...); }
JS updateLayout sets board style --gap, --tile-size and for each tile transform.
.grid { position:absolute; inset: var(--gap); display:grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); }
.grid .cell { background: rgba(238,228,218,.35); border-radius: 8px; }
This avoids CSS calc multiplication. JS:
function layout() {
const styles = getComputedStyle(board);
const gap = parseFloat(styles.getPropertyValue('--gap')) || 12;
const size = board.clientWidth;
const tile = (size - gap * 5) / 4;
board.style.setProperty('--gap', `${gap}px`);
board.style.setProperty('--tile-size', `${tile}px`);
for (const tileObj of tiles) {
const el = tileElements.get(tileObj.id);
if (el) placeTile(el, tileObj.x, tileObj.y);
}
}
function placeTile(el,x,y) {
const gap = parseFloat(board.style.getPropertyValue('--gap')) || parseFloat(getComputedStyle(board).getPropertyValue('--gap')) || 12;
const tile = parseFloat(board.style.getPropertyValue('--tile-size')) || ...
el.style.setProperty('--xpx', `${gap + x*(tile+gap)}px`);
el.style.setProperty('--ypx', `${gap + y*(tile+gap)}px`);
}
Need if board.clientWidth 0? After DOM loaded script at bottom, okay. Use requestAnimationFrame.
Tile colors:
.tile { border-radius: 8px; display flex; justify center; align center; font-weight: 900; font-size: clamp(2rem, 10vw, 3.5rem); color:#776e65; user-select:none; box-shadow: inset 0 -4px rgba...; will-change: transform; }
.tile[data-value="2"] background #eee4da; color #776e65; font-size...
4 #ede0c8, 8 #f2b179 color #f9f6f0, 16 etc. For >1024 font-size smaller.
.tile.new { animation: appear 160ms ease-out; }
@keyframes appear { 0% { opacity:0; transform: translate(var(--xpx),var(--ypx)) scale(.25); } 100% { opacity:1; transform: translate(var(--xpx),var(--ypx)) scale(1); } }
.tile.merging { animation: mergePop 180ms ease-out; }
@keyframes mergePop { 0% { transform: translate(var(--xpx),var(--ypx)) scale(1); } 50% { transform: translate(var(--xpx),var(--ypx)) scale(1.16); } 100% { transform: translate(var(--xpx),var(--ypx)) scale(1); } }
Potential issue: .tile.merging old elements have transition transform. When class merging added, animation overrides transform, but the transform has been set. Good.
Need z-index maybe by value? set tile.style.zIndex = tile.value.
Potential issue: When tile moves, if we set transform property to CSS vars, transition works. If animation class .new appears after element creation, it animates. But if placeTile sets CSS vars and then class new, keyframes use current vars. Good.
Need overlay:
.overlay { position:absolute; inset:0; display:grid; place-items:center; background: rgba(238,228,218,.75); z-index:10; opacity:1; transition: opacity .2s; }
.overlay.hidden { opacity:0; pointer-events:none; }
Need if overlay hidden but still in DOM. Modal.
Need game logic more detail.
JS structure:
const SIZE=4;
const MOVE_MS=140;
const STORAGE_KEY='2048-best';
let tiles = [];
let score = 0;
let best = Number(localStorage.getItem(STORAGE_KEY) || 0);
let nextId = 1;
let won = false;
let keepPlaying = false;
let locked = false;
const board = document.getElementById('board'), tileLayer = document.getElementById('tileLayer'), scoreEl, bestEl, overlay, etc.
bestEl.textContent = best;
function key(x,y) { return `${x},${y}`; }
function cellMap(tileList = tiles) { const map = new Map(); tileList.forEach(t => map.set(key(t.x,t.y), t)); return map; }
function emptyCells(tileList=tiles) { const map=cellMap(tileList); const arr=[]; for y... for x... if !map.has(key) arr.push({x,y}); return arr; }
function createTileElement(tile, extraClass='') {
const el=document.createElement('div');
el.className=`tile ${extraClass}`.trim();
el.textContent=tile.value;
el.dataset.value = tile.value;
el.setAttribute('aria-label', `${tile.value} tile`);
el.style.zIndex = String(tile.value);
placeTile(el,tile.x,tile.y);
tileLayer.appendChild(el);
tileElements.set(tile.id,el);
return el;
}
function renderAll() {
tileLayer.innerHTML='';
tileElements.clear();
for (const tile of tiles) createTileElement(tile, tile.justAdded?'new':'');
updateLayout? Maybe create uses current variables.
}
But in startNewGame, after clear, addRandomTile uses create. Need clearElements:
function clearTileLayer() { tileLayer.innerHTML=''; tileElements.clear(); }
function updateScore() { scoreEl.textContent=score; if (score>best) { best=score; bestEl.textContent=best; try localStorage... } }
function updateLayout() {
const gap = Math.max(8, Math.min(14, board.clientWidth * 0.025))? But CSS clamp handles. We can compute gap from CSS.
const computed = getComputedStyle(board);
let gap = parseFloat(computed.getPropertyValue('--gap'));
if (!Number.isFinite(gap)) gap=12;
const tile = (board.clientWidth - gap * 5) / 4;
board.style.setProperty('--gap', `${gap}px`);
board.style.setProperty('--tile-size', `${tile}px`);
for (const [id, el] of tileElements) { const tile = tiles.find...; if tile placeTile(el,tile.x,tile.y); }
}
Could use ResizeObserver:
const resizeObserver = new ResizeObserver(updateLayout); resizeObserver.observe(board); window.addEventListener('resize', updateLayout);
But createTileElement calls placeTile before tile size set? At script bottom after CSS, board.clientWidth known. Need call updateLayout after start? In startNewGame, maybe clear, then addRandomTile; createTileElement uses board variables maybe default not set. CSS can set default --gap: clamp? But JS sets --tile-size. We can call updateLayout before start. However updateLayout loops no tiles. It sets variables. Then startNewGame.
function placeTile(el,x,y) { const gap = parseFloat(board.style.getPropertyValue('--gap')) || 12; const tile = parseFloat(board.style.getPropertyValue('--tile-size')) || 96; ...}
Good.
function addRandomTile() {
const empties = emptyCells();
if (!empties.length) return null;
const spot = empties[Math.floor(Math.random()*empties.length)];
const value = Math.random() < 0.9 ? 2 : 4;
const tile = { id: nextId++, value, x: spot.x, y: spot.y };
tiles.push(tile);
createTileElement(tile, 'new');
return tile;
}
Potential issue: emptyCells uses tiles array. If called after tiles updated to newTiles but before create merged elements? We'll handle.
function startNewGame() {
locked=false; won=false; keepPlaying=false; score=0; nextId=1; tiles=[]; clearTileLayer(); updateScore(); hideOverlay(); addRandomTile(); addRandomTile(); updateLayout();
}
Need if nextId reset? okay. If old setTimeout from move after new game? Need handle with moveToken.
let moveToken=0; startNewGame increments moveToken. In move, const token=++moveToken. setTimeout if token !== moveToken return.
function move(direction) {
if (locked || (won && !keepPlaying)) return;
const {nextTiles, moved, gained} = computeMove(direction);
if (!moved) { nudge? return; }
locked=true;
score += gained; updateScore();
animateMove(nextTiles, movedTiles?);
setTimeout(() => { ... addRandomTile(); locked=false; const winTile=...; if (!won && !keepPlaying && max>=2048) { won=true; showWin(); } if (isGameOver()) showLose(); }, MOVE_MS+50)
}
Need computeMove returns oldTiles? Could use global tiles.
computeMove:
function getLine(positions) returns array of tile refs in movement order.
const map=cellMap();
const nextTiles=[];
const mergedOldIds=new Set();
let gained=0;
for (const line of buildLines(direction)) {
const ordered = line.map(pos => map.get(key(pos.x,pos.y))).filter(Boolean);
let targetIndex=0;
for (let i=0; i<ordered.length; i++) {
const current = ordered[i];
const next = ordered[i+1];
const target = line[targetIndex];
if (next && current.value === next.value) {
const merged = {id: nextId++, value: current.value*2, x: target.x, y: target.y, mergedFrom:[current,next]};
nextTiles.push(merged);
gained += merged.value;
mergedOldIds.add(current.id); mergedOldIds.add(next.id);
i++;
} else {
nextTiles.push({...current, x: target.x, y: target.y});
}
targetIndex++;
}
}
const oldById = new Map(tiles.map(t=>[t.id,t]));
const moved = gained>0 || nextTiles.some(t => { const old=oldById.get(t.id); return !old || old.x !== t.x || old.y !== t.y; });
return {nextTiles, moved, gained, mergedOldIds};
Potential issue targetIndex increments for merged and non-merged; yes.
Need oldById map includes old tile objects. For merged current/next, not in nextTiles by id. Good.
animateMove(nextTiles, mergedOldIds):
const oldTiles = tiles.slice();
const newById = new Map(nextTiles.map(t=>[t.id,t]));
for (const tile of oldTiles) {
const el = tileElements.get(tile.id);
if (!el) continue;
let target = null;
if (mergedOldIds.has(tile.id)) {
target = nextTiles.find(t => t.mergedFrom && t.mergedFrom.some(m=>m.id===tile.id));
el.classList.add('merging');
} else {
target = newById.get(tile.id);
}
if (target) placeTile(el,target.x,target.y);
}
Need if old tile not in newTiles and not merged? Shouldn't. If tile stays, target same, place no movement but okay.
Need remove .merging after? setTimeout removes.
After timeout:
tiles = nextTiles.map(t => {
const copy = {...t}; delete copy.mergedFrom; return copy;
});
clear removed old:
for (const [id, el] of tileElements) {
if (!tiles.some(t=>t.id===id)) { el.remove(); tileElements.delete(id);}
}
for (const tile of tiles) {
if (!tileElements.has(tile.id)) {
createTileElement(tile, 'merging'? maybe 'new merge-tile');
const el = tileElements.get(tile.id); el.classList.add('merged');
}
}
addRandomTile();
locked=false;
const max = Math.max(...tiles.map(t=>t.value), 0);
if (!won && !keepPlaying && max >= 2048) { won = true; showWin(); }
if (isGameOver()) { if (won && !keepPlaying) maybe overlay already win; if !won showLose(); else maybe show lose? If won and no moves, should win state visible. User wants lose states; if reach 2048 and no moves, win overlay. Fine.
}
Need if addRandomTile after win? In actual 2048, if you reach 2048, a new tile is spawned before win overlay? Usually yes. We can show overlay after spawn. Fine.
Need if game over after spawn. isGameOver:
function canMove() {
if emptyCells().length >0 return true;
const map=cellMap();
for y,x:
const t=map.get(key(x,y));
if ((x<SIZE-1 && map.get(key(x+1,y))?.value===t.value) || (y<SIZE-1 && map.get(key(x,y+1))?.value===t.value)) return true;
return false;
}
function isGameOver(){ return !canMove(); }
Need after startNewGame not show lose.
showOverlay(type):
if type==='win':
overlayKicker.textContent='Great job!';
title='You win!';
text=`You made a 2048 tile with a score of ${score}.`;
actions buttons:
Keep playing (primary) onclick keepPlaying=true; hideOverlay(); locked=false? Actually if locked false. If won && !keepPlaying move ignores. After keepPlaying true.
New game onclick startNewGame()
if type lose:
kicker='No more moves';
title='Game over';
text=`Final score: ${score}`;
button Try again.
Need overlay hidden class. Need focus maybe.
Need if showLose after win? If won && !keepPlaying, don't show lose. But if keepPlaying true and then lose, show lose. Fine.
Need if game over and overlay shown, locked? We can set locked=true? But moves ignored if gameOver. Let gameOver boolean.
let gameOver=false;
move: if (locked || gameOver || (won && !keepPlaying)) return;
showLose sets gameOver=true.
start resets.
showWin sets won=true; gameOver=false? If no moves maybe not. It's okay.
if isGameOver after win and !keepPlaying, show win.
if keepPlaying and isGameOver showLose.
Need if gameOver true and new game resets.
Need keyboard:
window.addEventListener('keydown', (event) => {
const keyMap = {ArrowUp:'up', ...};
if (keyMap[event.key]) { event.preventDefault(); move(keyMap[event.key]); }
});
Maybe focus body? Window handles.
Need touch/swipe:
let touchStart=null;
board.addEventListener('touchstart', e => { if e.touches.length===1 touchStart={x,y}; }, {passive:true});
board.addEventListener('touchend', e => { if !touchStart return; dx,dy; if max < 30 return; move(abs dx > abs dy ? dx>0?'right':'left' : dy>0?'down':'up'); touchStart=null; }, {passive:true});
Could include. Not required but nice.
Need maybe "smooth animations" include nudge on invalid? Could add board class "shake" if invalid. But if overlay won ignore? Need maybe.
function bumpBoard() { board.classList.remove('shake'); void board.offsetWidth; board.classList.add('shake'); setTimeout... }
CSS @keyframes shake.
Need invalid move when won overlay active? Maybe don't bump. Fine.
Need handle localStorage:
function loadBest() { try { return Number(localStorage.getItem(STORAGE_KEY)) || 0; } catch { return 0; } }
function saveBest() { try { localStorage.setItem(STORAGE_KEY, String(best)); } catch {} }
Need aria live for score? Score card maybe aria-live.
Need maybe show "Best" score.
Need ensure no external resources. All inline.
Need maybe file in current directory. Use write. Let's craft full index.html. Need maybe no markdown in file. Use write.
/tmp/ws/index.html (23275 bytes)Successfully wrote 23275 bytes to /tmp/ws/index.html
thinking
Need final concise. But maybe should verify file exists maybe use bash ls? Since "End with index.html present." We wrote. Could final say created. But maybe need ensure no errors? Could run grep? Not necessary. But maybe to be safe, maybe run ls -l index.html.
{"command":"ls -l index.html","timeout":5}-rw-r--r--. 1 node node 23275 Aug 1 20:08 index.html
thinking
Created `/tmp/ws/index.html` with a self-contained playable 2048 game, including arrow-key/WASD controls, smooth tile animations, score/best score tracking, win overlay, and game-over state.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Functional 2048 game; arrow keys and WASD move tiles and merge, score updates, click/drag/wheel have no effect as expected.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.