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
-
Forgiving hit detection matters. The 20% larger tap area was the single biggest improvement for playability with young children.
-
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.
-
Flutter can handle it. Even with dozens of balloons and particle effects,
CustomPaintermaintains 60 FPS easily. Flutter isn’t just for business apps. -
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!