DEV Community

Richard Fu
Richard Fu

Posted on • Originally published at richardfu.net on

Building Bubble 2048: A Technical Deep Dive

When I participated in Global Game Jam 2025 with the theme “Bubble,” I wanted to create something familiar yet innovative. The result? Bubble 2048 – a twist on the classic 2048 puzzle game where tiles don’t just slide… they bubble up.

But here’s the thing: I didn’t finish it during the jam last year. Life got in the way, and the project sat incomplete for nearly a year. Then, just before Global Game Jam 2026 kicked off, I discovered Claude Code – and everything changed. What had been a frustrating tangle of animation bugs and timing issues became manageable. With Claude’s help debugging the animation system and refining the game logic, I finally completed what I’d started at GGJ 2025, just in time to approach this year’s jam with renewed confidence.

Play the game here | GitHub Repository

Gameplay Demo

The Unique Mechanic: Double Movement System

The game’s defining feature is its dual-movement mechanic. Unlike classic 2048 where you make a move and wait for the next tile to spawn, Bubble 2048 adds a second layer:

  1. Player Move : Swipe in any direction (up, down, left, or right) – tiles slide and merge as expected
  2. Bubble Shift : After your move completes, ALL tiles automatically shift upward by one row (like bubbles rising in water), with another merge opportunity
  3. Spawn : Only after both movements complete does a new tile appear

This creates a fascinating strategic depth – you’re not just planning one move ahead, but considering how the automatic bubble shift will affect your board state.

Tech Stack: Simple but Effective

I kept the technology stack deliberately minimal:

  • React 19 with TypeScript for type safety and component structure
  • Vite for lightning-fast development and optimized builds
  • Pure CSS animations for all visual effects (no animation libraries needed)
  • Zero game engine dependencies – all game logic written from scratch

The entire project uses only 2 production dependencies: react and react-dom. Everything else is built in-house.

Game Logic Architecture

Grid State Management

The game state is built around a clean TypeScript interface:


interface Tile {
  id: string; // Unique identifier
  value: number; // 2, 4, 8, 16, 32...
  position: Position; // Current { row, col }
  previousPosition?: Position; // For slide animations
  mergedFrom?: [Tile, Tile]; // Which two tiles merged
  isNew?: boolean; // For spawn animation
}

type Grid = (Tile | null)[][]; // 4x4 array

Enter fullscreen mode Exit fullscreen mode

This structure supports everything needed for animations: we track where tiles came from (previousPosition), which tiles merged together (mergedFrom), and which just spawned (isNew).

The Movement Algorithm

The core movement logic (moveTiles) processes tiles in the correct traversal order. For right/down movements, we reverse the iteration to prevent tiles from “leap-frogging” during merges:


function getTraversalOrder(direction: Direction) {
  const rows = [0, 1, 2, 3];
  const cols = [0, 1, 2, 3];

  if (direction === 'down') rows.reverse();
  if (direction === 'right') cols.reverse();

  return { rows, cols };
}

Enter fullscreen mode Exit fullscreen mode

The algorithm:

  1. Iterate through tiles in traversal order
  2. For each tile, find the farthest position it can move to
  3. Check if the destination contains a tile with the same value
  4. If yes and it hasn’t merged yet → merge them (create new tile with doubled value)
  5. If no → just move the tile
  6. Track merged positions to prevent double-merging

The crucial anti-double-merge logic uses a Set:


const mergedTiles = new Set<string>();

// When checking if we can merge
if (next && next.value === tile.value && !mergedTiles.has(nextPosKey)) {
  // Merge and mark position
  mergedTiles.add(nextPosKey);
}

Enter fullscreen mode Exit fullscreen mode

The Bubble Mechanic

The bubble shift is simpler than player movement – it only moves upward and doesn’t cascade through multiple empty cells:


function bubbleShiftUp(grid: Grid): MoveResult {
  // Process rows from top to bottom (skip row 0)
  for (let row = 1; row < GRID_SIZE; row++) {
    for (let col = 0; col < GRID_SIZE; col++) {
      const tile = grid[row][col];
      if (!tile) continue;

      const aboveRow = row - 1;
      const aboveTile = grid[aboveRow][col];

      if (!aboveTile) {
        // Move up to empty space
        moveTileToPosition(tile, aboveRow, col);
      } else if (aboveTile.value === tile.value) {
        // Merge with tile above
        mergeTiles(aboveTile, tile);
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

This intentional simplicity prevents infinite cascade scenarios while still providing strategic depth.

The Animation Challenges

Getting smooth animations working was the most challenging part of the project – and honestly, what kept me from finishing during the jam. The tiles would jump around, animations wouldn’t trigger, and I couldn’t figure out why. This is where Claude Code became invaluable, helping me systematically debug and solve each issue. I documented the entire debugging process in docs/tile-animation-fix.md, but here are the key challenges:

Challenge 1: CSS Transform Conflicts

Problem : The base .tile class applied a transform to position tiles at their final location:


.tile {
  transform: translate(calc(var(--tile-x) * ...));
}

Enter fullscreen mode Exit fullscreen mode

When the animation class was added, the base transform took precedence, causing tiles to jump to their destination instantly.

Solution : Conditional base transform using :not() selectors:


.tile:not(.tile-moving):not(.tile-merged):not(.tile-new) {
  transform: translate(...);
}

Enter fullscreen mode Exit fullscreen mode

Now animations have full control over the transform property.

Challenge 2: Animation State Persistence

Problem : When cloning the grid between moves, animation state (previousPosition, mergedFrom) was being copied, causing stale animation data.

Solution : Modified cloneGrid() to only copy core properties:


export function cloneGrid(grid: Grid): Grid {
  return grid.map(row =>
    row.map(cell =>
      cell ? {
        id: cell.id,
        value: cell.value,
        position: { ...cell.position },
        // Don't copy animation state
      } : null
    )
  );
}

Enter fullscreen mode Exit fullscreen mode

Challenge 3: Animation Not Retriggering

Problem : CSS animations only start when the animation class is first added. React reuses DOM elements with the same key, so changing CSS variables alone doesn’t restart animations.

Solution : Force DOM reflow with direct manipulation:


useEffect(() => {
  if (shouldAnimate && elementRef.current) {
    const element = elementRef.current;
    element.classList.remove('tile-moving');
    void element.offsetWidth; // Force reflow
    element.classList.add('tile-moving');
  }
}, [previousPosition?.row, previousPosition?.col, shouldAnimate]);

Enter fullscreen mode Exit fullscreen mode

The void element.offsetWidth forces the browser to process the class removal before re-adding it.

Challenge 4: Bubble Wobble Effect

To enhance the ocean theme, I added an idle “wobble” animation to make tiles look like floating bubbles. The trick was making them wobble at different times:


const wobbleDelay = ((position.row * 4 + position.col) * 0.2) % 3;

Enter fullscreen mode Exit fullscreen mode

This position-based calculation creates a natural staggered effect where each bubble has its own wobble phase.

Input Handling: Supporting All Devices

The game supports three input methods:

  1. Keyboard : Arrow keys and WASD (case-insensitive)
  2. Touch : Swipe gestures with 30px minimum threshold
  3. Mouse : Click-and-drag gestures

The swipe detection logic calculates the primary axis to determine direction:


const deltaX = Math.abs(endX - startX);
const deltaY = Math.abs(endY - startY);

if (Math.max(deltaX, deltaY) < 30) return; // Too small

if (deltaX > deltaY) {
  // Horizontal swipe
  direction = endX > startX ? 'right' : 'left';
} else {
  // Vertical swipe
  direction = endY > startY ? 'down' : 'up';
}

Enter fullscreen mode Exit fullscreen mode

The Move Sequence: Timing is Everything

The game uses careful timing to coordinate both movements:


const ANIMATION_DURATION = 150; // Tile slide duration
const BUBBLE_DELAY = 100; // Extra pause before bubble shift

// Player move
setGrid(playerResult.grid);
setIsAnimating(true);

// Wait for animation + delay
setTimeout(() => {
  const cleanedGrid = clearAnimationState(playerResult.grid);
  const bubbleResult = bubbleShiftUp(cleanedGrid);

  if (bubbleResult.moved) {
    setGrid(bubbleResult.grid);

    // Wait for bubble animation
    setTimeout(() => {
      spawnNewTile();
      checkWinLose();
      setIsAnimating(false);
    }, ANIMATION_DURATION);
  }
}, ANIMATION_DURATION + BUBBLE_DELAY);

Enter fullscreen mode Exit fullscreen mode

This creates a smooth sequence: player swipe → tiles slide → pause → bubbles rise → new tile spawns.

Performance Optimizations

Despite being built without a game engine, the game runs smoothly:

  • React.memo on the Tile component prevents unnecessary re-renders
  • CSS animations instead of JavaScript for 60fps performance
  • Passive event listeners for touch events to improve scroll performance
  • LocalStorage for best score persistence (with graceful error handling)

Deployment: GitHub Pages with Vite

Deploying to GitHub Pages required configuring the base path in vite.config.ts:


export default defineConfig({
  base: '/bubble-2048/',
  plugins: [react()],
});

Enter fullscreen mode Exit fullscreen mode

Then build and deploy:


npm run build
# Push dist/ folder to gh-pages branch

Enter fullscreen mode Exit fullscreen mode

Key Learnings

Building this game taught me several valuable lessons:

  1. CSS animations don’t restart automatically – you need to remove and re-add classes, forcing a reflow
  2. Simple state is better – direct prop calculations beat complex useState/useEffect coordination
  3. Animation timing is crucial – the 250ms pause between movements makes both animations visible
  4. Clean state between phases – clearing animation properties prevents conflicts
  5. Game jams force focus – limited time means prioritizing core mechanics over feature creep

Future Improvements

If I continue developing this, potential additions include:

  • Undo functionality – storing move history for one-step-back
  • Daily challenges – seeded random number generation for reproducible puzzles
  • Leaderboards – tracking and displaying high scores
  • Sound effects – bubble pops, merge sounds, and ambient water audio
  • Progressive difficulty – larger grids or modified bubble mechanics

Try It Yourself

Play the game : https://furic.github.io/bubble-2048/
Source code : https://github.com/furic/bubble-2048

The entire codebase is MIT licensed and well-documented. Check out the animation system deep dive in the docs folder if you’re interested in the technical details.


My best score is 3332, what’s yours?

Bubble 2048 - End game result

The post Building Bubble 2048: A Technical Deep Dive appeared first on Richard Fu.

Top comments (0)