Building Nova Drop · Part 1 of 6
Building a physics merge puzzle in pure Dart — no engine, no Box2D
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.
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, r²), 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
- Building a physics merge puzzle in pure Dart — no engine, no Box2D
- Making merges feel oddly satisfying: juice on one CustomPainter
- Beyond Suika: cosmic storms and a hand-rolled obstacle system
- An infinite Odyssey: a star map, 200 levels and a hand-painted creation myth
- Monetizing a relaxing game without wrecking the vibe: AdMob & IAP
- 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.
Where to find Nova Drop
- Official site: novadrop.nurale.games
- iOS: App Store · Android: Google Play