DEV Community

Cover image for I Rebuilt 2048 From Scratch and Accidentally Learned How Much I Didn't Know About CSS Grids
Suzanne Elinor
Suzanne Elinor

Posted on • Originally published at braintilegame.com

I Rebuilt 2048 From Scratch and Accidentally Learned How Much I Didn't Know About CSS Grids

I have a confession. I've shipped production code for years, and I still couldn't have told you, off the top of my head, how to detect whether two adjacent tiles in a 2D array should merge when you swipe left. Turns out, neither could half of Stack Overflow, based on how many wildly different (and wildly broken) implementations are floating around out there.

So I did what any reasonable developer does when faced with a solved problem: I ignored every existing solution and built my own from scratch anyway. This is the story of how a "simple weekend project" turned into a two-week rabbit hole involving array rotation math, a CSS positioning bug that made tiles teleport on mobile, and me yelling "WHY IS IT OFFSET BY EXACTLY ONE PADDING VALUE" at 1am.

If you've ever wanted to build 2048 yourself — or you're just here to laugh at my pain — buckle up.

Why 2048, though?

Everyone's first instinct when picking a "simple" project to rebuild is Tic-Tac-Toe or a to-do list. I wanted something that looked simple but would actually punish me for cutting corners. 2048 is deceptive like that. The rules fit in two sentences:

Slide tiles in a direction. Tiles with the same number merge into one, doubling in value. Get to 2048.

Sounds like an afternoon, right?

It is not an afternoon.

The second you sit down to actually write the merge logic, you realize the "simple" game is secretly a state machine wearing a numbers costume, and every edge case you didn't think about (What happens when three 2s are in a row and you slide left? Do all three merge into one 4, or does it merge two and leave one? Answer: the first two merge, the third stays a 2. Nobody tells you this. You just have to feel it out by playing the original obsessively) is waiting to break your grid into nonsense.

The core problem: rows, columns, and me pretending I understand linear algebra

Here's the actual gameplay loop, stripped down to its ugly truth:

function slideRow(row) {
  let tiles = row.filter(v => v);       // squeeze out the zeros
  let gained = 0;
  const merged = [];
  let i = 0;
  while (i < tiles.length) {
    if (i + 1 < tiles.length && tiles[i] === tiles[i+1]) {
      merged.push(tiles[i] * 2);
      gained += tiles[i] * 2;
      i += 2;                            // skip both merged tiles
    } else {
      merged.push(tiles[i]);
      i++;
    }
  }
  while (merged.length < 4) merged.push(0); // pad back to 4
  return { merged, gained };
}
Enter fullscreen mode Exit fullscreen mode

That's the whole trick for sliding left. Squeeze the non-zero values together, walk through them once, merge adjacent equal pairs, pad the rest with zeros. Clean, right?

Now do that for right, up, and down without writing the same function four times.

My first instinct was to write four nearly-identical functions with the loop direction flipped. My second, much better instinct — after staring at my own code in disgust — was this: every direction is just "slide left" if you reorient how you read the grid.

function applyDirection(grid, dir) {
  for (let i = 0; i < 4; i++) {
    let line = [];
    for (let j = 0; j < 4; j++) {
      if (dir === 'left')  line.push(grid[i][j]);
      if (dir === 'right') line.push(grid[i][3-j]);
      if (dir === 'up')    line.push(grid[j][i]);
      if (dir === 'down')  line.push(grid[3-j][i]);
    }
    const { merged } = slideRow(line);
    // ...write it back using the same reversed indexing
  }
}
Enter fullscreen mode Exit fullscreen mode

Read the grid differently depending on direction, always slide left internally, write it back with the same transformation reversed. One function. Four directions. My past self, who wrote four separate slide functions with copy-pasted bugs in three of them, is embarrassed. My present self feels like a genius for about eleven minutes, until the next bug shows up.

The bug that made me question my career choices

Everything worked beautifully on desktop. Buttery smooth. Tiles slid, merged, the little pop animation played, I was feeling unstoppable.

Then I opened it on my phone.

The tiles were... offset. Not randomly offset — precisely, mockingly offset, like they were rendering one padding-width away from where they should be. Every tile shifted up and to the left by exactly the board's padding value. It looked like the whole board had been nudged by a ghost with OCD.

Here's what was actually happening, and I want you to appreciate the stupidity of it before I explain the fix: I had two separate layers stacked on top of each other — a background grid of empty grey cells, and a foreground layer of the actual numbered tiles. The background grid had padding: 10px baked in so its cells sat inset from the board edge. The foreground tile layer, meanwhile, was positioned at top: 0; left: 0, completely unaware that the background layer had padding at all.

Two layers, two different coordinate systems, both confidently believing they were right.

/* The background grid — has padding */
.bg-grid {
  padding: 10px;
}

/* The tile layer — thinks it starts at (0,0), it does not */
.tiles-layer {
  position: absolute;
  top: 0; left: 0;
}
Enter fullscreen mode Exit fullscreen mode

The fix, once I actually found it (after a genuinely embarrassing amount of console.logging coordinates that all looked "fine" in isolation), was almost insulting in its simplicity: give both layers the same padding-aware offset, computed from one single source of truth instead of two independently-padded elements guessing at each other's intentions.

tilesLayer.style.top    = BOARD_PAD + 'px';
tilesLayer.style.left   = BOARD_PAD + 'px';
Enter fullscreen mode Exit fullscreen mode

One line. Two hours of my life. Worth it, mostly for the story.

Making it actually work on a phone, not just "sort of render" on a phone

There's a difference between "the CSS doesn't crash on mobile" and "this feels like a native app." I learned this the hard way when someone sent me a screenshot of my board absolutely eating itself on a smaller screen — tiles overflowing their container like a Jenga tower mid-collapse.

The problem was I'd hardcoded everything. 80px tiles. 10px gaps. Fixed pixel values sprinkled through both my CSS and my JavaScript like I was writing this for exactly one screen size and daring anyone else to try it.

The fix was to stop hardcoding and start measuring:

function computeBoardMetrics() {
  const boardW = boardEl.getBoundingClientRect().width;
  BOARD_PAD = Math.round(boardW * 0.028);
  CELL_GAP  = Math.round(boardW * 0.028);
  CELL_SIZE = Math.round((boardW - BOARD_PAD * 2 - CELL_GAP * 3) / 4);
}
Enter fullscreen mode Exit fullscreen mode

Instead of assuming the board is always 340px wide, I ask the browser how wide it actually is, right now, on this device, and calculate everything as a percentage of that. Add a ResizeObserver so it recalculates on orientation change, and suddenly the board behaves the same whether you're on an iPhone SE or an ultrawide monitor. Responsive design isn't a CSS media query trick, it turns out — sometimes it's just doing math instead of guessing.

Touch targets: the thing nobody tells you until you fail Apple's guidelines

I added on-screen arrow buttons for mobile players who don't want to swipe (looking at you, people who play with one thumb while holding a coffee in the other hand). My first version had them at a tidy, compact 44px.

Which, technically, meets the minimum touch target size Apple recommends. It also felt like trying to poke a specific ant in a colony. I bumped them to 56px, added touch-action: manipulation to kill the 300ms tap delay that plagues mobile Safari, and suddenly the buttons felt like they belonged there instead of being an apology tacked onto the corner of the screen.

.arrow-btn {
  width: 56px;
  height: 56px;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
Enter fullscreen mode Exit fullscreen mode

Small numbers, big difference. Nobody notices good touch targets. Everybody notices bad ones.

What I'd tell past-me before starting this

  1. The merge logic is the easy part. You'll bang it out in twenty minutes and feel like a wizard. The layout, the responsiveness, the animations — that's where the actual week goes.
  2. One coordinate system, always. The second you have two elements independently deciding where "zero" is, you will lose an evening to a bug that looks impossible until it's suddenly obvious.
  3. Test on a real phone earlier than you think you need to. "It'll probably scale fine" is the sentence right before you lose a weekend.
  4. Undo buttons are more work than they look. You need to snapshot the entire grid state and score before every move, not after, or you'll undo yourself into a broken game state that makes no sense. Ask me how I know.

Try it yourself

I ended up polishing this into a full playable game — BrainTileGame, a free 2048-style puzzle you can play in the browser, no download, works on mobile and desktop. If you want to poke at it, break it, or just see if the padding bug ever comes back to haunt me, it's live and free to play.

If you're building your own version, I'd genuinely love to see it — drop a comment if you get stuck on the merge logic or the mobile scaling, I've probably already made that exact mistake and can save you the 1am debugging session.


What's the most "should have been simple" project that ended up teaching you way more than expected? I'm collecting stories for the next time I need to feel less alone about this.

Top comments (2)

Collapse
 
frank_signorini profile image
Frank

How did you handle responsive design with CSS Grid in the 2048 game, I've struggled with that in similar projects. Would love to hear your approach.

Collapse
 
suzanne_builds profile image
Suzanne Elinor

Good question! Honestly, I didn't end up leaning on CSS Grid for the responsive part —
that was actually the trap I fell into at first. I had the board using a fixed grid-template-columns
with hardcoded pixel values, which looked fine on desktop and then completely fell apart on mobile.

What actually fixed it was ditching the "let CSS figure out the sizing" approach entirely and
switching to JS-measured layout instead:

const boardW = boardEl.getBoundingClientRect().width;
CELL_SIZE = Math.round((boardW - PAD*2 - GAP*3) / 4);

Then I feed those computed pixel values back into the tile positions directly, and recalculate
on a ResizeObserver whenever the container size changes (orientation flip, window resize, etc).

Felt a little like cheating at first — "real" responsive design is supposed to be pure CSS, right?
But for a board that has to stay perfectly square and perfectly aligned at any screen size,
measuring and computing beat every CSS-only trick I tried. aspect-ratio: 1/1 on the container
got me most of the way, but the actual tile positioning needed real numbers, not percentages.

What's your project — are you also doing a grid-based game, or something else entirely?