2048 · deepseek-v4.1-flash
Output generated Cost / run $0.0145 Runtime 153.3s
Prompt for this run
Build a playable 2048 game using HTML elements and CSS with a 4 by 4 board. Use HTML, CSS, and JavaScript as needed. Write it to `index.html` in the current working directory using your file tools. Inline your own CSS and JavaScript. Do not use external assets, CDNs, or network requests. No third-party libraries are provided. Required behavior: - Arrow keys slide tiles toward the selected edge. Adjacent equal tiles merge once per tile per move, and the score increases by the value of each new merged tile. - For example, moving [2, 2, 2, 2] left produces [4, 4, 0, 0], not [8, 0, 0, 0]. Moving [2, 2, 4, 0] left produces [4, 4, 0, 0]. These examples describe the move before spawning a new tile. - Start with two tiles. After a move that changes the board, spawn exactly one tile in an empty cell: 2 with 90% probability, otherwise 4. Do not spawn after an unchanged move. - Animate movement and merging without allowing rapid input to corrupt board state. Prevent arrow keys from scrolling the page. - Show score, a win message on reaching 2048, and Game Over only when no valid move remains. Provide New Game to reset board and score; allow continuing after a win. Verify: Play a short sequence of arrow-key moves and check that tiles slide, a valid move adds a tile, and any observed merge updates the score by its merged value. Use New Game and check that the board returns to two tiles with a reset score.
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
METRICS
Transcript · 38 messages
Build a playable 2048 game using HTML elements and CSS with a 4 by 4 board. Use HTML, CSS, and JavaScript as needed. Write it to `index.html` in the current working directory using your file tools. Inline your own CSS and JavaScript. Do not use external assets, CDNs, or network requests. No third-party libraries are provided. Required behavior: - Arrow keys slide tiles toward the selected edge. Adjacent equal tiles merge once per tile per move, and the score increases by the value of each new merged tile. - For example, moving [2, 2, 2, 2] left produces [4, 4, 0, 0], not [8, 0, 0, 0]. Moving [2, 2, 4, 0] left produces [4, 4, 0, 0]. These examples describe the move before spawning a new tile. - Start with two tiles. After a move that changes the board, spawn exactly one tile in an empty cell: 2 with 90% probability, otherwise 4. Do not spawn after an unchanged move. - Animate movement and merging without allowing rapid input to corrupt board state. Prevent arrow keys from scrolling the page. - Show score, a win message on reaching 2048, and Game Over only when no valid move remains. Provide New Game to reset board and score; allow continuing after a win. Verify: Play a short sequence of arrow-key moves and check that tiles slide, a valid move adds a tile, and any observed merge updates the score by its merged value. Use New Game and check that the board returns to two tiles with a reset score.
thinking
Let me build the 2048 game. I'll use absolute-positioned tiles with CSS transitions for animation. No external resources.
Let me design:
- Grid container with background cells.
- Tiles as absolutely positioned divs with transform/translate, CSS transition.
- Game state: 4x4 array.
- Tile objects with id, value, position, element.
Let me implement a robust approach where tiles are tracked as objects to allow smooth animation.
Implementation plan:
- board: array of 16 cells, each null or tile object {id, value, r, c, el}
- On move: compute new positions and merges.
- For animation, move DOM elements to new transform, then update.
A simpler robust approach: maintain tile objects, each with a DOM element positioned via `transform: translate(x,y)`. On move, compute the new grid. For each tile that moves, update its r,c and element transform. For merges, move both tiles to target cell, then after transition, remove one and update value of the other (with pop animation).
Let me write carefully.
Data model:
- `grid[r][c]` = tile or null.
- tile = {id, value, r, c, el, mergedFrom: null}
Move left algorithm per row:
- Build list of tiles in order from left.
- Iterate, placing into new positions.
- Standard: for each tile in order, if previous placed tile has same value and not yet merged, merge.
I'll implement a generic move(direction) that processes lines.
Let me define vectors: left (0,-1), right(0,1), up(-1,0), down(1,0). Process traversal order.
For each line (row or column), collect tiles in traversal order (from the edge they move toward). Then merge.
Pseudo:
```
function move(dir) {
const vector = ...;
let moved = false;
const traversals = buildTraversals(vector); // rows order and cols order
// reset merged flags
tiles.forEach(t => t.mergedThisMove = false);
traversals.rows.forEach(r => {
traversals.cols.forEach(c => {
const tile = grid[r][c];
if (!tile) return;
const {farthest, next} = findFarthest({r,c}, vector);
if (next && grid[next.r][next.c] && grid[next.r][next.c].value === tile.value && !grid[next.r][next.c].mergedThisMove) {
// merge
const target = grid[next.r][next.c];
// move tile to target
moveTile(tile, next.r, next.c); // position
grid[r][c] = null;
// merge into target after transition
target.mergedThisMove = true;
// mark for merge: schedule
pendingMerges.push({from: tile, into: target});
moved = true;
} else {
if (farthest.r !== r || farthest.c !== c) {
moveTile(tile, farthest.r, farthest.c);
grid[r][c] = null;
grid[farthest.r][farthest.c] = tile;
moved = true;
}
}
});
});
}
```
Hmm, merging complexity. When merging, we move the moving tile to the target's cell. But the target keeps its element. After transition, we remove the from tile's element and increment target value, update its DOM, add pop animation. Score += target.value * 2 (new merged value).
Actually score increases by value of each new merged tile, i.e., doubled value. So if two 2s merge, new tile 4, score += 4.
The `findFarthest` function needs to avoid the merging cell? Standard 2048 implementation: find farthest position along the vector and the next cell. Let me use standard approach.
```
function findFarthest(cell, vector) {
let prev;
let curr = {r: cell.r, c: cell.c};
do {
prev = curr;
curr = {r: prev.r + vector.r, c: prev.c + vector.c};
} while (inBounds(curr) && !grid[curr.r][curr.c]);
return {farthest: prev, next: inBounds(curr) ? curr : null};
}
```
This works because the moving tile is still in grid when computing? We need to be careful about order: when processing in correct traversal order, tiles closer to the edge are processed first, so they're already moved. The moving tile's own cell should be cleared before finding? Actually the standard implementation sets grid[cell] to null before finding farthest... Let me look at standard 2048 code.
Standard (from gabrielecirulli/2048 Grid.js):
```
var self = this;
var cell, tile;
var vector = this.getVector(direction);
var traversals = this.buildTraversals(vector);
var moved = false;
this.eachCell(function (x, y, tile) {
if (tile) tile.mergedFrom = null;
});
traversals.x.forEach(function (x) {
traversals.y.forEach(function (y) {
cell = { x: x, y: y };
tile = self.cellContent(cell);
if (tile) {
var positions = self.findFarthestPosition(cell, vector);
var next = self.cellContent(positions.next);
if (next && next.value === tile.value && !next.mergedFrom) {
var merged = new Tile(positions.next, tile.value * 2);
merged.mergedFrom = [tile, next];
self.insertTile(merged);
self.removeTile(tile);
tile.updatePosition(positions.next);
self.score += merged.value;
if (merged.value === 2048) self.won = true;
} else {
self.moveTile(tile, positions.farthest);
}
if (!self.positionsEqual(cell, tile)) {
moved = true;
}
}
});
});
```
Note: `insertTile(merged)` puts merged tile at next position, removing the existing `next` tile from the grid map (but not DOM). `removeTile(tile)` removes the moving tile.
In my model, I'll do similar. But for animation, the `next` tile element needs to be removed after transition, and the moving tile element removed too, and a new merged element appears. Actually in the original, DOM is rebuilt via render() each move with CSS animations.
For a simpler animation approach that's robust, maybe easiest: after computing the move on the model, re-render all tiles with absolute positions and transitions. But to animate, we want elements that persist. Original 2048 uses this approach: each tile has a DOM element keyed, and on render it positions them. For merges, the merged tile is a new tile and both source tiles' DOM elements are removed (the original keeps them? Actually original's render removes all tile elements and re-adds; animations handle via classes). Hmm.
Let me do a clean approach with persistent elements:
State: grid[r][c] = tile. tile = {id, value, r, c, el, merging}.
On move:
1. Compute moves producing a list of "actions":
- moveActions: [{tile, toR, toC}]
- mergeActions: [{a, b, toR, toC, newValue}] where a is moving into b's position.
2. Apply model changes: update grid.
3. Apply DOM changes: for each move action, set element transform to new position. For merges, move both elements to target; after transition (say 100ms), remove one element, set the other's value and update its text/class.
4. score update.
5. Spawn new tile.
But we need to avoid input during animation. Use `animating` flag or `inputLocked`. Actually spec says "Animate movement and merging without allowing rapid input to corrupt board state." So locking input during animation is fine. But that can feel sluggish; could use a short lock. I'll use ~120ms transition and lock for that duration.
Actually simpler: process the entire move synchronously on the model (correct state), and just lock input during the animation window to avoid visual corruption. Since model is already correct, even if input came, it wouldn't corrupt; but let's lock.
Let me define transition duration 120ms.
Let me implement:
```
let grid = [];
let tiles = []; // all active tiles
let score = 0;
let tileId = 0;
let locked = false;
let won = false;
let keepPlaying = false;
let over = false;
```
Positioning: board inner area size. Use CSS grid for background cells. Tiles absolutely positioned in the board container with padding. Cell size computed. Use percentage-based positions so responsive.
Let board size = 4 cells + gaps. Let's use CSS variables. Container `.board` position relative with padding = gap. Cell size = (boardSize - 5*gap)/4. Easier: use CSS grid for cells with `gap: 12px`, padding 12px. For tiles, compute position via JS measuring cell positions? Simpler: use fixed pixel board, e.g., 400px, gap 12px, padding 12px, cell = (400 - 5*12)/4 = 85px. Tile size 85px, positioned at left = 12 + c*(85+12), top = 12 + r*(85+12). Use transform translate for GPU animation.
But responsive: board fixed 400px might overflow small screens. I'll make it max-width and use CSS scaling? Let me keep board 400px, but on narrow screens use transform scale? For the test viewport 1200x800 it's fine. I'll add a media query making it 340px maybe. Actually tile positions computed in JS with constants. Let me compute from CSS variables via getComputedStyle, or just keep fixed and scale container.
Simplest robust: fixed constants in JS matching CSS:
- BOARD = 400, GAP = 12, PAD = 12, CELL = (400 - 5*12)/4 = 85.
tile translate = PAD + c*(CELL+GAP), same for top.
I'll set CSS accordingly. For small screens, wrap board in a container with `transform: scale()` using CSS media query? A transform scale on board would keep internal layout. Fine.
Actually to be safe, use a responsive approach: compute CELL from actual board width at load and on resize. But transitions use transform pixel values; on resize we'd need to reposition. Let's just do fixed 400 and CSS `zoom`? Not reliable. I'll use media query that scales down via transform-origin top center. Coordinates in screenshot fine.
Let me just do fixed 400px board. Good enough.
Colors for tile values.
Now write the move logic producing actions.
Let me carefully implement the model update and record moves:
```
function move(dir) {
if (locked || over || !alive()) return;
const vector = DIRS[dir];
const traversals = buildTraversals(vector);
let moved = false;
const moves = []; // {tile, toR, toC}
const merges = []; // {from, into, toR, toC, value}
// clear merged flags
for (const t of tiles) t.mergedFrom = null;
traversals.rows.forEach(r => {
traversals.cols.forEach(c => {
const tile = grid[r][c];
if (!tile) return;
const pos = findFarthest({r,c}, vector);
const next = pos.next ? grid[pos.next.r][pos.next.c] : null;
if (next && next.value === tile.value && !next.mergedFrom) {
// merge tile into next
grid[r][c] = null;
// next stays at its position; record merge
merges.push({from: tile, into: next, toR: next.r, toC: next.c, value: tile.value*2});
next.mergedFrom = tile; // mark
next.value *= 2; // update model immediately
next.r = next.r; // unchanged
moved = true;
// after merge, the next tile's value updated; remove tile from tiles list later
} else {
if (pos.farthest.r !== r || pos.farthest.c !== c) {
grid[r][c] = null;
grid[pos.farthest.r][pos.farthest.c] = tile;
tile.r = pos.farthest.r; tile.c = pos.farthest.c;
moves.push({tile, toR: tile.r, toC: tile.c});
moved = true;
}
}
});
});
...
}
```
Wait: standard uses `next.mergedFrom` truthy to prevent double merge. Here I mark `next.mergedFrom = tile`. But then a later tile could try to merge with `next`—blocked. Good.
But there's a subtlety with findFarthest when the moving tile's cell still contains itself. In standard, `cellContent(cell)` returns tile, then findFarthest starts from cell and steps; the first step checks if next cell occupied. It doesn't consider the starting cell's own occupancy because it starts stepping immediately. So it's fine; the tile's own cell being occupied doesn't matter. Good. Actually findFarthest loop: curr starts at cell, prev=cell, curr=cell+vector; while inBounds(curr) && !grid[curr] move. So it never checks start. Good.
But when we've already moved a tile into a cell, that's reflected. Good.
However, after a merge, the `next` tile has new value and remains in grid. Subsequent tiles may merge with it only if value matches and not mergedFrom. Good.
One issue: after merging, we set grid[r][c]=null but the `into` tile stays. We should NOT update into.r/c. Good.
Now applying DOM:
- For each move: set tile.el transform to new pos.
- For each merge: animate `from` tile to target pos (translate), then after transition, remove `from` from tiles and DOM; update `into` element value/class and add pop.
We must update the `into` DOM value after animation? Original shows the merged tile with pop. Let's update the `into` value immediately but hide? Hmm. If we update text immediately while `from` is sliding in, it would look like the destination doubled before arrival. Minor. Better: update after transition. Let's schedule.
For merges, set from tile transform to target, and also set into tile transform (unchanged). After 120ms, remove from element, update into.
Actually we can update the `into` tile's DOM at the moment of render (immediately) — the doubling appears, which is okay-ish. But to be cleaner, update after transition and add pop class.
Let me implement with setTimeout.
Lock input: `locked = true;` set when moved, and unlock after animation (say 130ms). Also spawn new tile immediately after model update (before unlocking) so it appears during/after. Spawn with pop animation.
Let me structure:
```
function doMove(dir) {
if (locked || over || paused) return;
const result = computeMove(dir);
if (!result.moved) return;
locked = true;
applyAnimations(result);
updateScore();
// spawn after short delay? spawn immediately
spawnTile();
if (won && !keepPlaying) showWin();
setTimeout(() => {
locked = false;
if (!movesAvailable()) { over = true; showGameOver(); }
}, 130);
}
```
Wait, game over check: should check after spawn. If no moves available and board full etc. Check after spawning. Also check immediately after move if board full? spawn happens in empty cell, so after spawn check.
But "Game Over only when no valid move remains" - check movesAvailable after each move+spawn.
Win: show win message when reaching 2048, allow continuing. When won and !keepPlaying, maybe don't lock? We show overlay with continue button. Let's set a `paused`/locked while overlay shown. Actually overlay covers board; clicking "Keep Going" sets keepPlaying=true and hides overlay.
Let me handle: when a merge creates 2048, set won=true. After move, if won && !keepPlaying, show win overlay and lock. continue button hides overlay, keepPlaying=true, unlocks (check game over).
Implement `updateUI`.
Now spawnTile: pick random empty cell, random value 2 (90%) or 4. Create tile element with class, position, add pop animation. Add to tiles and grid.
Initial: spawn two tiles.
New Game: reset model, remove all tile elements, score=0, spawn two, hide overlays.
Now let me also ensure arrow keys prevent default. keydown listener preventDefault for ArrowUp/Down/Left/Right. Also WASD maybe optional but nice.
Let me write the code.
CSS:
```
#board {
position: relative;
width: 400px; height: 400px;
background: #bbada0;
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
}
.cell-grid {
position: absolute; inset: 12px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 12px;
}
.cell { background: rgba(238,228,218,0.35); border-radius: 6px; }
.tile {
position: absolute;
width: 85px; height: 85px;
border-radius: 6px;
display:flex; align-items:center; justify-content:center;
font-weight: bold;
font-size: 36px;
transition: transform 0.12s ease-in-out;
will-change: transform;
z-index: 2;
}
```
Wait padding and grid inset: board width 400, padding 12, inset 12 gives content 376 wide. Grid 4 cols gap 12 => col = (376 - 36)/4 = 85. Good, matches CELL 85. Tile positions: left = 12 + c*(85+12). Using transform translate.
Tile element created with `transform: translate(x,y)`. Add transition.
Pop animation: keyframes scale. But transform is used for positioning; layering scale on transform is tricky since we set translate in inline style. We can use a child element for the tile face and animate scale on child, or use CSS `--x/--y` variables and transform: translate(var(--x),var(--y)) scale(...). Let's use CSS variables. `.tile { transform: translate(var(--x), var(--y)); }` and `.tile.pop { animation: pop 0.15s; }` with keyframes using translate(var(--x),var(--y)) scale(...). Keyframes can reference variables. That works.
Actually simpler: tile contains inner div `.tile-inner` that fills and has background/font; animate inner scale. Let me do that—cleaner. `.tile` handles position transition; `.tile-inner` handles background and pop scale.
```
.tile { position:absolute; width:85px;height:85px; transition: transform .12s ease-in-out; z-index:2;}
.tile-inner { width:100%;height:100%; border-radius:6px; display:flex;align-items:center;justify-content:center; font-weight:800; font-size:36px; }
@keyframes pop { 0%{transform:scale(0);} 100%{transform:scale(1);} }
@keyframes popbig {0%{transform:scale(1);}50%{transform:scale(1.18);}100%{transform:scale(1);} }
.tile-inner.appear { animation: pop .15s ease; }
.tile-inner.merged { animation: popbig .15s ease; }
```
For merged: after transition, set into inner text and add merged class, restart animation.
Color classes: tile-2 ... tile-2048 and tile-super.
Let me now write the JS.
Careful with findFarthest and traversals.
```
const SIZE = 4;
const cellSize = 85, gap = 12, pad = 12;
function posPx(r,c){ return `translate(${pad + c*(cellSize+gap)}px, ${pad + r*(cellSize+gap)}px)`; }
```
buildTraversals(vector): if vector.r === 1 (down) rows ascending? Standard:
```
var traversals = { x: [], y: [] };
for (var x = 0; x < size; x++) traversals.x.push(x);
for (var y = 0; y < size; y++) traversals.y.push(y);
if (vector.x === 1) traversals.x = traversals.x.reverse();
if (vector.y === 1) traversals.y = traversals.y.reverse();
```
Here x = column, y = row. My grid[r][c] where r=row=y, c=col=x. So traversals.rows (y) reversed when vector.r===1 (down). traversals.cols (x) reversed when vector.c===1 (right).
DIRS: left {r:0,c:-1}, right {0,1}, up{-1,0}, down{1,0}.
findFarthest({r,c}, vector):
```
let pr=r, pc=c;
while(true){
const nr=pr+vector.r, nc=pc+vector.c;
if(nr<0||nr>=4||nc<0||nc>=4) break;
if(grid[nr][nc]) break;
pr=nr; pc=nc;
}
return {farthest:{r:pr,c:pc}, next:(inBounds? {r:pr+vector.r...} )};
```
Need next separately: nr,nc after loop. Let me restructure:
```
function findFarthest(r,c,v){
let pr=r, pc=c;
let nr=r+v.r, nc=c+v.c;
while(nr>=0&&nr<4&&nc>=0&&nc<4&&!grid[nr][nc]){
pr=nr; pc=nc; nr+=v.r; nc+=v.c;
}
const next = (nr>=0&&nr<4&&nc>=0&&nc<4)?{r:nr,c:nc}:null;
return {farthest:{r:pr,c:pc}, next};
}
```
computeMove:
```
function computeMove(dir){
const v = DIRS[dir];
const rows = v.r===1 ? [3,2,1,0] : [0,1,2,3];
const cols = v.c===1 ? [3,2,1,0] : [0,1,2,3];
let moved=false;
const moves=[], merges=[];
for(const t of tiles) t.mergedFrom=null;
for(const r of rows){
for(const c of cols){
const tile = grid[r][c];
if(!tile) continue;
const {farthest,next} = findFarthest(r,c,v);
const nextTile = next ? grid[next.r][next.c] : null;
if(nextTile && nextTile.value===tile.value && !nextTile.mergedFrom){
// merge
grid[r][c]=null;
nextTile.mergedFrom = tile;
merges.push({from:tile, into:nextTile, toR:next.r, toC:next.c, value:nextTile.value*2});
nextTile.value = nextTile.value*2;
moved=true;
} else {
if(farthest.r!==r || farthest.c!==c){
grid[r][c]=null;
grid[farthest.r][farthest.c]=tile;
tile.r=farthest.r; tile.c=farthest.c;
moves.push({tile, toR:farthest.r, toC:farthest.c});
moved=true;
}
}
}
}
return {moved, moves, merges};
}
```
Wait: order of traversals arrays: with rows/cols reversed properly, and iterating rows outer, cols inner. Standard iterates x outer, y inner. The order should be such that tiles nearest the target edge are processed first. If v.c===1 (right), cols reversed [3,2,1,0], so c=3 first. For each such col, all rows. That's standard. Good.
But there's a subtle bug: when v.r !== 0, we need rows reversed; cols in ascending. The nesting outer/inner: for vertical movement, we process all columns for the first row. Standard x outer, y inner: for each column, process rows. Hmm, does order matter for vertical? Since each column is independent, order between columns doesn't matter. Within a column, rows must be processed from the direction edge. My loops: rows outer, cols inner means for vertical move, rows are outer and reversed, so within a row we process cols. But vertical move needs within-column ordering by row. If rows outer: processing r=3 first (for down), then for each r process all c. That processes all cells of row 3 before row 2. Within column c, row3 before row2 - correct. And columns independent. So fine. Similarly horizontal: cols outer? My loops rows outer, cols inner means for horizontal left: rows ascending, cols ascending, so for row 0 process c=0,1,2,3 left-to-right; then row1. Within a row, left-to-right correct. Good. So actually rows outer works for both. Fine.
Now apply animations:
```
function applyAnimations(moves, merges){
for(const m of moves){
m.tile.el.style.transform = posPx(m.toR,m.toC);
}
for(const mg of merges){
mg.from.el.style.transform = posPx(mg.toR,mg.toC);
}
}
```
After transition, process merges finalize:
```
setTimeout(()=>{
for(const mg of merges){
// remove from
mg.from.el.remove();
const idx = tiles.indexOf(mg.from); if(idx>=0) tiles.splice(idx,1);
// update into
setTileValue(mg.into);
const inner = mg.into.el.firstChild;
inner.classList.remove('merged'); void inner.offsetWidth; inner.classList.add('merged');
}
}, TRANSITION_MS);
```
But locked release should be after merges processed + maybe spawn animation. Let's set lock duration = TRANSITION_MS + 30.
Order: compute, apply, update score, spawn. Spawn creates element and sets transform. New tile appears with pop. Then after TRANSITION_MS finalize merges.
Potential issue: if a tile both moves and merges? No, a tile either moves or merges. The `into` tile doesn't move.
Score update: add sum of merge values. I'll compute `scoreGain = merges.reduce((s,m)=>s+m.value,0)`.
Actually careful: `merges[].value` set to nextTile.value*2 before update. Good.
setTileValue(tile): inner textContent = tile.value; inner.className = 'tile-inner tile-'+...; set background via class. Let me set inner class list.
tile color mapping: for value<=2048 use `tile-${value}`; else `tile-super`.
Font size adjust for large numbers: 3-digit -> smaller. Use classes: `.tile-inner[data-v="128"]...` or just set fontSize based on digits. I'll set `inner.style.fontSize` based on value length.
Now createTile(r,c,value):
```
function createTile(r,c,value,appear){
const el = document.createElement('div');
el.className='tile';
el.style.transform = posPx(r,c);
const inner = document.createElement('div');
inner.className='tile-inner';
el.appendChild(inner);
const tile = {id:tileId++, value, r, c, el, inner, mergedFrom:null};
paintTile(tile);
if(appear) inner.classList.add('appear');
boardEl.appendChild(el);
grid[r][c]=tile;
tiles.push(tile);
return tile;
}
```
paintTile sets inner.textContent and class and fontSize.
Note: appendChild after setting transform; transition might animate from 0? Since newly added element starts at transform set; no transition from initial because it's initial value at insertion. But adding `appear` pop animates scale. Good. Actually sometimes browsers animate newly inserted elements from default; setting transform before append avoids.
Wait, but `el.style.transform` set before append, so initial computed is the target. Good.
Spawn:
```
function spawnTile(){
const empties=[];
for r,c if !grid[r][c] empties.push({r,c});
if(!empties.length) return;
const {r,c} = empties[Math.floor(Math.random()*empties.length)];
createTile(r,c, Math.random()<0.9?2:4, true);
}
```
movesAvailable: if any empty -> true; else check adjacent equal.
```
function movesAvailable(){
for r,c if(!grid[r][c]) return true;
for r,c {
const v=grid[r][c].value;
if(c<3 && grid[r][c+1] && grid[r][c+1].value===v) return true;
if(r<3 && grid[r+1][c] && grid[r+1][c].value===v) return true;
}
return false;
}
```
doMove:
```
function doMove(dir){
if(locked || over || (won && !keepPlaying)) return;
const {moved, moves, merges} = computeMove(dir);
if(!moved) return;
locked = true;
applyAnimations(moves, merges);
let gain = 0;
for(const mg of merges) gain += mg.value;
score += gain;
updateScore();
spawnTile();
// check win
if(!won && tiles.some(t=>t.value>=2048)){ won = true; }
const lockTime = TRANSITION_MS + 20;
setTimeout(()=>{
for(const mg of merges){
mg.from.el.remove();
const i=tiles.indexOf(mg.from); if(i>=0) tiles.splice(i,1);
paintTile(mg.into);
mg.into.inner.classList.remove('merged'); void mg.into.inner.offsetWidth; mg.into.inner.classList.add('merged');
}
}, TRANSITION_MS);
setTimeout(()=>{
locked = false;
if(won && !keepPlaying){ showWin(); return; }
if(!movesAvailable()){ over=true; showGameOver(); }
}, lockTime);
}
```
Hmm, but if won and !keepPlaying, we set locked=false then showWin which should lock again? The overlay prevents input anyway if we set a flag. I'll add `paused` variable or just rely on `won && !keepPlaying` check in doMove, which returns. Good. So no need lock.
But we should check win before game over. If won shown, don't show game over. Good.
Also, merges finalize should happen before spill lock; lockTime > TRANSITION_MS.
Now, potential issue: spawn happens immediately while sliding; the new tile appears while others slide. That's fine and matches 2048.
But wait: after computeMove updates grid, the moving tiles are already placed at new positions in grid. Spawn picks an empty cell correctly. Good.
Another issue: merge `from` tile removal from `tiles` after transition, but during that window `tiles` still includes it. That's only used for rendering/win check; fine. But win check `tiles.some(...)` runs before merge finalize; the `into` tile already has doubled value in model, so win detected. Good.
Edge: `nextTile.mergedFrom = tile` marks merged; but `tiles` list still has the from tile. Fine.
Now New Game:
```
function newGame(){
// remove all tile elements
for(const t of tiles) t.el.remove();
tiles=[]; grid = empty 4x4; score=0; won=false; keepPlaying=false; over=false; locked=false;
updateScore();
hide overlays;
spawnTile(); spawnTile();
}
```
But spawnTile uses boardEl and createTile. Ensure grid initialized.
Initialize grid = Array.from({length:4},()=>Array(4).fill(null)).
Overlays: win overlay with "You win!" and "Keep Going" button; game over overlay with "Game Over" and "New Game" button. Also a New Game button always visible.
Let me add header with title, score, New Game button. Then board, overlays positioned over board.
Also add "best"? not required. Skip or add. I'll keep simple.
Keyboard handler:
```
window.addEventListener('keydown', e=>{
const map={ArrowLeft:'left',ArrowRight:'right',ArrowUp:'up',ArrowDown:'down'};
const dir = map[e.key];
if(dir){ e.preventDefault(); doMove(dir); }
});
```
Also WASD optional.
Let me also add touch/swipe for mobile? Not required but easy. Skip or add simple. I'll add basic touch for completeness but not required. Keep it minimal to reduce bugs. Maybe add swipe.
Let me write the file now.
Let me also think about whether `transition: transform .12s` plus setting transform on newly created tiles could cause them to slide from previous tile's position because reused DOM? No, new elements.
For spawned tile appear animation uses inner scale, fine.
Let me write index.html.
Colors:
- 2: #eee4da, text #776e65
- 4: #ede0c8, text #776e65
- 8: #f2b179 white
- 16: #f59563
- 32: #f67c5f
- 64: #f65e3b
- 128: #edcf72
- 256: #edcc61
- 512: #edc850
- 1024: #edc53f
- 2048: #edc22e
- super: #3c3a32
Now writing.
Also prevent page scroll on arrow keys globally. Good.
Let me consider layout: body centered, background #faf8ef.
Let me write.
One more thing: transition of transform may not fire if same value; fine.
Let me create board element structure:
```
<div class="game">
<header>
<h1>2048</h1>
<div class="scores">
<div class="score-box"><span>SCORE</span><strong id="score">0</strong></div>
</div>
<button id="new-game">New Game</button>
</header>
<div id="board">
<div class="grid-bg">... 16 cells ...</div>
<div class="overlay win" id="win-overlay">...</div>
<div class="overlay over" id="over-overlay">...</div>
</div>
</div>
```
grid-bg absolute inset 12px? Actually board padding is inside; I'll place grid-bg with inset:12px and grid gap 12.
Let me set board CSS:
```
#board{position:relative;width:400px;height:400px;background:#bbada0;border-radius:10px;padding:12px;box-sizing:border-box; touch-action:none;}
.grid-bg{position:absolute;top:12px;left:12px;width:376px;height:376px;display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(4,1fr);gap:12px;}
.grid-cell{background:rgba(238,228,218,.35);border-radius:6px;}
```
376 = 400-24. 4 cols with gap 12: (376-36)/4=85. Good.
Tiles appended to #board directly (so their transform coordinates relative to board, matching pad).
Overlays absolute inset 0, flex center, background rgba, border-radius, z-index 10, hidden by default.
Responsive: media max-width 460: scale board via transform scale(0.8). But transform on board would affect overlay and tile coordinates consistently, okay. But body centered; transform-origin center. Fine. I'll add.
Now let's write the final code.
For number font size: value length 1-2 -> 36px, 3 -> 30, 4 -> 24, 5+ -> 20.
Let me write file.I'll build the 2048 game in a single self-contained `index.html`.
/tmp/job-bUwe4C/index.html (14395 bytes)Successfully wrote to /tmp/job-bUwe4C/index.html
thinking
Now let's run in browser and inspect.
{"action":"reload"}Frame 1, +0ms
[Browser image supplied to model]
{"console":["error: Cannot read properties of undefined (reading 'length')"],"dom":"2048\nSCORE\n0\nNew Game","images":"attached"}
thinking
Error: cannot read properties of undefined (reading 'length'). In newGame, `grid` is undefined when compute? Actually newGame is called at bottom; but `for (var i = 0; i < tiles.length; i++)` — tiles is undefined at first call! Because newGame references tiles.length before initializing. Yes. Need to guard. Let me initialize tiles=[] and grid=emptyGrid() before newGame call, or guard. Simple: declare `var tiles = [];` initially, but var declarations later assign. The var declarations are hoisted (undefined), and I assign `grid; tiles; ...` without initializer. Let me set initial values in declaration: `var grid = emptyGrid(); var tiles = [];` But emptyGrid is function declaration hoisted, fine. However later newGame resets. Let me change declaration line. Actually declarations currently: `var grid; var tiles; ...`. Change to `var grid = emptyGrid(); var tiles = []; var score = 0; var tileId = 0; ...`. Then newGame call fine.
{"path":"/tmp/job-bUwe4C/index.html","edits":[{"oldText":" var grid; // grid[r][c] = tile | null\n var tiles; // array of allSuccessfully replaced 1 block(s) in /tmp/job-bUwe4C/index.html.
thinking
Wait, emptyGrid is a function declaration defined after? Function declarations are hoisted, so calling emptyGrid() at var init time works because the whole script is parsed and function declarations hoisted. Yes. Reload.
{"action":"reload"}Frame 1, +1ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2","images":"attached"}
thinking
Two tiles at start, good. Now test moves. Let me use the debug hook via inspect? The browser tool doesn't have eval. Let me use keyboard actions and inspect. I can call window.__2048 via... no eval tool. I could add a query param? Let's just do keyboard moves and inspect DOM. Let me press ArrowLeft. But the debug hook isn't accessible. I'll inspect board via DOM text. Actually I can use `bash`? No browser JS eval. Let me rely on visual/inspect. Let me do a controlled test though. Better: I can add a temporary test harness that runs moves on load and prints to console. But simpler: use keyboard and observe. Actually, I can test logic more rigorously by extracting the JS and running in node. But DOM dependencies. Let me instead add a small inline self-test triggered by `?test=1` query. But browser reload probably loads file://... maybe with no query. Hmm. Alternatively write a temporary test file that includes the game logic? That's extra. Let me just do keyboard sequence and inspect DOM. The debug hook would be nice. Can the browser tool run JS? It has inspect/click/type/key. No eval. So use key actions. Let me press ArrowLeft and inspect.
{"action":"key","text":"ArrowLeft"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2\n2","images":"attached"}
thinking
Hmm, ArrowLeft seems to have moved one tile left (row0 col3 -> col1?), and there's a tiny glyph in col1 row0. Wait, the tile at top-right moved? Actually screenshot: row0 has a small dot at col1 and a "2" at col3. row3 has a 2 at col1. That's odd - looks like the top-right 2 moved left to col1? But there's also a "2" at col3 still. And row3 tile moved from col2 to col1. Wait, original board: row0 col3 = 2, row3 col2 = 2. After ArrowLeft, row0 col1 = ? (small "2" maybe squished?) and row0 col3 = 2; row3 col1 = 2. Hmm that doesn't match. Let me think. The small glyph in row0 col1 might be a mid-animation tile captured. The screenshot likely taken during animation. The dom text shows "2\n2\n2" = 3 tiles: score 0, original two, plus spawned one. So a move occurred and spawned a tile. Score still 0 (no merge). Good. But why does row0 col3 still have a 2 after moving left? Maybe that's the spawned tile. And row0 col1 tiny is the moving tile mid-animation. Yes! The moving tile from col3 moving to col0, mid-flight near col1. And row3 col2 moved to... row3 shows 2 at col1? Let me look: row3 tile at col1. Original row3 col2 -> moving left should stop at col0 (empty). But screenshot shows col1. Again mid-animation. So both moving left, mid-flight. Makes sense. The screenshot captured at start of animation. So the move worked. Let me wait and re-screenshot / inspect. There's no wait action. I can inspect again to get DOM text but text doesn't include positions. Let me take a screenshot again.
{"action":"screenshot"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2\n2","images":"attached"}
thinking
Now after settling: row0 col0=2, col1=2, row3 col0=2. Wait original was row0col3=2, row3col2=2. After left: should be row0col0=2, row3col0=2, then spawn one tile. But we see row0 col0 and col1 both 2. That means the spawned tile was placed at row0col1. So board: [2,2,0,0] in row0, row3 col0=2. Score 0. Good, 3 tiles. Now test a merge. Press ArrowLeft again: row0 [2,2,...] should merge to [4,0,0,0], score +4, spawn one. Let's do it.
{"action":"key","text":"ArrowLeft"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n4\nNew Game\n2\n2\n2\n4","images":"attached"}
thinking
Score went to 4. DOM text now has "2,2,2,4" = 4 tiles: row3 col0 2, a spawned tile, and the merged 4. Screenshot mid-animation. Let me screenshot after settle.
{"action":"screenshot"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n4\nNew Game\n4\n2\n4","images":"attached"}
thinking
After settle: row0 col0=4 (merged), row0 col2=4 (spawned!), row3 col0=2. Score 4. Correct merge.
Now test the multi-merge example [2,2,2,2] -> [4,4,0,0]. That's harder to set up manually. I could use the debug hook but no eval. Let me instead add a hidden test capability controlled by URL hash and reload with hash? The browser reload likely preserves URL. I don't know the URL. Hmm.
Alternatively I could temporarily inject a script that runs tests and logs to console, then reload, then remove. Let me do that: add a block at the end guarded by `location.hash === '#selftest'`, running deterministic scenarios and console.log. But browser reload may not let me set hash. I could just run the test unconditionally on load temporarily, check console, then remove.
Actually simpler and safer: create a separate temporary test.html that loads the same logic? Not same file. Alternatively, I can extract the script and run in node with a minimal DOM stub. That's a lot.
Let me just temporarily append a self-test that overrides Math.random and manipulates grid via the exposed hook, then reload and inspect console. The hook exposes getGrid and move. But to set a specific board, I'd need a setter. Let me add a `__2048.setGrid` debug function... but I don't want debug leftovers necessarily. Actually the debug hook is already there; adding setGrid is fine but let me keep it out of the final. I'll add temporarily.
Actually, a cleaner approach: test the pure logic by copying just computeMove into node. But it depends on grid/tiles. I could reason about correctness—it's the standard algorithm. The visible test showed a simple adjacent merge works.
Let me test the tricky case [2,2,2,2] -> [4,4]. I can do it via gameplay: set up row with four 2s using moves from a new game? That requires luck. Better use debug setter.
Let me temporarily add to the debug hook a setState function and a self-test that runs on hash. Actually I'll just add a test function that runs when `location.search` includes test. But reload may not include query.
Simplest: temporarily add `runSelfTests()` at the end before newGame, which after newGame overrides the grid and runs moves, logging. Then reload, read console, then remove the test code. Let me add a `setBoard` to debug hook and tests.
Let me write test:
- Test A: [2,2,2,2] in row 0, rest empty. move left -> after merge model (before spawn) should be [4,4,0,0]. But move triggers spawn, adding a tile. For test I can call computeMove directly if exposed. Let me expose computeMove? Or temporarily add a test hook that calls computeMove. Let me add `_compute: computeMove` and `_load: function(rows){...}` to the debug hook temporarily.
Actually I'll just add these to the permanent debug hook or not. Let's add temporarily.
Let me edit: add to __2048 a `load` and `compute`. Then inject test script after newGame call. Then reload.
Actually easier: add the test inline in the script itself, logging to console, then remove. Let me do it.
I'll append inside the IIFE before newGame():
```
window.__test = function(){
// set up
...
}
```
and call it. Hmm, but I want to use the existing functions. Let me just add temporary code before `newGame();`:
```
if (location.hash === '#selftest') {
setTimeout(function(){
function load(rows){
for (var i=0;i<tiles.length;i++) tiles[i].el.remove();
tiles=[]; grid=emptyGrid();
for (var r=0;r<4;r++) for(var c=0;c<4;c++) if(rows[r][c]) createTile(r,c,rows[r][c],false);
}
function vals(){ return grid.map(function(row){return row.map(function(t){return t?t.value:0;});}); }
load([[2,2,2,2],[0,0,0,0],[0,0,0,0],[0,0,0,0]]);
var res = computeMove('left');
console.log('A', JSON.stringify(vals()), JSON.stringify(res.merges.map(function(m){return m.value;})), JSON.stringify(res.moves.length));
load([[2,2,4,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]]);
res = computeMove('left');
console.log('B', JSON.stringify(vals()));
load([[2,0,2,4],[0,0,0,0],[0,0,0,0],[0,0,0,0]]);
res = computeMove('left');
console.log('C', JSON.stringify(vals()));
},100);
}
```
But computeMove mutates and animates; fine for test. However `createTile` appends and uses board. Fine.
But reload with #selftest—will the browser tool reload the same URL including hash? I don't know the current URL. Let me find out. I can read the page URL? Not directly. Let me just run tests unconditionally on load (temporarily) and then remove.
Let me add a block right before `newGame();` that runs after a delay, logging, regardless. Then reload and inspect console.
But careful: running tests after newGame's two random tiles would interfere visually; tests override board, that's fine.
Let me do it.Let me temporarily inject a self-test to verify the tricky merge rules, then remove it.
{"path":"/tmp/job-bUwe4C/index.html","edits":[{"oldText":" newGame();\n})();","newText":" newGame();\n\n setTimeout(function () {\n fuSuccessfully replaced 1 block(s) in /tmp/job-bUwe4C/index.html.
{"action":"reload"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2","images":"attached"}
thinking
The console is empty because console log happened after 200ms and the reload result captured immediately. Let me inspect again or screenshot — actually the tool returns current console at time of call. Let me call inspect or screenshot to get updated console after time passed. Probably the console buffer accumulates. Let me call inspect.
{"action":"inspect"}Frame 1, +0ms
[Browser image supplied to model]
{"console":["log: SELFTEST A [[4,4,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]]","log: SELFTEST B [[4,4,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]]","log: SELFTEST C [[4,4,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]]","log: SELFTEST D [[0,0,4,4],[0,0,0,0],[0,0,0,0],[0,0,0,0]]"],"dom":"2048\nSCORE\n0\nNew Game\n2\n2\n2\n2","images":"attached"}
thinking
All four merge tests pass exactly: - A: [2,2,2,2] left -> [4,4,0,0] ✓ - B: [2,2,4,0] left -> [4,4,0,0] ✓ - C: [2,0,2,4] left -> [4,4,0,0] ✓ - D: [2,2,2,2] right -> [0,0,4,4] ✓ Now remove the test block. Then test New Game resets to 2 tiles and score 0. Also test game over maybe. Let me remove test block.
All merge rules verified. Now removing the test code.
{"path":"/tmp/job-bUwe4C/index.html","edits":[{"oldText":" newGame();\n\n setTimeout(function () {\n function load(rows) {\n for (Successfully replaced 1 block(s) in /tmp/job-bUwe4C/index.html.
thinking
Now reload and test New Game button. After reload, make some moves then click New Game. Let me reload, do a move, click new game, check score 0 and 2 tiles. The debug hook remains, which is fine (harmless). But let me consider removing it to be clean? It's not external, no harm. I'll keep it or remove. I'll leave it—actually it exposes internals but harmless. Keep for potential verification. Let me reload and click New Game.
{"action":"reload"}Frame 1, +1ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n4","images":"attached"}
thinking
Fresh game with two tiles, score 0. Good. Now do a couple moves then click New Game.
{"action":"key","text":"ArrowDown"}{"action":"key","text":"ArrowRight"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n4\n2","images":"attached"}
Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n4\n2","images":"attached"}
thinking
Moves work. Now click New Game and verify reset. Click at the New Game button, coordinates ~ (745,195).
{"action":"click","x":745,"y":196}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2","images":"attached"}
thinking
New Game reset: score 0, two tiles, appearing animation (small). Good. Screenshot captured mid-appear. Now I'm fairly confident. Remaining unverified: win message behavior, game over detection, input lock after rapid keys. The win requires reaching 2048 which takes long; I can't easily verify without a setter. Game over could be tested but time. The logic is straightforward. Let me quickly verify that rapid input doesn't corrupt by pressing many keys quickly and confirming tile count stays consistent. Actually the lock is short (150ms); rapid keypresses within lock are ignored. Multiple keys will be spaced by tool latency. It's fine. Let me consider the win overlay: `won && !keepPlaying` blocks doMove. Good. Keep Going unlocks. One potential bug: In doMove, if won and keepPlaying false, after unlocking we call showWin. But there's another setTimeout at TRANSITION_MS that finalizes merges. Both run. Fine. Another consideration: the spec says "Show score, a win message on reaching 2048, and Game Over only when no valid move remains." Our game over is only set after a move and spawn. Also after Keep Going. Good. But what if board is full at start? Can't happen. Potential issue: If the board is full and no moves after a move, we set over. Good. Also "allow continuing after a win" - Keep Going button. Good. One more: arrow keys prevent default - yes with passive:false. Let me double-check that `e.preventDefault()` works for arrow keys—yes. I think it's complete. Let me do a final check of the file for the debug hook. Should I remove it? It's harmless but exposing internals. The task doesn't forbid. I'll remove it to keep the artifact clean, but then I lose verification ability. I've already verified. Let me remove the debug hook to avoid any lint/quality concerns? It's fine to leave a small debug object, but cleaner to remove. I'll remove it. Actually removing means I can't verify further without re-adding. I've done enough verification. Let me remove.
New Game resets to two tiles and score 0. Let me remove the debug hook for a clean final artifact and do a final reload.
{"path":"/tmp/job-bUwe4C/index.html","edits":[{"oldText":" // Expose a small debug hook for automated checks (harmless in normal use).\n wSuccessfully replaced 1 block(s) in /tmp/job-bUwe4C/index.html.
{"action":"reload"}Frame 1, +0ms
[Browser image supplied to model]
{"console":[],"dom":"2048\nSCORE\n0\nNew Game\n2\n2","images":"attached"}
thinking
Clean. Final file is at /tmp/job-bUwe4C/index.html. Let me report. Let me quickly note behaviors verified and unverified. Verified: - Initial board two tiles, score 0. - Arrow moves slide tiles (animation visible), one tile spawns per changed move, no spawn for unchanged move? I didn't explicitly test unchanged move. Actually I did two moves; both changed. Not explicitly tested unchanged. Could test: press ArrowUp when board already against top. But minor. Let me quickly test unchanged move doesn't spawn. Fresh board has two random tiles. Hard to guarantee. Press ArrowLeft then ArrowLeft again - second may or may not change. Not reliable. I'll report it as logic-verified (computeMove returns moved flag; doMove returns early if not moved) but not runtime verified. - Merge updates score by merged value (observed +4). - Merge algorithm cases [2,2,2,2]->[4,4], [2,2,4]->[4,4], [2,0,2]->[4,4], right direction — verified via temporary self-test. - New Game resets score and two tiles. Unverified at runtime: - Win overlay / Keep Going (requires reaching 2048; code path inspected). - Game Over detection (requires full board; code path inspected). - Rapid-input lock (short animation window; lock flag implemented).
Done. `index.html` is complete and self-contained (inline CSS/JS, no external resources). ## What I checked in the browser - **Initial state**: loads with exactly two tiles and score `0`, no console errors. - **Sliding + spawn**: arrow-key moves animate tiles and spawn exactly one new tile (verified the tile count went 2 → 3 after a changed move). - **Merge scoring**: set up `[2, 2, …]` and moved left; result was a `4` tile and the score went from `0` to `4` (gain = merged tile value). Also confirmed visually that the merge animation plays. - **Merge edge cases** (via a temporary in-page self-test, since these are hard to reach by play): - `[2,2,2,2]` left → `[4,4,0,0]` ✓ - `[2,2,4,0]` left → `[4,4,0,0]` ✓ - `[2,0,2,4]` left → `[4,4,0,0]` ✓ - `[2,2,2,2]` right → `[0,0,4,4]` ✓ - **New Game button**: resets the board to two tiles and score to `0`, and clears overlays. - **No scrolling**: arrow key handler calls `preventDefault()` with `{ passive: false }`. ## Not verified at runtime (code path reviewed only) - **Win message / "Keep Going"**: reaching 2048 in play is impractical in the time budget; the logic (`won` flag on a 2048 merge, overlay, continue handler) is implemented. - **Game Over overlay**: requires filling the board with no moves left; `movesAvailable()` is checked after each move/spawn and after continuing. - **Rapid-input lock**: input is locked (`locked` flag) for the animation window; I didn't forcefully stress-test simultaneous keypresses, though the model is updated synchronously before animation so state can't be corrupted even if a key slipped through. The final file is saved at `/tmp/job-bUwe4C/index.html` with the temporary debug/test code removed.