Why money in an idle game cannot be a plain number

The largest number JavaScript holds is 1e308, and an idle game reaches it in days. When it does, everything turns to infinity at once, with no error and no warning.

The ceiling nobody sees coming

Every number in JavaScript is a 64-bit float, and the largest one it can hold is about 1.8 × 10^308. That is an absurd number. It is larger than the count of atoms in the observable universe, cubed. It is tempting to end the discussion there and use a plain number.

But idle games do not grow by adding. They grow by multiplying, and multiplicative growth eats through any ceiling far sooner than intuition suggests.

The real problem is not reaching the ceiling: it is what happens when you do. JavaScript does not complain. No exception, no console warning, no error return. The number simply becomes Infinity, and from then on every calculation that touches it becomes infinite too. Cash is infinite, price is infinite, the difference between them is NaN, and the screen shows "$ NaN" to a player who spent three days building that.

The defect with no symptom

An overflowed save does not break when it overflows: it breaks the next time someone subtracts. The trail is long gone, and the player's save cannot be recovered, because the missing information was erased by the rounding itself.

How long it takes to overflow

The FutCapitalist ladder has ten steps, and the last one costs around 2.6 × 10^10 for its first unit. That looks far from 10^308, and it is. But every unit bought makes the next one 7% more expensive, and that is the detail that changes everything: buying 500 units of one business multiplies its price by 1.07^500, roughly 10^14.

Multiply that by the milestones (which multiply output), by the club's division, by the trophies and by the fanbase accumulated across several lives, and the number on the player's screen passes 10^308 with no special event happening. It is just a Tuesday.

Anyone who has played idle games knows this: there are famous games in the genre whose endgame is, literally, the float barrier. The game stalls at a number and climbs no further, because there is nowhere left to climb.

The fix is storing the number in two parts

The way out is to store not the value, but the mantissa and the exponent separately. Instead of trying to hold the whole number, you keep "1.234" and "308" in different fields and implement arithmetic on top of that pair. Adding two numbers with distant exponents becomes cheap (the smaller one simply vanishes into rounding, exactly as it does in real mathematics), and multiplying becomes adding exponents.

That lifts the ceiling from 10^308 to something near 10^(10^9), large enough that no human player reaches it in any number of lives.

// what happens with a plain number const cash = 1e308 * 10 // Infinity cash - 1e308 // Infinity cash - cash // NaN, and the screen shows "$ NaN" // what happens with Decimal const cash2 = d('1e308').times(10) // 1e309, and life goes on

What it costs

Nothing is free. A Decimal is an object, so every operation allocates memory where there used to be a processor instruction. In a game that advances state many times per second, that matters, and the rule that survived is simple: money, output, cost, strength and fanbase are Decimal; time in milliseconds, unit counts and list indices stay plain numbers.

That boundary is not decorative. Time has to be a plain number because it is compared and divided thousands of times per second in the clock advance, and it never approaches the ceiling: 10^308 milliseconds is many times the age of the universe.

The annoying part is that the boundary does not defend itself. A number where a Decimal belongs compiles cleanly, runs cleanly, passes the tests and only shows up in someone's save three weeks later. That is why the project runs type checking as a separate step from the tests: the runtime that executes the tests ignores types, so without that step the mistake would reach production having never been stopped.

The detail that almost got through

There is one more trap, and it is subtle. The Decimal mantissa is still a float. It does not overflow, but it does round.

Which means adding eight hours of output in one go does not give exactly the same result as adding 288,000 slices of 100 milliseconds. The difference is tiny, around 10^-15 in relative terms, and it does not grow over time. But it exists.

The rule that came out of it

In the test that compares offline advance against normal advance, the cycle count is compared for exact equality, because a cycle is a whole number and has to match precisely. The cash, on the other hand, is compared within a tolerance. Swapping those two criteria gives you a test that either fails correct code or passes broken code, and both are terrible.

It is the kind of thing that only surfaces when you try to prove that two different routes arrive at the same place. Which is exactly why it is worth trying to prove.

← All posts

You can also just play.

Play free now