Building Nova Drop · Part 2 of 6

Making merges feel oddly satisfying: juice on one CustomPainter

Luca Panteghini · Nurale

TL;DR — Part 1 left us with a correct but lifeless physics solver. This post is the "juice": a lightweight particle system (bursts, imploding rings, floating score), screen shake and a white flash, reactive audio whose volume tracks impact speed, and an escalation curve — chains → Fever → the screen-clearing Supernova — where each merge dials everything up. It's all driven by one ChangeNotifier game loop and drawn by a single CustomPainter.

"Oddly satisfying" is the entire pitch of a merge puzzle, and it's almost all game feel. The physics decides where things go; the juice decides whether landing a four-chain feels like a firework or a spreadsheet update. None of it touches the simulation from Part 1 — the world just emits MergeEvents and impact intensities, and a layer on top turns those into light, motion, haptics and sound.

Nova Drop in Fever mode: a dark violet well full of glowing planets and moons, a bright chain multiplier and burst particles, the screen tinted with energy.
A chain in flight: burst particles, an expanding ring and a floating +points ×multiplier, scaled up by the current chain length.

One loop, one painter

The architecture is intentionally flat. GameController is a ChangeNotifier that owns all the state — the physics world, the particle system, score, chain, and a handful of decaying "effect" scalars. The game screen drives it with a Ticker (one update(dt) per frame), and a single CustomPainter reads the controller and the particle lists and paints the whole scene: bodies, particles, rings, floating text, the danger line. No widget per body, no animation controllers — just immutable-ish state mutated each tick and repainted.

class GameController extends ChangeNotifier {
  PhysicsWorld? world;
  final ParticleSystem particles = ParticleSystem();
  double shake = 0;   // screen-shake intensity, read by the painter
  double flash = 0;   // white flash (Supernova / Big Bang)
  // …score, chain, fever…

  void update(double dt) {
    dt = math.min(dt, 1 / 20);   // clamp: no giant jumps after a stall
    _stepPhysics(dt);            // Part 1 — emits merges & impacts
    particles.update(dt);
    shake = math.max(0, shake - dt * 18);    // ease back to rest
    flash = math.max(0, flash - dt * 2.2);
    notifyListeners();           // one repaint per frame
  }
}

The "effects" — shake and flash — are just numbers that get spiked by an event and decay every frame. The painter offsets the canvas by a shake-scaled random jitter and overlays a white rectangle at flash opacity. Cheap, and because they're plain scalars they compose: two merges in quick succession stack their shake.

The particle system: flat lists, no per-frame allocation

The particle system is about 180 lines and holds three flat lists — particles, rings, floating texts. Updating them is a reverse loop that removes the dead ones in place; the only allocation is at emission time. Particles get a soft drag and a gentle gravity so a burst arcs nicely instead of flying in straight lines:

void update(double dt) {
  for (var i = particles.length - 1; i >= 0; i--) {
    final p = particles[i];
    p.life -= dt;
    if (p.life <= 0) { particles.removeAt(i); continue; }
    p.vel *= 0.96;                  // drag
    p.vel += Offset(0, 220 * dt);   // a little gravity
    p.pos += p.vel * dt;
  }
  // rings expand on an ease-out curve; floating text drifts up and fades
}

Rings expand on an ease-out cubic so they snap out fast and settle — that curve is what reads as "impact" rather than "growing circle." It's hand-rolled so the particle system stays independent of Flutter's animation library:

static double easeOutCubic(double t) { final i = 1 - t; return 1 - i * i * i; }
// per ring, each frame:
r.radius = r.maxRadius * easeOutCubic(1 - r.life / r.maxLife);

There are two emitters worth calling out. burst is the obvious one — N particles fired in random directions with jittered speed, size and lifetime so no two bursts look stamped. Its mirror image, implode, spawns particles on an outer ring and aims them inward, with velocity tuned to arrive at the centre exactly as they die — the visual grammar of a Nova or a black hole sucking everything in:

void implode(Offset center, Color color, {int count = 16, double radius = 160, ...}) {
  for (var i = 0; i < count; i++) {
    final angle = _rng.nextDouble() * 2 * math.pi;
    final pos = center + Offset(math.cos(angle), math.sin(angle)) * radius * (0.6 + _rng.nextDouble() * 0.4);
    final life = /* jittered */;
    final vel = (center - pos) / life;   // arrive at the centre as it dies
    particles.add(Particle(pos: pos, vel: vel, maxLife: life, color: color, glow: true));
  }
}

The escalation curve: every merge dials it up

The core feedback loop lives in _onMerge, and its guiding rule is simple: the longer your chain, the more of everything you get — more particles, faster particles, a bigger ring, a louder pop, heavier haptics, more shake. A chain is just merges landed inside a 1.4 s rolling window:

void _onMerge(MergeEvent e) {
  chain += 1;
  _chainTimer = GameConstants.chainWindowSeconds;   // 1.4 s window resets
  final multiplier = chain * (fever ? 2 : 1);
  score += (GameConstants.mergeScore[e.resultTier] * multiplier * stormMult).round();

  final color = palette.tierColors[e.resultTier];
  particles.burst(e.position, color, count: 10 + chain * 3, speed: 200 + chain * 40, size: 4 + e.resultTier.toDouble());
  particles.ring(e.position, color, maxRadius: radius * 2.2, width: 3 + chain.toDouble());
  particles.floatText(e.position - Offset(0, radius),
      multiplier > 1 ? '+$points  x$multiplier' : '+$points',
      multiplier > 1 ? palette.accent : Colors.white);

  AudioService.instance.playChainPop(chain - 1);    // pitch climbs with the chain
  if (chain >= 4) Haptics.heavy(); else if (e.resultTier >= 5) Haptics.medium(); else Haptics.light();
  shake = math.min(1.0, (0.08 + chain * 0.06 + e.resultTier * 0.02) * difficulty.juiceScale);
}

Three details do a lot of heavy lifting:

One more multiplier sits on top of all of it: the chosen difficulty has a juiceScale (0.85 on Easy up to 1.5 on Inferno) that the shake — and other effects — are multiplied by. The harder you play, the harder the game physically hits back.

Fever

Hit a five-chain and the game tips into Fever: a six-second window where every merge scores double. It announces itself with big floating text and a full device vibrate, then quietly counts itself down in update:

if (chain >= GameConstants.feverChain && !fever) {   // feverChain = 5
  fever = true; _feverTimer = GameConstants.feverSeconds; // 6 s
  particles.floatText(center, 'FEVER! x2', palette.accent, scale: 2.0, life: 1.4);
  Haptics.vibrate();
}

The Supernova: the trophy moment

Fuse two Neutron Stars — the top tier — and the game spends its entire effects budget at once. The clearRadius from Part 1 wipes ~90% of the field; then comes a long whiteout, a violent shake, three concentric rings and well over a hundred particles. It is meant to feel like the end of a world, and it's the moment the game nudges you to share:

void _onSupernova(Offset center) {
  final cleared = world.clearRadius(center, world.board.width * 0.9);  // ~90%
  flash = 1.8;   // long whiteout
  shake = 3 * difficulty.juiceScale;   // violent (and scaled by difficulty)
  particles.ring(center, Colors.white, maxRadius: w.width * 1.5, width: 20);
  particles.ring(center, palette.accent, maxRadius: w.width * 1.2, width: 12);
  particles.ring(center, Colors.white, maxRadius: w.width * 0.7, width: 8);
  particles.burst(center, Colors.white, count: 120, speed: 900, size: 8, life: 1.4);
  particles.burst(center, palette.accent, count: 60, speed: 500);
  particles.floatText(center, 'SUPERNOVA! +$bonus', Colors.white, scale: 2.2, life: 2.0);
  AudioService.instance.play(Sfx.supernova);
  Haptics.vibrate();
}

Because flash and shake are just decaying scalars, "spend everything" is literally setting them to big numbers; they ease back to zero on their own over the next second.

Reactive audio: the board plays itself

Static "tap" sounds get grating fast in a physics game. Instead, the world records every meaningful impact (a landing, a hard body-to-body hit) as a normalized 0..1 intensity derived from the normal velocity, between a floor and a ceiling threshold. The controller plays only the loudest impact of each step, throttled, so a busy frame makes one satisfying thud instead of a wall of noise:

void _onImpacts(List<double> impacts) {
  if (impacts.isEmpty || _impactCooldown > 0) return;
  var strongest = 0.0;
  for (final i in impacts) if (i > strongest) strongest = i;
  _impactCooldown = GameConstants.impactSoundCooldown;   // 70 ms
  AudioService.instance.play(Sfx.thud, volume: 0.25 + 0.75 * strongest);
}

A gentle settle is a whisper; a body slamming down after a merge-pop is a thump. The volume is the physics — and all the SFX are tiny procedurally-generated WAVs, so this costs nothing in app size.

The takeaway

Game feel doesn't need an engine or a timeline editor. A flat particle system with no per-frame allocation, a couple of decaying scalars for shake and flash, audio whose pitch and volume are functions of game state, and one rule — every merge dials everything up — turn a correct simulation into something you reach for one more time. And it all repaints from a single CustomPainter, which keeps the frame budget boringly predictable. Next: how the same engine grows special moves, environmental forces and cosmic storms.

The series

  1. Building a physics merge puzzle in pure Dart — no engine, no Box2D
  2. Making merges feel oddly satisfying: juice on one CustomPainter
  3. Beyond Suika: cosmic storms and a hand-rolled obstacle system
  4. An infinite Odyssey: a star map, 200 levels and a hand-painted creation myth
  5. Monetizing a relaxing game without wrecking the vibe: AdMob & IAP
  6. Shipping Nova Drop in 18 languages with Flutter

Nova Drop is a relaxing cosmic merge puzzle — drop cosmic bodies, chain reactions and chase the Supernova. Out now, free on iOS and Android.