oneshotlm
Compare all models on this prompt →

2048 · qwen3.6-plus

Build a playable 2048 game with arrow-key tile sliding and merging, smooth animations, a score, and win and lose states.

⛨ sandboxed Open in new tab ↗
transcript · 19 messages
user

Build a playable 2048 game with arrow-key tile sliding and merging, smooth animations, a score, and win and lose states.

assistant
thinking
The user wants a complete, self-contained 2048 game in a single HTML file. Let me build this with:

1. A 4x4 grid
2. Arrow key controls for sliding tiles
3. Tile merging logic (same numbers merge)
4. Score tracking
5. Win state (reaching 2048)
6. Lose state (no more moves available)
7. Smooth CSS animations for tile movement and appearance
8. Clean, polished UI

Let me think through the implementation:

- Grid representation: 4x4 array
- Each tile has a value, position, and unique ID for animation tracking
- On key press: slide all tiles in that direction, merge where applicable
- Add a new tile (2 or 4) after each successful move
- Check for win (any tile == 2048)
- Check for lose (no empty cells and no adjacent merges possible)

For animations, I'll use CSS transitions and track tiles by unique IDs. When a move happens, tiles get new positions via CSS transforms, and merging tiles get a "pop" animation.

Let me build this step by step. I'll use a DOM-based approach where each tile is a div positioned absolutely within the grid container, with CSS transforms for smooth movement.

Key design decisions:
- Use CSS Grid or absolute positioning for tiles. I'll go with absolute positioning + CSS transforms for smooth sliding animations.
- Each tile gets a unique ID so I can track it across moves
- New tiles get a "appear" animation (scale from 0)
- Merged tiles get a "merge" animation (scale up briefly)
- Tile colors change based on value

Let me write this out carefully.
write /tmp/ws/index.html (9458 bytes)
assistant
thinking
The user wants a complete, self-contained 2048 game in a single index.html file. Let me plan the features:

1. 4x4 grid game board
2. Arrow key controls for tile sliding
3. Tile merging logic
4. Smooth CSS animations for tile movements and merges
5. Score tracking
6. Win state (reaching 2048)
7. Lose state (no moves possible)
8. Responsive design
9. No external dependencies

Let me design this carefully with:
- CSS Grid or absolute positioning for tiles
- CSS transitions for smooth animations
- Proper game logic (2048 rules)
- Color-coded tiles
- Touch support for mobile would be nice too

I'll use absolute positioning for tiles so I can animate their positions with CSS transitions. Each tile will be positioned based on its grid coordinates.

Let me write this out fully.
write /tmp/ws/index.html (16760 bytes)
toolResult

Successfully wrote 16760 bytes to /tmp/ws/index.html

assistant
thinking
The code has a significant bug — the `move` function was left incomplete with a `return` placeholder and the real logic is in `doMove`. But I also left the `move` function in the file which references `mergedPositions` but the merge tracking logic in `doMove` has issues. Let me review and rewrite the file properly.

The main issues:
1. The dead `move` function is still in the file
2. The merge tracking logic needs fixing — after the grid is mutated, `oldGrid` is used to find removed tile positions, but the tile objects in `oldGrid` are shallow copies so they still reference the same `el`
3. Need to clean up the logic

Let me rewrite the entire file cleanly.

I notice there's a dead `move` function and the merge logic has issues. Let me rewrite this cleanly.

write /tmp/ws/index.html (20685 bytes)
toolResult

Successfully wrote 20685 bytes to /tmp/ws/index.html

assistant
thinking
The code has duplicate `doMove` and `doMove2` functions which is messy. Let me clean this up into a single, well-structured version with no dead code.

The code still has duplicate functions and dead code. Let me write a clean, single-pass version:

write /tmp/ws/index.html (13730 bytes)
toolResult

Successfully wrote 13730 bytes to /tmp/ws/index.html

assistant
thinking
The merge animation logic for moving removed tiles is buggy - it iterates all snap tiles and moves every mergedId tile to every kept tile position. I need to fix the merge tracking so each removed tile is paired with its specific kept tile. Let me rewrite the move function properly.
write /tmp/ws/index.html (14674 bytes)
toolResult

Successfully wrote 14674 bytes to /tmp/ws/index.html

assistant
thinking
There's a bug in the changed detection - the code references `const a = snapEls` instead of looking up by grid position. Let me fix this properly and also verify the overall logic is clean.

There's a bug in the change detection logic. Let me fix it:

edit {"path":"/tmp/ws/index.html","edits":[{"oldText":" // Determine if board changed\n let changed = false;\n for (let r = 0; r < N &&
toolResult

Successfully replaced 1 block(s) in /tmp/ws/index.html.

assistant
thinking
The user wants me to verify the file is complete and working. Let me read it to check the current state.
read {"path":"/tmp/ws/index.html"}
toolResult

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>2048</title> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #faf8ef; display: flex; justify-content: center; align-items: center; min-height: 100vh; overflow: hidden; user-select: none; } .container { width: 100%; max-width: 500px; padding: 20px; } header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } h1 { font-size: 64px; font-weight: 800; color: #776e65; line-height: 1; } .scores { display: flex; gap: 8px; } .score-box { background: #bbada0; border-radius: 8px; padding: 8px 20px; text-align: center; min-width: 90px; } .score-box .label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: #eee4da; letter-spacing: 1px; } .score-box .value { font-size: 24px; font-weight: 700; color: #fff; line-height: 1.2; } .score-pop { animation: scorePop 0.5s ease; } @keyframes scorePop { 0%{transform:scale(1)} 40%{transform:scale(1.15)} 100%{transform:scale(1)} } .controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .controls p { color: #776e65; font-size: 15px; } button { background: #8f7a66; color: #f9f6f2; border: none; border-radius: 6px; padding: 10px 20px; font-size: 16px; font-weight: 700; cursor: pointer; transition: background 0.15s; } button:hover { background: #9f8b77; } .board-wrapper { position: relative; width: 100%; aspect-ratio: 1; background: #bbada0; border-radius: 12px; padding: 12px; } .board-bg { display: grid; grid-template-columns: repeat(4,1fr); grid-template-rows: repeat(4,1fr); gap: 12px; width: 100%; height: 100%; } .cell-bg { background: rgba(238,228,218,0.35); border-radius: 8px; } .tile-container { position: absolute; top: 12px; left: 12px; right: 12px; bottom: 12px; } .tile { position: absolute; display: flex; justify-content: center; align-items: center; font-weight: 800; border-radius: 8px; transition: top 0.15s ease, left 0.15s ease; } .tile.merged { animation: popMerge 0.2s ease; } .tile.new-tile { animation: appear 0.2s ease; } @keyframes popMerge { 0%{transform:scale(1)} 50%{transform:scale(1.2)} 100%{transform:scale(1)} } @keyframes appear { 0%{transform:scale(0);opacity:0} 100%{transform:scale(1);opacity:1} } .overlay { position: absolute; inset: 0; border-radius: 12px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; } .overlay.active { opacity: 1; pointer-events: all; } .overlay.lose { background: rgba(238,228,218,0.75); } .overlay.win { background: rgba(237,194,46,0.55); } .overlay h2 { font-size: 48px; font-weight: 800; } .overlay.lose h2 { color: #776e65; } .overlay.win h2 { color: #f9f6f2; } @media (max-width: 520px) { .container { padding: 12px; } h1 { font-size: 48px; } .score-box { min-width: 70px; padding: 6px 12px; } .score-box .value { font-size: 20px; } .board-wrapper { padding: 8px; } .board-bg { gap: 8px; } .tile-container { top: 8px; left: 8px; right: 8px; bottom: 8px; } } </style> </head> <body> <div class="container"> <header> <h1>2048</h1> <div class="scores"> <div class="score-box"><div class="label">Score</div><div class="value" id="score">0</div></div> <div class="score-box"><div class="label">Best</div><div class="value" id="best">0</div></div> </div> </header> <div class="controls"> <p>Join the tiles, get to <strong>2048!</strong></p> <button id="newGameBtn">New Game</button> </div> <div class="board-wrapper" id="boardWrapper"> <div class="board-bg"> <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div> <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div> <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div> <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div> </div> <div class="tile-container" id="tileContainer"></div> <div class="overlay lose" id="loseOverlay"> <h2>Game Over!</h2> <button id="retryBtn">Try Again</button> </div> <div class="overlay win" id="winOverlay"> <h2>You Win!</h2> <button id="keepBtn">Keep Going</button> </div> </div> </div> <script> (() => { const N = 4, GAP = 12; let grid, score, best, won, over, busy; let idSeq = 0; const container = document.getElementById('tileContainer'); const scoreEl = document.getElementById('score'); const bestEl = document.getElementById('best'); const loseOverlay = document.getElementById('loseOverlay'); const winOverlay = document.getElementById('winOverlay'); const COLORS = { 2:{bg:'#eee4da',cl:'#776e65',sz:0.52},4:{bg:'#ede0c8',cl:'#776e65',sz:0.52}, 8:{bg:'#f2b179',cl:'#f9f6f2',sz:0.48},16:{bg:'#f59563',cl:'#f9f6f2',sz:0.48}, 32:{bg:'#f67c5f',cl:'#f9f6f2',sz:0.48},64:{bg:'#f65e3b',cl:'#f9f6f2',sz:0.48}, 128:{bg:'#edcf72',cl:'#f9f6f2',sz:0.42},256:{bg:'#edcc61',cl:'#f9f6f2',sz:0.42}, 512:{bg:'#edc850',cl:'#f9f6f2',sz:0.42},1024:{bg:'#edc53f',cl:'#f9f6f2',sz:0.35}, 2048:{bg:'#edc22e',cl:'#f9f6f2',sz:0.35} }; function stl(v) { return COLORS[v] || {bg:'#3c3a32',cl:'#f9f6f2',sz:0.28}; } function cellSize() { const r = container.getBoundingClientRect(); return (r.width - GAP * (N - 1)) / N; } function p(r, c) { const s = cellSize(); return { t: r * (s + GAP), l: c * (s + GAP), s }; } function mkEl(val, r, c) { const p2 = p(r, c), s = stl(val); const el = document.createElement('div'); el.className = 'tile new-tile'; Object.assign(el.style, { width: p2.s+'px', height: p2.s+'px', top: p2.t+'px', left: p2.l+'px', fontSize: p2.s * s.sz + 'px', background: s.bg, color: s.cl }); el.textContent = val; container.appendChild(el); return el; } function mvEl(el, r, c) { const p2 = p(r, c); el.style.top = p2.t + 'px'; el.style.left = p2.l + 'px'; } function restyleEl(el, val) { const s = stl(val), p2 = p(0, 0); el.style.background = s.bg; el.style.color = s.cl; el.style.fontSize = p2.s * s.sz + 'px'; el.textContent = val; } function init() { grid = Array.from({length:N}, () => Array(N).fill(null)); score = 0; won = false; over = false; busy = false; idSeq = 0; container.innerHTML = ''; scoreEl.textContent = '0'; loseOverlay.classList.remove('active'); winOverlay.classList.remove('active'); addRand(); addRand(); } function addRand() { const e = []; for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (!grid[r][c]) e.push([r, c]); if (!e.length) return; const [r, c] = e[Math.random() * e.length | 0]; const v = Math.random() < 0.9 ? 2 : 4; const id = ++idSeq; grid[r][c] = { v, id, el: mkEl(v, r, c) }; } function addScore(n) { score += n; scoreEl.textContent = score; if (score > best) { best = score; bestEl.textContent = best; try { localStorage.setItem('best2048', best); } catch(e) {} } const parent = scoreEl.parentElement; parent.classList.remove('score-pop'); void parent.offsetWidth; parent.classList.add('score-pop'); } // Return N lines of [r,c] for a given direction. // Each line is ordered from the sliding direction's "leading edge" inward. function lines(dir) { const result = []; for (let i = 0; i < N; i++) { const line = []; for (let j = 0; j < N; j++) { if (dir === 'left') line.push([i, j]); if (dir === 'right') line.push([i, N-1-j]); if (dir === 'up') line.push([j, i]); if (dir === 'down') line.push([N-1-j, i]); } result.push(line); } return result; } function doMove(dir) { if (busy || over) return; busy = true; // Snapshot: remember every tile's element and original position const snapEls = new Map(); // id -> {el, r, c, v} for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c]) snapEls.set(grid[r][c].id, { el: grid[r][c].el, r, c, v: grid[r][c].v }); // Build new grid by sliding each line const allLines = lines(dir); let totalSc = 0; // Track merges: map from removedId -> {keptId, finalR, finalC} const removedMap = new Map(); for (const line of allLines) { const tiles = line.map(([r, c]) => grid[r][c]); const compact = tiles.filter(Boolean); for (let i = 0; i < compact.length - 1; i++) { if (compact[i].v === compact[i+1].v) { compact[i].v *= 2; totalSc += compact[i].v; // removed tile slides into the kept tile's destination removedMap.set(compact[i+1].id, { el: compact[i+1].el, destR: null, // filled after we know final positions destC: null, keptId: compact[i].id, }); compact.splice(i+1, 1); } } while (compact.length < N) compact.push(null); for (let j = 0; j < N; j++) { const [r, c] = line[j]; grid[r][c] = compact[j]; } } // Determine if board changed let changed = false; for (let r = 0; r < N && !changed; r++) for (let c = 0; c < N && !changed; c++) { const b = grid[r][c]; // Look up what was here before let wasHere = null; for (const [id, info] of snapEls) { if (info.r === r && info.c === c) wasHere = info; } if (!b && wasHere) changed = true; else if (b && !wasHere) changed = true; else if (b && wasHere && b.id !== wasHere.id) changed = true; } if (!changed) { busy = false; return; } // Assign merge destination positions for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && removedMap.has(grid[r][c].id)) { // This kept tile's id is in removedMap — shouldn't happen. // Actually removedMap keys are removedIds, keptIds are values. } // Fix: removedMap's values have keptId; find kept tile position for (const [rid, info] of removedMap) { for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && grid[r][c].id === info.keptId) { info.destR = r; info.destC = c; } } addScore(totalSc); // 1. Animate all active tiles to new positions for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && grid[r][c].el) mvEl(grid[r][c].el, r, c); // 2. Animate removed merge tiles to merge destination, then remove for (const [, info] of removedMap) { if (info.el && info.destR !== null) { mvEl(info.el, info.destR, info.destC); const elRef = info.el; setTimeout(() => { if (elRef && elRef.parentNode) elRef.remove(); }, 150); } // Restyle the kept tile for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && grid[r][c].id === info.keptId) { const elRef = grid[r][c].el; setTimeout(() => { restyleEl(elRef, grid[r][c].v); elRef.classList.remove('merged'); void elRef.offsetWidth; elRef.classList.add('merged'); }, 150); } } // After animations, spawn and check states setTimeout(() => { addRand(); if (!won) { for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && grid[r][c].v === 2048) { won = true; setTimeout(() => winOverlay.classList.add('active'), 150); } } if (!won && isGameOver()) { over = true; setTimeout(() => loseOverlay.classList.add('active'), 150); } busy = false; }, 180); } function isGameOver() { for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { if (!grid[r][c]) return false; const v = grid[r][c].v; if (c < N-1 && grid[r][c+1] && grid[r][c+1].v === v) return false; if (r < N-1 && grid[r+1][c] && grid[r+1][c].v === v) return false; } return true; } // Keyboard document.addEventListener('keydown', e => { const map = { ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down', a:'left', d:'right', w:'up', s:'down' }; if (map[e.key]) { e.preventDefault(); doMove(map[e.key]); } }); // Touch / swipe let tx, ty; const wrapper = document.getElementById('boardWrapper'); wrapper.addEventListener('touchstart', e => { tx = e.touches[0].clientX; ty = e.touches[0].clientY; }, {passive: true}); wrapper.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - tx; const dy = e.changedTouches[0].clientY - ty; const ax = Math.abs(dx), ay = Math.abs(dy); if (Math.max(ax, ay) < 20) return; if (ax > ay) doMove(dx > 0 ? 'right' : 'left'); else doMove(dy > 0 ? 'down' : 'up'); }, {passive: true}); // Mouse drag let mx, my, mdown; wrapper.addEventListener('mousedown', e => { mx = e.clientX; my = e.clientY; mdown = true; }); wrapper.addEventListener('mouseup', e => { if (!mdown) return; mdown = false; const dx = e.clientX - mx, dy = e.clientY - my; const ax = Math.abs(dx), ay = Math.abs(dy); if (Math.max(ax, ay) < 20) return; if (ax > ay) doMove(dx > 0 ? 'right' : 'left'); else doMove(dy > 0 ? 'down' : 'up'); }); // Buttons document.getElementById('newGameBtn').addEventListener('click', init); document.getElementById('retryBtn').addEventListener('click', init); document.getElementById('keepBtn').addEventListener('click', () => winOverlay.classList.remove('active')); // Resize window.addEventListener('resize', () => { for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c] && grid[r][c].el) mvEl(grid[r][c].el, r, c); }); best = parseInt(localStorage.getItem('best2048') || '0'); bestEl.textContent = best; init(); })(); </script> </body> </html>

assistant
thinking
The code looks mostly good but has a messy section with dead code:

```javascript
    // Assign merge destination positions
    for (let r = 0; r < N; r++)
      for (let c = 0; c < N; c++)
        if (grid[r][c] && removedMap.has(grid[r][c].id)) {
          // This kept tile's id is in removedMap — shouldn't happen.
          // Actually removedMap keys are removedIds, keptIds are values.
        }
    // Fix: removedMap's values have keptId; find kept tile position
    for (const [rid, info] of removedMap) {
      for (let r = 0; r < N; r++)
        for (let c = 0; c < N; c++)
          if (grid[r][c] && grid[r][c].id === info.keptId) {
            info.destR = r;
            info.destC = c;
          }
    }
```

Let me clean that up by removing the dead code block.

There's a dead code block. Let me clean it up.

edit {"path":"/tmp/ws/index.html","edits":[{"newText":" // Find merge destination positions (where kept tiles ended up)\n for (const [rid,
toolResult

Successfully replaced 1 block(s) in /tmp/ws/index.html.

assistant

Let me verify the final file is clean:

bash {"command":"wc -l /tmp/ws/index.html && echo \"---\" && grep -c \"function\" /tmp/ws/index.html"}
toolResult

411 /tmp/ws/index.html --- 12

assistant

`/tmp/ws/index.html` is ready — a fully self-contained, single-file 2048 game. Here's what's included: **Gameplay** - Full 4×4 grid with standard 2048 rules: arrow keys (or WASD) slide and merge tiles - Touch/swipe and mouse-drag support for mobile - New tiles spawn as 2 (90%) or 4 (10%) **Animations** - **Slide**: tiles smoothly transition to new positions via CSS transitions on `top`/`left` - **Merge**: removed tiles slide into the merge point then vanish; the surviving tile pops with a scale-up animation - **Spawn**: new tiles appear with a scale-in animation - **Score**: the score box bounces when points are added **UI** - Score counter and **Best** score (persisted to `localStorage`) - **Win overlay** ("You Win!") at 2048, with a "Keep Going" button to continue playing - **Lose overlay** ("Game Over!") when no moves remain, with "Try Again" - New Game button always available - Responsive — adapts down to ~320px screens **No external dependencies** — no CDNs, no network requests, fully sandboxed.

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

modelqwen3.6-plus
vendorQwen
harnesspi-agent
generated2026-08-01

EVALUATION

3/5 working

2048 game responds to arrow keys and WASD with tile merging and score updates; clean minimal UI.

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.

idle motion0.0%
on click0.0%
on drag0.0%
on wheel0.0%
on enter + space0.0%
on arrow keys2.2%
on w/a/s/d1.0%
frame spread25.7 / 255
console errors0
js errors none

METRICS

runtime511.7s
tokens in188k
tokens out25k
cached0
cost / run$0.1103