Exact offline progress in an idle game: a closed-form solution instead of a replay loop
Every idle game has to answer one question: you closed the app eight hours ago, so what happened while you were away?
The common answer is to simulate it. Step the game forward in chunks, maybe one second, maybe one minute, until you've caught up. It's simple. It's also slow for long gaps, and inaccurate when chunks are big, because a cascade of producers compounds within each step.
Genesis doesn't simulate. It solves.
The producer chain is a linear ODE
Genesis has eight tiers, and each tier produces the one below it. Tier 8 makes tier 7, tier 7 makes tier 6, and tier 1 makes Biomass. If production rates are constant, that's a chain of linear differential equations:
- the top tier grows linearly with time,
- the next one down grows with t²,
- and so on, until Biomass is a polynomial of degree eight in t.
That polynomial has an exact solution, so tick(dt) evaluates it once, whether dt is a 16 ms frame or an 86,400-second day. There's no replay loop and no step size, and the cost is O(tiers²) regardless of how long you were gone. Offline and online run the same code, which means they can't drift apart. Offline progress is capped at 24 hours.
The invariant that makes it exact
The closed form is only exact if the rates are constant across the gap. So the design has a strict rule: every modifier depends on levels and counts, never on live amounts.
"Production +10% per upgrade level" is fine, because levels don't change while you're away. "Production +1% per 1,000 Biomass you hold" breaks it: Biomass changes during the gap, so the rate isn't constant and the formula silently goes wrong.
Every mechanic in the game is classified by how it interacts with time, and the class that depends on continuously sampled quantities is banned. When a crucible needed decaying production (the Entropy challenge), the decay runs only in the online frame loop, never in tick(), and the crucible can't be active offline.
The ratchet, and the soft cap
Exact offline progress created an exploit I didn't see coming. Leave for 24 hours, come back, buy producers with the windfall, leave again. Each cycle compounds, because the produced units of each tier feed the next. A player who did this repeatedly outgrew someone actively playing.
The fix (written up as an architecture decision so I'd remember why) is a soft cap on produced units: units a tier produces count at full weight only up to about ten times the units you bought. Past that, they count for less. It ends the ratchet without discounting offline output in general.
It also makes the equations non-linear, so the closed form becomes an approximation. The integrator is tested to stay within a 5% error budget against brute-force stepping. Alternatives I rejected: banking offline time to spend later, a harvest-on-return screen, the AdVenture Capitalist model, and pure tuning (which couldn't reach it).
Derive state; don't store it
One bug class I've killed for good: stored progress that can disagree with reality. Gate progress used to be stored in two fields that were wiped on certain resets. Test saves lost progress they'd earned. The fix deleted both fields. Progress is now derived from the strand itself whenever it's needed. No field, no bug.
Saves and migrations
Saves are versioned, checksummed with a key (a deterrent against casual editing, not security) and migrated forward on load. One migration bug taught me a test I now use everywhere: a save from an old version re-ran its migration on every load, and, because challenge exits deserialize a snapshot, on every challenge exit too, wiping the strand each time.
The test: a double round-trip must be a no-op. Load, save, load, save. If the second save differs from the first, something is migrating twice.
Android WebView isn't desktop Chrome
Genesis is a web game in a Capacitor wrapper, and the biggest performance surprise was the canvas. On desktop the DNA helix rendered in 2 ms at a device pixel ratio of 3. On a real Android phone it ran at 3 to 5 frames per second.
The culprits were ctx.filter and ctx.shadowBlur. In the Android WebView both fall back to software rendering, at about a millisecond per state change, and the helix changed state hundreds of times a frame. Both are now banned from the render path. Glows are pre-rendered sprites. Profile on the phone, because a desktop profile tells you almost nothing.
A smaller one from early on: background images were missing in the APK but fine in the browser, because of absolute asset URLs. Every image now goes through the bundler and is preloaded behind a loading screen before the UI appears.