Building Nova Drop · Part 4 of 6

An infinite Odyssey: a star map, 200 procedural levels and a hand-painted creation myth

Luca Panteghini · Nurale

TL;DR — Endless mode is the arcade; the Odyssey is the journey. It's 200 hand-tuned levels — generated from a single parametric difficulty curve, then capped by ceilings the headless simulator proved are beatable — followed by an infinite tail. You travel them on a star map built as a pure, testable layout: real stars grouped into constellations, each with an astronomy note in all 18 languages. And between levels, a creation-of-the-universe story plays out, painted entirely in-engine with zero image assets.

The two modes split the audience cleanly. Infinite Cosmos (Part 3) is for the high-score chaser. The Odyssey is for the player who wants somewhere to go — a sequence with shape, milestones and a story. The interesting engineering is that almost none of it is hand-authored data: the levels, the map and the narrative are all generated or laid out by deterministic code, which is the only way one part-time dev ships 200 levels (and then infinitely more).

Nova Drop's Odyssey star map: a winding path of real stars (Sirius, Canopus, Arcturus, Vega, Capella) in the ORIONIS constellation, each node a level with its own goal.
The Odyssey star map: a serpentine path of real stars, grouped into constellations, each node a level with its own goal — laid out by a pure, deterministic function.

200 levels from one function

There is no list of 200 hand-written levels. There's a single function, levelFor(n), that derives a level's goal and constraints from its number. It cycles through goal types for variety, grows the targets with n, and — crucially — clamps them to ceilings, because an un-clamped linear curve becomes mathematically impossible on high levels (hundreds of points per second, 20-long chains). Those ceilings were tuned against a headless simulator, not by guessing:

LevelDef levelFor(int n) {
  final cycle = [LevelGoal.score, LevelGoal.merges, LevelGoal.reachTier,
                 LevelGoal.chain, LevelGoal.score];
  var goal = cycle[(n - 1) % cycle.length];

  // grows with n, but always within a beatable plateau (verified by sim)
  var target = switch (goal) {
    LevelGoal.score    => (400 + (n - 1) * 240).clamp(400, kScoreTargetMax), // 12000
    LevelGoal.merges   => (8 + n).clamp(8, kMergesTargetMax),                 // 50
    LevelGoal.reachTier=> (3 + n ~/ 6).clamp(2, kReachTierMax),               // 6
    LevelGoal.chain    => (3 + n ~/ 8).clamp(3, kChainTargetMax),             // 8
    LevelGoal.supernova=> 1,
  };

  // from level 11, score goals become a visible race against the clock
  final isRace = n >= 11 && goal == LevelGoal.score;
  final timeLimit = isRace ? (target ~/ 40 + 45).clamp(75, 200) : null;
  // …drop limits, container shape, milestone unlocks…
  return LevelDef(number: n, goal: goal, target: target, timeLimit: timeLimit, /* … */);
}

LevelDef.count = 200 marks where the curated run ends, but levelFor keeps producing valid levels forever — the Odyssey simply continues with reduced rewards past 200, so there's always a next node. A handful of levels are special-cased: the milestone levels (10, 20, 30, 40, 50) are themed "learn the power you just unlocked," with a soft goal so you can focus on the new toy.

This is the payoff of the parametric approach. A single, readable curve plus a few clamps gives a difficulty ramp that's tunable in one place, reproducible, and provably beatable — and it doesn't stop at 200. The clamps aren't a guess: a level_sim_test plays the curve headlessly to confirm the high levels are winnable, so a bad constant fails a test instead of a player.

Keeping 200 levels from feeling like one

A parametric curve risks sameness, so two systems inject variety. First, goal rotation: you're never doing the same task twice in a row — chase a score, then land N merges, then reach a tier, then build a long chain. Second, from level 60 the container itself changes shape: the classic well gives way to 15 hand-designed forms — twin wells, an hourglass, a central tube, a Y-splitter, kinematic gates that open and close — rotated one per level:

const int kFirstContainerLevel = 60;
ContainerShape containerForLevel(int n) => n < kFirstContainerLevel
    ? ContainerShape.classic
    : kVariantShapes[(n - kFirstContainerLevel) % kVariantShapes.length];

Each shape is just a set of capsule-collider walls (Part 3), so the physics engine renders and collides them with no special code — the well's geometry is data, and changing it changes the whole puzzle.

The star map: a pure layout of real stars

The campaign's home screen is a star map — a serpentine path of level nodes winding upward, grouped into constellations. Like the storm director in Part 3, the map's layout is a pure, testable module: it has no Flutter UI dependency, just geometry and data. Levels are grouped ten to a constellation, and every position is seeded by the level number, so the map is stable and extends infinitely past the 200 curated levels:

const int kLevelsPerConstellation = 10;

int constellationIndexFor(int level) => (level - 1) ~/ kLevelsPerConstellation;
int constellationFirstLevel(int idx)  => idx * kLevelsPerConstellation + 1;

// asterism points are deterministic from the constellation index — same map every time
List<Offset> constellationAsterism(int idx) {
  final r = math.Random((idx * 2246822519) & 0x7fffffff);
  return [for (var i = 0; i < kLevelsPerConstellation; i++)
    Offset(0.16 + r.nextDouble() * 0.68,
           0.14 + (i + r.nextDouble()) / kLevelsPerConstellation * 0.72)];
}

Each constellation carries a real celestial name (ORIONIS, LYRA, CYGNUS, VELA…) and a cosmic accent colour that tints its nebula glow and nodes. And each node is a real star — Sirius, Vega, and so on. Tap one and you get a full-screen card with the level's objective, your stars and best time, and a fixed "astronomy" section: a little diagram of the constellation with the selected star highlighted, plus a genuine scientific note about that star.

Nova Drop star card for CAPELLA: level objective, reward, Play button, and an 'About this star' section with a constellation diagram and a real astronomy note.
The star card: objective and reward up top, then a fixed "About this star" panel — a constellation diagram with the star highlighted, and a real astronomy note (localized into all 18 languages).

That astronomy note is localized into all 18 languages — but the star and constellation names are not, exactly like the cosmic tiers and the special-move names. It's the same rule from the localization post (Part 6): translate what a player needs to understand, keep what is identity.

const Map<String, Map<String, String>> kStarScience = {
  'SIRIUS': {
    'en': 'The brightest star in the night sky, in Canis Major — a binary '
          'system about 8.6 light-years away.',
    'it': 'La stella più brillante del cielo notturno, nel Cane Maggiore…',
    // …16 more languages…
  },
  // …with a generic, per-constellation fallback for stars without a dedicated note
};

The daily-reward streak uses the same idea — it's literally the Big Dipper (Dubhe, Merak, Phecda, Megrez, Alioth, Mizar, Alkaid), filled in star by star as your streak grows. The whole game quietly teaches you the night sky.

A creation myth, painted not authored

Between levels, the Odyssey tells a story: the creation of the universe, beat by beat, from the void before the Big Bang to the heat death at the end. The thing I'm fond of is that none of it is image or video assets — every scene is a procedural painting, named by an enum and drawn by a CustomPainter (the same approach as the gameplay scene in Part 2):

enum CosmosScene {
  // intro
  voidBefore, bigBang, particles, firstStars, galaxies, milkyWay, yourOdyssey,
  // lore beats across the campaign
  solarSystem, planets, earth, water, life, nebula, redGiant, supernova,
  binaryStars, cluster, supermassive, expansion,
  // later arcs: civilization, interstellar flight… and finally:
  embers, heatDeath,
}

Beats are scheduled roughly every three levels and grouped into three arcs that span the campaign (early cosmos, life and civilization, then entropy and contemplation). Each beat is shown once — its "seen" state is persisted — and, naturally, the text of every beat is localized into all 18 languages. Painting the story instead of shipping art keeps the download tiny and means the narrative renders crisply at any resolution, on the same engine that draws everything else.

The takeaway

A campaign this size is only feasible for a solo dev because it's generated, not authored: 200+ levels from one clamped parametric curve (proven beatable by a headless sim), a star map laid out by a pure deterministic function that extends forever, and a creation myth painted in-engine with zero assets. The recurring pattern across this whole series shows up again here — pure, testable logic that produces data, with a thin impure layer that renders it. Next: the unglamorous but essential part — how a relaxing game like this pays for itself without nagging you to death.

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.