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, andisRunningin state. woodrepresents total resources collected so far, whilestartedAtstores the timestamp of when chopping began.- We also use
welcometo store the "you were away for X seconds" message shown when returning.
2. Offline progress logic
- When the page loads, we check
localStoragefor any saved data. - If
startedAtexists, we calculate how much time passed since it was saved usingDate.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.