Idle Games Are Weirdly Hard to Build

Idle Games Are Weirdly Hard to Build

I thought building an idle game would be easy. Numbers go up, players feel good, ship it. Turns out there's a lot more to it.

I've been working on Idle Rampage, a monster-themed idle game for mobile. What I thought would be a quick side project turned into months of learning about game design, economy balancing, and why every idle game feels the same (it's because the math works).

The problem with time

The whole point of an idle game is that stuff happens when you're not playing. You close the app, go to work, come back, and you've earned a bunch of resources. That's the core loop.

But how do you actually implement that?

My first attempt was embarrassingly naive. I had a setInterval running every second that added resources:

setInterval(() => {
  setGold(gold => gold + goldPerSecond);
}, 1000);

Works great... until the player closes the app. Then nothing happens. The interval stops. When they come back, they've earned nothing.

The real kicker? I didn't notice this bug for a week because I was actively testing the game. I was always looking at it, so the interval was always running. It wasn't until I showed it to a friend and they said "wait, I thought you earned stuff offline?" that I realized my core mechanic was broken.

Timestamp-based progression

The fix is to track timestamps instead of ticks:

const now = Date.now();
const elapsed = (now - lastSaveTime) / 1000; // seconds
const offlineEarnings = goldPerSecond * elapsed;
setGold(gold => gold + offlineEarnings);
setLastSaveTime(now);

When the app opens, you figure out how long it's been and simulate that time. Simple math, but it changes everything.

I cap offline earnings at 8 hours. Why? Because if players come back after a week and have 10 trillion gold, they've skipped half the game. The cap creates a reason to check back regularly without being annoying.

But here's where it gets tricky: what if the player changes their device time? I've seen players do this to cheese offline games. My solution is to also track server time on app resume (using an API call) and compare it to the device time. If they're more than a few minutes apart, I use the smaller of the two. Not perfect, but it catches most exploits without punishing players whose clocks are just slightly off.

Saving state reliably

Mobile apps get killed by the OS all the time. You can't rely on componentWillUnmount or any cleanup lifecycle. The app might just... disappear.

The solution is aggressive auto-saving:

// Save every time gold changes
useEffect(() => {
  saveState();
}, [gold, buildings, upgrades]);

// Also save on a timer as backup
useEffect(() => {
  const interval = setInterval(saveState, 30000);
  return () => clearInterval(interval);
}, []);

I use zustand with its persist middleware, which handles serializing to AsyncStorage. It's been rock solid.

But here's a gotcha I ran into: don't save TOO often. My first version saved on every single gold tick (once per second). This worked fine in dev, but when I tested on an older Android phone, the constant writes to AsyncStorage caused noticeable lag. The game would stutter every second like clockwork.

The fix was to debounce the saves:

const debouncedSave = useMemo(
  () => debounce(saveState, 2000),
  []
);

useEffect(() => {
  debouncedSave();
}, [gold, buildings, upgrades]);

Now state changes queue up and save in batches. Much smoother, and the 2-second delay is short enough that players won't lose meaningful progress if the app crashes.

Balancing is an art

The numbers in an idle game grow exponentially. Like, really exponentially. Your first upgrade might cost 10 gold. Your 50th might cost 10 billion. And somehow the player needs to get there without it feeling like a grind.

I spent way more time in spreadsheets than I expected. Here's the basic formula for upgrade costs:

const upgradeCost = baseCost * Math.pow(costMultiplier, level);
// e.g., baseCost = 10, multiplier = 1.15
// Level 1: 10
// Level 10: 40
// Level 50: 10,836
// Level 100: 1,174,313

That 1.15 multiplier seems tiny, but it adds up fast. Tweak it to 1.12 and the game is way easier. 1.18 and it's a grind. Finding the right values takes iteration and playtesting.

The trick is making sure income scales at roughly the same rate as costs. If your production buildings have a 1.15 cost multiplier, but each level only increases production by 10%, you've created a death spiral where upgrades get proportionally worse over time. Players will hit a wall and quit.

Here's a better approach:

const buildingCost = baseCost * Math.pow(1.15, level);
const buildingProduction = baseProduction * level; // linear scaling

// But add a global multiplier that grows with level
const totalProduction = buildingProduction * Math.pow(1.05, level);

This way early levels feel linear and predictable, but higher levels start compounding. It creates natural "breakpoints" where suddenly your income jumps and you can afford the next tier of upgrades. Those moments feel amazing.

Number formatting saves your sanity

Once you get into the millions, billions, trillions... your UI breaks. I'm not talking about technical bugs - I mean the numbers stop fitting on the screen and players can't tell how much they have.

You need a number formatter:

const formatNumber = (num: number): string => {
  const suffixes = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc'];

  if (num < 1000) return num.toFixed(0);

  const exp = Math.floor(Math.log10(num) / 3);
  const suffix = suffixes[Math.min(exp, suffixes.length - 1)];
  const shortened = num / Math.pow(1000, exp);

  return shortened.toFixed(2) + suffix;
};

// 1234 -> "1.23K"
// 1234567 -> "1.23M"
// 1234567890 -> "1.23B"

But here's the thing nobody tells you: abbreviations need to go WAY higher than you think. I originally had suffixes up to trillion, thinking "no way players reach that." They reached it in two days. I had to add suffixes up to octillion and beyond.

Some games use scientific notation after a certain point (1.23e15). Others make up silly names (bazillion, kajillion). I went with the short scale system (thousand, million, billion) because it's what Cookie Clicker uses and players already understand it.

The prestige system

Every good idle game has a 'prestige' or 'rebirth' mechanic. You reset your progress in exchange for a permanent multiplier. This solves a fundamental problem: eventually the numbers get so big that progress feels meaningless.

Prestige gives players a reason to start over. 'I'll reset now, get a 2x multiplier, and progress faster next time.' It's like new game plus for idle games.

Getting the prestige curve right is tricky. Reset too early and the multiplier is tiny. Too late and players get bored waiting. I ended up with a soft cap where returns diminish but never stop completely.

Here's the formula I settled on:

const calculatePrestigePoints = (totalGoldEarned: number): number => {
  // Square root scaling - early game gives good returns, late game slows down
  return Math.floor(Math.sqrt(totalGoldEarned / 1000000));
};

const prestigeMultiplier = 1 + (prestigePoints * 0.1);
// 10 points = 2x multiplier
// 100 points = 11x multiplier
// 1000 points = 101x multiplier

The square root is key. It means your first prestige feels impactful (going from 1x to 2x production is huge!), but you can't just prestige spam to break the game. Each successive prestige requires exponentially more gold for linear returns.

I also added a "prestige calculator" that shows how long it would take to reach your current point again with the new multiplier. If it's less than 10% of the time you've already spent, the game tells you it's a good time to prestige. This prevents players from prestiging too early and feeling like they wasted their time.

Performance optimization for 60fps

Idle games seem simple, but keeping them running smoothly at 60fps is harder than you'd think. You're updating numbers every frame, animating currency counters, rendering building lists, and persisting state - all at once.

My first performance bottleneck was the currency counter animation. I wanted the numbers to smoothly count up instead of jumping instantly:

// DON'T DO THIS - it's slow
const animateCounter = (target: number) => {
  let current = gold;
  const step = (target - current) / 60; // 60 frames

  const interval = setInterval(() => {
    current += step;
    setGold(current);
  }, 16); // ~60fps
};

This caused re-renders on every frame. For one counter, fine. But I had 5+ counters on screen (gold, monsters, upgrades, etc.). Every counter triggered a re-render. The app crawled.

The fix was to use requestAnimationFrame and only update the display value, not the actual state:

const useAnimatedNumber = (target: number, duration: number = 500) => {
  const [display, setDisplay] = useState(target);

  useEffect(() => {
    const start = display;
    const startTime = Date.now();

    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);

      setDisplay(start + (target - start) * progress);

      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };

    requestAnimationFrame(animate);
  }, [target]);

  return display;
};

Now the actual gold state updates instantly (for calculations), but the display smoothly animates. Players see the satisfying number growth without killing performance.

Why idle games feel similar

I used to think all idle games were lazy clones of each other. After building one, I understand: the mechanics are similar because the math works.

Exponential growth, prestige systems, offline earnings, upgrade trees - these aren't lazy design choices. They're solutions to real problems that every idle game faces. You can put different art on it, but the underlying structure is constrained by what makes the gameplay loop satisfying.

There's a famous talk by the developer of Swarm Simulator where he explains that idle game mechanics are "discovered, not invented." The exponential curve isn't arbitrary - it's the natural consequence of compound interest. Buildings that produce resources which buy more buildings create an exponential feedback loop. The math dictates the design.

Testing is actually important

I know, hot take. But hear me out - idle games have a unique testing challenge. You can't just write unit tests and call it done. You need to simulate time passing, sometimes weeks of it.

My first major bug made it to production: players who prestiged with exactly zero gold would get NaN for their prestige points, which corrupted their save file. The calculation divided by zero in an edge case I didn't think to test.

Now I have tests that simulate entire game sessions:

test('prestige calculation handles edge cases', () => {
  const game = new GameState();

  // Edge case: prestige at zero
  expect(game.calculatePrestigePoints(0)).toBe(0);

  // Edge case: prestige at very small numbers
  expect(game.calculatePrestigePoints(1)).toBe(0);

  // Normal case
  expect(game.calculatePrestigePoints(1000000)).toBe(1);
});

test('simulate 1 week of gameplay', () => {
  const game = new GameState();
  game.gold = 100;
  game.goldPerSecond = 10;

  // Simulate 1 week offline
  game.processOfflineProgress(7 * 24 * 60 * 60);

  // Should cap at 8 hours
  expect(game.gold).toBe(100 + (10 * 8 * 60 * 60));
});

The second test is crucial. It caught a bug where my offline cap wasn't actually working - I was capping the time elapsed but then applying it multiple times in different parts of the code. Players could get weeks worth of earnings by clever timing.

Zustand saved my sanity

I tried Redux first because that's what everyone uses for React Native. The boilerplate drove me crazy. For a game where state changes constantly (gold updates every second, buildings produce resources, upgrades modify production), writing actions and reducers for every little thing was miserable.

Zustand is so much simpler:

const useGameStore = create(
  persist(
    (set, get) => ({
      gold: 0,
      buildings: [],
      goldPerSecond: 0,

      addGold: (amount) => set((s) => ({ gold: s.gold + amount })),

      buyBuilding: (id) => {
        const building = get().buildings.find(b => b.id === id);
        if (get().gold >= building.cost) {
          set((s) => ({
            gold: s.gold - building.cost,
            buildings: s.buildings.map(b =>
              b.id === id ? { ...b, level: b.level + 1 } : b
            )
          }));
          get().recalculateProduction();
        }
      },

      recalculateProduction: () => {
        const total = get().buildings.reduce((sum, b) =>
          sum + (b.baseProduction * b.level), 0
        );
        set({ goldPerSecond: total });
      }
    }),
    { name: 'game-storage' }
  )
);

You just... mutate state. And it works. Persistence is built in. No action types, no switch statements, no boilerplate.

The get() function is especially nice for derived state. In Redux, I'd need selectors and memoization and all this ceremony. In Zustand, I just call get() and read the current state. It's perfect for game logic where everything is interconnected.

The psychology of progression

Here's what surprised me most: the actual gameplay doesn't matter as much as the feeling of progression. I spent weeks on the monster battle system - animations, special effects, different monster types. Players barely noticed. You know what they cared about? The satisfaction of seeing numbers go up.

Cookie Clicker understood this. The entire game is clicking a cookie. That's it. But the progression is so well-tuned that people play it for hundreds of hours. Every few minutes you can afford something new. There's always a goal just within reach.

I studied Cookie Clicker's pacing and noticed a pattern: you should be able to afford a new upgrade or building every 2-5 minutes in the early game, extending to 10-15 minutes in the mid game. Any longer and players get bored. Any shorter and it feels overwhelming.

This informed my balancing. I built a Google Sheet that simulated a full playthrough and adjusted costs until the pacing felt right. The spreadsheet has 200+ rows of buildings, upgrades, and costs. It's ugly but it works.

Mobile-specific gotchas

Building for React Native comes with quirks that desktop web doesn't have. The biggest one: Android's back button. On my first test on a real device, I pressed the back button and the app closed, losing all my progress since the last autosave.

The fix:

useEffect(() => {
  const backHandler = BackHandler.addEventListener(
    'hardwareBackPress',
    () => {
      saveState();
      return false; // Let the app close after saving
    }
  );

  return () => backHandler.remove();
}, []);

Another gotcha: AsyncStorage has size limits on Android (6MB by default). My save file was approaching 1MB because I was storing every single building upgrade as a separate object. I refactored to store just the level counts and recalculate everything on load. Dropped the save file to 50KB.

Also, iOS and Android handle backgrounding differently. iOS is more aggressive about suspending apps. I had to use AppState to detect when the app goes background and immediately save:

useEffect(() => {
  const subscription = AppState.addEventListener('change', nextAppState => {
    if (nextAppState.match(/inactive|background/)) {
      saveState();
      setLastSaveTime(Date.now());
    }
  });

  return () => subscription.remove();
}, []);

What I learned

Building an idle game taught me more about game design than any tutorial. The genre looks simple but the details matter. Every number, every curve, every mechanic is carefully tuned to create that 'just one more upgrade' feeling.

I learned more about exponential math than I ever did in school. I learned about mobile performance optimization the hard way. I learned that game balance is both an art and a science, requiring spreadsheets and gut feel in equal measure.

Most importantly, I learned why idle games are so addictive. They're perfectly tuned to give you constant, measurable progress. In real life, growth is slow and non-linear. In idle games, you can watch numbers double every few minutes. It's progress distilled into its purest, most satisfying form.

Idle Rampage isn't finished yet, but it's playable and I'm proud of it. Check it out in the Projects section if you want to see numbers go up. Just don't blame me when you're still playing at 2am, telling yourself "just one more upgrade."

Send a Message