Building Nova Drop · Part 1 of 6

Building a physics merge puzzle in pure Dart — no engine, no Box2D

Luca Panteghini · Nurale

TL;DR — Nova Drop is a Suika-style merge puzzle, and the whole feel of the genre is the physics. I wrote the engine by hand: a specialized 2D solver that only ever simulates circles inside a box. Semi-implicit Euler integration, impulse-based collision resolution with positional correction, a fixed 120 Hz timestep, and — the one twist a generic engine won't give you — merge detection folded into the same collision pass. No Flame, no Forge2D/Box2D. About 600 lines of Dart, zero game-engine dependencies.

When you drop two Comets and they touch, they fuse into a Moon and shove their neighbours outward in a little burst of chaos. That single interaction is the entire game, and it has to feel right — weighty, a bit springy, slightly unpredictable. A general-purpose physics engine can simulate the collisions, but the merge isn't a physics primitive, and bolting it on top of someone else's solver means fighting their broadphase and their contact callbacks. So I didn't. The simulation is small and the rules are narrow, which means I can specialize hard.

Nova Drop's well filled with glowing cosmic bodies of different sizes resting in a stack, mid-game.
The entire world: circles resting in a box. A few dozen bodies, one shape, one container — which is exactly why a general-purpose engine is overkill here.

Why not Forge2D (or Flame)?

Box2D — and its Dart port, Forge2D — is built for arbitrary rigid bodies: polygons, joints, friction, continuous collision detection. Nova Drop needs exactly one shape (a circle), one container (the well), gravity, and a bespoke rule that two equal circles annihilate into a bigger one. Ninety percent of a general engine would be dead weight, and the 10% I need (the merge) would live awkwardly outside it.

Writing it myself also buys two things that matter on mobile: full control of the timestep (essential for a stable stack of bodies) and resolution independence. Every dimension in the engine is a fraction of the board, so the same simulation runs identically on a small phone and a tablet. Here's the entire dependency story for the game's logic — there isn't an engine in it:

dependencies:
  shared_preferences: ^2.5.5   # saves & settings
  google_mobile_ads: ^9.0.0    # AdMob
  in_app_purchase: ^3.3.0      # remove-ads IAP
  audioplayers: ^6.7.1         # SFX
  share_plus: ^13.1.0          # share your Supernova
  # …no flame, no forge2d, no box2d.

The world: circles in a box

A body is deliberately tiny — just enough state to integrate it and animate its little face. Mass is derived from the radius (area-proportional, ), so bigger tiers throw their weight around exactly as you'd expect:

class CelestialBody {
  final int id;
  int tier;
  Offset pos;
  Offset vel;
  double radius;
  double spawnScale;    // 0..1 birth animation
  double mergeCooldown; // blocks double-merges in the same instant
  double wobble;        // phase for the face micro-animation

  double get mass => radius * radius;
}

The world holds the bodies and a couple of output buffers the game loop reads after each step — merges that happened, impacts to turn into sound. Gravity and every radius are expressed relative to the board, so nothing is hard-coded in pixels:

double get _gravity => GameConstants.gravityFactor * board.height; // 2.4·h
double radiusForTier(int tier) =>
    GameConstants.tiers[tier].radiusFactor * board.width;

The ten tiers — Stardust, Asteroid, Comet, Moon, Planet, Ringed World, Gas Giant, Star, Red Giant, Neutron Star — are pure data: a name, a radius as a fraction of board width, and the Nova energy a merge into them grants.

static const List<TierDef> tiers = [
  TierDef('Stardust',     0.042, 2),
  TierDef('Asteroid',     0.056, 3),
  TierDef('Comet',        0.073, 4),
  // … up to …
  TierDef('Neutron Star', 0.276, 40),
];

One step: integrate, then resolve

Each step does two things in order. First it integrates every body with semi-implicit (symplectic) Euler — update velocity from acceleration, then move the body by the new velocity. That ordering is the cheap trick that keeps a tower of stacked bodies from buzzing apart; fully explicit Euler injects energy and the stack explodes.

for (final b in bodies) {
  var ax = wind;          // ambient forces (see Part 3)
  var ay = gravity;
  // …optional gravity-well term added to ax/ay here…
  b.vel = Offset(b.vel.dx + ax * dt, b.vel.dy + ay * dt); // velocity first
  b.vel *= GameConstants.airDamping;                      // 0.998 — a whisper of drag
  b.pos += b.vel * dt;                                     // then position
  if (b.spawnScale < 1) b.spawnScale = math.min(1, b.spawnScale + dt * 6);
  if (b.mergeCooldown > 0) b.mergeCooldown = math.max(0, b.mergeCooldown - dt);
}

Then it resolves constraints. Instead of a single pass, it runs the solver eight times per step — walls, then obstacles, then body-vs-body pairs. Iterating a cheap solver is the classic way to get a stiff-looking stack without a stiff (and unstable) solver. Merge detection only runs on the first iteration, so a pair can't be "discovered" twice while the later iterations are still nudging bodies apart:

for (var iter = 0; iter < GameConstants.solverIterations; iter++) { // 8
  _resolveWalls();
  if (obstacles.isNotEmpty) _resolveObstacles();
  _resolvePairs(detectMerges: iter == 0);
}

Walls are the simplest constraint: clamp the body inside the box and reflect the velocity with a low restitution (0.08 — the bodies are heavy and barely bouncy), plus a touch of floor friction so things settle instead of sliding forever:

if (b.pos.dy + b.radius > board.height) {        // floor
  b.pos = Offset(b.pos.dx, board.height - b.radius);
  if (b.vel.dy > 0) {
    _recordImpact(b.vel.dy);                      // becomes a "thud" — Part 2
    b.vel = Offset(b.vel.dx * 0.92, -b.vel.dy * GameConstants.restitution);
  }
}

Body vs body: contacts and merges in one pass

A naïve all-pairs check is O(n²), and on a full board (dozens of bodies × eight solver iterations × 120 steps a second) that adds up. So the pair loop runs a one-axis sweep-and-prune broadphase: sort the bodies by their left edge (pos.dx - radius) and, in the inner loop, bail the moment a candidate's left edge passes the current body's right edge — nothing further right can possibly touch it. That collapses the common case from O(n²) toward ~O(n), and the sort indices live in a reused buffer so the broadphase allocates nothing per step:

final order = _order..clear();              // reused index buffer
for (var i = 0; i < n; i++) order.add(i);
order.sort((x, y) => (bodies[x].pos.dx - bodies[x].radius)
    .compareTo(bodies[y].pos.dx - bodies[y].radius));

for (var ii = 0; ii < n; ii++) {
  final a = bodies[order[ii]];
  for (var jj = ii + 1; jj < n; jj++) {
    final b = bodies[order[jj]];
    if (b.pos.dx - b.radius > a.pos.dx + a.radius) break;  // prune the rest
    // …narrow-phase below…
  }
}

For each surviving pair there are two outcomes: if they're the same tier and both off cooldown, they're marked to merge; otherwise they get a normal contact response.

final delta = b.pos - a.pos;
final distSq = delta.distanceSquared;
final minDist = a.radius + b.radius;
if (distSq >= minDist * minDist || distSq == 0) continue; // not touching

final dist = math.sqrt(distSq);
final normal = delta / dist;
final penetration = minDist - dist;

if (detectMerges && a.tier == b.tier &&
    a.tier <= GameConstants.maxTier &&
    a.mergeCooldown == 0 && b.mergeCooldown == 0) {
  toMerge.add((a, b));
  a.mergeCooldown = 0.05; b.mergeCooldown = 0.05; // lock both this step
  continue;
}

A non-merging contact gets the standard two-part fix: positional correction (push the bodies apart, split by mass so the heavier one barely moves) and a normal impulse (so they actually bounce). The impulse is the textbook formula, scaled by inverse masses:

// positional correction — 60% of penetration, mass-weighted
final totalMass = a.mass + b.mass;
final correction = normal * (penetration * 0.6);
a.pos -= correction * (b.mass / totalMass);
b.pos += correction * (a.mass / totalMass);

// normal impulse (only if they're approaching)
final relVel = b.vel - a.vel;
final velAlongNormal = relVel.dx * normal.dx + relVel.dy * normal.dy;
if (velAlongNormal < 0) {
  final e = GameConstants.restitution;
  final j = -(1 + e) * velAlongNormal / (1 / a.mass + 1 / b.mass);
  final impulse = normal * j;
  a.vel -= impulse / a.mass;
  b.vel += impulse / b.mass;
}

One small but important detail: the list of pairs to merge is collected into a reused buffer and applied after the loop, never mid-iteration. Mutating bodies while scanning it would invalidate the indices; deferring also means the buffer allocates once for the lifetime of the world, not once per solver iteration.

The merge — and the "merge pop"

When two bodies merge, the new one is born at their mass-weighted midpoint (so it appears where the heavier contributor was), inherits the average velocity, and gets a short cooldown so it can't instantly merge again. Then comes the signature move of the genre: a radial shockwave that shoves the neighbours away. That little burst of chaos is the whole risk/reward of Suika-likes — a merge can set off a chain, or it can knock a body over your danger line.

void _merge(CelestialBody a, CelestialBody b) {
  final mid = Offset(
    (a.pos.dx * a.mass + b.pos.dx * b.mass) / (a.mass + b.mass),
    (a.pos.dy * a.mass + b.pos.dy * b.mass) / (a.mass + b.mass),
  );
  bodies.remove(a); bodies.remove(b);

  if (a.tier >= GameConstants.maxTier) {            // two Neutron Stars…
    pendingMerges.add(MergeEvent(mid, tiers.length, isSupernova: true));
    _shockwave(mid, supernovaShockRadius, supernovaShockImpulse); // …SUPERNOVA
    return;
  }

  final body = spawn(a.tier + 1, mid, vel: (a.vel + b.vel) / 2);
  body.mergeCooldown = 0.04;
  pendingMerges.add(MergeEvent(mid, a.tier + 1));
  _shockwave(mid, body.radius * 2.6, mergePopImpulse * board.width); // the pop
}

The shockwave itself is four lines — a linear falloff from the centre, applied as an instantaneous velocity change to anything inside the radius:

void _shockwave(Offset center, double radius, double strength) {
  for (final b in bodies) {
    final delta = b.pos - center;
    final dist = delta.distance;
    if (dist >= radius || dist == 0) continue;
    b.vel += (delta / dist) * strength * (1 - dist / radius);
  }
}

The world never decides what a merge means — it just reports MergeEvents in a buffer. Scoring, chains, particles and sound all happen one layer up, in the game controller, which is the subject of Part 2.

Determinism: a fixed timestep the frame can't corrupt

Physics must not depend on frame rate. If the simulation advanced by the real frame delta, a 120 Hz phone and a janky 40 fps frame would behave differently, and a long stall could tunnel a fast body straight through the floor. So the controller accumulates real time and feeds the world in fixed 1/120 s slices, with a safety cap so a huge stall can't trigger a death spiral of catch-up steps:

void _stepPhysics(double dt) {
  _accumulator += dt;
  final step = 1 / GameConstants.physicsHz;   // 1/120 s
  var safety = 0;
  while (_accumulator >= step && safety < 8) {
    world.step(step);
    for (final m in world.pendingMerges) _onMerge(m); // score, juice, sound
    _onImpacts(world.pendingImpacts);
    _accumulator -= step;
    safety++;
  }
}

120 Hz (not 60) is a deliberate choice: at the speeds a merge-pop can fling a small body, 60 Hz steps were just coarse enough to occasionally let one clip a thin wall. Doubling the rate and keeping the per-step work tiny fixed it without a continuous-collision system.

The takeaway

If your "physics" is really one shape obeying a few narrow rules, a general engine is the expensive option, not the cheap one. A few hundred lines of Dart — symplectic integration, an iterated impulse solver, positional correction, and your domain rule (here, the merge) living inside the collision pass — gets you a simulation you fully understand and can tune to the millimetre. Next up: how that bone-dry simulation is dressed in enough particles, shake and sound to feel oddly satisfying, all on a single CustomPainter.

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.