Building Nova Drop · Part 3 of 6

Beyond Suika: cosmic storms and a hand-rolled obstacle system

Luca Panteghini · Nurale

TL;DR — A merge puzzle gets old if all you do is drop. Nova Drop adds three things on top of the Part 1 engine: an obstacle system where everything (rocks, bumpers, pistons, blades, walls, gates, platforms, conveyor belts) is one capsule collider; a storm director — a pure, testable class with no Flutter or physics dependency — that escalates Endless mode in deterministic waves; and a set of unlockable special moves that are really just physics "verbs" sharing one energy pool. The engine stayed small; the verbs and modifiers grew around it. (The moves themselves are a gameplay surprise — I'll show the pattern with one and keep the rest sealed.)

The trick to extending a tiny engine without it metastasizing is to keep adding data and verbs, not new systems. A special move is a function that pokes velocities. An obstacle is one collider shape reused eight ways. A storm is a list of effects chosen by a deterministic rule. None of them know about the others, and the solver from Part 1 never had to change.

Animated clip of Nova Drop's environmental forces shoving cosmic bodies around the well.
Environmental forces in motion — wind, low gravity, a gravity well. Each is a few lines added to the integration step.

Special moves are physics verbs

There are five special moves, but I'm going to keep most of them a secret — they're a reward you unlock by climbing the Odyssey (Part 4), and half their charm is not knowing what the next one does. What's worth sharing is the pattern, because it's the cleanest part of the design: a move is just a player-triggered physics verb. Each one spends from a single shared credits pool that fills as you merge (every tier grants some energy) and tops up via a rewarded ad or IAP; the most expensive move defines the cap — energyMax = 1200 — so the pool doubles as a charge bar for everything. The moves are gated behind milestone levels (10, 20, 30, 40, 50), so even the in-game buttons stay locked until you earn them.

Mechanically, each move is a method on the physics world that changes velocities or removes bodies — nothing more exotic than that. Take the first one you unlock, NOVA: an implosion: an instant inward kick, then a vortex that keeps pulling (with a tangential swirl, which is what makes it read as "something being unleashed") for half a second:

void _applyVortex(double dt) {           // runs each step while the timer > 0
  for (final b in bodies) {
    final delta = _novaCenter - b.pos;
    final dist = delta.distance;
    if (dist >= radius || dist == 0) continue;
    final dir = delta / dist;
    final tangent = Offset(-dir.dy, dir.dx);         // perpendicular = swirl
    b.vel += (dir * pull + tangent * swirl) * (1 - dist / radius) * dt;
  }
}

The other four are variations on that same idea — each one a small function that adds a force, sorts the field, attracts bodies or removes them — and that's all I'll say about what they do. Some are "targeted": they enter an aiming phase that slows time to a quarter speed so you can place them where you want:

final scaledDt = phase == GamePhase.aiming
    ? dt * GameConstants.novaSlowmoScale   // 0.25 — bullet-time to aim
    : dt;

Because a move is just "spend credits, then call a world method," adding a sixth would be a constants entry plus one function. The hard part of a special move is never the physics — it's the juice (Part 2) and the balancing.

One collider to model everything: the capsule

The solver only knows circles, so I never added a second collider type. Instead, every obstacle is a capsule — a line segment with a radius — and a plain circular collider is just the degenerate case where the segment has zero length (segA == segB). That one primitive covers the whole catalogue:

enum ObstacleType { rock, bumper, pole, blade, wall, gate, platform, belt }

Collision is "closest point on the segment, then treat it like a circle." The elegant payoff is that kinematic obstacles — a rising piston, a swinging blade, a sliding platform — need almost no special handling: you resolve the contact in the obstacle's frame of reference by subtracting its surface velocity, and the body automatically picks the motion back up:

final q = _closestOnSegment(b.pos, o.segA, o.segB);
final delta = b.pos - q;
if (delta.distanceSquared >= (b.radius + o.radius)²) continue;   // no contact
final normal = delta / delta.distance;
b.pos += normal * (minDist - dist);                 // depenetrate

final vs = o.isDynamic ? o.surfaceVelAt(q) : Offset.zero;   // moving surface
final vn = (b.vel - vs).dot(normal);
if (vn < 0) {
  b.vel -= normal * ((1 + o.restitution) * vn);     // bounce in the obstacle's frame
  if (o.isBumper) b.vel += normal * boost;          // flipper kick
}

A few constants per type give them character: a rock damps (restitution 0.25), a bumper is springy (0.85) and adds a boost. Two of them — platforms and conveyor belts — also apply tangential friction via a grip term, which is what lets a belt physically carry bodies sideways. Gates are the fussiest: a kinematic door whose opening follows a smoothstep profile so it closes gently and never crushes or tunnels a body through:

double _smooth(double s) => s * s * (3 - 2 * s);   // smoothstep
// gate coverage cycles: closed → ease open → open → ease closed

The storm director: a pure, testable escalation engine

Endless mode (Cosmo Infinito) would be monotonous if it never changed. So it has Cosmic Storms: timed waves that switch on environmental forces and obstacles, with a score multiplier as the risk/reward carrot. The piece I'm proudest of here is that the director is a pure class — no Flutter, no reference to the physics world, no clock of its own. You advance it with update(dt) and read its state. That makes the entire escalation curve unit-testable.

enum StormPhase { calm, warning, storm }

class StormDirector {
  bool update(double dt) {              // returns true when the phase changed
    _phaseTimer += dt;
    if (_phaseTimer < _phaseDuration) return false;
    _phaseTimer -= _phaseDuration;
    switch (_phase) {
      case StormPhase.calm:    _nextEffects = effectsForCycle(_cycle);
                               _phase = StormPhase.warning; break;   // announce
      case StormPhase.warning: _activeEffects = _nextEffects;
                               _phase = StormPhase.storm;   break;   // hit
      case StormPhase.storm:   _activeEffects = const [];
                               _cycle += 1; _phase = StormPhase.calm; break;
    }
    return true;
  }
}

The cycle is calm → warning → storm → calm, and the warning phase exists purely so the UI can show you what's coming — it isn't punishing. Which effects a storm brings is chosen deterministically from a seed and the cycle number, so the difficulty ramp is reproducible (and the same seed always yields the same run, which is gold for debugging). The first storm is always a gentle one (just wind or low gravity); later cycles widen the pool to include obstacles and eventually combine two effects:

List<StormEffect> effectsForCycle(int cycle) {
  final rng = math.Random(_seed ^ (cycle * 0x9E3779B1));   // deterministic
  if (cycle == 0) return [softEffects[rng.nextInt(2)]];     // wind | lowGravity
  final window = (2 + (cycle - 1) ~/ unlockEvery + 1).clamp(2, all.length);
  final pool = List.of(all.take(window));                   // grows with cycle
  // pick 1 effect, or 2 once past the threshold…
}

The director only describes the storm. The controller does the wiring: when update reports a phase change, it asks the director to build a LevelScene (obstacles + forces) and hands it to the physics world, and it swaps the ambient audio loop to match the dominant effect:

if (storm.update(dt)) {
  world.configureScene(storm.buildScene(world.board));   // physics reacts
  // …start/stop the matching ambient loop (wind, low-gravity, well)…
}

This split — a pure rules engine that produces a description, an impure adapter that applies it — is the same shape as the merge layer in Part 2. It keeps the parts that are worth testing (the escalation logic) free of the parts that are annoying to test (audio, Flutter, the live simulation).

The takeaway

You can add a lot of variety to a small engine without growing the engine. Special moves are functions that nudge velocities; an entire obstacle zoo collapses into one capsule collider plus per-type constants; and a difficulty curve becomes a pure, deterministic, unit-testable class that hands the simulation a list of effects. The Part 1 solver — circles in a box — never learned about any of it. Next: the campaign that wraps it all — a star map of real stars, 200 procedural levels and a hand-painted creation myth.

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.