Building a Physics-Based Balloon Popping Game in Flutter

Happy Balloon Pop started as a weekend project for my kids. They wanted a game where they could pop colorful balloons on a tablet. What began as a simple tap-the-balloon exercise turned into a full physics simulation with floating animations, particle effects, and a progressive difficulty system. Here’s how I built it in Flutter.

Game Design: Keeping It Simple and Fun

The core gameplay loop is straightforward: balloons float up from the bottom of the screen, and the player taps them to pop them before they escape off the top. Each popped balloon earns points, and the game gets progressively harder as the player’s score increases.

Since this is a game aimed at young children, I had a few design constraints:

  • Bright, cheerful colors — no dark themes or scary elements
  • Forgiving tap targets — balloons should be easy to hit
  • Positive feedback — every pop should feel satisfying
  • No failure state — missed balloons just float away; no “game over” penalty that frustrates kids

Balloon Data Model

Each balloon tracks its position, velocity, size, color, and animation state:

class Balloon {
  Offset position;
  double radius;
  Color color;
  double velocityY;
  double swayPhase;
  double swayAmplitude;
  bool isPopped;
  double opacity;

  Balloon({
    required this.position,
    required this.color,
    this.radius = 40.0,
    this.velocityY = -2.0,
    this.swayAmplitude = 20.0,
    this.isPopped = false,
    this.opacity = 1.0,
  }) : swayPhase = Random().nextDouble() * 2 * pi;
}

The swayPhase is randomized so that balloons don’t all sway in sync — it looks much more natural when each balloon has its own rhythm.

Balloon Movement Physics

Balloons don’t just move straight up — they float with a gentle side-to-side sway, like they’re caught in a breeze. I achieve this by combining a constant upward velocity with a sinusoidal horizontal offset:

class BalloonPhysics {
  static void updateBalloon(Balloon balloon, double dt, double elapsed) {
    if (balloon.isPopped) return;

    // Upward float
    balloon.position = Offset(
      balloon.position.dx +
          sin(elapsed * 1.5 + balloon.swayPhase) * balloon.swayAmplitude * dt,
      balloon.position.dy + balloon.velocityY * dt * 60,
    );
  }
}

The sin function creates the swaying motion. By multiplying elapsed time with a frequency factor and adding the balloon’s unique phase offset, each balloon sways independently. The amplitude controls how far left and right the balloon drifts.

I also add slight variation to the upward velocity for each balloon, so they don’t all rise at exactly the same speed:

Balloon spawnBalloon(double screenWidth, double screenHeight) {
  final random = Random();
  return Balloon(
    position: Offset(
      random.nextDouble() * (screenWidth - 80) + 40,
      screenHeight + 50, // Start below screen
    ),
    color: _balloonColors[random.nextInt(_balloonColors.length)],
    radius: 35.0 + random.nextDouble() * 15.0,
    velocityY: -(1.5 + random.nextDouble() * 1.5),
    swayAmplitude: 15.0 + random.nextDouble() * 15.0,
  );
}

final _balloonColors = [
  const Color(0xFFFF6B6B), // Red
  const Color(0xFF4ECDC4), // Teal
  const Color(0xFFFFE66D), // Yellow
  const Color(0xFF95E1D3), // Mint
  const Color(0xFFFF8A5C), // Orange
  const Color(0xFFAA96DA), // Purple
  const Color(0xFFFF70A6), // Pink
  const Color(0xFF70D6FF), // Sky blue
];

The Game Loop

I drive the simulation with Flutter’s Ticker, which fires on every frame. This is the game’s heartbeat:

class _BalloonGameState extends State<BalloonGame>
    with SingleTickerProviderStateMixin {
  late Ticker _ticker;
  final List<Balloon> _balloons = [];
  int _score = 0;
  double _elapsed = 0;
  double _spawnTimer = 0;
  double _spawnInterval = 1.5; // seconds between spawns

  @override
  void initState() {
    super.initState();
    _ticker = createTicker(_onTick);
    _ticker.start();
  }

  void _onTick(Duration duration) {
    final dt = duration.inMicroseconds / Duration.microsecondsPerSecond;
    _elapsed = dt;

    // Update spawn timer
    _spawnTimer += 1 / 60;
    if (_spawnTimer >= _spawnInterval) {
      _spawnTimer = 0;
      _spawnBalloon();
    }

    // Update all balloons
    for (final balloon in _balloons) {
      BalloonPhysics.updateBalloon(balloon, 1 / 60, _elapsed);
    }

    // Remove off-screen balloons
    _balloons.removeWhere(
      (b) => b.position.dy < -100 || (b.isPopped && b.opacity <= 0),
    );

    setState(() {});
  }
}

Tap Detection and Pop Animation

When the player taps the screen, I check which balloon (if any) was hit. I iterate in reverse order so that balloons rendered on top (most recently spawned) get priority:

void _onTapDown(TapDownDetails details) {
  final tapPos = details.localPosition;

  for (int i = _balloons.length - 1; i >= 0; i--) {
    final balloon = _balloons[i];
    if (balloon.isPopped) continue;

    final distance = (tapPos - balloon.position).distance;
    if (distance <= balloon.radius * 1.2) { // Slightly forgiving hit area
      _popBalloon(balloon);
      break; // Only pop one balloon per tap
    }
  }
}

Notice the * 1.2 on the radius check — this makes the hit area 20% larger than the visual balloon, which is crucial for a kid-friendly game. Small fingers on a touchscreen need generous tap targets.

The pop animation is a quick scale-up followed by a fade-out, driven by a short AnimationController:

void _popBalloon(Balloon balloon) {
  balloon.isPopped = true;
  _score++;

  // Spawn particle effects at balloon position
  _spawnPopParticles(balloon.position, balloon.color);

  // Play pop sound
  _audioPlayer.play(AssetSource('sounds/pop.mp3'));

  // Haptic feedback
  HapticFeedback.lightImpact();

  // Update difficulty
  _adjustDifficulty();
}

Particle Effects on Pop

When a balloon pops, I spawn a burst of small colored particles that fly outward and fade. This is what makes popping feel satisfying:

class PopParticle {
  Offset position;
  Offset velocity;
  double radius;
  Color color;
  double lifetime;
  double maxLifetime;

  PopParticle({
    required this.position,
    required this.velocity,
    required this.color,
    this.radius = 4.0,
    this.maxLifetime = 0.6,
  }) : lifetime = 0;
}

void _spawnPopParticles(Offset center, Color color) {
  final random = Random();
  for (int i = 0; i < 12; i++) {
    final angle = (i / 12) * 2 * pi + random.nextDouble() * 0.3;
    final speed = 150.0 + random.nextDouble() * 100.0;
    _particles.add(PopParticle(
      position: center,
      velocity: Offset(cos(angle) * speed, sin(angle) * speed),
      color: color.withValues(alpha: 0.8),
    ));
  }
}

void _updateParticles(double dt) {
  for (final p in _particles) {
    p.lifetime += dt;
    p.position += p.velocity * dt;
    p.velocity = Offset(p.velocity.dx, p.velocity.dy + 300 * dt); // Gravity
  }
  _particles.removeWhere((p) => p.lifetime >= p.maxLifetime);
}

The particles fly outward in a circle, then curve downward due to simulated gravity. Combined with the fade-out, this creates a confetti-like burst that kids love.

Rendering Everything

I render the entire game scene with a single CustomPainter for performance:

class GamePainter extends CustomPainter {
  final List<Balloon> balloons;
  final List<PopParticle> particles;

  GamePainter(this.balloons, this.particles);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();

    // Draw balloons
    for (final balloon in balloons) {
      if (balloon.isPopped) continue;
      paint.color = balloon.color;
      canvas.drawOval(
        Rect.fromCenter(
          center: balloon.position,
          width: balloon.radius * 2,
          height: balloon.radius * 2.4, // Slightly taller than wide
        ),
        paint,
      );
      // Draw balloon string
      _drawString(canvas, balloon);
    }

    // Draw particles
    for (final particle in particles) {
      final progress = particle.lifetime / particle.maxLifetime;
      paint.color = particle.color.withValues(alpha: 1.0 - progress);
      canvas.drawCircle(
        particle.position,
        particle.radius * (1.0 - progress * 0.5),
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Drawing balloons as ovals (slightly taller than wide) gives them a more realistic balloon shape than perfect circles. The tiny string dangling from the bottom completes the look.

Progressive Difficulty System

The game gradually gets harder as the player’s score increases. I adjust three parameters:

void _adjustDifficulty() {
  // More frequent spawns
  _spawnInterval = max(0.4, 1.5 - (_score * 0.02));

  // Faster rising
  _baseVelocity = -(1.5 + _score * 0.03);

  // Occasional smaller balloons at higher scores
  if (_score > 20) {
    _minRadius = 25.0;
  }
}

The difficulty curve is gentle — a child can play for a while before things get challenging. The maximum difficulty caps at reasonable values so it never becomes impossible.

Sound Effects Integration

Sound is critical for a satisfying pop. I use the audioplayers package with pre-loaded sound assets:

final _popPlayer = AudioPlayer();

@override
void initState() {
  super.initState();
  // Pre-load for zero latency on first pop
  _popPlayer.setSource(AssetSource('sounds/pop.mp3'));
}

I created several pop sound variations and randomly select one for each pop to avoid repetitiveness. The slight variation keeps the audio from becoming annoying during extended play sessions.

Making It Child-Friendly

Beyond the gameplay, several design decisions make Happy Balloon Pop suitable for young children:

  • No text-heavy UI — large, colorful buttons with icons instead of labels
  • No ads during gameplay — ads only appear between rounds, and they’re rewarded ads that the child can skip
  • No in-app purchases — the full game is free
  • Auto-pause on app switch — if a child accidentally leaves the app, the game pauses
  • Volume control — parents can mute sounds without leaving the game

Lessons Learned

  1. Forgiving hit detection matters. The 20% larger tap area was the single biggest improvement for playability with young children.

  2. Juice makes the game. The particle burst, haptic feedback, and sound together transform a simple tap into a rewarding moment. Without these, the game felt flat and boring.

  3. Flutter can handle it. Even with dozens of balloons and particle effects, CustomPainter maintains 60 FPS easily. Flutter isn’t just for business apps.

  4. Test with real kids. My children were my QA team. They found usability issues I never would have noticed — like balloons being too small to tap with chubby fingers.

Happy Balloon Pop is available on the App Store and Google Play. It’s free and designed for kids of all ages. I hope your little ones enjoy it as much as mine do!