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
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:
- Player Move : Swipe in any direction (up, down, left, or right) – tiles slide and merge as expected
- Bubble Shift : After your move completes, ALL tiles automatically shift upward by one row (like bubbles rising in water), with another merge opportunity
- 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
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 };
}
The algorithm:
- Iterate through tiles in traversal order
- For each tile, find the farthest position it can move to
- Check if the destination contains a tile with the same value
- If yes and it hasn’t merged yet → merge them (create new tile with doubled value)
- If no → just move the tile
- 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);
}
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);
}
}
}
}
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) * ...));
}
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(...);
}
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
)
);
}
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]);
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;
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:
- Keyboard : Arrow keys and WASD (case-insensitive)
- Touch : Swipe gestures with 30px minimum threshold
- 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';
}
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);
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()],
});
Then build and deploy:
npm run build
# Push dist/ folder to gh-pages branch
Key Learnings
Building this game taught me several valuable lessons:
- CSS animations don’t restart automatically – you need to remove and re-add classes, forcing a reflow
- Simple state is better – direct prop calculations beat complex useState/useEffect coordination
- Animation timing is crucial – the 250ms pause between movements makes both animations visible
- Clean state between phases – clearing animation properties prevents conflicts
- 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?

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

Top comments (0)