Skip to content

jengine-idle

An idle game: generators and upgrades as data, a prestige that trades the run for a bonus, and offline progress from the wall clock.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-idle
Version0.1.0
Entry pointjengine_idle::IdlePackage
Script namespaceIdle
Listed byprojects/arcade-classics

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-idle", "source": { "type": "path", "path": "../../packages/jengine-idle" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Idle == nil then return end.

Scene keyRust type
idle_gameIdleGamepackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Idle.buy(e, generator, count)idle_buy
Idle.upgrade(e, upgrade)idle_upgrade
Idle.prestige(e)idle_prestige
Idle.click(e, amount)idle_click
Idle.load(e, json, now)idle_load
Idle.reset(e)idle_reset
  • projects/arcade-classics/assets/scenes/cellar.scene

From packages/jengine-idle/README.md.

An idle game: generators that make a thing, upgrades that multiply it, a prestige that throws the run away for a permanent bonus, and progress while the game is closed.

Used by projects/arcade-classics (Cinder Cellar).

ModuleWhat it is
numbersBig numbers and how to say them, the exponential cost curve, and the closed form for “how many can I afford”.
contentGenerators, upgrades and the prestige curve as rows in the project’s data tables.
gameThe run: buying, producing, prestige, saving, and coming back.

Offline progress is why the engine grew a wall clock

Section titled “Offline progress is why the engine grew a wall clock”

The engine had now_ms — monotonic, arbitrary origin, explicitly not a date. It measures a session and nothing else, so two readings either side of a restart mean nothing, and “how long was I away?” was unanswerable. That question is the entire premise of this genre (and half the premise of a daily reward), so the engine now has Time.get_unix() / jengine_core::profiling::unix_time() and the save carries a stamp.

Coming back is then arithmetic, with a clamp at each end:

let away = (now - self.save.stamp).max(0.0); // clocks go backwards
let paid_for = away.min(OFFLINE_CAP); // eight hours
let gained = self.rate * paid_for * OFFLINE_RATE; // half of being there

Both clamps are load-bearing. A system clock moves backwards for ordinary reasons — a timezone change, an NTP correction, a player setting the date forward to cheat and then back — and an unclamped subtraction hands out negative money for it. Without the cap, a save from last year buys the whole game on the way back in, which is not a reward, it is the end of the game. And offline production pays half, because if it paid full there would be no reason to be there.

The other half of the same idea is that the stamp has to stay current while the game is running, or a session that never gets saved measures its own length as absence next time. The system restamps on its autosave tick.

The closed form, and why a loop will not do

Section titled “The closed form, and why a loop will not do”

The price of the next one is base * growth^owned, and the total of the next n is a geometric series. The “buy max” button every one of these games has needs that series inverted: how many can this bank afford?

let inner = budget * (growth - 1.0) / unit + 1.0;
let n = inner.log(growth).floor().max(0.0);

Counting them one at a time would be a loop whose length the player chooses — a shallow curve and a large bank is eighteen thousand iterations of something that runs every frame, and a late-game player has worse. Two logarithms answer it instead. tests/numbers.rs checks the closed form against the loop it replaces, and checks that one more would not have been affordable, which is the off-by-one this shape invites.

f64 is the right storage — 15 significant digits is more precision than an idle game’s arithmetic deserves, and it reaches 1e308 — so the real work is the writing down: say() gives three significant figures and a short-scale suffix (1.23K, 999M, 4.70Sx), and falls back to exponent notation past the last name rather than running off the end of the list.

(earned / required) ^ curve, floored. A curve below 1 flattens it, so a run ten times as long is not ten times the reward — that is what stops the second run from being the last one. The reset takes the currency, the generators and the upgrades; the prestige currency, the reset count and the lifetime totals survive it.

Generators and upgrades carry unlock_at / show_at thresholds on lifetime earnings, because twelve buttons on the first screen is how these games lose people. The content loader refuses a table where nothing is available at the start, an upgrade that boosts a generator that does not exist (it would appear, cost money and silently do nothing), and a cost curve that never rises.

idle_game on one entity, in a "version": 2 scene. The package draws nothing and saves nothing — it publishes the whole run as a JSON string and the game hands that to the engine’s save system.

"idle_game": {
"tables": { "generators": "data/generators", "upgrades": "data/upgrades",
"prestige": "data/prestige" },
"save_slot": "arcade", "save_key": "cellar", "autosave": 10.0
}
CallDoes
Idle.buy(e, generator, count)buy that many; 0 buys as many as the bank allows
Idle.upgrade(e, upgrade)buy an upgrade
Idle.prestige(e)trade the run for the bonus
Idle.click(e, amount)the button every one of these games opens with
Idle.load(e, json, now)restore a save and pay for the time away
Idle.reset(e)wipe everything, prestige included
  • IdleState every frame: currency, earned, rate, prestige, prestige_gain, prestige_required, resets, lifetime, played, offline_paid, offline_seconds, and the generators / upgrades lists — each row carrying its price, its “max” order, what it produces, and whether it is unlocked and affordable, so the game draws a button without recomputing the curve in Lua. save is the whole run as a string.
  • IdleEvents.events: buy, upgrade, prestige, offline, autosave.