Rebuilding the "Welcome Back" mechanic from idle games in React

November 2, 2025 — React, Game Dev

Idle games like Melvor Idle or Cookie Clicker, all share that small but satisfying moment when you come back after a break and the game greets you:

"Welcome back! You were away for 2 hours and earned 240 gold."

In this post, we'll rebuild that offline progress calculation mechanic - the "Welcome Back" screen - in React. We'll keep it simple, using localStorage for persistence, and later talk about how this could be made secure with a backend like Supabase.


The core idea

In an idle game, you don't simulate the world every second. You just record when the player started an action, and when they return, you calculate how much time passed.

The whole mechanic can be expressed in a single formula:

gained = Math.floor((now - last_saved) / tick_ms) * rate;

That's it. Whether the player closed the tab, lost connection, or left for a day, the game can instantly compute progress based on timestamps.


Flow overview

Here's what actually happens behind the scenes when you perform an idle action, close the game, and come back later. The logic is simple - record the start time, calculate how long you were away, and reward progress accordingly.


Why this design is clever

  • Lightweight - no background timers or intervals needed
  • Scalable - only a few timestamps per player
  • Deterministic - can always be recalculated exactly (as long as the server and client clocks are synced)
  • Cheat-resistant - if stored on the server, local clock tricks don't work

Building it in React

Here's the full code for the demo. When you click "Start chopping", the game records a timestamp. If you close or refresh the page, it will greet you on return with the calculated offline progress.

import { useEffect, useState } from 'react';

const STORAGE_KEY = 'idleChoppingData';
const TICK_MS = 3000; // 1 wood every 3s
const WOOD_PER_TICK = 1;
export const woodPerMinute = (WOOD_PER_TICK * 60000) / TICK_MS;
export const woodPerHour = woodPerMinute * 60;

function formatTimeDifference(ms: number): string {
  const seconds = Math.floor(ms / 1000);
  const minutes = Math.floor(seconds / 60);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);

  const parts: string[] = [];

  if (days > 0) {
    parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
  }
  if (hours % 24 > 0) {
    parts.push(`${hours % 24} ${hours % 24 === 1 ? 'hour' : 'hours'}`);
  }
  if (minutes % 60 > 0) {
    parts.push(`${minutes % 60} ${minutes % 60 === 1 ? 'minute' : 'minutes'}`);
  }
  if (seconds % 60 > 0) {
    parts.push(`${seconds % 60} ${seconds % 60 === 1 ? 'second' : 'seconds'}`);
  }

  if (parts.length === 0) return 'a moment';
  if (parts.length === 1) return parts[0];

  const last = parts.pop();
  return `${parts.join(', ')} and ${last}`;
}

export function useIdleDemo() {
  const [wood, setWood] = useState(0);
  const [startedAt, setStartedAt] = useState<number | null>(null);
  const [welcome, setWelcome] = useState<string | null>(null);
  const [isRunning, setIsRunning] = useState(false);

  // On load — check for ongoing or past session
  useEffect(() => {
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
    if (!saved) return;

    const { wood: savedWood, startedAt: savedStartedAt } = saved;
    setWood(savedWood ?? 0);

    if (savedStartedAt) {
      const now = Date.now();
      const diff = now - savedStartedAt;
      const gained = Math.floor(diff / TICK_MS);
      if (gained > 0) {
        setWelcome(`You were away for ${formatTimeDifference(diff)} and gathered ${gained} wood.`);
        setWood(savedWood + gained);
      } else {
        // still running session
        setStartedAt(savedStartedAt);
        setIsRunning(true);
      }
    }
  }, []);

  const start = () => {
    const now = Date.now();
    setStartedAt(now);
    setIsRunning(true);
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ wood, startedAt: now }));
  };

  const stop = () => {
    if (!startedAt) return;
    const now = Date.now();
    const diff = now - startedAt;
    const gained = Math.floor(diff / TICK_MS);
    const total = wood + gained;
    setWood(total);
    setStartedAt(null);
    setIsRunning(false);
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ wood: total }));
  };

  const applyWelcome = () => {
    const now = Date.now();
    setWelcome(null);
    setStartedAt(now);
    setIsRunning(true);
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ wood, startedAt: now }));
  };

  return { wood, start, stop, welcome, applyWelcome, isRunning };
}

export default function IdleWelcomeDemo() {
  const { wood, start, stop, welcome, applyWelcome, isRunning } = useIdleDemo();

  return (
    <div className="relative overflow-hidden py-1 px-6 mt-6 mb-6 bg-slate-900 text-white rounded-xl">
      <h2 className="text-xl font-bold mb-4">Idle "Welcome Back" Demo</h2>
      <p className="text-xs opacity-60 mb-4">
        Gathering {woodPerMinute} wood/min • {woodPerHour} wood/hour
      </p>

      {isRunning && <p className="mt-4 text-green-400">ā³ Chopping in progress...</p>}

      <p className="text-sm opacity-75">Wood collected:</p>
      <p className="text-4xl font-semibold mb-6">{wood}</p>

      {!isRunning ? (
        <button
          onClick={start}
          className="px-4 py-2 rounded-lg bg-green-600 hover:bg-green-500 transition"
        >
          Start chopping
        </button>
      ) : (
        <button
          onClick={stop}
          className="px-4 py-2 rounded-lg bg-rose-600 hover:bg-rose-500 transition"
        >
          Stop chopping
        </button>
      )}

      {welcome && (
        <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
          <div className="bg-slate-800 p-6 rounded-lg text-center max-w-sm w-[90%] shadow-lg">
            <h3 className="text-lg font-semibold mb-2">Welcome back!</h3>
            <p className="mb-4">{welcome}</p>
            <button
              onClick={applyWelcome}
              className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg transition"
            >
              Continue
            </button>
          </div>
        </div>
      )}

      <p className="text-xs text-slate-400 mt-6">
        šŸ’” Try closing and reopening the page for a bit, then come back to see how much wood you've
        earned.
      </p>
    </div>
  );
}

Breaking down the code

1. State structure

  • We track wood, startedAt, and isRunning in state.
  • wood represents total resources collected so far, while startedAt stores the timestamp of when chopping began.
  • We also use welcome to store the "you were away for X seconds" message shown when returning.

2. Offline progress logic

  • When the page loads, we check localStorage for any saved data.
  • If startedAt exists, we calculate how much time passed since it was saved using Date.now() - startedAt.
  • Then we determine how many 3-second ticks fit into that gap and award the correct amount of wood.

3.Session control

  • Clicking Start chopping saves the current timestamp and sets isRunning = true.
  • Clicking Stop chopping calculates progress up to that moment, adds it to total wood, and resets the timer.
  • Everything persists to localStorage, so progress isn't lost on refresh.

4.Welcome back screen

  • When a returning player's elapsed time is detected, we show a small modal with a message like "Welcome back! You were away 120s and earned 40 wood."
  • The player can click Continue to dismiss it and resume chopping automatically.

5. Persistence and security

  • Since this demo is client-only, everything is stored in localStorage.
  • For a production setup, you'd move this logic to a backend (like Supabase, yes I'm Supabase fanboy) and rely on server time for accurate, cheat-proof progress calculation. But that's for another post.

Playable demo

Idle "Welcome Back" Demo

Gathering 20 wood/min • 1200 wood/hour

Wood collected:

0

šŸ’” Try closing and reopening the page for a bit, then come back to see how much wood you've earned.


Protecting against cheating

In a real game, relying on the browser's clock means a player could just change their system time and "earn" a week's worth of resources instantly.

That's why production idle games move this logic to the backend. The server stores the official started_at timestamp and uses server time to calculate the difference. The same formula applies - it just uses an authoritative clock instead of Date.now().

If you're using Supabase, this becomes a simple RPC call that updates progress based on now() on the database side.


Wrapping up

This small demo captures the core of how idle games handle offline progress: save a timestamp, calculate elapsed time, reward the player, and resume.

It's simple, cheap, and reliable - exactly why so many games use it.