← Journal
Design March 2026 · 6 min read

The feel of a tile: animating 2048 in Flutter

Spring physics, implicit animations, and why 60fps isn't enough if the timing feels wrong.

SS
Sahdeep Singh
Founder · Engineering

When you merge two 512 tiles in 2048, something should happen that makes you want to do it again immediately. That feeling — the one that makes you lean forward — is not the number 1024 appearing. It's the quarter-second of animation that gets you there.

We spent more time on tile animations than on any other single feature in 2048 Flutter. Here's what we learned.

The problem with linear slide

The original 2048 uses a simple CSS transition to slide tiles. It works. But when you play it next to a version with spring physics, the difference is obvious — the linear version feels like it's running on rails. The spring version feels like tiles have mass.

Flutter's AnimationController with a CurvedAnimation gets you most of the way there. We started with Curves.easeOutCubic for slides and it felt better than linear. But the merge — two tiles becoming one — still felt flat.

Spring physics for the merge

The merge animation needed to do two things: communicate collision, and then settle. That's a classic spring problem. We used Flutter's SpringSimulation:

final spring = SpringDescription(
  mass: 1,
  stiffness: 800,
  damping: 28,  // slightly underdamped
);

final controller = AnimationController.unbounded(vsync: this);
controller.animateWith(
  SpringSimulation(spring, 0, 1, 0)
);

The key parameter is damping. Too high (overdamped) and the tile just slides into place — no pop. Too low (underdamped) and it bounces like a rubber ball. We landed on 28 out of 800 stiffness, which gives one subtle overshoot before settling. Users described it as "satisfying" without being able to say why.

The glow: shadow timing matters

On the 2048 tile specifically, we wanted a golden glow. Easy enough with a BoxDecoration shadow. The mistake we made first was animating the shadow at the same rate as the scale. It looked technically correct but felt wrong — the glow appeared too late.

The glow should lead the scale slightly, not follow it. It signals "something is about to happen" rather than "something happened."

We solved this with staggered animation curves on the same controller:

final scale = CurvedAnimation(
  parent: controller,
  curve: const Interval(0.0, 1.0, curve: Curves.elasticOut),
);
final glow = CurvedAnimation(
  parent: controller,
  curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
);

The glow peaks at 70% of the animation duration and then fades as the tile settles to its final size. It now reads as: illuminate → pop → settle.

Haptics: the invisible animation

We added haptic feedback on merge and almost removed it. Light impact felt too subtle. Medium impact felt like a drum hit every time you swiped. The fix was to use light impact for every tile slide, and medium impact only when a merge produces a value ≥ 512:

if (newValue >= 512) {
  HapticFeedback.mediumImpact();
} else {
  HapticFeedback.lightImpact();
}

In user testing, people didn't consciously notice the haptics — which is exactly right. They noticed when we turned them off.

Performance: staying at 60fps

With 16 tiles potentially animating simultaneously on a swipe, we needed to be careful. The main win was using RepaintBoundary around the game board so tile repaints don't trigger a full-widget repaint. Beyond that, we kept our animation widgets as leaf nodes with no subtrees.

One thing that surprised us: the BoxShadow glow is cheap if the shadow radius is small. We kept it under 24px and saw no measurable frame drops on any device we tested.

The version on Play Store has had the same tile animation since launch. We've touched everything else. That's how you know it's right.

More from the lab
EngineeringMay 2026

Shipping 15 themes without shipping 15 bugs

Read →
StudioJan 2026

Why we build classics, not clones

Read →